diff --git a/.agents/skills/pr/SKILL.md b/.agents/skills/pr/SKILL.md index 6a72949c96..f2a9ec8dad 100644 --- a/.agents/skills/pr/SKILL.md +++ b/.agents/skills/pr/SKILL.md @@ -210,7 +210,7 @@ the diff first — `git diff --name-only main...HEAD` answers most of these. **Flip without asking** when it is self-contained: a single-file fix, test-only, docs-only, one call site, no new public surface. -Unattended (webmux oneshot) there is nobody to ask, so the judgement holds and the action +Unattended (a one-shot run) there is nobody to ask, so the judgement holds and the action degrades: flip the self-contained ones, and leave the rest at a clean draft with a line in the PR description saying why — `left in draft: adds a migration, wants a human look before ready`. Don't flip a wide-blast-radius change just because the round came back clean, and don't ask a diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 80970d1e76..0d174a548f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,4 +1,13 @@ -# Basic set up for three package managers +# Weekly, grouped version updates for every shipped manifest, plus one grouped +# security-update PR per manifest. Security-update PRs have no open-PR limit, so +# without an `applies-to: security-updates` group each alert would get its own PR. +# Bun has no security updates: /cli alerts are fixed through its version group. +# +# Deliberately NOT covered here: +# - /rust-client: Cargo.toml is generated and gitignored +# - /backend/parsers/windmill-parser-wasm: excluded from the workspace, needs nightly cargo +# - /benchmarks/pulumi: one-off load-test infrastructure, not shipped +# - /integration_tests: CI-only version: 2 updates: @@ -13,12 +22,32 @@ updates: directory: "/frontend" schedule: interval: "weekly" + groups: + # minor + patch bumps in one PR; majors in their own PR so they can be + # reviewed separately without blocking the routine bumps + frontend-deps: + patterns: ["*"] + update-types: ["minor", "patch"] + frontend-majors: + patterns: ["*"] + update-types: ["major"] + frontend-security: + applies-to: security-updates + patterns: ["*"] # Maintain dependencies for cargo - package-ecosystem: "cargo" directory: "/backend" schedule: interval: "weekly" + groups: + # majors stay as individual PRs: they usually need code changes + cargo-deps: + patterns: ["*"] + update-types: ["minor", "patch"] + cargo-security: + applies-to: security-updates + patterns: ["*"] # Maintain dependencies for Docker - package-ecosystem: "docker" @@ -31,3 +60,52 @@ updates: directory: "/python-client/wmill" schedule: interval: "weekly" + groups: + python-client-security: + applies-to: security-updates + patterns: ["*"] + + # Maintain dependencies for the CLI (bun.lock) + - package-ecosystem: "bun" + directory: "/cli" + schedule: + interval: "weekly" + groups: + cli-deps: + patterns: ["*"] + + # Maintain dependencies for the YAML validator + - package-ecosystem: "npm" + directory: "/windmill-yaml-validator" + schedule: + interval: "weekly" + groups: + yaml-validator-deps: + patterns: ["*"] + yaml-validator-security: + applies-to: security-updates + patterns: ["*"] + + # Maintain dependencies for the TypeScript client (devDependencies only) + - package-ecosystem: "npm" + directory: "/typescript-client" + schedule: + interval: "weekly" + groups: + typescript-client-deps: + patterns: ["*"] + typescript-client-security: + applies-to: security-updates + patterns: ["*"] + + # Maintain dependencies for multiplayer + - package-ecosystem: "npm" + directory: "/multiplayer" + schedule: + interval: "weekly" + groups: + multiplayer-deps: + patterns: ["*"] + multiplayer-security: + applies-to: security-updates + patterns: ["*"] diff --git a/.github/workflows/ai-agent-tests.yml b/.github/workflows/ai-agent-tests.yml index cd07602109..8a3e9e6dbc 100644 --- a/.github/workflows/ai-agent-tests.yml +++ b/.github/workflows/ai-agent-tests.yml @@ -63,11 +63,11 @@ jobs: with: bun-version: 1.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "20" - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.11" diff --git a/.github/workflows/ai-evals-test.yml b/.github/workflows/ai-evals-test.yml index 87eefab0f3..573bc77df9 100644 --- a/.github/workflows/ai-evals-test.yml +++ b/.github/workflows/ai-evals-test.yml @@ -77,7 +77,7 @@ jobs: with: bun-version: 1.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: # Node 24 ships npm 11, which frontend/package-lock.json is authored # with; npm 10 rejects it ("Missing: picomatch@4.0.5 from lock file"). diff --git a/.github/workflows/backend-test-windows.yml b/.github/workflows/backend-test-windows.yml index f9153c0ce0..54a62ec495 100644 --- a/.github/workflows/backend-test-windows.yml +++ b/.github/workflows/backend-test-windows.yml @@ -67,7 +67,7 @@ jobs: # tests what production runs, instead of floating on the latest v2.x. deno-version: 2.2.1 - - uses: actions/setup-go@v2 + - uses: actions/setup-go@v7 with: go-version: 1.21.5 @@ -75,11 +75,11 @@ jobs: with: bun-version: 1.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "20" - - uses: astral-sh/setup-uv@v6.2.1 + - uses: astral-sh/setup-uv@v7 with: version: "0.11.24" diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index 9f65c2ae47..466259f6f8 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -53,16 +53,16 @@ jobs: # Pin to the Deno version shipped in the runtime image (Dockerfile) so CI # tests what production runs, instead of floating on the latest v2.x. deno-version: 2.2.1 - - uses: actions/setup-go@v2 + - uses: actions/setup-go@v7 with: go-version: 1.21.5 - uses: oven-sh/setup-bun@v2 with: bun-version: 1.4.0 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "24" - - uses: astral-sh/setup-uv@v6.2.1 + - uses: astral-sh/setup-uv@v7 with: version: "0.11.24" - uses: shivammathur/setup-php@v2 @@ -295,4 +295,4 @@ jobs: # never reaches it. Pin the target dir (matching the cache step above) so # its own tests run off this compile rather than a second bundled build. (cd windmill-duckdb-ffi-internal && export CARGO_TARGET_DIR="$PWD/target" && ./build_dev.sh && cargo test --release -p windmill_duckdb_ffi_internal) - DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline --all -- --nocapture --test-threads=10 + DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline,smtp --all -- --nocapture --test-threads=10 diff --git a/.github/workflows/check-docs-links.yml b/.github/workflows/check-docs-links.yml index ec17c06771..e32e976f0c 100644 --- a/.github/workflows/check-docs-links.yml +++ b/.github/workflows/check-docs-links.yml @@ -16,7 +16,7 @@ jobs: sparse-checkout: | frontend/src .github/scripts - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "22.x" - name: Verify docs links are not 404 diff --git a/.github/workflows/check-system-prompts.yml b/.github/workflows/check-system-prompts.yml index 3e684c82f9..370a177716 100644 --- a/.github/workflows/check-system-prompts.yml +++ b/.github/workflows/check-system-prompts.yml @@ -28,7 +28,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" diff --git a/.github/workflows/cli-tests.yml b/.github/workflows/cli-tests.yml index e26231eda4..19afa777ae 100644 --- a/.github/workflows/cli-tests.yml +++ b/.github/workflows/cli-tests.yml @@ -43,7 +43,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: "20" @@ -89,7 +89,7 @@ jobs: cache-workspaces: backend - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: "20" @@ -143,7 +143,7 @@ jobs: cache-workspaces: backend - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: "20" diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index 7a3882df7c..55036776c6 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -40,11 +40,14 @@ jobs: # an author_association gate: the pull_request webhook payload reports private org # members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently # skips auto-review for every private member. + # Dependabot-triggered runs get a separate secret scope, so this job has no API + # key and cannot review. workflow_call stays open for an explicit request. if: | github.event_name == 'workflow_call' || ( github.event.pull_request.draft == false && - github.event.pull_request.head.repo.fork == false + github.event.pull_request.head.repo.fork == false && + github.event.pull_request.user.login != 'dependabot[bot]' ) permissions: contents: read @@ -213,7 +216,7 @@ jobs: - name: Set up Node.js if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 22 diff --git a/.github/workflows/discord-notification.yml b/.github/workflows/discord-notification.yml index d731faafd9..c83cbc90d3 100644 --- a/.github/workflows/discord-notification.yml +++ b/.github/workflows/discord-notification.yml @@ -13,7 +13,8 @@ on: jobs: notify_discord_when_pr_opened: - if: (github.event.pull_request.draft == false) && (github.event.action == 'opened' || github.event.action == 'ready_for_review') + # No thread is opened for Dependabot PRs, so nothing downstream may assume one. + if: (github.event.pull_request.draft == false) && (github.event.action == 'opened' || github.event.action == 'ready_for_review') && (github.event.pull_request.user.login != 'dependabot[bot]') uses: ./.github/workflows/shareable-discord-notification.yml with: PR_TITLE: ${{ github.event.pull_request.title }} @@ -28,7 +29,8 @@ jobs: DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_AI_BOT_TOKEN }} merge_success_emoji: - if: github.event.action == 'closed' + # Must match open_thread's exclusion: this reacts to a thread that was never created. + if: github.event.action == 'closed' && github.event.pull_request.user.login != 'dependabot[bot]' uses: ./.github/workflows/shareable-discord-notification.yml with: PR_STATUS: "merged" diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index a8b20623bc..c94521d332 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -40,6 +40,11 @@ jobs: runs-on: ubicloud if: (github.event_name != 'workflow_dispatch') || (github.event.inputs && !github.event.inputs.ee) + # Jobs that build from, extract or retag this image must reference it by + # this digest: concurrent main-branch runs push :dev too, so a tag run + # reading :dev can ship a main build under the release version. + outputs: + digest: ${{ steps.docker_build.outputs.digest }} steps: - uses: actions/checkout@v4 with: @@ -112,6 +117,8 @@ jobs: build_ee: runs-on: ubicloud if: (github.event_name != 'workflow_dispatch') || github.event.inputs.ee + outputs: + digest: ${{ steps.docker_build.outputs.digest }} steps: - uses: actions/checkout@v4 with: @@ -192,31 +199,31 @@ jobs: - uses: actions/checkout@v4 - run: | # pulling docker image with desired arch so that actions-docker-extract doesn't do it - docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }} - docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }} + docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} + docker pull --platform "linux/$ARCH" ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} - run: | # Checks the image is in docker prior to running actions-docker-extract. It fails if not # Also useful to visually check that the arch is the right opencontainers - docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }} - docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }} + docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} + docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} - uses: shrink/actions-docker-extract@v3 id: extract with: - image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }} + image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} path: "/usr/src/app/windmill" - uses: shrink/actions-docker-extract@v3 id: extract-duckdb-ffi-internal with: - image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }} + image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} path: "/usr/src/app/libwindmill_duckdb_ffi_internal.so" - uses: shrink/actions-docker-extract@v3 id: extract-ee with: - image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }} + image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} path: "/usr/src/app/windmill" - name: Rename binary with corresponding architecture @@ -373,8 +380,8 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Tag main and latest run: | - docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest - docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main + docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest + docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:main - uses: sigstore/cosign-installer@v4.1.2 if: startsWith(github.ref, 'refs/tags/v') @@ -409,8 +416,8 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Tag main and latest for ee run: | - docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest - docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:${{ env.DEV_SHA }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:main + docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:latest + docker buildx imagetools create ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} --tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee:main - uses: sigstore/cosign-installer@v4.1.2 if: startsWith(github.ref, 'refs/tags/v') @@ -447,12 +454,18 @@ jobs: dockerhub-user: windmilllabs dockerhub-password: ${{ secrets.DOCKER_PAT }} + # Filed under main, not under the release tag this job runs on. The scanned image is + # `-ee:main` and the Security tab only reads the default branch, so tag-only uploads + # left main's last Docker Scout result at 2025-01-29: the tool showed as failing and + # its alerts could never close. `sha` is the release commit, which is on main. - name: Upload SARIF result id: upload-sarif if: ${{ github.event_name != 'pull_request_target' }} - uses: github/codeql-action/upload-sarif@v2 + uses: github/codeql-action/upload-sarif@v4 with: sarif_file: sarif.output.json + ref: refs/heads/main + sha: ${{ github.sha }} # docker_scout_ee: # runs-on: ubicloud @@ -549,6 +562,8 @@ jobs: push: true sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }} file: "./docker/DockerfileCuda" + build-args: | + BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} tags: | ${{ steps.meta-ee-public.outputs.tags }} labels: | @@ -602,6 +617,8 @@ jobs: push: true sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }} file: "./docker/DockerfileSlim" + build-args: | + BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} tags: | ${{ steps.meta-ee-public.outputs.tags }} labels: | @@ -656,6 +673,8 @@ jobs: push: true sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }} file: "./docker/DockerfileSlimEe" + build-args: | + BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} tags: | ${{ steps.meta-ee-public.outputs.tags }} labels: | @@ -709,6 +728,8 @@ jobs: push: true sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }} file: "./docker/DockerfileFull" + build-args: | + BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ needs.build.outputs.digest }} tags: | ${{ steps.meta-public.outputs.tags }} labels: | @@ -761,6 +782,8 @@ jobs: push: true sbom: ${{ startsWith(github.ref, 'refs/tags/v') && github.event_name == 'push' }} file: "./docker/DockerfileFullEe" + build-args: | + BASE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}-ee@${{ needs.build_ee.outputs.digest }} tags: | ${{ steps.meta-ee-public.outputs.tags }} labels: | diff --git a/.github/workflows/frontend-check.yml b/.github/workflows/frontend-check.yml index 178ed53dc5..6347614e16 100644 --- a/.github/workflows/frontend-check.yml +++ b/.github/workflows/frontend-check.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubicloud-standard-8 steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v5 + - uses: actions/setup-node@v7 with: node-version: 24 cache: "npm" diff --git a/.github/workflows/git-sync-test.yml b/.github/workflows/git-sync-test.yml index bd15ec8876..b19f899a20 100644 --- a/.github/workflows/git-sync-test.yml +++ b/.github/workflows/git-sync-test.yml @@ -141,7 +141,7 @@ jobs: with: deno-version: v2.x - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "20" diff --git a/.github/workflows/go_on_release.yml b/.github/workflows/go_on_release.yml index 78ce12c6c3..6e53db09e7 100644 --- a/.github/workflows/go_on_release.yml +++ b/.github/workflows/go_on_release.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubicloud-standard-8 steps: - uses: actions/checkout@v4 - - uses: actions/setup-go@v4 + - uses: actions/setup-go@v7 - name: install_jq run: | sudo apt-get update diff --git a/.github/workflows/npm_on_release.yml b/.github/workflows/npm_on_release.yml index 7d30265c74..631ab53153 100644 --- a/.github/workflows/npm_on_release.yml +++ b/.github/workflows/npm_on_release.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubicloud-standard-8 steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v7 with: node-version: "20.x" registry-url: "https://registry.npmjs.org" @@ -21,7 +21,7 @@ jobs: runs-on: ubicloud-standard-8 steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v7 with: node-version: "20.x" registry-url: "https://registry.npmjs.org" @@ -32,7 +32,7 @@ jobs: runs-on: ubicloud-standard-8 steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v3 + - uses: actions/setup-node@v7 with: node-version: "20.x" registry-url: "https://registry.npmjs.org" diff --git a/.github/workflows/pi-pr-review.yml b/.github/workflows/pi-pr-review.yml index 9b536b6ed5..a18dc28ff2 100644 --- a/.github/workflows/pi-pr-review.yml +++ b/.github/workflows/pi-pr-review.yml @@ -38,11 +38,14 @@ jobs: # an author_association gate: the pull_request webhook payload reports private org # members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently # skips auto-review for every private member. + # Dependabot-triggered runs get a separate secret scope, so this job has no API + # key and cannot review. workflow_call stays open for an explicit request. if: | github.event_name == 'workflow_call' || ( github.event.pull_request.draft == false && - github.event.pull_request.head.repo.fork == false + github.event.pull_request.head.repo.fork == false && + github.event.pull_request.user.login != 'dependabot[bot]' ) permissions: contents: read @@ -206,7 +209,7 @@ jobs: - name: Set up Node.js if: steps.pi_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true' - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: 22 diff --git a/.github/workflows/pr-ready-review.yml b/.github/workflows/pr-ready-review.yml index 4bfb5b4147..5e5c6a8c61 100644 --- a/.github/workflows/pr-ready-review.yml +++ b/.github/workflows/pr-ready-review.yml @@ -37,11 +37,14 @@ jobs: # an author_association gate: the pull_request webhook payload reports private org # members as CONTRIBUTOR/NONE (only public members show as MEMBER), which silently # skips auto-review for every private member. + # Dependabot-triggered runs get a separate secret scope, so this job has no API + # key and cannot review. workflow_call stays open for an explicit request. if: | github.event_name == 'workflow_call' || ( (github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true) && - github.event.pull_request.head.repo.fork == false + github.event.pull_request.head.repo.fork == false && + github.event.pull_request.user.login != 'dependabot[bot]' ) permissions: contents: read diff --git a/.github/workflows/publish-cli-docs.yml b/.github/workflows/publish-cli-docs.yml index 9e76117eb5..d6a2bdb332 100644 --- a/.github/workflows/publish-cli-docs.yml +++ b/.github/workflows/publish-cli-docs.yml @@ -38,7 +38,7 @@ jobs: ssh-key: ${{ secrets.CLI_DOCS_DEPLOY_KEY }} fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" diff --git a/.github/workflows/sdk-tests.yml b/.github/workflows/sdk-tests.yml index 6f7d79c6af..260fbb3a02 100644 --- a/.github/workflows/sdk-tests.yml +++ b/.github/workflows/sdk-tests.yml @@ -43,7 +43,7 @@ jobs: with: bun-version: latest - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "20.x" @@ -58,7 +58,7 @@ jobs: uses: actions/checkout@v4 - name: Setup uv - uses: astral-sh/setup-uv@v5 + uses: astral-sh/setup-uv@v7 # The interpreter is named explicitly: on a clean checkout uv picks the # runner's system python and stops with "not compatible with the locked diff --git a/.github/workflows/validate-openapi.yml b/.github/workflows/validate-openapi.yml index 5bc59ebc4c..846621b503 100644 --- a/.github/workflows/validate-openapi.yml +++ b/.github/workflows/validate-openapi.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v7 with: node-version: '20' diff --git a/.github/workflows/yaml-validator-tests.yml b/.github/workflows/yaml-validator-tests.yml index 04d31451d4..c5524312cb 100644 --- a/.github/workflows/yaml-validator-tests.yml +++ b/.github/workflows/yaml-validator-tests.yml @@ -24,7 +24,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "20" diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 103817a597..6dc5574962 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.814.0" + ".": "1.817.0" } diff --git a/AGENTS.md b/AGENTS.md index a315f1a67c..77b38c3267 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,12 +55,23 @@ Open-source platform for internal tools, workflows, API integrations, background > defaults in this section apply only to a plain single checkout. **Discover the real > values before running anything** — see "Per-worktree ports and database" below. -**Check whether they are already running before starting anything.** In a webmux worktree -(`$WEBMUX_WORKTREE_PATH` is set) the backend and frontend are already up in sibling tmux panes — -use those, don't spawn your own. `tmux list-panes -t "$(tmux display-message -p -t "$TMUX_PANE" -'#{window_id}')" -F '#{pane_index} #{pane_current_command}'` shows what is running; read its log -with `tmux capture-pane`, and see `backend/AGENTS.md` to restart it with different cargo features. -A second server started in your own shell fights the first one for the port. The commands below +**Check whether they are already running before starting anything.** In a managed worktree the +backend and frontend are already up in sibling panes — use those, don't spawn your own. A second +server started in your own shell fights the first one for the port. + +**herdr** is the worktree manager; `$HERDR_ENV=1` marks one of its panes. `herdr pane list +--workspace "$HERDR_WORKSPACE_ID"` lists this worktree's panes — match on `cwd`, since the servers +run from `backend/` and `frontend/` — and `herdr pane read --source recent-unwrapped +--lines 50` reads one's log; `herdr --skill` prints the full reference for +inspecting and driving panes and agents. The plugins that provision these worktrees live in the +private `windmill-labs/windmill-herdr` — clone it and run `./setup.sh` to install them and the +keybindings they need. + +A worktree from the older **webmux** setup sets `$WEBMUX_WORKTREE_PATH` instead and is driven +through tmux: `tmux list-panes -t "$(tmux display-message -p -t "$TMUX_PANE" '#{window_id}')" -F +'#{pane_index} #{pane_current_command}'` for what is running, `tmux capture-pane` for a pane's log. + +See `backend/AGENTS.md` to restart the backend with different cargo features. The commands below are for a plain checkout with nothing running. - **Backend**: `cargo run` from `backend/` (API at http://localhost:8000) @@ -72,10 +83,16 @@ are for a plain checkout with nothing running. ### Per-worktree ports and database -In a webmux worktree the authoritative values live in -`$(git rev-parse --git-dir)/webmux/runtime.env` — `BACKEND_PORT`, `FRONTEND_PORT`, -`DATABASE_URL`, `CARGO_FEATURES`, `WM_DB_NAME`. Every pane sources it at startup. Read that -first: it is not a `.env*` file, so the repo's secret-file read rules don't stand in the way. +**`.env.local` in the worktree root holds the real values** — `BACKEND_PORT`, `FRONTEND_PORT`, +`REMOTE`, `DATABASE_URL`, `CARGO_FEATURES`, `WM_DB_NAME`. Every manager writes those fields, so it +is correct whichever one you are in. Read it with `cat .env.local` from a shell: the file-read +tool denies every `.env.*` path, and `DATABASE_URL` is written with an `export` prefix that a +`^DATABASE_URL=` grep misses. + +herdr keeps nothing besides that file; its hooks write it and read it back. A webmux worktree +additionally has `$(git rev-parse --git-dir)/webmux/runtime.env`, which every pane sources at +startup and which carries the extras `.env.local` lacks: `WEBMUX_*`, `WM_CLONE_DB`, +`USE_RUST_PLUGIN`. In a plain checkout, fall back to `.env` / `.env.local` (repo root) and `backend/.env`. @@ -84,19 +101,25 @@ hook. It is not a copy of the main dev instance: you get the `admins` workspace, `admin@windmill.dev` superadmin, the license key copied from the base database, and whatever the migrations seed — and none of your own workspaces, scripts, flows or apps. Create whatever a test needs. Cloning the base `windmill` database instead is -opt-in per project via `WM_CLONE_DB` in `.webmux.yaml`; read the note there before turning it on. +opt-in via `WM_CLONE_DB` (the `windmill.worktree` plugin's `config.env` under herdr, or +`.webmux.yaml` under webmux); read the note in `.webmux.yaml` before turning it on. The database is named after the **worktree directory, not the branch** (`scripts/worktree-common.sh`): `windmill_` + the directory basename with `-` → `_`, which Postgres then truncates at 63 -characters. Branch `hugo/win-2340-ai-agent-evals-standalone-agent-runs-and-eval-datasets` sits in -a worktree directory named `win-2340-…`, so its database is -`windmill_win_2340_ai_agent_evals_standalone_agent_runs_and_eval` — no `hugo_`, and the tail -chopped. Take `WM_DB_NAME` from `runtime.env` instead of reconstructing the name. Read those, or -discover from what is already running: +characters. herdr creates the directory under `~/.herdr/worktrees//` from the branch name +with each `/` turned into `-`, so branch +`hugo/win-2544-add-options-field-to-the-postgresql-resource-type` gets the database +`windmill_hugo_win_2544_add_options_field_to_the_postgresql_reso` — the whole branch name, cut +mid-word at the limit. The two drift apart as soon as the branch is renamed, and webmux named its +directories after the ticket alone, so reconstructing the name from the branch you are on is +wrong in both. Take `WM_DB_NAME` from `.env.local` instead. Read that, or discover from what is +already running: ```bash +# match on the worktree directory, truncated the way Postgres truncates it (63 - len('windmill_')): psql postgres://postgres:changeme@localhost:5432/postgres -tAc \ - "select datname from pg_database where datname like 'windmill%'" | grep "$(git branch --show-current | tr - _)" + "select datname from pg_database where datname like 'windmill%'" \ + | grep -F "$(basename "$(git rev-parse --show-toplevel)" | tr '/-' '_' | cut -c1-54)" # the port the frontend actually proxies to (REMOTE of this worktree's vite): for p in $(pgrep -f vite); do case "$(readlink /proc/$p/cwd)" in *"$(basename "$(git rev-parse --show-toplevel)")"*) tr '\0' '\n' < /proc/$p/environ | grep -E '^REMOTE=|^PORT=';; esac; done diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a5ee31990..595426d619 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,93 @@ # Changelog +## [1.817.0](https://github.com/windmill-labs/windmill/compare/v1.816.0...v1.817.0) (2026-09-22) + + +### Features + +* add workspace mentions to AI composer ([#11264](https://github.com/windmill-labs/windmill/issues/11264)) ([e7ba595](https://github.com/windmill-labs/windmill/commit/e7ba5954b69514d72c140f9b1e697fb2236df23e)) +* allow workspace-scoped dynamic custom tags ([#11283](https://github.com/windmill-labs/windmill/issues/11283)) ([efa7a0a](https://github.com/windmill-labs/windmill/commit/efa7a0a70af43f8827542059dd450326a9b88d62)) +* check dynamic worker tags on the tag they resolve to ([#11271](https://github.com/windmill-labs/windmill/issues/11271)) ([69fafb9](https://github.com/windmill-labs/windmill/commit/69fafb9262f90184e6c2dc9a84f1c3feeb9d9638)) +* deploy from the UI to a workspace on another instance ([#11245](https://github.com/windmill-labs/windmill/issues/11245)) ([4d12ea4](https://github.com/windmill-labs/windmill/commit/4d12ea461454f89367bd91daa7e8c39a89035507)) + + +### Bug Fixes + +* add missing credential fields to settings redaction lists ([#11272](https://github.com/windmill-labs/windmill/issues/11272)) ([2a40f25](https://github.com/windmill-labs/windmill/commit/2a40f25bda81d41b9f578338c85656744f59ebfc)) +* apply SSRF validation to workspace webhook URLs ([#11285](https://github.com/windmill-labs/windmill/issues/11285)) ([586699c](https://github.com/windmill-labs/windmill/commit/586699c4938b5203920a43e69a706310078b93f5)) +* apply token path scopes to the native trigger list ([#11281](https://github.com/windmill-labs/windmill/issues/11281)) ([faf7b22](https://github.com/windmill-labs/windmill/commit/faf7b22be0827738c90c1f3ed6cd3e214d6c63d3)) +* check workflow-as-code task tags against CUSTOM_TAGS ([#11273](https://github.com/windmill-labs/windmill/issues/11273)) ([2699d50](https://github.com/windmill-labs/windmill/commit/2699d5065ea3dfe985636733fb6022ca23187291)) +* clear the impersonation token from session storage on logout ([#11274](https://github.com/windmill-labs/windmill/issues/11274)) ([9936673](https://github.com/windmill-labs/windmill/commit/9936673ee62fb6dd929a654a6138aeca687c0fb7)) +* enforce token path scopes on GET /raw_apps/list ([#11284](https://github.com/windmill-labs/windmill/issues/11284)) ([9ad2c91](https://github.com/windmill-labs/windmill/commit/9ad2c91ddb661ab60ada9ec4039ef8d025602969)) +* keep a cancel that lands after a worker last read its queue row ([#11246](https://github.com/windmill-labs/windmill/issues/11246)) ([02006f3](https://github.com/windmill-labs/windmill/commit/02006f364a4217e967435d9e71d8617c03b65413)) +* size the AI chat output budget by model, not provider ([#11286](https://github.com/windmill-labs/windmill/issues/11286)) ([7d0f2ce](https://github.com/windmill-labs/windmill/commit/7d0f2ce3bf920a68f1ea22d59481d239947f1b51)) +* stop cutting DeepSeek chat turns off mid-thought ([#11282](https://github.com/windmill-labs/windmill/issues/11282)) ([5821700](https://github.com/windmill-labs/windmill/commit/58217006ece56bbaf0447af67cac487e72b928c7)) +* stop the raw app editor stealing focus in ai sessions ([#11287](https://github.com/windmill-labs/windmill/issues/11287)) ([7322143](https://github.com/windmill-labs/windmill/commit/732214361f36eb07d65854118bd053c96ce681fb)) + +## [1.816.0](https://github.com/windmill-labs/windmill/compare/v1.815.0...v1.816.0) (2026-09-21) + + +### Features + +* add an ACL editor for data table roles ([#11063](https://github.com/windmill-labs/windmill/issues/11063)) ([9994e03](https://github.com/windmill-labs/windmill/commit/9994e03bc75404503001a5db37e175b66e7371f2)) +* add per-model context window override in workspace AI settings ([#11232](https://github.com/windmill-labs/windmill/issues/11232)) ([c637b06](https://github.com/windmill-labs/windmill/commit/c637b06a548a5725aceb7504bcb62383d35614ac)) +* **ai-chat:** disclose an app's exposure on deploy, refuse drag-and-drop apps ([#11137](https://github.com/windmill-labs/windmill/issues/11137)) ([85b36a2](https://github.com/windmill-labs/windmill/commit/85b36a2de5f3fa08750b6d2ab8203f4b745cfdd2)) +* data table roles in the DB manager and raw apps ([#11139](https://github.com/windmill-labs/windmill/issues/11139)) ([e1e3692](https://github.com/windmill-labs/windmill/commit/e1e3692fbc82d50c021ef8bf8ca7019d760705f0)) +* drafts follow their item through a move; behind means base ≠ head ([#10577](https://github.com/windmill-labs/windmill/issues/10577)) ([f00b2fc](https://github.com/windmill-labs/windmill/commit/f00b2fcb1e65cb8f8e95d55f7cc58b816ac7d89d)) +* edit variables, resources and triggers in their own session tab ([#11206](https://github.com/windmill-labs/windmill/issues/11206)) ([6186a06](https://github.com/windmill-labs/windmill/commit/6186a0645d3f3e92e06b666a6b5774b8af2b488f)) +* let every EE instance set the announcement banner ([#11238](https://github.com/windmill-labs/windmill/issues/11238)) ([87761d1](https://github.com/windmill-labs/windmill/commit/87761d179799026ebaf9ed91bf589852b5453ee3)) +* snap-collapse the sidebar by dragging it to the left edge ([#11225](https://github.com/windmill-labs/windmill/issues/11225)) ([56a6ad3](https://github.com/windmill-labs/windmill/commit/56a6ad322e8354695a0a96eef9e83ffa135b3ad4)) + + +### Bug Fixes + +* bump git sync hub scripts to cli 1.815.1 ([#11239](https://github.com/windmill-labs/windmill/issues/11239)) ([9e0e835](https://github.com/windmill-labs/windmill/commit/9e0e835c6876305d3a44902fb1432fd9c2348a7c)) +* check custom_instance_user can connect during instance database setup ([#11244](https://github.com/windmill-labs/windmill/issues/11244)) ([1b0f119](https://github.com/windmill-labs/windmill/commit/1b0f119c7b8ac2aacf2e2b4428c57f661df87ae5)) +* **cli:** read wmill.yaml from the branch a git-sync deploy writes to ([#11236](https://github.com/windmill-labs/windmill/issues/11236)) ([974b026](https://github.com/windmill-labs/windmill/commit/974b0265f85f9cfac65bbd2f6ebbcb0e966e3301)) +* guard data table migration routes against operators and unauthorized authors ([#11243](https://github.com/windmill-labs/windmill/issues/11243)) ([b742544](https://github.com/windmill-labs/windmill/commit/b7425443e1e08be029fc676047f727f84b646323)) +* tell the user when a draft conflict has stopped their edits saving ([#11241](https://github.com/windmill-labs/windmill/issues/11241)) ([d8ebbc0](https://github.com/windmill-labs/windmill/commit/d8ebbc06f5c56dad2570115700d640105d856c57)) + + +### Performance Improvements + +* index audit logs by workspace and operation ([#11235](https://github.com/windmill-labs/windmill/issues/11235)) ([3b8c326](https://github.com/windmill-labs/windmill/commit/3b8c3264ecb33884420b1bdac9cae9c841e721a0)) +* keep the sessions page fast with many ai sessions ([#11224](https://github.com/windmill-labs/windmill/issues/11224)) ([4cbc944](https://github.com/windmill-labs/windmill/commit/4cbc944c0bcb938300c964437d745d15a3ea608f)) +* retire the legacy pre-partitioning audit table ([#11240](https://github.com/windmill-labs/windmill/issues/11240)) ([9f2685d](https://github.com/windmill-labs/windmill/commit/9f2685d788e624c9ed9db5cbaee456ea21af2b29)) + +## [1.815.0](https://github.com/windmill-labs/windmill/compare/v1.814.0...v1.815.0) (2026-09-18) + + +### Features + +* add a workspace toggle that adds its admins and developers to new forks ([#11215](https://github.com/windmill-labs/windmill/issues/11215)) ([9d335de](https://github.com/windmill-labs/windmill/commit/9d335de87a4dbaa51038d55afe8d980761dcdfaf)) +* add an instance setting to refuse a token in MCP URLs ([#11162](https://github.com/windmill-labs/windmill/issues/11162)) ([37e493a](https://github.com/windmill-labs/windmill/commit/37e493ae66ed5c000ecac492d60fc0fdf4bda71f)) +* **ai-chat:** render get_run through the run tools' card ([#11204](https://github.com/windmill-labs/windmill/issues/11204)) ([6f9c4dc](https://github.com/windmill-labs/windmill/commit/6f9c4dc29455d13b0e64af05c2d6aa8bd5ff4fd6)) +* **ai-chat:** tell the chat which kind of app it is looking at ([#11208](https://github.com/windmill-labs/windmill/issues/11208)) ([c8c06d8](https://github.com/windmill-labs/windmill/commit/c8c06d8f79774abf109192e71a8b6fc37c7937ba)) +* attach files to a flow chat message ([#11185](https://github.com/windmill-labs/windmill/issues/11185)) ([c4c9677](https://github.com/windmill-labs/windmill/commit/c4c9677982b75c63d98ebf85b1904e0c341ba957)) +* **auth:** 2 h login links and a click-to-sign-in page for emailed ones ([#11203](https://github.com/windmill-labs/windmill/issues/11203)) ([5639187](https://github.com/windmill-labs/windmill/commit/5639187fec6d517a72e82df49d63d7438301127c)) +* cap user token expiration with an instance setting ([#11159](https://github.com/windmill-labs/windmill/issues/11159)) ([9320312](https://github.com/windmill-labs/windmill/commit/9320312eac56f944c4d31504601293ab4e816ccc)) +* flow chat job-backed detail, smooth streaming and answer chrome ([#11186](https://github.com/windmill-labs/windmill/issues/11186)) ([e2a91ca](https://github.com/windmill-labs/windmill/commit/e2a91ca2b141a0bee64ddfafe3cbf3ff86b6fb35)) +* put a data table's connection under Postgres roles ([#11020](https://github.com/windmill-labs/windmill/issues/11020)) ([0e807fb](https://github.com/windmill-labs/windmill/commit/0e807fb1dd80d7536ec144cd49445abc7961e504)) +* render an AI agent result as its answer, not as raw JSON ([#11051](https://github.com/windmill-labs/windmill/issues/11051)) ([a089928](https://github.com/windmill-labs/windmill/commit/a08992834d45d0211336f4fc32c3421646ca47c5)) +* support $flow_expr[...] dynamic tags on flow steps ([#11170](https://github.com/windmill-labs/windmill/issues/11170)) ([48f0025](https://github.com/windmill-labs/windmill/commit/48f00259c5e7361d3553dbcb809461e1cde96f8f)) + + +### Bug Fixes + +* **frontend:** inline only the package version, not the whole package.json ([#11191](https://github.com/windmill-labs/windmill/issues/11191)) ([813e486](https://github.com/windmill-labs/windmill/commit/813e486e166ac6215364817a7732b66f8dc1d463)) +* keep instance groups when editing auto-invite ([#11217](https://github.com/windmill-labs/windmill/issues/11217)) ([df61dea](https://github.com/windmill-labs/windmill/commit/df61dea5fa8b18d1e0044dc0db6702b053d5119f)) +* key the large root font size on screen width, not window width ([#11216](https://github.com/windmill-labs/windmill/issues/11216)) ([3b4e13d](https://github.com/windmill-labs/windmill/commit/3b4e13d1c564c6195e30b55b0671f7533e3ce408)) +* re-encrypt git sync secrets on workspace key rotation ([#11218](https://github.com/windmill-labs/windmill/issues/11218)) ([f0d66a4](https://github.com/windmill-labs/windmill/commit/f0d66a42eba27502767423af0c08b2ca1f16e8df)) +* re-point cloned fork identities that name nobody in the fork ([#11161](https://github.com/windmill-labs/windmill/issues/11161)) ([9690c44](https://github.com/windmill-labs/windmill/commit/9690c4462cf264a5577b87d07d465b5442b4e09d)) +* register the job token with the sensitive log masking system ([#10943](https://github.com/windmill-labs/windmill/issues/10943)) ([53afecd](https://github.com/windmill-labs/windmill/commit/53afecd4588247bc1812d3e68a30db1f3c3b2724)) +* show the New menu's description panel only on hover ([#11199](https://github.com/windmill-labs/windmill/issues/11199)) ([ecd0a6c](https://github.com/windmill-labs/windmill/commit/ecd0a6c77bc3a057b8072dbb0aca731e8bd3d882)) +* stop picker listing a draft twice when drafts share a friendly path ([#11214](https://github.com/windmill-labs/windmill/issues/11214)) ([72507d5](https://github.com/windmill-labs/windmill/commit/72507d52a021de8101794ee8f4a3392be1ba20c7)) + + +### Performance Improvements + +* count completed jobs on the v2_job_completed index with a timeout ([#11211](https://github.com/windmill-labs/windmill/issues/11211)) ([d1a2536](https://github.com/windmill-labs/windmill/commit/d1a25360b070994a31fb033707dbfde1fb553939)) + ## [1.814.0](https://github.com/windmill-labs/windmill/compare/v1.813.0...v1.814.0) (2026-09-17) diff --git a/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts b/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts index 2e29b9064d..3d0c6d6734 100644 --- a/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/shared/baseEvalRunner.ts @@ -98,9 +98,11 @@ export async function runEval( // Wrap tools to intercept fn calls for tracking. // Cast to ProductionTool since the eval Tool has a narrower toolCallbacks type // but the actual callbacks passed at runtime will satisfy both interfaces. + // `fn` forwards `this`: the chat loop calls it on a per-iteration copy carrying the def the + // model was sent, and a tool reading `this.def` must see that one, as it does in production. const wrappedTools = tools.map((tool) => ({ ...tool, - fn: async (p: any) => { + fn: async function (this: unknown, p: any) { toolCallsCount++; toolsCalled.push(tool.def.function.name); let argumentsText = ""; @@ -120,7 +122,7 @@ export async function runEval( toolName: tool.def.function.name, argumentsText, }); - return tool.fn(p); + return tool.fn.call(this, p); }, })); diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index d27bb4d69a..90df0cf870 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -14,7 +14,6 @@ import type { DataMetric, DataTableTables, DataTableTableSchema, - EndpointTool, GetDraftForUserResponse, GetOwnDraftResponse, ListDraftsResponse, @@ -49,12 +48,16 @@ export interface BenchmarkWorkspaceFlow { export interface BenchmarkWorkspaceApp { path: string summary: string + /** Defaults to true. Set false for a drag-and-drop app, which the chat can list + * and read but has no tool to edit — its value is a grid, not files. */ + rawApp?: boolean value: { - files: Record - runnables: Record + files?: Record + runnables?: Record data?: unknown policy?: unknown custom_path?: unknown + [key: string]: unknown } } @@ -994,7 +997,7 @@ function buildBenchmarkListableApp(app: BenchmarkWorkspaceApp): ListableApp { extra_perms: {}, edited_at: BENCHMARK_TIMESTAMP, execution_mode: 'viewer', - raw_app: true + raw_app: app.rawApp ?? true } } @@ -1012,123 +1015,10 @@ function buildBenchmarkApp(app: BenchmarkWorkspaceApp): AppWithLastVersion { execution_mode: 'viewer', extra_perms: {}, custom_path: app.value.custom_path as string | undefined, - raw_app: true + raw_app: app.rawApp ?? true } } -// ============= API endpoint catalog (McpService.listMcpTools + raw fetch) ============= -// The global chat's API catalog tools list endpoints via McpService and execute -// them with a plain relative fetch('/api/...'), which has no meaning in the -// vitest environment. A representative slice of the real catalog is served here, -// and `handleBenchmarkApiFetch` answers the executed calls. - -const BENCHMARK_MCP_TOOLS: EndpointTool[] = [ - { - name: 'listWorkers', - description: 'List workers', - instructions: 'List all workers with their last ping and job counts.', - path: '/workers/list', - method: 'GET', - query_params_schema: { - type: 'object', - properties: { page: { type: 'integer' }, per_page: { type: 'integer' } } - } - }, - { - name: 'listQueue', - description: 'List queued jobs', - instructions: '', - path: '/w/{workspace}/jobs/queue/list', - method: 'GET', - path_params_schema: { - type: 'object', - properties: { workspace: { type: 'string' } }, - required: ['workspace'] - } - }, - { - name: 'getJob', - description: 'get job', - instructions: '', - path: '/w/{workspace}/jobs_u/get/{id}', - method: 'GET', - path_params_schema: { - type: 'object', - properties: { workspace: { type: 'string' }, id: { type: 'string', format: 'uuid' } }, - required: ['workspace', 'id'] - }, - query_params_schema: { - type: 'object', - properties: { - no_logs: { type: 'boolean' }, - no_code: { type: 'boolean' }, - approval_token: { type: 'string' } - }, - required: [] - } - }, - { - name: 'runScriptByPath', - description: 'Run the deployed version of a script by path', - instructions: '', - path: '/w/{workspace}/jobs/run/p/{path}', - method: 'POST', - path_params_schema: { - type: 'object', - properties: { workspace: { type: 'string' }, path: { type: 'string' } }, - required: ['workspace', 'path'] - }, - body_schema: { type: 'object', properties: {} } - }, - { - name: 'runFlowByPath', - description: 'Run the deployed version of a flow by path', - instructions: '', - path: '/w/{workspace}/jobs/run/f/{path}', - method: 'POST', - path_params_schema: { - type: 'object', - properties: { workspace: { type: 'string' }, path: { type: 'string' } }, - required: ['workspace', 'path'] - }, - body_schema: { type: 'object', properties: {} } - }, - // Draft-covered endpoints, present so steering cases exercise the guard the - // way production does (hidden from search, refused at call time). - { - name: 'getScriptByPath', - description: 'Get a script by path', - instructions: '', - path: '/w/{workspace}/scripts/get/p/{path}', - method: 'GET' - }, - { - name: 'createFlow', - description: 'Create a flow', - instructions: '', - path: '/w/{workspace}/flows/create', - method: 'POST' - }, - { - name: 'deleteSchedule', - description: 'Delete a schedule', - instructions: '', - path: '/w/{workspace}/schedules/delete/{path}', - method: 'DELETE' - }, - { - name: 'getVariable', - description: 'Get a variable', - instructions: '', - path: '/w/{workspace}/variables/get/{path}', - method: 'GET' - } -] - -export function listBenchmarkMcpTools(): EndpointTool[] { - return BENCHMARK_MCP_TOOLS -} - /** A stand-in Windmill hub. `search_hub_scripts` and a `hub/` read go out over * relative `/api/...` fetches, which have no origin here, so without these the * hub tools throw and no case can exercise hub reuse. Serving fixtures rather @@ -1288,26 +1178,6 @@ const BENCHMARK_WORKERS = [ } ] -const BENCHMARK_JOB_GET_PATH = /^\/api\/w\/([^/]+)\/jobs_u\/get\/([^/]+)$/ -const BENCHMARK_RUN_BY_PATH = /^\/api\/w\/([^/]+)\/jobs\/run\/(p|f)\/([^/]+)$/ - -/** `executeEndpoint` sends a JSON string; anything else means no args were supplied. */ -function parseBenchmarkRequestBody( - body: BodyInit | null | undefined -): Record | undefined { - if (typeof body !== 'string') { - return undefined - } - try { - const parsed = JSON.parse(body) - return typeof parsed === 'object' && parsed !== null - ? (parsed as Record) - : undefined - } catch { - return undefined - } -} - /** True when `handleBenchmarkApiFetch` has an answer for this `/api/...` url. * Any other relative fetch must keep its normal (non-benchmark) behavior — * intercepting it with a synthetic 404 sends the model into retry loops. */ @@ -1319,17 +1189,13 @@ export function hasBenchmarkApiHandler(url: string): boolean { const path = url.split('?')[0] return ( path === '/api/workers/list' || - BENCHMARK_JOB_GET_PATH.test(path) || - BENCHMARK_RUN_BY_PATH.test(path) || - /^\/api\/w\/[^/]+\/jobs\/queue\/list$/.test(path) || path === '/api/embeddings/query_hub_scripts' || path.startsWith('/api/scripts/hub/get_full/') || BENCHMARK_AI_MODELS_PATH.test(path) ) } -/** Answer a relative `/api/...` fetch — from the API catalog executor, or from the - * chat's hub tools. */ +/** Answer the relative `/api/...` fetches no mocked service covers. */ export function handleBenchmarkApiFetch(url: string, init?: RequestInit): Response { const path = url.split('?')[0] if (path === '/api/workers/list') { @@ -1346,39 +1212,6 @@ export function handleBenchmarkApiFetch(url: string, init?: RequestInit): Respon ?.aiProviders?.find((entry) => entry.path === resourcePath) return Response.json({ data: (seed?.models ?? []).map((id) => ({ id })) }) } - if (/^\/api\/w\/[^/]+\/jobs\/queue\/list$/.test(path)) { - return Response.json([]) - } - const jobGet = BENCHMARK_JOB_GET_PATH.exec(path) - if (jobGet) { - const id = decodeURIComponent(jobGet[2]) - const job = getBenchmarkCompletedJob(decodeURIComponent(jobGet[1]), id) - if (!job) { - return Response.json({ error: `Job not found for "${id}"` }, { status: 404 }) - } - // The real endpoint lets a caller drop the bulky fields. Ignoring that here would - // size the model's context off a payload it explicitly asked to shrink. - const query = new URLSearchParams(url.split('?')[1] ?? '') - if (query.get('no_logs') === 'true') { - delete job.logs - } - if (query.get('no_code') === 'true') { - delete job.raw_code - } - return Response.json(job) - } - const runByPath = BENCHMARK_RUN_BY_PATH.exec(path) - if (runByPath) { - const workspace = decodeURIComponent(runByPath[1]) - const runnablePath = decodeURIComponent(runByPath[3]) - const args = parseBenchmarkRequestBody(init?.body) - // The real endpoint answers with the bare job id as text, not JSON. - return new Response( - runByPath[2] === 'f' - ? runBenchmarkFlowByPath({ workspace, path: runnablePath, args }) - : runBenchmarkScriptByPath({ workspace, path: runnablePath, args }) - ) - } if (path === '/api/embeddings/query_hub_scripts') { const text = new URLSearchParams(url.split('?')[1] ?? '').get('text') ?? '' return Response.json(searchBenchmarkHubScripts(text)) diff --git a/ai_evals/adapters/frontend/mockBackendApi.test.ts b/ai_evals/adapters/frontend/mockBackendApi.test.ts index d9cfa08bf4..85bd1f3320 100644 --- a/ai_evals/adapters/frontend/mockBackendApi.test.ts +++ b/ai_evals/adapters/frontend/mockBackendApi.test.ts @@ -1,83 +1,18 @@ -import { afterEach, beforeEach, describe, expect, it } from 'bun:test' -import { - createBenchmarkCompletedJob, - getBenchmarkCompletedJob, - handleBenchmarkApiFetch, - hasBenchmarkApiHandler, - listBenchmarkMcpTools, - resetBenchmarkMockBackend, - registerBenchmarkWorkspaceRunnables -} from './mockBackend' +import { describe, expect, it } from 'bun:test' +import { handleBenchmarkApiFetch, hasBenchmarkApiHandler } from './mockBackend' -const WORKSPACE = 'benchmark-api-ws' +// `list_workers`'s client call is not faked, so its request really does leave as a relative +// `/api/...` url that node's fetch cannot resolve. Without this route the tool throws instead +// of answering. +describe('benchmark API fetch handlers', () => { + it('lists workers, the way list_workers reaches them', async () => { + const url = '/api/workers/list?per_page=100' -// A catalog entry with no fetch handler is a dead end: the catalog executor builds a -// relative `/api/...` url, the stub declines it, and node's fetch throws on the relative -// url instead of returning a result the model can act on. Mutating entries are reachable -// too — the eval runners define no `requestConfirmation`, so `call_api_endpoint` executes -// unconfirmed. -describe('benchmark API catalog', () => { - beforeEach(() => resetBenchmarkMockBackend()) - afterEach(() => resetBenchmarkMockBackend()) + expect(hasBenchmarkApiHandler(url)).toBe(true) + const body = await handleBenchmarkApiFetch(url).json() - it('answers every endpoint it advertises', () => { - const unanswered = listBenchmarkMcpTools() - .map((tool) => - `/api${tool.path.replace('{workspace}', WORKSPACE)}`.replace(/\{[^}]+\}/g, 'x') - ) - .filter((url) => !hasBenchmarkApiHandler(url)) - - // The draft-covered entries are refused by name before any fetch, so they are - // advertised without a handler on purpose. - expect(unanswered).toEqual([ - `/api/w/${WORKSPACE}/scripts/get/p/x`, - `/api/w/${WORKSPACE}/flows/create`, - `/api/w/${WORKSPACE}/schedules/delete/x`, - `/api/w/${WORKSPACE}/variables/get/x` - ]) - }) - - it('runs a deployed script by path, the way call_api_endpoint reaches it', async () => { - registerBenchmarkWorkspaceRunnables(WORKSPACE, { - scripts: [ - { - path: 'f/evals/greet', - summary: 'Greet', - language: 'bun', - content: 'export async function main() {}' - } - ] - }) - - const res = handleBenchmarkApiFetch( - `/api/w/${WORKSPACE}/jobs/run/p/${encodeURIComponent('f/evals/greet')}`, - { method: 'POST', body: JSON.stringify({ name: 'ada' }) } - ) - - expect(res.status).toBe(200) - const job = getBenchmarkCompletedJob(WORKSPACE, (await res.text()).trim()) - expect(job).toMatchObject({ success: true, args: { name: 'ada' } }) - }) - - it('serves a recorded job so a model can check the run it just started', async () => { - const id = createBenchmarkCompletedJob({ - workspace: WORKSPACE, - jobKind: 'preview', - result: 'Hello, World!' - }) - - const res = handleBenchmarkApiFetch(`/api/w/${WORKSPACE}/jobs_u/get/${id}`) - - expect(res.status).toBe(200) - expect(await res.json()).toMatchObject({ - id, - success: true, - result: 'Hello, World!' - }) - }) - - it('404s an unknown job id instead of letting the fetch fall through', () => { - expect(hasBenchmarkApiHandler(`/api/w/${WORKSPACE}/jobs_u/get/missing`)).toBe(true) - expect(handleBenchmarkApiFetch(`/api/w/${WORKSPACE}/jobs_u/get/missing`).status).toBe(404) + // The tool counts the list and maps over it, so it needs a bare array, not an envelope. + expect(Array.isArray(body)).toBe(true) + expect(body[0]).toMatchObject({ worker: expect.any(String), worker_group: expect.any(String) }) }) }) diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index c9e889b9a0..0c8a22a987 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -5,8 +5,8 @@ import { mkdir, writeFile } from 'fs/promises' import { dirname, resolve } from 'path' import { handleBenchmarkApiFetch, hasBenchmarkApiHandler } from './mockBackend' -// The API catalog executor issues relative fetch('/api/...') calls, which have -// no meaning in the vitest environment — serve the ones the benchmark handles. +// Some tools reach the backend by relative fetch('/api/...'), which has no meaning +// in the vitest environment — serve the ones the benchmark handles. // Every other relative fetch keeps its normal behavior (it fails the same way // it does without this stub) so unrelated tools see an unchanged environment. // The frontend builds API URLs from location.origin (fetchAvailableModels does), and node has no @@ -92,8 +92,7 @@ vi.mock('$lib/gen', async () => { runBenchmarkFlowPreview, runBenchmarkScriptByPath, runBenchmarkScriptPreview, - updateBenchmarkDraft, - listBenchmarkMcpTools + updateBenchmarkDraft } = await import('./mockBackend') function wrapService(target: T, overrides: Record): T { @@ -434,12 +433,6 @@ vi.mock('$lib/gen', async () => { queryResourceTypes: async (data: { workspace: string }) => hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.queryResourceTypes(data) }), - McpService: wrapService(actual.McpService, { - listMcpTools: async (data: { workspace: string }) => - hasBenchmarkWorkspace(data.workspace) - ? listBenchmarkMcpTools() - : actual.McpService.listMcpTools(data) - }), VariableService: wrapService(actual.VariableService, { existsVariable: async (data: { workspace: string; path: string }) => hasBenchmarkWorkspace(data.workspace) diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 45316b2810..96aa3ab983 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -1112,9 +1112,9 @@ - opens the Kafka triggers page - does not write, deploy, or delete anything -- id: global-openpage7-compare-review +- id: global-draft-does-not-open-compare prompt: |- - Create a TypeScript script draft at f/evals/global/compare_review_demo that returns the string "ok" (no need to test it), then open the review page so I can look over the pending change and deploy it myself. + Create a TypeScript script draft at f/evals/global/compare_review_demo that returns the string "ok" (no need to test it). I want to look over the change before it gets deployed. runtime: maxTurns: 8 validate: @@ -1122,6 +1122,24 @@ toolExpect: requiredToolsUsed: - write_script + forbiddenToolsUsed: + - open_page + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + judgeChecklist: + - creates the script draft without opening any workspace page + - does not deploy or delete anything + +- id: global-openpage7-compare-fork-deploy + prompt: |- + I'm done with my changes in this fork and want to ship them to the parent workspace myself. Take me there. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: - open_page forbiddenToolsUsed: - deploy_workspace_item @@ -1131,17 +1149,9 @@ field: page stringIncludesAnyOf: - compare - # The eval chat is untracked (no modified-items mask), so the model must scope - # the review by passing the item it changed explicitly — an omitted mask would - # preselect every pending change in the workspace. - - tool: open_page - field: items - stringIncludesAnyOf: - - f/evals/global/compare_review_demo skipJudge: true judgeChecklist: - - creates the script draft, then opens the Compare & Deploy review page instead of deploying itself - - preselects only the created script on the review page + - opens Compare & Deploy only because the user explicitly wants to deploy the fork into its parent - does not deploy or delete anything - id: global-openpage8-runs-label-and-worker @@ -1919,11 +1929,10 @@ - when the lookup fails, tells the user instead of inventing table names - does not write scripts or resources to answer a read-only question -# --- Dedicated tools preferred over the API catalog --- -# The harness serves worker/queue reads itself (benchmark fetch handlers in -# adapters/frontend), so these cases do not require an mcp-enabled eval backend. -# The stale `api-catalog` in the id below is kept so results stay comparable -# across benchmark runs. +# --- Dedicated tools for workspace, run and instance state --- +# The harness serves the worker read itself (a benchmark fetch handler in +# adapters/frontend). The stale `api-catalog` in the id below is kept so +# results stay comparable across benchmark runs. - id: global-test30-api-catalog-workers prompt: |- @@ -1937,8 +1946,6 @@ requiredToolsUsed: - list_workers forbiddenToolsUsed: - - call_api_get - - call_api_endpoint - write_script - deploy_workspace_item # Read-only workspace inspection produces no draft; validate via tool use. @@ -1990,13 +1997,11 @@ requiredToolsUsed: - test_run_script forbiddenToolsUsed: - - call_api_endpoint - deploy_workspace_item - delete_workspace_item # The judge only sees the drafts artifact and cannot observe runs, so it always # docks the prompt's "run it" requirement — validate deterministically instead: - # draft content via valueIncludes, the test run via toolExpect (test_run_script - # required, call_api_endpoint forbidden). + # draft content via valueIncludes, the test run via toolExpect. skipJudge: true judgeChecklist: - creates an AI draft of f/evals/global/format_greeting with the name uppercased in the greeting @@ -2014,13 +2019,11 @@ requiredToolsUsed: - delete_workspace_item forbiddenToolsUsed: - - call_api_endpoint - - search_api_endpoints - write_script # Deletion produces no draft; validate via tool use. skipJudge: true judgeChecklist: - - deletes the deployed script via delete_workspace_item rather than a raw API endpoint + - deletes the deployed script via delete_workspace_item - id: global-test33-run-deployed-script-with-form prompt: |- @@ -2041,7 +2044,6 @@ - read_workspace_item forbiddenToolsUsed: - test_run_script - - call_api_endpoint - write_script - deploy_workspace_item # An empty form pushes the work back onto the user, so the prefill is part of @@ -2054,7 +2056,7 @@ # Running produces no draft, and the judge cannot observe runs; validate via tool use. skipJudge: true judgeChecklist: - - runs the deployed script through run_script rather than a preview test run or a raw API endpoint + - runs the deployed script through run_script rather than a preview test run - passes the name "ada" so the confirmation form comes up prefilled - id: global-test34-run-with-secret-from-variable @@ -2113,7 +2115,6 @@ - read_workspace_item forbiddenToolsUsed: - test_run_flow - - call_api_endpoint - write_flow - deploy_workspace_item # An empty form pushes the work back onto the user, so the prefill is part of @@ -2126,7 +2127,7 @@ # Running produces no draft, and the judge cannot observe runs; validate via tool use. skipJudge: true judgeChecklist: - - runs the deployed flow through run_flow rather than a preview test run or a raw API endpoint + - runs the deployed flow through run_flow rather than a preview test run - passes the customer "acme" so the confirmation form comes up prefilled - id: global-test36-draft-flow-test-run-not-deployed @@ -2151,7 +2152,6 @@ # version instead — the edit would not be in what ran. forbiddenToolsUsed: - run_flow - - call_api_endpoint - deploy_workspace_item # The judge cannot observe runs, and the edit's content is already pinned by # global-test5 on this fixture; what this case guards is where the run went. @@ -2611,3 +2611,40 @@ judgeChecklist: - runs the existing script rather than rewriting it - passes the GitHub resource as the bare string $res:f/evals/global/github_main + +- id: global-drag-and-drop-app-not-editable + prompt: |- + Add a refresh button to the ops console app, and the same to the sales board app. + initial: ai_evals/fixtures/frontend/global/initial/apps_code_and_drag_and_drop.json + runtime: + maxTurns: 12 + validate: + # One request, two apps, only one of them editable: the code app must come back with a + # draft and the drag-and-drop one must not. Refusing both, or editing both, fails here — + # which is what makes this a test of the distinction rather than of caution. + draftCountExactly: 1 + requiredDrafts: + - type: app + path: f/evals/global/ops_console + forbiddenDrafts: + - type: app + path: f/evals/global/sales_board + toolExpect: + # Deliberately not constraining write_app_file/patch_app_file by argument: an entry there + # fails when its tool was never called, so naming both would fail on whichever the model + # did not pick. The draft assertions above cover the same ground, tool-agnostically. + forbiddenToolsUsed: + - init_app + - deploy_workspace_item + - delete_app_file + - delete_app_runnable + assistantExpect: + # A refusal leaves no draft for the judge to read, so the explanation is checked here. + # Only the app kind: substring tests cannot see paraphrase, and every wording of "I can't + # edit it" defeats a fixed list. + requiredMentionsAnyOf: + - - drag-and-drop + - drag and drop + - low-code + - no-code + skipJudge: true diff --git a/ai_evals/fixtures/frontend/global/initial/apps_code_and_drag_and_drop.json b/ai_evals/fixtures/frontend/global/initial/apps_code_and_drag_and_drop.json new file mode 100644 index 0000000000..fbef7b3e3d --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/apps_code_and_drag_and_drop.json @@ -0,0 +1,30 @@ +{ + "user": { + "username": "admin", + "is_admin": true, + "folders": ["evals"], + "folders_read": ["evals"] + }, + "workspace": { + "apps": [ + { + "path": "f/evals/global/sales_board", + "summary": "Sales board", + "rawApp": false, + "value": { + "grid": [] + } + }, + { + "path": "f/evals/global/ops_console", + "summary": "Ops console", + "value": { + "files": { + "/App.tsx": "export default function App() {\n\treturn
Ops console
\n}\n" + }, + "runnables": {} + } + } + ] + } +} diff --git a/backend/.sqlx/query-044310c0e7745867a24a9d3653ff29c87ce48ffd89fac070c8314556f884ce55.json b/backend/.sqlx/query-044310c0e7745867a24a9d3653ff29c87ce48ffd89fac070c8314556f884ce55.json new file mode 100644 index 0000000000..be7ac83a7b --- /dev/null +++ b/backend/.sqlx/query-044310c0e7745867a24a9d3653ff29c87ce48ffd89fac070c8314556f884ce55.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by\n FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash\n WHERE s.workspace_id = $1 AND s.path = $2\n ORDER by s.created_at DESC\n LIMIT $3 OFFSET $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "hash", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "deployment_msg", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_by", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + true, + false, + false + ] + }, + "hash": "044310c0e7745867a24a9d3653ff29c87ce48ffd89fac070c8314556f884ce55" +} diff --git a/backend/.sqlx/query-c92cc71e6d10c41368f7aa75b0799c2e1e9ca0ed33077ade8d7560e7cc21fa06.json b/backend/.sqlx/query-06abbf945bee93349ff88f64906b96ea1e853ef202510281427cfa9beeff81b3.json similarity index 52% rename from backend/.sqlx/query-c92cc71e6d10c41368f7aa75b0799c2e1e9ca0ed33077ade8d7560e7cc21fa06.json rename to backend/.sqlx/query-06abbf945bee93349ff88f64906b96ea1e853ef202510281427cfa9beeff81b3.json index 8a4b957c2a..7adefa7eb8 100644 --- a/backend/.sqlx/query-c92cc71e6d10c41368f7aa75b0799c2e1e9ca0ed33077ade8d7560e7cc21fa06.json +++ b/backend/.sqlx/query-06abbf945bee93349ff88f64906b96ea1e853ef202510281427cfa9beeff81b3.json @@ -1,22 +1,22 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM v2_job_queue WHERE id = $1 RETURNING 1", + "query": "SELECT 1 AS one FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE", "describe": { "columns": [ { "ordinal": 0, - "name": "?column?", + "name": "one", "type_info": "Int4" } ], "parameters": { "Left": [ - "Uuid" + "Text" ] }, "nullable": [ null ] }, - "hash": "c92cc71e6d10c41368f7aa75b0799c2e1e9ca0ed33077ade8d7560e7cc21fa06" + "hash": "06abbf945bee93349ff88f64906b96ea1e853ef202510281427cfa9beeff81b3" } diff --git a/backend/.sqlx/query-06ce02cd7ce2f5a57355153edb573c242f9ba758db66e9a5e16f30e3e1494201.json b/backend/.sqlx/query-06ce02cd7ce2f5a57355153edb573c242f9ba758db66e9a5e16f30e3e1494201.json new file mode 100644 index 0000000000..b543f00b44 --- /dev/null +++ b/backend/.sqlx/query-06ce02cd7ce2f5a57355153edb573c242f9ba758db66e9a5e16f30e3e1494201.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings ws\n SET datatable = (\n SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg(\n dt.key,\n CASE WHEN dt.value->'reference'->>'workspace_id' = $1\n AND dt.value->'reference'->>'datatable' = $2\n THEN jsonb_set(dt.value, '{reference,datatable}', to_jsonb($3::text))\n ELSE dt.value END\n ))\n FROM jsonb_each(ws.datatable->'datatables') dt\n )\n WHERE EXISTS (\n SELECT 1 FROM jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) d\n WHERE d.value->'reference'->>'workspace_id' = $1\n AND d.value->'reference'->>'datatable' = $2\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "06ce02cd7ce2f5a57355153edb573c242f9ba758db66e9a5e16f30e3e1494201" +} diff --git a/backend/.sqlx/query-09e3f8fa8a9e64048971983584bfa00ab137c70b73f381452cadfae6e1cda934.json b/backend/.sqlx/query-09e3f8fa8a9e64048971983584bfa00ab137c70b73f381452cadfae6e1cda934.json new file mode 100644 index 0000000000..a4d174992e --- /dev/null +++ b/backend/.sqlx/query-09e3f8fa8a9e64048971983584bfa00ab137c70b73f381452cadfae6e1cda934.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE draft AS d\n SET path = $3::text,\n value = (\n SELECT CASE\n WHEN s.clean -> 'path' = to_jsonb($2::text)\n OR s.clean -> 'draft_path' = to_jsonb($2::text)\n THEN to_json(\n s.clean\n || CASE WHEN s.clean -> 'path' = to_jsonb($2::text)\n THEN jsonb_build_object('path', $3::text)\n ELSE '{}'::jsonb END\n || CASE WHEN s.clean -> 'draft_path' = to_jsonb($2::text)\n THEN jsonb_build_object('draft_path', $3::text)\n ELSE '{}'::jsonb END\n )\n ELSE d.value\n END\n FROM (SELECT replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)),\n chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean) s\n )\n WHERE d.workspace_id = $1\n AND d.path = $2::text\n AND d.typ::text = ANY($4::text[])", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "09e3f8fa8a9e64048971983584bfa00ab137c70b73f381452cadfae6e1cda934" +} diff --git a/backend/.sqlx/query-0cc6770a81ecaecafe0d9b7100f94b329c4ec7978b016af4c964415e0c38396c.json b/backend/.sqlx/query-0cc6770a81ecaecafe0d9b7100f94b329c4ec7978b016af4c964415e0c38396c.json new file mode 100644 index 0000000000..23d3b51c76 --- /dev/null +++ b/backend/.sqlx/query-0cc6770a81ecaecafe0d9b7100f94b329c4ec7978b016af4c964415e0c38396c.json @@ -0,0 +1,71 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value as \"value!: sqlx::types::Json>\",\n created_at, base\n FROM draft\n WHERE workspace_id = $1\n AND (email = $2 OR email IS NULL)\n AND path = $3\n AND typ = $4\n ORDER BY email NULLS LAST\n LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!: sqlx::types::Json>", + "type_info": "Json" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "base", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github", + "data_pipeline", + "trigger_amqp" + ] + } + } + } + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "0cc6770a81ecaecafe0d9b7100f94b329c4ec7978b016af4c964415e0c38396c" +} diff --git a/backend/.sqlx/query-0d10e0fa5cf4033c7d93c9ed56be8209046007917f44da954eccf2188e5bff1f.json b/backend/.sqlx/query-0d10e0fa5cf4033c7d93c9ed56be8209046007917f44da954eccf2188e5bff1f.json new file mode 100644 index 0000000000..a99ffad770 --- /dev/null +++ b/backend/.sqlx/query-0d10e0fa5cf4033c7d93c9ed56be8209046007917f44da954eccf2188e5bff1f.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT permissioned_as, permissioned_as_email FROM v2_job\n WHERE id = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "permissioned_as", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "permissioned_as_email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "0d10e0fa5cf4033c7d93c9ed56be8209046007917f44da954eccf2188e5bff1f" +} diff --git a/backend/.sqlx/query-0d9a83d77632bc0de19a48eddb7e6f80b2a41db4f1d352920d0c295ea3a018b1.json b/backend/.sqlx/query-0d9a83d77632bc0de19a48eddb7e6f80b2a41db4f1d352920d0c295ea3a018b1.json new file mode 100644 index 0000000000..93251596cf --- /dev/null +++ b/backend/.sqlx/query-0d9a83d77632bc0de19a48eddb7e6f80b2a41db4f1d352920d0c295ea3a018b1.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE draft\n SET path = $3,\n -- Both path keys, not just the typed one: the editors mirror the\n -- typed path into the other while it differs from the row's path,\n -- and the loaders prefer the mirror — left naming the old location\n -- it un-does this move on the next save. `create_missing = false`\n -- on both, so a draft carrying only one keeps only one.\n value = to_json(\n jsonb_set(\n jsonb_set(\n CASE WHEN $7::text IS NULL THEN to_jsonb(value)\n ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text))\n END,\n ARRAY[$5::text], to_jsonb($3::text), false\n ),\n ARRAY[$8::text], to_jsonb($3::text), false\n )\n )\n WHERE workspace_id = $1\n AND path = $2\n AND typ = $4\n AND email = $6\n -- A pre-sanitizer NUL escape makes `to_jsonb` raise 22P05. Excluded\n -- here so the statement can't 500; reported below instead. Unlike the\n -- passive carry, rewriting the value IS this operation, so skipping it\n -- silently would move the row and leave its typed path stale.\n AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0\n -- Skipped on a summary-only edit, where the \"target\" row is this\n -- row and the guard would refuse the update against itself.\n AND ($2 = $3 OR NOT EXISTS (\n SELECT 1 FROM draft o\n WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ::text = ANY($9::text[])\n -- Of this kind only the caller's own row and the legacy one collide:\n -- teammates' drafts of one item share its path by design, but a deploy\n -- there wipes those two together, so a second would discard edits the\n -- caller never saw. The other app kind is a different item on the same\n -- deployed path, so it collides whoever owns it.\n AND (o.typ <> $4 OR o.email = $6 OR o.email IS NULL)\n ))\n RETURNING id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github", + "data_pipeline", + "trigger_amqp" + ] + } + } + }, + "Text", + "Text", + "Text", + "Text", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "0d9a83d77632bc0de19a48eddb7e6f80b2a41db4f1d352920d0c295ea3a018b1" +} diff --git a/backend/.sqlx/query-2b93351b9c2c91272c3f69c9c8eba192d2e4d087c7d7db8d3b257bb864c8265d.json b/backend/.sqlx/query-0f374865faa5bdf884345808a004ce891a85daa00e037aba4b65b60f87243bd3.json similarity index 65% rename from backend/.sqlx/query-2b93351b9c2c91272c3f69c9c8eba192d2e4d087c7d7db8d3b257bb864c8265d.json rename to backend/.sqlx/query-0f374865faa5bdf884345808a004ce891a85daa00e037aba4b65b60f87243bd3.json index b82fcf2e17..2ff7820d86 100644 --- a/backend/.sqlx/query-2b93351b9c2c91272c3f69c9c8eba192d2e4d087c7d7db8d3b257bb864c8265d.json +++ b/backend/.sqlx/query-0f374865faa5bdf884345808a004ce891a85daa00e037aba4b65b60f87243bd3.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (workspace_id, datatable, timestamp) DO UPDATE SET name = EXCLUDED.name, code_up = EXCLUDED.code_up, code_down = EXCLUDED.code_down WHERE NOT $7 OR (datatable_migrations.name = $8::text AND datatable_migrations.code_up = $9::text AND datatable_migrations.code_down IS NOT DISTINCT FROM $10::text)", + "query": "INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (workspace_id, datatable, timestamp) DO UPDATE SET name = EXCLUDED.name, code_up = EXCLUDED.code_up, code_down = EXCLUDED.code_down WHERE datatable_migrations.name = $7::text AND datatable_migrations.code_up = $8::text AND datatable_migrations.code_down IS NOT DISTINCT FROM $9::text", "describe": { "columns": [], "parameters": { @@ -11,7 +11,6 @@ "Varchar", "Text", "Text", - "Bool", "Text", "Text", "Text" @@ -19,5 +18,5 @@ }, "nullable": [] }, - "hash": "2b93351b9c2c91272c3f69c9c8eba192d2e4d087c7d7db8d3b257bb864c8265d" + "hash": "0f374865faa5bdf884345808a004ce891a85daa00e037aba4b65b60f87243bd3" } diff --git a/backend/.sqlx/query-11af34e5fe5b8ed59453d58628ed5fb3b2120835158f22d715b73460f6a55108.json b/backend/.sqlx/query-11af34e5fe5b8ed59453d58628ed5fb3b2120835158f22d715b73460f6a55108.json new file mode 100644 index 0000000000..fdf0049344 --- /dev/null +++ b/backend/.sqlx/query-11af34e5fe5b8ed59453d58628ed5fb3b2120835158f22d715b73460f6a55108.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft_move WHERE email = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "11af34e5fe5b8ed59453d58628ed5fb3b2120835158f22d715b73460f6a55108" +} diff --git a/backend/.sqlx/query-132bab72036874c33805ff194fce6641817d09528f9d1b7210abe9881b817710.json b/backend/.sqlx/query-132bab72036874c33805ff194fce6641817d09528f9d1b7210abe9881b817710.json new file mode 100644 index 0000000000..15d1703e5d --- /dev/null +++ b/backend/.sqlx/query-132bab72036874c33805ff194fce6641817d09528f9d1b7210abe9881b817710.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO remote_deploy_token\n (workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key,\n connected_at, member_since, target_changed_at)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10\n WHERE $9::timestamptz IS NOT NULL\n OR EXISTS (SELECT 1 FROM token WHERE token_hash = $11)\n ON CONFLICT (workspace_id, email) DO UPDATE SET\n base_url = EXCLUDED.base_url, remote_workspace_id = EXCLUDED.remote_workspace_id,\n token = EXCLUDED.token, remote_email = EXCLUDED.remote_email,\n proxy_key = EXCLUDED.proxy_key, connected_at = EXCLUDED.connected_at,\n member_since = EXCLUDED.member_since, target_changed_at = EXCLUDED.target_changed_at\n WHERE remote_deploy_token.connected_at < EXCLUDED.connected_at\n RETURNING connected_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "connected_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Text", + "Varchar", + "Varchar", + "Timestamptz", + "Timestamptz", + "Timestamptz", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "132bab72036874c33805ff194fce6641817d09528f9d1b7210abe9881b817710" +} diff --git a/backend/.sqlx/query-9a1483a81f5b086e0765d3d69483e29b09f66090e1f9d394564c16d921d2e66c.json b/backend/.sqlx/query-148fbee57c6abbb6f361818c5b6bfc8a1db451d31dfddbb5be945b3551c8becb.json similarity index 57% rename from backend/.sqlx/query-9a1483a81f5b086e0765d3d69483e29b09f66090e1f9d394564c16d921d2e66c.json rename to backend/.sqlx/query-148fbee57c6abbb6f361818c5b6bfc8a1db451d31dfddbb5be945b3551c8becb.json index e751b8fc7c..09e5b51ab9 100644 --- a/backend/.sqlx/query-9a1483a81f5b086e0765d3d69483e29b09f66090e1f9d394564c16d921d2e66c.json +++ b/backend/.sqlx/query-148fbee57c6abbb6f361818c5b6bfc8a1db451d31dfddbb5be945b3551c8becb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at\n FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash\n WHERE s.workspace_id = $1 AND s.path = $2\n ORDER by s.created_at DESC", + "query": "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by\n FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash\n WHERE s.workspace_id = $1 AND s.path = $2\n ORDER by s.created_at DESC LIMIT 1", "describe": { "columns": [ { @@ -17,6 +17,11 @@ "ordinal": 2, "name": "created_at", "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "created_by", + "type_info": "Varchar" } ], "parameters": { @@ -28,8 +33,9 @@ "nullable": [ false, true, + false, false ] }, - "hash": "9a1483a81f5b086e0765d3d69483e29b09f66090e1f9d394564c16d921d2e66c" + "hash": "148fbee57c6abbb6f361818c5b6bfc8a1db451d31dfddbb5be945b3551c8becb" } diff --git a/backend/.sqlx/query-14cef81d324504d9cc90a9ffeb9f73daf9e09cac11685988bc861cc348a0c518.json b/backend/.sqlx/query-14cef81d324504d9cc90a9ffeb9f73daf9e09cac11685988bc861cc348a0c518.json new file mode 100644 index 0000000000..45fbabdae9 --- /dev/null +++ b/backend/.sqlx/query-14cef81d324504d9cc90a9ffeb9f73daf9e09cac11685988bc861cc348a0c518.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT remote_deploy_target FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "remote_deploy_target", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "14cef81d324504d9cc90a9ffeb9f73daf9e09cac11685988bc861cc348a0c518" +} diff --git a/backend/.sqlx/query-1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d.json b/backend/.sqlx/query-1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d.json new file mode 100644 index 0000000000..46b7d2509c --- /dev/null +++ b/backend/.sqlx/query-1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_queue q SET scheduled_for = now()\n FROM (SELECT id, scheduled_for FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 FOR UPDATE) prev\n WHERE q.id = prev.id AND NOT q.running AND q.suspend = 0 AND q.canceled_by IS NULL\n AND q.scheduled_for > now()\n RETURNING prev.scheduled_for", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "scheduled_for", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d" +} diff --git a/backend/.sqlx/query-18b38efcb141101192b372e7d7e7e47481877f3ab717682f3b84e665c95a5214.json b/backend/.sqlx/query-18b38efcb141101192b372e7d7e7e47481877f3ab717682f3b84e665c95a5214.json new file mode 100644 index 0000000000..d26cdf3a7d --- /dev/null +++ b/backend/.sqlx/query-18b38efcb141101192b372e7d7e7e47481877f3ab717682f3b84e665c95a5214.json @@ -0,0 +1,49 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,\n av.created_by as created_by, av.created_at as created_at\n FROM app a\n JOIN LATERAL (\n SELECT av2.id, COALESCE(v.ord, 0) AS ord\n FROM app_version av2\n LEFT JOIN unnest(a.versions) WITH ORDINALITY AS v(id, ord) ON v.id = av2.id\n WHERE av2.app_id = a.id\n ORDER BY ord DESC, av2.id DESC\n LIMIT $3 OFFSET $4\n ) page ON TRUE\n JOIN app_version av ON av.id = page.id\n LEFT JOIN deployment_metadata dm ON av.id = dm.app_version\n WHERE a.workspace_id = $1 AND a.path = $2\n ORDER BY page.ord DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "app_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "deployment_msg", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + true, + false, + false + ] + }, + "hash": "18b38efcb141101192b372e7d7e7e47481877f3ab717682f3b84e665c95a5214" +} diff --git a/backend/.sqlx/query-46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b.json b/backend/.sqlx/query-1c6859291963a9ca38d73f500213a575bfcd4469524f0dc8812470737759f02b.json similarity index 74% rename from backend/.sqlx/query-46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b.json rename to backend/.sqlx/query-1c6859291963a9ca38d73f500213a575bfcd4469524f0dc8812470737759f02b.json index dcd3573e92..7b78a7d9b4 100644 --- a/backend/.sqlx/query-46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b.json +++ b/backend/.sqlx/query-1c6859291963a9ca38d73f500213a575bfcd4469524f0dc8812470737759f02b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "WITH legacy AS (\n DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL\n RETURNING value\n )\n INSERT INTO draft (workspace_id, email, path, typ, value, created_at)\n SELECT $1, $4, $2, $3, value, now() FROM legacy\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now()\n RETURNING 1 as \"one!\"", + "query": "WITH legacy AS (\n DELETE FROM draft\n WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL\n RETURNING value, base\n )\n INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)\n SELECT $1, $4, $2, $3, value, now(), base FROM legacy\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = EXCLUDED.value, created_at = now(), base = EXCLUDED.base\n RETURNING 1 as \"one!\"", "describe": { "columns": [ { @@ -55,5 +55,5 @@ null ] }, - "hash": "46f00a75b2e7e4ac70758a9687070f68bc0421f1aa228f80157adda63191d33b" + "hash": "1c6859291963a9ca38d73f500213a575bfcd4469524f0dc8812470737759f02b" } diff --git a/backend/.sqlx/query-1d8ccd32266637d7f7915f92a8483dce0c9986f3847227c2f66daa84d4109d7d.json b/backend/.sqlx/query-1d8ccd32266637d7f7915f92a8483dce0c9986f3847227c2f66daa84d4109d7d.json deleted file mode 100644 index b9cbc6c382..0000000000 --- a/backend/.sqlx/query-1d8ccd32266637d7f7915f92a8483dce0c9986f3847227c2f66daa84d4109d7d.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "1d8ccd32266637d7f7915f92a8483dce0c9986f3847227c2f66daa84d4109d7d" -} diff --git a/backend/.sqlx/query-207db6c65949b85fda6d6289921bb04695b1c50e17755dad3bd162254f67458f.json b/backend/.sqlx/query-207db6c65949b85fda6d6289921bb04695b1c50e17755dad3bd162254f67458f.json new file mode 100644 index 0000000000..e698dbecb2 --- /dev/null +++ b/backend/.sqlx/query-207db6c65949b85fda6d6289921bb04695b1c50e17755dad3bd162254f67458f.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE remote_deploy_token SET token = $1\n WHERE workspace_id = $2 AND email = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "207db6c65949b85fda6d6289921bb04695b1c50e17755dad3bd162254f67458f" +} diff --git a/backend/.sqlx/query-232ff36c2d522383061cafbf9639698dbc44a18c0f016aae94a1705f06fd1e48.json b/backend/.sqlx/query-232ff36c2d522383061cafbf9639698dbc44a18c0f016aae94a1705f06fd1e48.json new file mode 100644 index 0000000000..f32e47b387 --- /dev/null +++ b/backend/.sqlx/query-232ff36c2d522383061cafbf9639698dbc44a18c0f016aae94a1705f06fd1e48.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email)\n SELECT m.workspace_id, m.typ, m.old_path, $4, $5::text\n FROM draft_move m\n WHERE m.workspace_id = $1 AND m.typ::text = ANY($2::text[])\n AND m.new_path = $3 AND m.email IS NULL\n AND NOT EXISTS (\n SELECT 1 FROM draft_move o\n WHERE o.workspace_id = m.workspace_id AND o.typ = m.typ\n AND o.old_path = m.old_path AND o.email = $5::text\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "TextArray", + "Text", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "232ff36c2d522383061cafbf9639698dbc44a18c0f016aae94a1705f06fd1e48" +} diff --git a/backend/.sqlx/query-297c7a40dfce729d44aa37bc7c65560517bd25e40c0752a00467829191e2eb98.json b/backend/.sqlx/query-297c7a40dfce729d44aa37bc7c65560517bd25e40c0752a00467829191e2eb98.json new file mode 100644 index 0000000000..cda61b894a --- /dev/null +++ b/backend/.sqlx/query-297c7a40dfce729d44aa37bc7c65560517bd25e40c0752a00467829191e2eb98.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings ws\n SET datatable = (\n SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg(\n dt.key,\n CASE WHEN dt.value->'reference'->>'workspace_id' = $2\n THEN jsonb_set(dt.value, '{reference,workspace_id}', to_jsonb($1::text))\n ELSE dt.value END\n ))\n FROM jsonb_each(ws.datatable->'datatables') dt\n )\n WHERE jsonb_typeof(ws.datatable->'datatables') = 'object'\n AND ws.datatable::text LIKE '%\"reference\"%'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "297c7a40dfce729d44aa37bc7c65560517bd25e40c0752a00467829191e2eb98" +} diff --git a/backend/.sqlx/query-2a391cc1bfcd2f75b46144a394c01237e09c3060da88170f1f6e06468309d213.json b/backend/.sqlx/query-2a391cc1bfcd2f75b46144a394c01237e09c3060da88170f1f6e06468309d213.json new file mode 100644 index 0000000000..a893edfbd7 --- /dev/null +++ b/backend/.sqlx/query-2a391cc1bfcd2f75b46144a394c01237e09c3060da88170f1f6e06468309d213.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE capture_config SET server_id = NULL, last_server_ping = NULL\n WHERE workspace_id = $1 AND trigger_kind = 'postgres'\n AND (trigger_config->>'postgres_resource_path' = $2\n OR trigger_config->>'postgres_resource_path' LIKE $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "2a391cc1bfcd2f75b46144a394c01237e09c3060da88170f1f6e06468309d213" +} diff --git a/backend/.sqlx/query-2d6b117324eaf076a0ed06d2cb0ce73279957d6a39fdc6b1ecb6e0a1e02f921f.json b/backend/.sqlx/query-2d6b117324eaf076a0ed06d2cb0ce73279957d6a39fdc6b1ecb6e0a1e02f921f.json new file mode 100644 index 0000000000..4b96519a06 --- /dev/null +++ b/backend/.sqlx/query-2d6b117324eaf076a0ed06d2cb0ce73279957d6a39fdc6b1ecb6e0a1e02f921f.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)\n SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account,\n CASE WHEN $3 THEN NULL ELSE added_via END\n FROM usr WHERE workspace_id = $2\n AND (NOT $3 OR (NOT operator AND NOT disabled AND NOT is_service_account))\n ON CONFLICT DO NOTHING", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "2d6b117324eaf076a0ed06d2cb0ce73279957d6a39fdc6b1ecb6e0a1e02f921f" +} diff --git a/backend/.sqlx/query-2f39fce0ee700117f3e4c066e0b56ee979e4c392970278304e80368b770bb7b4.json b/backend/.sqlx/query-2f39fce0ee700117f3e4c066e0b56ee979e4c392970278304e80368b770bb7b4.json new file mode 100644 index 0000000000..99b1a9472d --- /dev/null +++ b/backend/.sqlx/query-2f39fce0ee700117f3e4c066e0b56ee979e4c392970278304e80368b770bb7b4.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "add_admins_and_developers_to_forks", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2f39fce0ee700117f3e4c066e0b56ee979e4c392970278304e80368b770bb7b4" +} diff --git a/backend/.sqlx/query-3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075.json b/backend/.sqlx/query-3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075.json new file mode 100644 index 0000000000..90566827e7 --- /dev/null +++ b/backend/.sqlx/query-3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(DISTINCT email) FROM usr WHERE is_service_account AND NOT disabled", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "3190736818379ad22628aae3c5a25ab0199084b35664cbb0a586917bfa0da075" +} diff --git a/backend/.sqlx/query-334dbcd48fb59c96c62c2705ab2d1ce716cd52417f487cc1a8dd376017b2db7d.json b/backend/.sqlx/query-334dbcd48fb59c96c62c2705ab2d1ce716cd52417f487cc1a8dd376017b2db7d.json new file mode 100644 index 0000000000..0c94309c58 --- /dev/null +++ b/backend/.sqlx/query-334dbcd48fb59c96c62c2705ab2d1ce716cd52417f487cc1a8dd376017b2db7d.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Name" + ] + }, + "nullable": [ + null + ] + }, + "hash": "334dbcd48fb59c96c62c2705ab2d1ce716cd52417f487cc1a8dd376017b2db7d" +} diff --git a/backend/.sqlx/query-359cd29f531d263a8cf7205e0869229a610767087f01e0154be8da0620fa114b.json b/backend/.sqlx/query-359cd29f531d263a8cf7205e0869229a610767087f01e0154be8da0620fa114b.json deleted file mode 100644 index 23cf739f90..0000000000 --- a/backend/.sqlx/query-359cd29f531d263a8cf7205e0869229a610767087f01e0154be8da0620fa114b.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "WITH all_audit AS (SELECT username, operation, timestamp FROM audit_partitioned UNION ALL SELECT username, operation, timestamp FROM audit),\n active_users as (SELECT distinct username as email FROM all_audit WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n active_authors as (SELECT distinct email FROM usr WHERE usr.operator IS false AND email IN (SELECT email FROM active_users)),\n active_authors_agg as (SELECT array_agg(email) as authors FROM active_authors),\n active_ops_agg as (SELECT array_agg(email) as operators from active_users WHERE email NOT IN (SELECT email FROM active_authors))\n SELECT active_authors_agg.authors, active_ops_agg.operators, array_length(active_authors_agg.authors, 1) as author_count, array_length(active_ops_agg.operators, 1) as operator_count FROM active_authors_agg, active_ops_agg", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "authors", - "type_info": "VarcharArray" - }, - { - "ordinal": 1, - "name": "operators", - "type_info": "VarcharArray" - }, - { - "ordinal": 2, - "name": "author_count", - "type_info": "Int4" - }, - { - "ordinal": 3, - "name": "operator_count", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null, - null, - null, - null - ] - }, - "hash": "359cd29f531d263a8cf7205e0869229a610767087f01e0154be8da0620fa114b" -} diff --git a/backend/.sqlx/query-372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11.json b/backend/.sqlx/query-372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11.json new file mode 100644 index 0000000000..f5a2ac20ed --- /dev/null +++ b/backend/.sqlx/query-372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1 AND workspace_id = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11" +} diff --git a/backend/.sqlx/query-3a015ab279785a15f89c89d8c4d155e85370d4c2b7ed14fbb510919e7624ca1c.json b/backend/.sqlx/query-3a015ab279785a15f89c89d8c4d155e85370d4c2b7ed14fbb510919e7624ca1c.json new file mode 100644 index 0000000000..c4cc310dbf --- /dev/null +++ b/backend/.sqlx/query-3a015ab279785a15f89c89d8c4d155e85370d4c2b7ed14fbb510919e7624ca1c.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email)\n SELECT $1, t::draft_kind, $3, $4, $5 FROM unnest($2::text[]) t", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "TextArray", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "3a015ab279785a15f89c89d8c4d155e85370d4c2b7ed14fbb510919e7624ca1c" +} diff --git a/backend/.sqlx/query-3a0ac41e645225bbe54b83f246500ca014154e76eb322646e7e4e753b6420acd.json b/backend/.sqlx/query-3a0ac41e645225bbe54b83f246500ca014154e76eb322646e7e4e753b6420acd.json new file mode 100644 index 0000000000..b3b683eca1 --- /dev/null +++ b/backend/.sqlx/query-3a0ac41e645225bbe54b83f246500ca014154e76eb322646e7e4e753b6420acd.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM token WHERE token_prefix = 'GONE_TOKEN'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "3a0ac41e645225bbe54b83f246500ca014154e76eb322646e7e4e753b6420acd" +} diff --git a/backend/.sqlx/query-3b6f1e9765de11ef2da0ec5388c72e4a01f50d48b76b90746eef72919c408e44.json b/backend/.sqlx/query-3b6f1e9765de11ef2da0ec5388c72e4a01f50d48b76b90746eef72919c408e44.json new file mode 100644 index 0000000000..9901e61b6a --- /dev/null +++ b/backend/.sqlx/query-3b6f1e9765de11ef2da0ec5388c72e4a01f50d48b76b90746eef72919c408e44.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email, token FROM remote_deploy_token\n WHERE workspace_id = $1 AND token <> '' FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "token", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "3b6f1e9765de11ef2da0ec5388c72e4a01f50d48b76b90746eef72919c408e44" +} diff --git a/backend/.sqlx/query-3db5f1d8ec1f8d867cf01c066876122e78f0c76a93a1d9860e8dff05d9a02c09.json b/backend/.sqlx/query-3db5f1d8ec1f8d867cf01c066876122e78f0c76a93a1d9860e8dff05d9a02c09.json new file mode 100644 index 0000000000..db2181ae3c --- /dev/null +++ b/backend/.sqlx/query-3db5f1d8ec1f8d867cf01c066876122e78f0c76a93a1d9860e8dff05d9a02c09.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE draft_move SET new_path = $4\n WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND new_path = $3 AND ($5::text IS NULL OR email = $5)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "TextArray", + "Text", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "3db5f1d8ec1f8d867cf01c066876122e78f0c76a93a1d9860e8dff05d9a02c09" +} diff --git a/backend/.sqlx/query-4692810d2be817bbb5de9b476d68d695941bd4fb5ccef393e4da522ed479d601.json b/backend/.sqlx/query-4692810d2be817bbb5de9b476d68d695941bd4fb5ccef393e4da522ed479d601.json new file mode 100644 index 0000000000..b0ad1eaf86 --- /dev/null +++ b/backend/.sqlx/query-4692810d2be817bbb5de9b476d68d695941bd4fb5ccef393e4da522ed479d601.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE postgres_trigger SET server_id = NULL, last_server_ping = NULL\n WHERE workspace_id = $1\n AND (postgres_resource_path = $2 OR postgres_resource_path LIKE $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "4692810d2be817bbb5de9b476d68d695941bd4fb5ccef393e4da522ed479d601" +} diff --git a/backend/.sqlx/query-bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e.json b/backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json similarity index 82% rename from backend/.sqlx/query-bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e.json rename to backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json index 578edc6044..5c0e45b0d4 100644 --- a/backend/.sqlx/query-bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e.json +++ b/backend/.sqlx/query-4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ", + "query": "\n UPDATE schedule SET\n schedule = $1,\n timezone = $2,\n args = $3,\n on_failure = $4,\n on_failure_times = $5,\n on_failure_exact = $6,\n on_failure_extra_args = $7,\n on_recovery = $8,\n on_recovery_times = $9,\n on_recovery_extra_args = $10,\n on_success = $11,\n on_success_extra_args = $12,\n ws_error_handler_muted = $13,\n retry = $14,\n summary = $15,\n no_flow_overlap = $16,\n tag = $17,\n paused_until = $18,\n path = $19,\n workspace_id = $20,\n cron_version = COALESCE($21, cron_version),\n description = $22,\n dynamic_skip = $23,\n email = $24,\n edited_by = $25,\n edited_at = now(),\n permissioned_as = $26,\n labels = COALESCE($27, labels)\n WHERE path = $19 AND workspace_id = $20\n RETURNING\n workspace_id,\n path,\n edited_by,\n edited_at,\n schedule,\n timezone,\n enabled,\n script_path,\n is_flow,\n args AS \"args: _\",\n extra_perms,\n email,\n permissioned_as,\n error,\n on_failure,\n on_failure_times,\n on_failure_exact,\n on_failure_extra_args AS \"on_failure_extra_args: _\",\n on_recovery,\n on_recovery_times,\n on_recovery_extra_args AS \"on_recovery_extra_args: _\",\n on_success,\n on_success_extra_args AS \"on_success_extra_args: _\",\n ws_error_handler_muted,\n retry,\n no_flow_overlap,\n summary,\n description,\n tag,\n paused_until,\n cron_version,\n dynamic_skip,\n labels\n ", "describe": { "columns": [ { @@ -236,5 +236,5 @@ true ] }, - "hash": "bd2609f999054c5a2a85f2aafe4005bc22731c27f0bf27b3b06ec426a05eda2e" + "hash": "4d2c91937d87d385290b918af0238825cdd7784d0d611cd0069b15d11039ac56" } diff --git a/backend/.sqlx/query-5048e21546f9710697100100e1255ab103979433bc386d7c89d0e30db12bfd57.json b/backend/.sqlx/query-5048e21546f9710697100100e1255ab103979433bc386d7c89d0e30db12bfd57.json new file mode 100644 index 0000000000..804e294296 --- /dev/null +++ b/backend/.sqlx/query-5048e21546f9710697100100e1255ab103979433bc386d7c89d0e30db12bfd57.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"datatable!\"\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE dt.value->'reference'->>'workspace_id' = $1\n AND dt.value->'reference'->>'datatable' = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "datatable!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "5048e21546f9710697100100e1255ab103979433bc386d7c89d0e30db12bfd57" +} diff --git a/backend/.sqlx/query-51e1741eb9e959945302fd6f7e8c621b9b03b92f6c20ab1bdd1637232f26952d.json b/backend/.sqlx/query-51e1741eb9e959945302fd6f7e8c621b9b03b92f6c20ab1bdd1637232f26952d.json new file mode 100644 index 0000000000..b6babc98b0 --- /dev/null +++ b/backend/.sqlx/query-51e1741eb9e959945302fd6f7e8c621b9b03b92f6c20ab1bdd1637232f26952d.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT clock_timestamp() AS \"started_at!\", s.remote_deploy_target,\n s.remote_deploy_target_changed_at,\n (SELECT u.created_at FROM usr u\n WHERE u.workspace_id = s.workspace_id AND u.email = $2) AS member_since\n FROM workspace_settings s WHERE s.workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "started_at!", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "remote_deploy_target", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "remote_deploy_target_changed_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "member_since", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null, + true, + true, + null + ] + }, + "hash": "51e1741eb9e959945302fd6f7e8c621b9b03b92f6c20ab1bdd1637232f26952d" +} diff --git a/backend/.sqlx/query-530fe81babd82e7b4a9142962c62750560e459acc7c83c48d47d7bdad29051be.json b/backend/.sqlx/query-530fe81babd82e7b4a9142962c62750560e459acc7c83c48d47d7bdad29051be.json new file mode 100644 index 0000000000..65266cbae0 --- /dev/null +++ b/backend/.sqlx/query-530fe81babd82e7b4a9142962c62750560e459acc7c83c48d47d7bdad29051be.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, read_only)\n VALUES (encode(sha256('READ_ONLY_TOKEN'::bytea), 'hex'), 'READ_ONLY_', 'READ_ONLY_TOKEN',\n 'test@windmill.dev', 'read only', false, true)", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "530fe81babd82e7b4a9142962c62750560e459acc7c83c48d47d7bdad29051be" +} diff --git a/backend/.sqlx/query-538dd1779874e4003932d7f17750239c625f85e25b3364bf2edf566f518c8ee2.json b/backend/.sqlx/query-538dd1779874e4003932d7f17750239c625f85e25b3364bf2edf566f518c8ee2.json new file mode 100644 index 0000000000..03613519ad --- /dev/null +++ b/backend/.sqlx/query-538dd1779874e4003932d7f17750239c625f85e25b3364bf2edf566f518c8ee2.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"datatable!\"\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE dt.value->'reference'->>'workspace_id' = $1\n AND dt.value->'reference'->>'datatable' = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "datatable!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "538dd1779874e4003932d7f17750239c625f85e25b3364bf2edf566f518c8ee2" +} diff --git a/backend/.sqlx/query-5517b714165f959a1df1a0ac9cbe848344974bc2271459ba4ae5952274c11429.json b/backend/.sqlx/query-5517b714165f959a1df1a0ac9cbe848344974bc2271459ba4ae5952274c11429.json new file mode 100644 index 0000000000..c29a42c56b --- /dev/null +++ b/backend/.sqlx/query-5517b714165f959a1df1a0ac9cbe848344974bc2271459ba4ae5952274c11429.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET remote_deploy_target_changed_at = '2000-01-01'\n WHERE workspace_id = 'test-workspace'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "5517b714165f959a1df1a0ac9cbe848344974bc2271459ba4ae5952274c11429" +} diff --git a/backend/.sqlx/query-58e5cfe9eb87bda9f7de87c403861b6e7b9d35a41594681e2a92a87359e6a018.json b/backend/.sqlx/query-58e5cfe9eb87bda9f7de87c403861b6e7b9d35a41594681e2a92a87359e6a018.json new file mode 100644 index 0000000000..f4f7acd0b7 --- /dev/null +++ b/backend/.sqlx/query-58e5cfe9eb87bda9f7de87c403861b6e7b9d35a41594681e2a92a87359e6a018.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT username FROM usr WHERE workspace_id = $1 AND email = $2 AND disabled = false", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "58e5cfe9eb87bda9f7de87c403861b6e7b9d35a41594681e2a92a87359e6a018" +} diff --git a/backend/.sqlx/query-5959c60da91f1e62080a4455e2a4627cb1fd3d8c70210bf7f38cb222dd15d88f.json b/backend/.sqlx/query-5959c60da91f1e62080a4455e2a4627cb1fd3d8c70210bf7f38cb222dd15d88f.json new file mode 100644 index 0000000000..b78808a389 --- /dev/null +++ b/backend/.sqlx/query-5959c60da91f1e62080a4455e2a4627cb1fd3d8c70210bf7f38cb222dd15d88f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft_move dest\n WHERE dest.email = $1\n AND EXISTS (SELECT 1 FROM draft_move src\n WHERE src.email = $2\n AND src.workspace_id = dest.workspace_id\n AND src.old_path = dest.old_path\n AND src.typ = dest.typ)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "5959c60da91f1e62080a4455e2a4627cb1fd3d8c70210bf7f38cb222dd15d88f" +} diff --git a/backend/.sqlx/query-5b292c49ce6b64f4de61f74e18831b3f107fc6d0312ac3da9247a922bcb05b67.json b/backend/.sqlx/query-5b292c49ce6b64f4de61f74e18831b3f107fc6d0312ac3da9247a922bcb05b67.json new file mode 100644 index 0000000000..7633c95854 --- /dev/null +++ b/backend/.sqlx/query-5b292c49ce6b64f4de61f74e18831b3f107fc6d0312ac3da9247a922bcb05b67.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT t.token, t.remote_email, t.proxy_key, t.connected_at FROM remote_deploy_token t\n JOIN workspace_settings s ON s.workspace_id = t.workspace_id\n WHERE t.workspace_id = $1 AND t.email = $2 AND t.base_url = $3\n AND t.remote_workspace_id = $4 AND t.token <> ''\n AND s.remote_deploy_target_changed_at IS NOT DISTINCT FROM t.target_changed_at\n AND (EXISTS (SELECT 1 FROM usr u WHERE u.workspace_id = t.workspace_id\n AND u.email = t.email AND u.created_at = t.member_since)\n OR EXISTS (SELECT 1 FROM password p WHERE p.email = t.email AND p.super_admin))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "token", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "remote_email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "proxy_key", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "connected_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "5b292c49ce6b64f4de61f74e18831b3f107fc6d0312ac3da9247a922bcb05b67" +} diff --git a/backend/.sqlx/query-a6decdebcd9750691f20f874b66a9c6f2ede08c33605c6a3c3dfc213a3eda76a.json b/backend/.sqlx/query-5ccfbd0f345b9b86ca356008def6cc1011a49fc8d8ad046ac572e81cf8938995.json similarity index 63% rename from backend/.sqlx/query-a6decdebcd9750691f20f874b66a9c6f2ede08c33605c6a3c3dfc213a3eda76a.json rename to backend/.sqlx/query-5ccfbd0f345b9b86ca356008def6cc1011a49fc8d8ad046ac572e81cf8938995.json index 0532a5d3a0..7cd917d623 100644 --- a/backend/.sqlx/query-a6decdebcd9750691f20f874b66a9c6f2ede08c33605c6a3c3dfc213a3eda76a.json +++ b/backend/.sqlx/query-5ccfbd0f345b9b86ca356008def6cc1011a49fc8d8ad046ac572e81cf8938995.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n UPDATE workspace_settings\n SET\n ai_config = source_ws.ai_config,\n large_file_storage = source_ws.large_file_storage,\n ducklake = source_ws.ducklake,\n dbt_warehouses = source_ws.dbt_warehouses,\n datatable = source_ws.datatable,\n git_app_installations = source_ws.git_app_installations\n FROM workspace_settings source_ws\n WHERE source_ws.workspace_id = $1\n AND workspace_settings.workspace_id = $2\n ", + "query": "\n UPDATE workspace_settings\n SET\n ai_config = source_ws.ai_config,\n large_file_storage = source_ws.large_file_storage,\n ducklake = source_ws.ducklake,\n dbt_warehouses = source_ws.dbt_warehouses,\n datatable = source_ws.datatable,\n git_app_installations = source_ws.git_app_installations,\n add_admins_and_developers_to_forks = source_ws.add_admins_and_developers_to_forks\n FROM workspace_settings source_ws\n WHERE source_ws.workspace_id = $1\n AND workspace_settings.workspace_id = $2\n ", "describe": { "columns": [], "parameters": { @@ -11,5 +11,5 @@ }, "nullable": [] }, - "hash": "a6decdebcd9750691f20f874b66a9c6f2ede08c33605c6a3c3dfc213a3eda76a" + "hash": "5ccfbd0f345b9b86ca356008def6cc1011a49fc8d8ad046ac572e81cf8938995" } diff --git a/backend/.sqlx/query-6b58f2df413a867a44311fd779a2017c41b9a0e3985a25372e5f64723124e473.json b/backend/.sqlx/query-6b58f2df413a867a44311fd779a2017c41b9a0e3985a25372e5f64723124e473.json new file mode 100644 index 0000000000..20b5bd1a94 --- /dev/null +++ b/backend/.sqlx/query-6b58f2df413a867a44311fd779a2017c41b9a0e3985a25372e5f64723124e473.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO remote_deploy_token\n (workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key,\n connected_at)\n VALUES ($1, $2, '', '', '', '', '', clock_timestamp())\n ON CONFLICT (workspace_id, email) DO UPDATE SET\n token = '', proxy_key = '', connected_at = clock_timestamp()", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "6b58f2df413a867a44311fd779a2017c41b9a0e3985a25372e5f64723124e473" +} diff --git a/backend/.sqlx/query-94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3.json b/backend/.sqlx/query-6c57c46c5a0462f379ed6a22ae97e363009177fdc45d795447a920f455f193cc.json similarity index 77% rename from backend/.sqlx/query-94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3.json rename to backend/.sqlx/query-6c57c46c5a0462f379ed6a22ae97e363009177fdc45d795447a920f455f193cc.json index d54260b4f7..b8a19247ca 100644 --- a/backend/.sqlx/query-94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3.json +++ b/backend/.sqlx/query-6c57c46c5a0462f379ed6a22ae97e363009177fdc45d795447a920f455f193cc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM token_expiry_notification n\n USING token t\n WHERE n.token_hash = t.token_hash\n AND n.expiration > now()\n AND n.expiration <= now() + interval '7 days'\n RETURNING t.token_prefix, t.label, t.email, t.workspace_id", + "query": "DELETE FROM token_expiry_notification n\n USING token t\n WHERE n.token_hash = t.token_hash\n AND n.expiration > now()\n AND n.expiration <= now() + make_interval(days => $1)\n RETURNING t.token_prefix, t.label, t.email, t.workspace_id", "describe": { "columns": [ { @@ -25,7 +25,9 @@ } ], "parameters": { - "Left": [] + "Left": [ + "Int4" + ] }, "nullable": [ false, @@ -34,5 +36,5 @@ true ] }, - "hash": "94fd0a57cfc9341b2e9deae60506c6c06aa6934b87200da14231f12f65149cd3" + "hash": "6c57c46c5a0462f379ed6a22ae97e363009177fdc45d795447a920f455f193cc" } diff --git a/backend/.sqlx/query-6f9fb5d72f486358fa25d6887bd69b93910e028f140c07048f2c1c8d63ee6909.json b/backend/.sqlx/query-6f9fb5d72f486358fa25d6887bd69b93910e028f140c07048f2c1c8d63ee6909.json new file mode 100644 index 0000000000..d87d3aeba5 --- /dev/null +++ b/backend/.sqlx/query-6f9fb5d72f486358fa25d6887bd69b93910e028f140c07048f2c1c8d63ee6909.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO global_settings (name, value) VALUES ($1, $2)\n ON CONFLICT (name) DO UPDATE SET value = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "6f9fb5d72f486358fa25d6887bd69b93910e028f140c07048f2c1c8d63ee6909" +} diff --git a/backend/.sqlx/query-6fcdb09cdd7fedd7e54fdc0e49203f453fc1b85272fe212c0e1bf0ebd28bf58a.json b/backend/.sqlx/query-6fcdb09cdd7fedd7e54fdc0e49203f453fc1b85272fe212c0e1bf0ebd28bf58a.json deleted file mode 100644 index 17fbb0d5bb..0000000000 --- a/backend/.sqlx/query-6fcdb09cdd7fedd7e54fdc0e49203f453fc1b85272fe212c0e1bf0ebd28bf58a.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n WITH capped AS (\n SELECT timestamp, operation, resource, parameters\n FROM audit_partitioned\n WHERE workspace_id = 'admins'\n AND operation = 'workspace_fairness.capped'\n UNION ALL\n SELECT timestamp, operation, resource, parameters\n FROM audit\n WHERE workspace_id = 'admins'\n AND operation = 'workspace_fairness.capped'\n ORDER BY timestamp DESC\n LIMIT 200\n ), uncapped AS (\n SELECT timestamp, operation, resource, parameters\n FROM audit_partitioned\n WHERE workspace_id = 'admins'\n AND operation = 'workspace_fairness.uncapped'\n UNION ALL\n SELECT timestamp, operation, resource, parameters\n FROM audit\n WHERE workspace_id = 'admins'\n AND operation = 'workspace_fairness.uncapped'\n ORDER BY timestamp DESC\n LIMIT 200\n )\n SELECT timestamp AS \"timestamp!\",\n operation::text AS \"operation!\",\n resource AS workspace_id,\n parameters\n FROM (SELECT * FROM capped UNION ALL SELECT * FROM uncapped) e\n ORDER BY timestamp DESC\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "timestamp!", - "type_info": "Timestamptz" - }, - { - "ordinal": 1, - "name": "operation!", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "workspace_id", - "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "parameters", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [] - }, - "nullable": [ - null, - null, - null, - null - ] - }, - "hash": "6fcdb09cdd7fedd7e54fdc0e49203f453fc1b85272fe212c0e1bf0ebd28bf58a" -} diff --git a/backend/.sqlx/query-71ee2cb6661cca1fa4d8874a7f6d368347c59f36fd87df6dc7996152ccb84af0.json b/backend/.sqlx/query-71ee2cb6661cca1fa4d8874a7f6d368347c59f36fd87df6dc7996152ccb84af0.json new file mode 100644 index 0000000000..a03fab7081 --- /dev/null +++ b/backend/.sqlx/query-71ee2cb6661cca1fa4d8874a7f6d368347c59f36fd87df6dc7996152ccb84af0.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, name, enabled, pwd FROM datatable_role", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "enabled", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "pwd", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "71ee2cb6661cca1fa4d8874a7f6d368347c59f36fd87df6dc7996152ccb84af0" +} diff --git a/backend/.sqlx/query-75e6b5cd52d63ac094c90abd1524bc7a919692a96b076b8aaf24d812723531c8.json b/backend/.sqlx/query-75e6b5cd52d63ac094c90abd1524bc7a919692a96b076b8aaf24d812723531c8.json new file mode 100644 index 0000000000..27c62583f1 --- /dev/null +++ b/backend/.sqlx/query-75e6b5cd52d63ac094c90abd1524bc7a919692a96b076b8aaf24d812723531c8.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM usr WHERE email = $1 AND is_service_account IS true\n AND ($2::varchar IS NULL OR workspace_id = $2))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [ + null + ] + }, + "hash": "75e6b5cd52d63ac094c90abd1524bc7a919692a96b076b8aaf24d812723531c8" +} diff --git a/backend/.sqlx/query-7761959d05fff8c314d04c1bc9fb4e84a61008dbec0995ae192f1b3a1c261579.json b/backend/.sqlx/query-7761959d05fff8c314d04c1bc9fb4e84a61008dbec0995ae192f1b3a1c261579.json new file mode 100644 index 0000000000..c8b20a20ea --- /dev/null +++ b/backend/.sqlx/query-7761959d05fff8c314d04c1bc9fb4e84a61008dbec0995ae192f1b3a1c261579.json @@ -0,0 +1,72 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base)\n VALUES ($1, $2, $3::text, $4,\n CASE WHEN $10::bool\n THEN to_json($5::text::jsonb || jsonb_strip_nulls(jsonb_build_object(\n 'path', CASE WHEN $5::text::jsonb -> 'path' = to_jsonb($11::text)\n THEN to_jsonb($3::text) END,\n 'draft_path', CASE WHEN $5::text::jsonb -> 'draft_path' = to_jsonb($11::text)\n THEN to_jsonb($3::text) END)))\n ELSE $5::text::json\n END,\n COALESCE($8::timestamptz, now()), $9)\n ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL\n DO UPDATE SET value = CASE\n WHEN NOT $10::bool\n OR position(chr(92) || 'u0000' in replace(draft.value::text, chr(92) || chr(92), '')) > 0\n THEN EXCLUDED.value\n ELSE to_json((to_jsonb(EXCLUDED.value) - 'path' - 'draft_path')\n || jsonb_strip_nulls(jsonb_build_object(\n 'path', to_jsonb(draft.value) -> 'path',\n 'draft_path', to_jsonb(draft.value) -> 'draft_path')))\n END,\n created_at = EXCLUDED.created_at,\n base = EXCLUDED.base\n WHERE $7::bool = true\n OR $6::timestamptz IS NULL\n OR draft.created_at <= $6::timestamptz\n RETURNING path, created_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github", + "data_pipeline", + "trigger_amqp" + ] + } + } + }, + "Text", + "Timestamptz", + "Bool", + "Timestamptz", + "Text", + "Bool", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "7761959d05fff8c314d04c1bc9fb4e84a61008dbec0995ae192f1b3a1c261579" +} diff --git a/backend/.sqlx/query-797896e0152046844dbfee9d0e57e0055d742e44119bfb31ae7ff37a89b34d14.json b/backend/.sqlx/query-797896e0152046844dbfee9d0e57e0055d742e44119bfb31ae7ff37a89b34d14.json new file mode 100644 index 0000000000..ce3d615eb0 --- /dev/null +++ b/backend/.sqlx/query-797896e0152046844dbfee9d0e57e0055d742e44119bfb31ae7ff37a89b34d14.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft_move\n WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND old_path IN ($3, $4) AND ($5::text IS NULL OR email = $5)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "TextArray", + "Varchar", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "797896e0152046844dbfee9d0e57e0055d742e44119bfb31ae7ff37a89b34d14" +} diff --git a/backend/.sqlx/query-79799b5a2e499df6c28e286c42b9ad2db940c2455ab19cc95e5198baf96d5629.json b/backend/.sqlx/query-79799b5a2e499df6c28e286c42b9ad2db940c2455ab19cc95e5198baf96d5629.json new file mode 100644 index 0000000000..c36b6fb361 --- /dev/null +++ b/backend/.sqlx/query-79799b5a2e499df6c28e286c42b9ad2db940c2455ab19cc95e5198baf96d5629.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"datatable!\"\n FROM workspace_settings ws\n JOIN workspace w ON w.id = ws.workspace_id AND w.deleted = false\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE ws.workspace_id <> $1\n AND dt.value->'database'->>'resource_type' = 'instance'\n AND dt.value->'database'->>'resource_path' = $2\n ORDER BY ws.workspace_id, dt.key\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "datatable!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "79799b5a2e499df6c28e286c42b9ad2db940c2455ab19cc95e5198baf96d5629" +} diff --git a/backend/.sqlx/query-88a134e4ca82d5ce0334977c7713021ae3e99a5a61ea1c944c1df1368746dfa5.json b/backend/.sqlx/query-7b33adb5cf051bc123340982b97a80a96ee43e1c91d9907d50cc2ee14111623e.json similarity index 78% rename from backend/.sqlx/query-88a134e4ca82d5ce0334977c7713021ae3e99a5a61ea1c944c1df1368746dfa5.json rename to backend/.sqlx/query-7b33adb5cf051bc123340982b97a80a96ee43e1c91d9907d50cc2ee14111623e.json index 7cfe9aa070..541832d45f 100644 --- a/backend/.sqlx/query-88a134e4ca82d5ce0334977c7713021ae3e99a5a61ea1c944c1df1368746dfa5.json +++ b/backend/.sqlx/query-7b33adb5cf051bc123340982b97a80a96ee43e1c91d9907d50cc2ee14111623e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n workspace.is_dev_workspace, workspace.dev_workspace_label,\n workspace.owner AS \"created_by?\",\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,\n usr.disabled\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false", + "query": "SELECT workspace.id, workspace.name, usr.username, workspace_settings.color, workspace.parent_workspace_id,\n workspace.is_dev_workspace, workspace.dev_workspace_label,\n workspace.owner AS \"created_by?\",\n CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings,\n usr.disabled, usr.is_service_account\n FROM workspace\n JOIN usr ON usr.workspace_id = workspace.id\n JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id\n WHERE usr.email = $1 AND workspace.deleted = false", "describe": { "columns": [ { @@ -52,6 +52,11 @@ "ordinal": 9, "name": "disabled", "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "is_service_account", + "type_info": "Bool" } ], "parameters": { @@ -69,8 +74,9 @@ true, false, null, + false, false ] }, - "hash": "88a134e4ca82d5ce0334977c7713021ae3e99a5a61ea1c944c1df1368746dfa5" + "hash": "7b33adb5cf051bc123340982b97a80a96ee43e1c91d9907d50cc2ee14111623e" } diff --git a/backend/.sqlx/query-7ff60d155510303e307c47dcb6153ccaac72871eb68822d8e296c0d747e55f35.json b/backend/.sqlx/query-7ff60d155510303e307c47dcb6153ccaac72871eb68822d8e296c0d747e55f35.json new file mode 100644 index 0000000000..6a004023a0 --- /dev/null +++ b/backend/.sqlx/query-7ff60d155510303e307c47dcb6153ccaac72871eb68822d8e296c0d747e55f35.json @@ -0,0 +1,43 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version\n LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version\n WHERE flow_version.path = $1 AND flow_version.workspace_id = $2\n ORDER BY flow_version.created_at DESC\n LIMIT $3 OFFSET $4", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "deployment_msg", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "7ff60d155510303e307c47dcb6153ccaac72871eb68822d8e296c0d747e55f35" +} diff --git a/backend/.sqlx/query-81d5cceb2e405e1d17730dfa93a32f1bca7b235af9db581147d838454a4c9083.json b/backend/.sqlx/query-81d5cceb2e405e1d17730dfa93a32f1bca7b235af9db581147d838454a4c9083.json new file mode 100644 index 0000000000..40b6e22db6 --- /dev/null +++ b/backend/.sqlx/query-81d5cceb2e405e1d17730dfa93a32f1bca7b235af9db581147d838454a4c9083.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM remote_deploy_token WHERE workspace_id = $1\n AND ($2::text IS NULL OR base_url <> $2 OR remote_workspace_id <> $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "81d5cceb2e405e1d17730dfa93a32f1bca7b235af9db581147d838454a4c9083" +} diff --git a/backend/.sqlx/query-86af9d51a158ea5cb6161461ecddf2a63695f8cbf8af648da5a0a77a5b9d02ba.json b/backend/.sqlx/query-86af9d51a158ea5cb6161461ecddf2a63695f8cbf8af648da5a0a77a5b9d02ba.json new file mode 100644 index 0000000000..7627d9828d --- /dev/null +++ b/backend/.sqlx/query-86af9d51a158ea5cb6161461ecddf2a63695f8cbf8af648da5a0a77a5b9d02ba.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO datatable_role (id, name, enabled, pwd) VALUES ($1, $2, $3, $4)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Bool", + "Text" + ] + }, + "nullable": [] + }, + "hash": "86af9d51a158ea5cb6161461ecddf2a63695f8cbf8af648da5a0a77a5b9d02ba" +} diff --git a/backend/.sqlx/query-87afd5289e54b6accda8a399ec3c66df3d19217059f62903be3c74eadb9c15d5.json b/backend/.sqlx/query-87afd5289e54b6accda8a399ec3c66df3d19217059f62903be3c74eadb9c15d5.json deleted file mode 100644 index 8cdb2d5907..0000000000 --- a/backend/.sqlx/query-87afd5289e54b6accda8a399ec3c66df3d19217059f62903be3c74eadb9c15d5.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email)\n SELECT $2, path, typ,\n CASE WHEN typ IN ('script', 'flow')\n THEN to_json(to_jsonb(value) - 'on_behalf_of')\n ELSE value END,\n created_at, email\n FROM draft\n WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "87afd5289e54b6accda8a399ec3c66df3d19217059f62903be3c74eadb9c15d5" -} diff --git a/backend/.sqlx/query-8ce4461e7dd087782a239972c173786891eb99e9ba86b11cfb17d28856babd2e.json b/backend/.sqlx/query-8ce4461e7dd087782a239972c173786891eb99e9ba86b11cfb17d28856babd2e.json new file mode 100644 index 0000000000..c732c4549d --- /dev/null +++ b/backend/.sqlx/query-8ce4461e7dd087782a239972c173786891eb99e9ba86b11cfb17d28856babd2e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE remote_deploy_token SET token = $1 WHERE workspace_id = 'test-workspace'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "8ce4461e7dd087782a239972c173786891eb99e9ba86b11cfb17d28856babd2e" +} diff --git a/backend/.sqlx/query-dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99.json b/backend/.sqlx/query-8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363.json similarity index 93% rename from backend/.sqlx/query-dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99.json rename to backend/.sqlx/query-8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363.json index 01c0fd19af..7a822a8d6a 100644 --- a/backend/.sqlx/query-dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99.json +++ b/backend/.sqlx/query-8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ", + "query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url,\n add_admins_and_developers_to_forks\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ", "describe": { "columns": [ { @@ -177,6 +177,11 @@ "ordinal": 34, "name": "guest_jwt_jwks_url", "type_info": "Text" + }, + { + "ordinal": 35, + "name": "add_admins_and_developers_to_forks", + "type_info": "Bool" } ], "parameters": { @@ -219,8 +224,9 @@ false, false, true, - true + true, + false ] }, - "hash": "dc4a57df3becc610f631ef22c116450390addbfae85fecc61c991d94167e6e99" + "hash": "8ebe054b41793f1a7b85f1f8d29cd21d12fa1207b58bfc46249c250cdcdb5363" } diff --git a/backend/.sqlx/query-3c057e0284f1219865b8f7dc6eb3c1293c4b72d0b52abeefd1e954617984a2d8.json b/backend/.sqlx/query-91e2456814ac29534f5a1f65a3bb97462276930f0a83ffb458f7cb26035c8c3d.json similarity index 59% rename from backend/.sqlx/query-3c057e0284f1219865b8f7dc6eb3c1293c4b72d0b52abeefd1e954617984a2d8.json rename to backend/.sqlx/query-91e2456814ac29534f5a1f65a3bb97462276930f0a83ffb458f7cb26035c8c3d.json index 3206ba4433..57dbc41a9f 100644 --- a/backend/.sqlx/query-3c057e0284f1219865b8f7dc6eb3c1293c4b72d0b52abeefd1e954617984a2d8.json +++ b/backend/.sqlx/query-91e2456814ac29534f5a1f65a3bb97462276930f0a83ffb458f7cb26035c8c3d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "DELETE FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3 RETURNING name", + "query": "DELETE FROM datatable_migrations WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3 AND code_up = $4 AND code_down IS NOT DISTINCT FROM $5 RETURNING name", "describe": { "columns": [ { @@ -13,12 +13,14 @@ "Left": [ "Text", "Text", - "Int8" + "Int8", + "Text", + "Text" ] }, "nullable": [ false ] }, - "hash": "3c057e0284f1219865b8f7dc6eb3c1293c4b72d0b52abeefd1e954617984a2d8" + "hash": "91e2456814ac29534f5a1f65a3bb97462276930f0a83ffb458f7cb26035c8c3d" } diff --git a/backend/.sqlx/query-960d6eca64de008f821b1e96345530f057d91c0e0986fdc0de7d9a3ef0c143e2.json b/backend/.sqlx/query-960d6eca64de008f821b1e96345530f057d91c0e0986fdc0de7d9a3ef0c143e2.json new file mode 100644 index 0000000000..7d819b9b84 --- /dev/null +++ b/backend/.sqlx/query-960d6eca64de008f821b1e96345530f057d91c0e0986fdc0de7d9a3ef0c143e2.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE remote_deploy_token SET workspace_id = $1 WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "960d6eca64de008f821b1e96345530f057d91c0e0986fdc0de7d9a3ef0c143e2" +} diff --git a/backend/.sqlx/query-965f9fb9d73f21695cf9c4018107bf8dec0a9b1c38a6776f8b3db7f2f8a883b7.json b/backend/.sqlx/query-965f9fb9d73f21695cf9c4018107bf8dec0a9b1c38a6776f8b3db7f2f8a883b7.json new file mode 100644 index 0000000000..a7fe120abb --- /dev/null +++ b/backend/.sqlx/query-965f9fb9d73f21695cf9c4018107bf8dec0a9b1c38a6776f8b3db7f2f8a883b7.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE remote_deploy_token SET connected_at = clock_timestamp() + interval '1 hour'\n WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "965f9fb9d73f21695cf9c4018107bf8dec0a9b1c38a6776f8b3db7f2f8a883b7" +} diff --git a/backend/.sqlx/query-9723edfd6de38cf0bf21123a46d88fa7d0b650b5cfd80c099f785f5e411062e5.json b/backend/.sqlx/query-9723edfd6de38cf0bf21123a46d88fa7d0b650b5cfd80c099f785f5e411062e5.json new file mode 100644 index 0000000000..4704ddd84e --- /dev/null +++ b/backend/.sqlx/query-9723edfd6de38cf0bf21123a46d88fa7d0b650b5cfd80c099f785f5e411062e5.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft_move WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND (old_path = $3 OR (new_path = $3 AND $4::text IS NULL))", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "TextArray", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9723edfd6de38cf0bf21123a46d88fa7d0b650b5cfd80c099f785f5e411062e5" +} diff --git a/backend/.sqlx/query-975e404ac3a6258bb8220e122e3de094c7ab23330fdbc74d6e4ad472ddd3c820.json b/backend/.sqlx/query-975e404ac3a6258bb8220e122e3de094c7ab23330fdbc74d6e4ad472ddd3c820.json new file mode 100644 index 0000000000..87666986a3 --- /dev/null +++ b/backend/.sqlx/query-975e404ac3a6258bb8220e122e3de094c7ab23330fdbc74d6e4ad472ddd3c820.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO global_settings (name, value) VALUES ($1, $2)\n ON CONFLICT (name) DO UPDATE SET value = $2, updated_at = now()", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "975e404ac3a6258bb8220e122e3de094c7ab23330fdbc74d6e4ad472ddd3c820" +} diff --git a/backend/.sqlx/query-9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b.json b/backend/.sqlx/query-9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b.json new file mode 100644 index 0000000000..0bc09d9430 --- /dev/null +++ b/backend/.sqlx/query-9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE v2_job_completed SET status = 'canceled'::job_status, canceled_by = $2, canceled_reason = $3 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b" +} diff --git a/backend/.sqlx/query-9b61979cc78e8b7b63ffa6a59cf3d29ae8b924200c46da457a9346379c60fa9f.json b/backend/.sqlx/query-9b61979cc78e8b7b63ffa6a59cf3d29ae8b924200c46da457a9346379c60fa9f.json new file mode 100644 index 0000000000..112ca1603f --- /dev/null +++ b/backend/.sqlx/query-9b61979cc78e8b7b63ffa6a59cf3d29ae8b924200c46da457a9346379c60fa9f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET remote_deploy_target = $1::jsonb,\n remote_deploy_target_changed_at = CASE\n WHEN remote_deploy_target IS DISTINCT FROM $1::jsonb THEN clock_timestamp()\n ELSE remote_deploy_target_changed_at END\n WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9b61979cc78e8b7b63ffa6a59cf3d29ae8b924200c46da457a9346379c60fa9f" +} diff --git a/backend/.sqlx/query-9bd1995747f0073b3a866d1238e78f7e6dfa9185056f7731547ff05b8176b271.json b/backend/.sqlx/query-9bd1995747f0073b3a866d1238e78f7e6dfa9185056f7731547ff05b8176b271.json new file mode 100644 index 0000000000..9c4d587c01 --- /dev/null +++ b/backend/.sqlx/query-9bd1995747f0073b3a866d1238e78f7e6dfa9185056f7731547ff05b8176b271.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET add_admins_and_developers_to_forks = $1 WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9bd1995747f0073b3a866d1238e78f7e6dfa9185056f7731547ff05b8176b271" +} diff --git a/backend/.sqlx/query-9d1b202844e6935e636d1570a92d46af4419dda9410ffc46f19bc38e56efe609.json b/backend/.sqlx/query-9d1b202844e6935e636d1570a92d46af4419dda9410ffc46f19bc38e56efe609.json new file mode 100644 index 0000000000..f882ce2986 --- /dev/null +++ b/backend/.sqlx/query-9d1b202844e6935e636d1570a92d46af4419dda9410ffc46f19bc38e56efe609.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM draft_move\n WHERE workspace_id = $1 AND typ::text = ANY($2::text[])\n AND new_path = $4 AND old_path <> $3 AND ($5::text IS NULL OR email = $5)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "TextArray", + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9d1b202844e6935e636d1570a92d46af4419dda9410ffc46f19bc38e56efe609" +} diff --git a/backend/.sqlx/query-9f663180166f53d117e794f3f3a5723a0a43db163ecca7d5a63d4e74ab1d3be1.json b/backend/.sqlx/query-9f663180166f53d117e794f3f3a5723a0a43db163ecca7d5a63d4e74ab1d3be1.json new file mode 100644 index 0000000000..4dd7ea9bd8 --- /dev/null +++ b/backend/.sqlx/query-9f663180166f53d117e794f3f3a5723a0a43db163ecca7d5a63d4e74ab1d3be1.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT pg_advisory_xact_lock(hashtext('datatable_role_catalog'))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pg_advisory_xact_lock", + "type_info": "Void" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "9f663180166f53d117e794f3f3a5723a0a43db163ecca7d5a63d4e74ab1d3be1" +} diff --git a/backend/.sqlx/query-a0f1c0df6bc2f1fbca50edee90e42c94445536e201b322eda6f7a90bdf38f36a.json b/backend/.sqlx/query-a0f1c0df6bc2f1fbca50edee90e42c94445536e201b322eda6f7a90bdf38f36a.json deleted file mode 100644 index 3ed4f5a316..0000000000 --- a/backend/.sqlx/query-a0f1c0df6bc2f1fbca50edee90e42c94445536e201b322eda6f7a90bdf38f36a.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version \n LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version\n WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 \n ORDER BY flow_version.created_at DESC", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, - { - "ordinal": 1, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 2, - "name": "deployment_msg", - "type_info": "Text" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false, - false, - true - ] - }, - "hash": "a0f1c0df6bc2f1fbca50edee90e42c94445536e201b322eda6f7a90bdf38f36a" -} diff --git a/backend/.sqlx/query-a1bcf143135ecb9f32709a56a353d3246dc238920c3e155caa9cf767246fa6fb.json b/backend/.sqlx/query-a1bcf143135ecb9f32709a56a353d3246dc238920c3e155caa9cf767246fa6fb.json new file mode 100644 index 0000000000..18ee22083e --- /dev/null +++ b/backend/.sqlx/query-a1bcf143135ecb9f32709a56a353d3246dc238920c3e155caa9cf767246fa6fb.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET auto_invite = (COALESCE(auto_invite, '{}'::jsonb) - 'domain') || $1::jsonb WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Text" + ] + }, + "nullable": [] + }, + "hash": "a1bcf143135ecb9f32709a56a353d3246dc238920c3e155caa9cf767246fa6fb" +} diff --git a/backend/.sqlx/query-a1ed94ef0a6d4f7bc89ac4a2341bc06dc95d60ec92469523935f5a18a4d3f907.json b/backend/.sqlx/query-a1ed94ef0a6d4f7bc89ac4a2341bc06dc95d60ec92469523935f5a18a4d3f907.json new file mode 100644 index 0000000000..c536425f1d --- /dev/null +++ b/backend/.sqlx/query-a1ed94ef0a6d4f7bc89ac4a2341bc06dc95d60ec92469523935f5a18a4d3f907.json @@ -0,0 +1,85 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n -- The guard's own predicate, ordered own row, then legacy, then another\n -- user's other-kind row: each needs a different remedy, and a bare\n -- LIMIT 1 would name an arbitrary one.\n (SELECT typ::text FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[])\n AND (typ <> $2 OR email = $4 OR email IS NULL)\n ORDER BY CASE WHEN email = $4 THEN 0 WHEN email IS NULL THEN 1 ELSE 2 END\n LIMIT 1) as \"at_target\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[]) AND email = $4) as \"at_target_own!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3\n AND typ::text = ANY($6::text[]) AND email IS NULL) as \"at_target_legacy!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5\n AND typ = $2 AND email = $4\n AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0\n ) as \"poisoned!\",\n EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5\n AND typ = $2 AND email IS NULL) as \"legacy!\" ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "at_target", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "at_target_own!", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "at_target_legacy!", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "poisoned!", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "legacy!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github", + "data_pipeline", + "trigger_amqp" + ] + } + } + }, + "Text", + "Text", + "Text", + "TextArray" + ] + }, + "nullable": [ + null, + null, + null, + null, + null + ] + }, + "hash": "a1ed94ef0a6d4f7bc89ac4a2341bc06dc95d60ec92469523935f5a18a4d3f907" +} diff --git a/backend/.sqlx/query-a57de2bb0442a5ee8a607cd63cfcf675de175796184f620cb4b09670c8b0b19f.json b/backend/.sqlx/query-a57de2bb0442a5ee8a607cd63cfcf675de175796184f620cb4b09670c8b0b19f.json new file mode 100644 index 0000000000..f3b2cb571e --- /dev/null +++ b/backend/.sqlx/query-a57de2bb0442a5ee8a607cd63cfcf675de175796184f620cb4b09670c8b0b19f.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE capture_config SET server_id = NULL, last_server_ping = NULL\n WHERE workspace_id = $1 AND trigger_kind = 'postgres'\n AND (trigger_config->>'postgres_resource_path' = $2\n OR trigger_config->>'postgres_resource_path' LIKE $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "a57de2bb0442a5ee8a607cd63cfcf675de175796184f620cb4b09670c8b0b19f" +} diff --git a/backend/.sqlx/query-a725d5b99b65a1637406179b058a1ff36620099c6b87fd82a59eae1e35303c07.json b/backend/.sqlx/query-a725d5b99b65a1637406179b058a1ff36620099c6b87fd82a59eae1e35303c07.json new file mode 100644 index 0000000000..2acc85fcd4 --- /dev/null +++ b/backend/.sqlx/query-a725d5b99b65a1637406179b058a1ff36620099c6b87fd82a59eae1e35303c07.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE draft_move SET workspace_id = $1 WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "a725d5b99b65a1637406179b058a1ff36620099c6b87fd82a59eae1e35303c07" +} diff --git a/backend/.sqlx/query-a866da25370c8b829fd8038c55c0f43e27a6eea453415495b66c50045defacff.json b/backend/.sqlx/query-a866da25370c8b829fd8038c55c0f43e27a6eea453415495b66c50045defacff.json new file mode 100644 index 0000000000..18951e2a87 --- /dev/null +++ b/backend/.sqlx/query-a866da25370c8b829fd8038c55c0f43e27a6eea453415495b66c50045defacff.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM usr WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "a866da25370c8b829fd8038c55c0f43e27a6eea453415495b66c50045defacff" +} diff --git a/backend/.sqlx/query-a9ae6c222727b7078fd25ed91f51c363a4e5719b5ceb17e8194c2eb038f59c68.json b/backend/.sqlx/query-a9ae6c222727b7078fd25ed91f51c363a4e5719b5ceb17e8194c2eb038f59c68.json new file mode 100644 index 0000000000..6529400823 --- /dev/null +++ b/backend/.sqlx/query-a9ae6c222727b7078fd25ed91f51c363a4e5719b5ceb17e8194c2eb038f59c68.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE remote_deploy_token SET token = 'not-under-this-key'\n WHERE workspace_id = 'test-workspace'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "a9ae6c222727b7078fd25ed91f51c363a4e5719b5ceb17e8194c2eb038f59c68" +} diff --git a/backend/.sqlx/query-af1bc312f13d7590a138e2de4996dab8e5ac9c774cf81df9da85ac3c40e8b3c7.json b/backend/.sqlx/query-af1bc312f13d7590a138e2de4996dab8e5ac9c774cf81df9da85ac3c40e8b3c7.json new file mode 100644 index 0000000000..4ec7511c7c --- /dev/null +++ b/backend/.sqlx/query-af1bc312f13d7590a138e2de4996dab8e5ac9c774cf81df9da85ac3c40e8b3c7.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin)\n VALUES (encode(sha256('GONE_TOKEN'::bytea), 'hex'), 'GONE_TOKEN', 'GONE_TOKEN',\n 'test@windmill.dev', 'gone', true)", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "af1bc312f13d7590a138e2de4996dab8e5ac9c774cf81df9da85ac3c40e8b3c7" +} diff --git a/backend/.sqlx/query-255ba68caa78d0e814ea817693a319f769907025f9c7d1150d45e8ecb1bff4ab.json b/backend/.sqlx/query-b2014ff45992b34c08f27419c26e975ff2b1baee63651e0ce79156b331389923.json similarity index 52% rename from backend/.sqlx/query-255ba68caa78d0e814ea817693a319f769907025f9c7d1150d45e8ecb1bff4ab.json rename to backend/.sqlx/query-b2014ff45992b34c08f27419c26e975ff2b1baee63651e0ce79156b331389923.json index 245c65a7a8..77cd542d6b 100644 --- a/backend/.sqlx/query-255ba68caa78d0e814ea817693a319f769907025f9c7d1150d45e8ecb1bff4ab.json +++ b/backend/.sqlx/query-b2014ff45992b34c08f27419c26e975ff2b1baee63651e0ce79156b331389923.json @@ -1,15 +1,15 @@ { "db_name": "PostgreSQL", - "query": "UPDATE workspace_settings SET auto_invite = $1 WHERE workspace_id = $2", + "query": "DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2", "describe": { "columns": [], "parameters": { "Left": [ - "Jsonb", + "Text", "Text" ] }, "nullable": [] }, - "hash": "255ba68caa78d0e814ea817693a319f769907025f9c7d1150d45e8ecb1bff4ab" + "hash": "b2014ff45992b34c08f27419c26e975ff2b1baee63651e0ce79156b331389923" } diff --git a/backend/.sqlx/query-d631a26e5531589ff37e677f91a4f1d9f850e3e46c17130dd580426cda7a9f65.json b/backend/.sqlx/query-b30fb28004897cd1d201a416cdabf124a055eb44b3c5892fd18ff7baf6010ae9.json similarity index 74% rename from backend/.sqlx/query-d631a26e5531589ff37e677f91a4f1d9f850e3e46c17130dd580426cda7a9f65.json rename to backend/.sqlx/query-b30fb28004897cd1d201a416cdabf124a055eb44b3c5892fd18ff7baf6010ae9.json index 8cef9fc8aa..c15e0edb33 100644 --- a/backend/.sqlx/query-d631a26e5531589ff37e677f91a4f1d9f850e3e46c17130dd580426cda7a9f65.json +++ b/backend/.sqlx/query-b30fb28004897cd1d201a416cdabf124a055eb44b3c5892fd18ff7baf6010ae9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n ))\n RETURNING token_prefix", + "query": "UPDATE token SET label = $1\n WHERE email = $2 AND token_prefix = $3\n AND (label IS NULL OR (\n label <> 'session'\n AND label <> 'guest_session'\n AND lower(label) NOT LIKE 'ephemeral%'\n AND label <> 'debugger-token'\n AND label NOT LIKE 'mcp-oauth-%'\n AND NOT starts_with(label, 'embed_app:')\n AND NOT starts_with(label, 'sdk_app:')\n AND NOT starts_with(label, 'impersonation:')\n AND NOT starts_with(label, 'cli-login:')\n AND NOT starts_with(label, 'remote-deploy:')\n ))\n RETURNING token_prefix", "describe": { "columns": [ { @@ -20,5 +20,5 @@ false ] }, - "hash": "d631a26e5531589ff37e677f91a4f1d9f850e3e46c17130dd580426cda7a9f65" + "hash": "b30fb28004897cd1d201a416cdabf124a055eb44b3c5892fd18ff7baf6010ae9" } diff --git a/backend/.sqlx/query-b42af37fb474bea4c5419b0a46d9eadfe384013ab970ccf9c5effd1c78321b7c.json b/backend/.sqlx/query-b42af37fb474bea4c5419b0a46d9eadfe384013ab970ccf9c5effd1c78321b7c.json new file mode 100644 index 0000000000..65c7f47b30 --- /dev/null +++ b/backend/.sqlx/query-b42af37fb474bea4c5419b0a46d9eadfe384013ab970ccf9c5effd1c78321b7c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ws.datatable->'datatables' FROM workspace_settings ws\n WHERE ws.workspace_id = $1 FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b42af37fb474bea4c5419b0a46d9eadfe384013ab970ccf9c5effd1c78321b7c" +} diff --git a/backend/.sqlx/query-b9842d2d8abf382bd82d8fa1de012373638be391f884f81dc387ffc465badac6.json b/backend/.sqlx/query-b9842d2d8abf382bd82d8fa1de012373638be391f884f81dc387ffc465badac6.json new file mode 100644 index 0000000000..4b3a2f33e9 --- /dev/null +++ b/backend/.sqlx/query-b9842d2d8abf382bd82d8fa1de012373638be391f884f81dc387ffc465badac6.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT jsonb_object_keys(value->'databases') FROM global_settings\n WHERE name = 'custom_instance_pg_databases'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "jsonb_object_keys", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "b9842d2d8abf382bd82d8fa1de012373638be391f884f81dc387ffc465badac6" +} diff --git a/backend/.sqlx/query-b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142.json b/backend/.sqlx/query-b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142.json deleted file mode 100644 index 94cf77ebc5..0000000000 --- a/backend/.sqlx/query-b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via)\n SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via\n FROM usr WHERE workspace_id = $2\n ON CONFLICT DO NOTHING", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "b98844926ff127c528ed3e7bc63bf1ebed0192be267983e8fbd18f79997e6142" -} diff --git a/backend/.sqlx/query-bd8ac169ad15346f28f561233fe340efd453512c1464a413af2393ab8bfea9ee.json b/backend/.sqlx/query-bd8ac169ad15346f28f561233fe340efd453512c1464a413af2393ab8bfea9ee.json new file mode 100644 index 0000000000..8a59831e8a --- /dev/null +++ b/backend/.sqlx/query-bd8ac169ad15346f28f561233fe340efd453512c1464a413af2393ab8bfea9ee.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg,\n av.created_by as created_by, av.created_at as created_at\n FROM app a JOIN app_version av\n ON av.id = a.versions[array_upper(a.versions, 1)] AND av.app_id = a.id\n LEFT JOIN deployment_metadata dm ON av.id = dm.app_version\n WHERE a.workspace_id = $1 AND a.path = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "app_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "deployment_msg", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false, + false + ] + }, + "hash": "bd8ac169ad15346f28f561233fe340efd453512c1464a413af2393ab8bfea9ee" +} diff --git a/backend/.sqlx/query-c1d026c886799dabc39ce73e1fe09ccb175c7271df75d67aa9c72ad6f825a992.json b/backend/.sqlx/query-c1d026c886799dabc39ce73e1fe09ccb175c7271df75d67aa9c72ad6f825a992.json new file mode 100644 index 0000000000..f5fde93bf7 --- /dev/null +++ b/backend/.sqlx/query-c1d026c886799dabc39ce73e1fe09ccb175c7271df75d67aa9c72ad6f825a992.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM usr WHERE email = $1 RETURNING username, workspace_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "workspace_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "c1d026c886799dabc39ce73e1fe09ccb175c7271df75d67aa9c72ad6f825a992" +} diff --git a/backend/.sqlx/query-c5451ea9d9fa5146af242d1ee8c19ebd65b80e7ed9f29b9fb2e03767c2aa94ba.json b/backend/.sqlx/query-c5451ea9d9fa5146af242d1ee8c19ebd65b80e7ed9f29b9fb2e03767c2aa94ba.json new file mode 100644 index 0000000000..5d8ca8dc00 --- /dev/null +++ b/backend/.sqlx/query-c5451ea9d9fa5146af242d1ee8c19ebd65b80e7ed9f29b9fb2e03767c2aa94ba.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"datatable!\"\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE dt.value->'reference'->>'workspace_id' = $1\n ORDER BY ws.workspace_id, dt.key", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "datatable!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "c5451ea9d9fa5146af242d1ee8c19ebd65b80e7ed9f29b9fb2e03767c2aa94ba" +} diff --git a/backend/.sqlx/query-c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7.json b/backend/.sqlx/query-c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7.json new file mode 100644 index 0000000000..4ed2afc15b --- /dev/null +++ b/backend/.sqlx/query-c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT q.scheduled_for, s.path, s.schedule, s.cron_version, s.timezone\n FROM v2_job j\n JOIN v2_job_queue q USING (id)\n JOIN schedule s ON s.workspace_id = j.workspace_id AND s.path = j.trigger\n WHERE j.id = $1 AND j.workspace_id = $2 AND j.trigger_kind = 'schedule'\n AND j.parent_job IS NULL AND q.scheduled_for > now()", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "scheduled_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "schedule", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "cron_version", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "timezone", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + false + ] + }, + "hash": "c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7" +} diff --git a/backend/.sqlx/query-c85d362fe2e652d4ac01a35bf470e80b993020a2ff5dcb5849dc570d52798587.json b/backend/.sqlx/query-c85d362fe2e652d4ac01a35bf470e80b993020a2ff5dcb5849dc570d52798587.json new file mode 100644 index 0000000000..7a7da4308d --- /dev/null +++ b/backend/.sqlx/query-c85d362fe2e652d4ac01a35bf470e80b993020a2ff5dcb5849dc570d52798587.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM datatable_role WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "c85d362fe2e652d4ac01a35bf470e80b993020a2ff5dcb5849dc570d52798587" +} diff --git a/backend/.sqlx/query-c96486a83ba0f4bf8daf3c525d6f67554643d0e730bec2e64840ad3d1862e540.json b/backend/.sqlx/query-c96486a83ba0f4bf8daf3c525d6f67554643d0e730bec2e64840ad3d1862e540.json new file mode 100644 index 0000000000..7422dd2333 --- /dev/null +++ b/backend/.sqlx/query-c96486a83ba0f4bf8daf3c525d6f67554643d0e730bec2e64840ad3d1862e540.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM remote_deploy_token WHERE email = 'test@windmill.dev'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "c96486a83ba0f4bf8daf3c525d6f67554643d0e730bec2e64840ad3d1862e540" +} diff --git a/backend/.sqlx/query-cad45b99cd110589665e864f73403c2b12a3cf12ee81e67b0e9e520b3315e7d6.json b/backend/.sqlx/query-cad45b99cd110589665e864f73403c2b12a3cf12ee81e67b0e9e520b3315e7d6.json new file mode 100644 index 0000000000..85c999e3ba --- /dev/null +++ b/backend/.sqlx/query-cad45b99cd110589665e864f73403c2b12a3cf12ee81e67b0e9e520b3315e7d6.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings\n SET remote_deploy_target = $1, remote_deploy_target_changed_at = now()\n WHERE workspace_id = 'test-workspace'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "cad45b99cd110589665e864f73403c2b12a3cf12ee81e67b0e9e520b3315e7d6" +} diff --git a/backend/.sqlx/query-5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf.json b/backend/.sqlx/query-cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json similarity index 60% rename from backend/.sqlx/query-5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf.json rename to backend/.sqlx/query-cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json index 3d61a47eb1..f631078e76 100644 --- a/backend/.sqlx/query-5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf.json +++ b/backend/.sqlx/query-cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE v2_job_status SET\n flow_status = jsonb_set(\n jsonb_set(flow_status, ARRAY['modules', $3::INTEGER::TEXT, 'job'], to_jsonb($1::UUID::TEXT)),\n ARRAY['modules', $3::INTEGER::TEXT, 'type'],\n to_jsonb('InProgress'::text)\n )\n WHERE id = $2", + "query": "UPDATE v2_job_status SET\n flow_status = jsonb_set(\n jsonb_set(flow_status, ARRAY['modules', $3::INTEGER::TEXT, 'job'], to_jsonb($1::UUID::TEXT)),\n ARRAY['modules', $3::INTEGER::TEXT, 'type'],\n to_jsonb('InProgress'::text)\n )\n WHERE id = $2\n AND NOT COALESCE(\n flow_status->'modules'->$3::INTEGER @> '{\"type\": \"InProgress\", \"parallel\": true}',\n false\n )", "describe": { "columns": [], "parameters": { @@ -12,5 +12,5 @@ }, "nullable": [] }, - "hash": "5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf" + "hash": "cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b" } diff --git a/backend/.sqlx/query-d0548225d92e6e7d0eb9a0227a6522398d2a3ae99d0568b785e63749d6f3225a.json b/backend/.sqlx/query-d0548225d92e6e7d0eb9a0227a6522398d2a3ae99d0568b785e63749d6f3225a.json new file mode 100644 index 0000000000..2ff2722f77 --- /dev/null +++ b/backend/.sqlx/query-d0548225d92e6e7d0eb9a0227a6522398d2a3ae99d0568b785e63749d6f3225a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings ws\n SET datatable = (\n SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg(\n dt.key,\n (SELECT CASE WHEN r.v->'governed_by'->>'workspace_id' = $2\n THEN jsonb_set(r.v, '{governed_by,workspace_id}', to_jsonb($1::text))\n ELSE r.v END\n FROM (SELECT CASE WHEN dt.value->'reference'->>'workspace_id' = $2\n THEN jsonb_set(dt.value, '{reference,workspace_id}', to_jsonb($1::text))\n ELSE dt.value END AS v) r)\n ))\n FROM jsonb_each(ws.datatable->'datatables') dt\n )\n WHERE jsonb_typeof(ws.datatable->'datatables') = 'object'\n AND (ws.datatable::text LIKE '%\"reference\"%'\n OR ws.datatable::text LIKE '%\"governed_by\"%')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "d0548225d92e6e7d0eb9a0227a6522398d2a3ae99d0568b785e63749d6f3225a" +} diff --git a/backend/.sqlx/query-d21d9d7e37045529ae4b73febc145cb080009a7a6d9ef33111755b470ff5abe2.json b/backend/.sqlx/query-d21d9d7e37045529ae4b73febc145cb080009a7a6d9ef33111755b470ff5abe2.json new file mode 100644 index 0000000000..cde3e698d9 --- /dev/null +++ b/backend/.sqlx/query-d21d9d7e37045529ae4b73febc145cb080009a7a6d9ef33111755b470ff5abe2.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM remote_deploy_token WHERE workspace_id = $1 AND email = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "d21d9d7e37045529ae4b73febc145cb080009a7a6d9ef33111755b470ff5abe2" +} diff --git a/backend/.sqlx/query-d305aed6172e25707492677b6cd3b8f3f6ea8f2c8f5bdc1a97736c8e52378ee9.json b/backend/.sqlx/query-d305aed6172e25707492677b6cd3b8f3f6ea8f2c8f5bdc1a97736c8e52378ee9.json new file mode 100644 index 0000000000..4031e19231 --- /dev/null +++ b/backend/.sqlx/query-d305aed6172e25707492677b6cd3b8f3f6ea8f2c8f5bdc1a97736c8e52378ee9.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE usr SET created_at = created_at - interval '1 hour'\n WHERE workspace_id = 'test-workspace' AND email = 'test2@windmill.dev'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "d305aed6172e25707492677b6cd3b8f3f6ea8f2c8f5bdc1a97736c8e52378ee9" +} diff --git a/backend/.sqlx/query-d48ca62c86b1af7a9dd2450c1c28dc45020a2a553d8874c49f9eafedea5a9d40.json b/backend/.sqlx/query-d48ca62c86b1af7a9dd2450c1c28dc45020a2a553d8874c49f9eafedea5a9d40.json new file mode 100644 index 0000000000..49875acd78 --- /dev/null +++ b/backend/.sqlx/query-d48ca62c86b1af7a9dd2450c1c28dc45020a2a553d8874c49f9eafedea5a9d40.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT dt.key AS \"datatable!\"\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE ws.workspace_id = $1\n AND dt.key <> $2\n AND NOT dt.value ? 'permissions'\n AND dt.value->'database'->>'resource_type' = 'instance'\n AND dt.value->'database'->>'resource_path' = $3\n ORDER BY dt.key", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "datatable!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "d48ca62c86b1af7a9dd2450c1c28dc45020a2a553d8874c49f9eafedea5a9d40" +} diff --git a/backend/.sqlx/query-8aae160c589adf02e20b7e6ba860b66fb08f156776b96b7f3701aed330a7d000.json b/backend/.sqlx/query-d50d7a93f5c97570e3ef51ef542de41e9c1a9230911a2331d7127572722e340e.json similarity index 54% rename from backend/.sqlx/query-8aae160c589adf02e20b7e6ba860b66fb08f156776b96b7f3701aed330a7d000.json rename to backend/.sqlx/query-d50d7a93f5c97570e3ef51ef542de41e9c1a9230911a2331d7127572722e340e.json index d05c5b11af..edc0efd9ca 100644 --- a/backend/.sqlx/query-8aae160c589adf02e20b7e6ba860b66fb08f156776b96b7f3701aed330a7d000.json +++ b/backend/.sqlx/query-d50d7a93f5c97570e3ef51ef542de41e9c1a9230911a2331d7127572722e340e.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email as \"email!\", (email NOT IN (SELECT email FROM authors)) as operator_only, NULL::bool as is_workspace_admin, login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n UNION ALL\n SELECT email as \"email!\", operator as operator_only, is_admin as is_workspace_admin, 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC\n LIMIT $1 OFFSET $2", + "query": "WITH active_users AS (SELECT distinct username as email FROM audit_partitioned WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email as \"email!\", (email NOT IN (SELECT email FROM authors)) as operator_only, NULL::bool as is_workspace_admin, login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n UNION ALL\n SELECT email as \"email!\", operator as operator_only, is_admin as is_workspace_admin, 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC\n LIMIT $1 OFFSET $2", "describe": { "columns": [ { @@ -97,5 +97,5 @@ null ] }, - "hash": "8aae160c589adf02e20b7e6ba860b66fb08f156776b96b7f3701aed330a7d000" + "hash": "d50d7a93f5c97570e3ef51ef542de41e9c1a9230911a2331d7127572722e340e" } diff --git a/backend/.sqlx/query-d5fb5dde6300862f978739a3d9249fc2b3e7697c0da7d3195398933d3d81aadf.json b/backend/.sqlx/query-d5fb5dde6300862f978739a3d9249fc2b3e7697c0da7d3195398933d3d81aadf.json new file mode 100644 index 0000000000..6297c4d366 --- /dev/null +++ b/backend/.sqlx/query-d5fb5dde6300862f978739a3d9249fc2b3e7697c0da7d3195398933d3d81aadf.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT datatable FROM workspace_settings WHERE workspace_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "datatable", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "d5fb5dde6300862f978739a3d9249fc2b3e7697c0da7d3195398933d3d81aadf" +} diff --git a/backend/.sqlx/query-d8186f0cee285aa50db7626409aec7e0504b068ffa8bb185d9384ce1422fd3d1.json b/backend/.sqlx/query-d8186f0cee285aa50db7626409aec7e0504b068ffa8bb185d9384ce1422fd3d1.json deleted file mode 100644 index 70872a1328..0000000000 --- a/backend/.sqlx/query-d8186f0cee285aa50db7626409aec7e0504b068ffa8bb185d9384ce1422fd3d1.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "DELETE FROM audit WHERE timestamp <= now() - ($1::bigint::text || ' s')::interval", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [] - }, - "hash": "d8186f0cee285aa50db7626409aec7e0504b068ffa8bb185d9384ce1422fd3d1" -} diff --git a/backend/.sqlx/query-da5057c86607327bafc2942c218025ca9181a0c396405984d87e422e129521c1.json b/backend/.sqlx/query-da5057c86607327bafc2942c218025ca9181a0c396405984d87e422e129521c1.json new file mode 100644 index 0000000000..4e63a2afa6 --- /dev/null +++ b/backend/.sqlx/query-da5057c86607327bafc2942c218025ca9181a0c396405984d87e422e129521c1.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings ws\n SET datatable = (\n SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg(\n dt.key,\n CASE WHEN dt.value->'reference'->>'workspace_id' = $1\n AND dt.value->'reference'->>'datatable' = $2\n THEN jsonb_set(dt.value, '{reference,datatable}', to_jsonb($3::text))\n ELSE dt.value END\n ))\n FROM jsonb_each(ws.datatable->'datatables') dt\n )\n WHERE jsonb_typeof(ws.datatable->'datatables') = 'object'\n AND ws.datatable::text LIKE '%\"reference\"%'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "da5057c86607327bafc2942c218025ca9181a0c396405984d87e422e129521c1" +} diff --git a/backend/.sqlx/query-dc37f9ad685a2f2bd781be8678e0548a860815f30e866362344434f23aa7c83c.json b/backend/.sqlx/query-dc37f9ad685a2f2bd781be8678e0548a860815f30e866362344434f23aa7c83c.json new file mode 100644 index 0000000000..02d889c38b --- /dev/null +++ b/backend/.sqlx/query-dc37f9ad685a2f2bd781be8678e0548a860815f30e866362344434f23aa7c83c.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "WITH active_users as (SELECT distinct username as email FROM audit_partitioned WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n active_authors as (SELECT distinct email FROM usr WHERE usr.operator IS false AND email IN (SELECT email FROM active_users)),\n active_authors_agg as (SELECT array_agg(email) as authors FROM active_authors),\n active_ops_agg as (SELECT array_agg(email) as operators from active_users WHERE email NOT IN (SELECT email FROM active_authors))\n SELECT active_authors_agg.authors, active_ops_agg.operators, array_length(active_authors_agg.authors, 1) as author_count, array_length(active_ops_agg.operators, 1) as operator_count FROM active_authors_agg, active_ops_agg", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "authors", + "type_info": "VarcharArray" + }, + { + "ordinal": 1, + "name": "operators", + "type_info": "VarcharArray" + }, + { + "ordinal": 2, + "name": "author_count", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "operator_count", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null, + null, + null + ] + }, + "hash": "dc37f9ad685a2f2bd781be8678e0548a860815f30e866362344434f23aa7c83c" +} diff --git a/backend/.sqlx/query-dc8dfc37559e9b6713bde48155f48b5a2c7b8199eace1508e102b60d1ff40c04.json b/backend/.sqlx/query-dc8dfc37559e9b6713bde48155f48b5a2c7b8199eace1508e102b60d1ff40c04.json new file mode 100644 index 0000000000..def9ee0edb --- /dev/null +++ b/backend/.sqlx/query-dc8dfc37559e9b6713bde48155f48b5a2c7b8199eace1508e102b60d1ff40c04.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value->'roles' FROM global_settings WHERE name = 'custom_instance_pg_databases'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "dc8dfc37559e9b6713bde48155f48b5a2c7b8199eace1508e102b60d1ff40c04" +} diff --git a/backend/.sqlx/query-dfa54341a687431ea46ec842ab4167bcfd875cb4646ef859a7b855428bf48290.json b/backend/.sqlx/query-dfa54341a687431ea46ec842ab4167bcfd875cb4646ef859a7b855428bf48290.json new file mode 100644 index 0000000000..7acdcf8801 --- /dev/null +++ b/backend/.sqlx/query-dfa54341a687431ea46ec842ab4167bcfd875cb4646ef859a7b855428bf48290.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "\n WITH capped AS (\n SELECT timestamp, operation, resource, parameters\n FROM audit_partitioned\n WHERE workspace_id = 'admins'\n AND operation = 'workspace_fairness.capped'\n ORDER BY timestamp DESC\n LIMIT 200\n ), uncapped AS (\n SELECT timestamp, operation, resource, parameters\n FROM audit_partitioned\n WHERE workspace_id = 'admins'\n AND operation = 'workspace_fairness.uncapped'\n ORDER BY timestamp DESC\n LIMIT 200\n )\n SELECT timestamp AS \"timestamp!\",\n operation::text AS \"operation!\",\n resource AS workspace_id,\n parameters\n FROM (SELECT * FROM capped UNION ALL SELECT * FROM uncapped) e\n ORDER BY timestamp DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "timestamp!", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "operation!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "parameters", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null, + null, + null + ] + }, + "hash": "dfa54341a687431ea46ec842ab4167bcfd875cb4646ef859a7b855428bf48290" +} diff --git a/backend/.sqlx/query-e159b2ff15633f85e839ee4fe1ec2ecd11caf228ea8d0f52ad66def595644250.json b/backend/.sqlx/query-e159b2ff15633f85e839ee4fe1ec2ecd11caf228ea8d0f52ad66def595644250.json new file mode 100644 index 0000000000..3f7245f35f --- /dev/null +++ b/backend/.sqlx/query-e159b2ff15633f85e839ee4fe1ec2ecd11caf228ea8d0f52ad66def595644250.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE postgres_trigger SET server_id = NULL, last_server_ping = NULL\n WHERE workspace_id = $1\n AND (postgres_resource_path = $2 OR postgres_resource_path LIKE $3)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "e159b2ff15633f85e839ee4fe1ec2ecd11caf228ea8d0f52ad66def595644250" +} diff --git a/backend/.sqlx/query-e2061df65ffd4a72146c4ca316829265289c8d6f625ac272655c88e1ad0b1745.json b/backend/.sqlx/query-e2061df65ffd4a72146c4ca316829265289c8d6f625ac272655c88e1ad0b1745.json new file mode 100644 index 0000000000..0e3c0182a9 --- /dev/null +++ b/backend/.sqlx/query-e2061df65ffd4a72146c4ca316829265289c8d6f625ac272655c88e1ad0b1745.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT datatable FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "datatable", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "e2061df65ffd4a72146c4ca316829265289c8d6f625ac272655c88e1ad0b1745" +} diff --git a/backend/.sqlx/query-e2760debbb69182f1385bfe8e18933eeff7396430607c02cbd0cc682d5c34d61.json b/backend/.sqlx/query-e2760debbb69182f1385bfe8e18933eeff7396430607c02cbd0cc682d5c34d61.json new file mode 100644 index 0000000000..9930e0494f --- /dev/null +++ b/backend/.sqlx/query-e2760debbb69182f1385bfe8e18933eeff7396430607c02cbd0cc682d5c34d61.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT canceled_by IS NOT NULL AS \"canceled!\" FROM v2_job_completed WHERE id = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "canceled!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "e2760debbb69182f1385bfe8e18933eeff7396430607c02cbd0cc682d5c34d61" +} diff --git a/backend/.sqlx/query-e428f0cdb9077bffe2b7271d9b5f30ed81e8c26a9f1e65f423078947153ff9dd.json b/backend/.sqlx/query-e428f0cdb9077bffe2b7271d9b5f30ed81e8c26a9f1e65f423078947153ff9dd.json new file mode 100644 index 0000000000..57b5e2abd1 --- /dev/null +++ b/backend/.sqlx/query-e428f0cdb9077bffe2b7271d9b5f30ed81e8c26a9f1e65f423078947153ff9dd.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE draft_move SET email = $1 WHERE email = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "e428f0cdb9077bffe2b7271d9b5f30ed81e8c26a9f1e65f423078947153ff9dd" +} diff --git a/backend/.sqlx/query-e524e89440004953cc0b1cb57b8df6bc09f19cf53b141fac5aefa981463d39fa.json b/backend/.sqlx/query-e524e89440004953cc0b1cb57b8df6bc09f19cf53b141fac5aefa981463d39fa.json new file mode 100644 index 0000000000..af8d0eb1ab --- /dev/null +++ b/backend/.sqlx/query-e524e89440004953cc0b1cb57b8df6bc09f19cf53b141fac5aefa981463d39fa.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT ws.workspace_id AS \"workspace_id!\", dt.key AS \"datatable!\"\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt\n WHERE EXISTS (\n SELECT 1 FROM (VALUES ('reference'), ('governed_by')) k(link)\n WHERE dt.value->k.link->>'workspace_id' = $1\n AND dt.value->k.link->>'datatable' = $2\n )", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "datatable!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "e524e89440004953cc0b1cb57b8df6bc09f19cf53b141fac5aefa981463d39fa" +} diff --git a/backend/.sqlx/query-e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b.json b/backend/.sqlx/query-e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b.json new file mode 100644 index 0000000000..f218653d46 --- /dev/null +++ b/backend/.sqlx/query-e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base)\n SELECT $2, path, typ,\n to_json(\n CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END\n - CASE WHEN typ = 'flow' THEN 'version_id'\n WHEN typ IN ('app', 'raw_app') THEN 'parent_version'\n ELSE '' END\n ),\n created_at, email,\n CASE WHEN typ = 'script' THEN base END\n FROM (\n SELECT d.path, d.typ, d.created_at, d.email, d.base,\n replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean\n FROM draft d\n WHERE d.workspace_id = $1 AND (d.email = $3 OR d.email IS NULL)\n ) s", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "e6140682ebc65083f48714ab3299fdb239ef507e23003db03aff7d35b02c540b" +} diff --git a/backend/.sqlx/query-e63da92247045f2aaaa62b9d6ee17510fd05d0b7c5311b61e7257ccd07b16da2.json b/backend/.sqlx/query-e63da92247045f2aaaa62b9d6ee17510fd05d0b7c5311b61e7257ccd07b16da2.json new file mode 100644 index 0000000000..926add8159 --- /dev/null +++ b/backend/.sqlx/query-e63da92247045f2aaaa62b9d6ee17510fd05d0b7c5311b61e7257ccd07b16da2.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT m.new_path FROM draft_move m\n WHERE m.workspace_id = $1 AND m.typ = $2 AND m.old_path = $3\n AND (m.email IS NULL OR m.email = $4)\n AND NOT EXISTS (\n SELECT 1 FROM draft d\n WHERE d.workspace_id = $1 AND d.typ = $2 AND d.path = $3\n AND d.email IS NOT DISTINCT FROM $4\n )\n ORDER BY m.email IS NULL\n LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "new_path", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + { + "Custom": { + "name": "draft_kind", + "kind": { + "Enum": [ + "script", + "flow", + "app", + "raw_app", + "resource", + "variable", + "trigger_schedule", + "trigger_webhook", + "trigger_default_email", + "trigger_email", + "trigger_http", + "trigger_websocket", + "trigger_postgres", + "trigger_kafka", + "trigger_nats", + "trigger_mqtt", + "trigger_sqs", + "trigger_gcp", + "trigger_azure", + "trigger_poll", + "trigger_cli", + "trigger_nextcloud", + "trigger_google", + "trigger_github", + "data_pipeline", + "trigger_amqp" + ] + } + } + }, + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e63da92247045f2aaaa62b9d6ee17510fd05d0b7c5311b61e7257ccd07b16da2" +} diff --git a/backend/.sqlx/query-ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447.json b/backend/.sqlx/query-e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a.json similarity index 78% rename from backend/.sqlx/query-ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447.json rename to backend/.sqlx/query-e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a.json index 1c247ad5b5..7462224850 100644 --- a/backend/.sqlx/query-ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447.json +++ b/backend/.sqlx/query-e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ", + "query": "\n SELECT\n workspace_id,\n slack_team_id,\n slack_name,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n mute_critical_alerts,\n guest_access_enabled,\n add_admins_and_developers_to_forks,\n deploy_ui,\n large_file_storage,\n datatable\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ", "describe": { "columns": [ { @@ -45,16 +45,21 @@ }, { "ordinal": 8, + "name": "add_admins_and_developers_to_forks", + "type_info": "Bool" + }, + { + "ordinal": 9, "name": "deploy_ui", "type_info": "Jsonb" }, { - "ordinal": 9, + "ordinal": 10, "name": "large_file_storage", "type_info": "Jsonb" }, { - "ordinal": 10, + "ordinal": 11, "name": "datatable", "type_info": "Jsonb" } @@ -73,10 +78,11 @@ true, true, false, + false, true, true, true ] }, - "hash": "ede15bff96152f209aff756830cbc76b5afa1af6ed324376989117b1054c3447" + "hash": "e6e31fdf705896c81f9a0f45d47c1b93db0406aaf278bccba07be00e9f937e2a" } diff --git a/backend/.sqlx/query-ebaf3ed3097621da59dd201b5a4b9d1f440692f183c7c378f59e4b73f1c6e241.json b/backend/.sqlx/query-ebaf3ed3097621da59dd201b5a4b9d1f440692f183c7c378f59e4b73f1c6e241.json new file mode 100644 index 0000000000..e5de67e73f --- /dev/null +++ b/backend/.sqlx/query-ebaf3ed3097621da59dd201b5a4b9d1f440692f183c7c378f59e4b73f1c6e241.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings\n SET datatable = jsonb_set(\n jsonb_set(\n datatable #- ARRAY['datatables', $2, 'reference'],\n ARRAY['datatables', $2, 'database'], $3::jsonb),\n ARRAY['datatables', $2, 'forked_from'], $4::jsonb\n )\n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Jsonb", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "ebaf3ed3097621da59dd201b5a4b9d1f440692f183c7c378f59e4b73f1c6e241" +} diff --git a/backend/.sqlx/query-ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b.json b/backend/.sqlx/query-ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b.json new file mode 100644 index 0000000000..b11e41fe89 --- /dev/null +++ b/backend/.sqlx/query-ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM v2_job_queue WHERE id = $1 RETURNING canceled_by, canceled_reason", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "canceled_by", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "canceled_reason", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true, + true + ] + }, + "hash": "ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b" +} diff --git a/backend/.sqlx/query-ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842.json b/backend/.sqlx/query-ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842.json new file mode 100644 index 0000000000..a1d873778f --- /dev/null +++ b/backend/.sqlx/query-ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "ee3ed82783b5b7d2f41f16a1d94f006687bd9de4611203b5dd0c28eacee02842" +} diff --git a/backend/.sqlx/query-ef98b0fab16d3b19997fd639b74a895cce1e8ae010307431e6787d20c7b25780.json b/backend/.sqlx/query-ef98b0fab16d3b19997fd639b74a895cce1e8ae010307431e6787d20c7b25780.json new file mode 100644 index 0000000000..3b89a91024 --- /dev/null +++ b/backend/.sqlx/query-ef98b0fab16d3b19997fd639b74a895cce1e8ae010307431e6787d20c7b25780.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version \n LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version\n WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 \n ORDER BY flow_version.created_at DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 2, + "name": "created_by", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "deployment_msg", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true + ] + }, + "hash": "ef98b0fab16d3b19997fd639b74a895cce1e8ae010307431e6787d20c7b25780" +} diff --git a/backend/.sqlx/query-a2122e8520268919e2ddc85ef46b5f9322229bb2a1e104b6eabaf2a697c2776a.json b/backend/.sqlx/query-f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff.json similarity index 85% rename from backend/.sqlx/query-a2122e8520268919e2ddc85ef46b5f9322229bb2a1e104b6eabaf2a697c2776a.json rename to backend/.sqlx/query-f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff.json index 0f43afed39..6964dfa8d6 100644 --- a/backend/.sqlx/query-a2122e8520268919e2ddc85ef46b5f9322229bb2a1e104b6eabaf2a697c2776a.json +++ b/backend/.sqlx/query-f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at,\n nt.summary,\n nt.enabled\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n )\n LIMIT $3\n OFFSET $4\n ", + "query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at,\n nt.summary,\n nt.enabled\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n ) AND\n ( $7\n OR nt.script_path = ANY($8)\n OR EXISTS ( SELECT 1 FROM unnest($9::text[]) AS pfx\n WHERE nt.script_path = pfx\n OR left(nt.script_path, length(pfx) + 1) = pfx || '/' ) )\n LIMIT $3\n OFFSET $4\n ", "describe": { "columns": [ { @@ -93,7 +93,10 @@ "Int8", "Int8", "Text", - "Bool" + "Bool", + "Bool", + "TextArray", + "TextArray" ] }, "nullable": [ @@ -111,5 +114,5 @@ false ] }, - "hash": "a2122e8520268919e2ddc85ef46b5f9322229bb2a1e104b6eabaf2a697c2776a" + "hash": "f3cd54017710536747b8aca797a8b983774ee2127dceb790181ed91b987f44ff" } diff --git a/backend/.sqlx/query-f3ee09fb17955ca8d886f446d397063c4094546a7807343b570b823796372cef.json b/backend/.sqlx/query-f3ee09fb17955ca8d886f446d397063c4094546a7807343b570b823796372cef.json new file mode 100644 index 0000000000..64183afaad --- /dev/null +++ b/backend/.sqlx/query-f3ee09fb17955ca8d886f446d397063c4094546a7807343b570b823796372cef.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT workspace_id FROM workspace_settings WHERE datatable::text LIKE $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "f3ee09fb17955ca8d886f446d397063c4094546a7807343b570b823796372cef" +} diff --git a/backend/.sqlx/query-f4adc9e26ebfebce18a29fb2c21bf06394cacb8a9699a608327b097e0ac1363e.json b/backend/.sqlx/query-f4adc9e26ebfebce18a29fb2c21bf06394cacb8a9699a608327b097e0ac1363e.json new file mode 100644 index 0000000000..b3dd09e2ba --- /dev/null +++ b/backend/.sqlx/query-f4adc9e26ebfebce18a29fb2c21bf06394cacb8a9699a608327b097e0ac1363e.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings\n SET datatable = CASE WHEN $3::jsonb = 'null'::jsonb\n THEN datatable #- ARRAY['datatables', $2, 'permissions']\n ELSE jsonb_set(datatable, ARRAY['datatables', $2, 'permissions'], $3::jsonb)\n END\n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "f4adc9e26ebfebce18a29fb2c21bf06394cacb8a9699a608327b097e0ac1363e" +} diff --git a/backend/.sqlx/query-f7ece5036ad92e485b5e15a70e5052b42aaf5c87e5be6333b2df67a81990c8a6.json b/backend/.sqlx/query-f7ece5036ad92e485b5e15a70e5052b42aaf5c87e5be6333b2df67a81990c8a6.json new file mode 100644 index 0000000000..1be51feb85 --- /dev/null +++ b/backend/.sqlx/query-f7ece5036ad92e485b5e15a70e5052b42aaf5c87e5be6333b2df67a81990c8a6.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings ws\n SET datatable = (\n SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg(\n dt.key,\n (SELECT CASE WHEN r.v->'governed_by'->>'workspace_id' = $1\n AND r.v->'governed_by'->>'datatable' = $2\n THEN jsonb_set(r.v, '{governed_by,datatable}', to_jsonb($3::text))\n ELSE r.v END\n FROM (SELECT CASE WHEN dt.value->'reference'->>'workspace_id' = $1\n AND dt.value->'reference'->>'datatable' = $2\n THEN jsonb_set(dt.value, '{reference,datatable}', to_jsonb($3::text))\n ELSE dt.value END AS v) r)\n ))\n FROM jsonb_each(ws.datatable->'datatables') dt\n )\n WHERE EXISTS (\n SELECT 1 FROM jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) d,\n LATERAL (VALUES ('reference'), ('governed_by')) k(link)\n WHERE d.value->k.link->>'workspace_id' = $1\n AND d.value->k.link->>'datatable' = $2\n )", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "f7ece5036ad92e485b5e15a70e5052b42aaf5c87e5be6333b2df67a81990c8a6" +} diff --git a/backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json b/backend/.sqlx/query-f9692bba979c2b5a22e65106a330a0c0827dea6115daf9d006f8ccda0d041d76.json similarity index 59% rename from backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json rename to backend/.sqlx/query-f9692bba979c2b5a22e65106a330a0c0827dea6115daf9d006f8ccda0d041d76.json index 5661c59faf..c3e7b0168d 100644 --- a/backend/.sqlx/query-a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf.json +++ b/backend/.sqlx/query-f9692bba979c2b5a22e65106a330a0c0827dea6115daf9d006f8ccda0d041d76.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) FROM usr WHERE is_service_account = true", + "query": "SELECT count(*) FROM remote_deploy_token WHERE email = 'test2@windmill.dev'", "describe": { "columns": [ { @@ -16,5 +16,5 @@ null ] }, - "hash": "a37c2c4d5656d4b44433de84c454046f7586e36b7bd6a4679d70c359d4aacfcf" + "hash": "f9692bba979c2b5a22e65106a330a0c0827dea6115daf9d006f8ccda0d041d76" } diff --git a/backend/.sqlx/query-fc2081d9077c743bc3978ae1905db558549b2356fc6ac8f0557aa40e1345ff46.json b/backend/.sqlx/query-fc2081d9077c743bc3978ae1905db558549b2356fc6ac8f0557aa40e1345ff46.json new file mode 100644 index 0000000000..27cb37b876 --- /dev/null +++ b/backend/.sqlx/query-fc2081d9077c743bc3978ae1905db558549b2356fc6ac8f0557aa40e1345ff46.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT d.email IS NULL as \"legacy!\", COALESCE(u.username, p.username) as username\n FROM draft d\n LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email\n LEFT JOIN password p ON p.email = d.email AND p.super_admin = true\n WHERE d.workspace_id = $1 AND d.path = $2 AND d.typ::text = ANY($3::text[])\n -- A row a move already brought here from the very path being renamed is this\n -- rename's own draft, not an item in its way: the record says which owners\n -- it moved (all of them for an item move, one for a draft-only move). Any\n -- other row is a second item and still collides.\n AND NOT EXISTS (\n SELECT 1 FROM draft_move m\n WHERE m.workspace_id = $1 AND m.typ::text = ANY($3::text[])\n AND m.old_path = $4 AND m.new_path = $2\n AND (m.email IS NULL OR m.email IS NOT DISTINCT FROM d.email)\n )\n ORDER BY 2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "legacy!", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "TextArray", + "Text" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "fc2081d9077c743bc3978ae1905db558549b2356fc6ac8f0557aa40e1345ff46" +} diff --git a/backend/.sqlx/query-fcb34e643b888122766e115a01394ab31ac856252aaa76c75ea27a447009c363.json b/backend/.sqlx/query-fcb34e643b888122766e115a01394ab31ac856252aaa76c75ea27a447009c363.json new file mode 100644 index 0000000000..26920a2943 --- /dev/null +++ b/backend/.sqlx/query-fcb34e643b888122766e115a01394ab31ac856252aaa76c75ea27a447009c363.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE datatable_role SET name = $2, enabled = $3, pwd = $4 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar", + "Bool", + "Text" + ] + }, + "nullable": [] + }, + "hash": "fcb34e643b888122766e115a01394ab31ac856252aaa76c75ea27a447009c363" +} diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 7bd84dfca3..750e0594e1 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -23,7 +23,7 @@ ## Cargo features & running the dev backend The dev backend runs under `cargo watch` and is launched by default with **only -`--features quickjs`** (see the tmux backend pane). That baseline compiles fast but +`--features quickjs`** (see the backend pane). That baseline compiles fast but **deliberately omits most functionality** — notably S3/object storage, the S3 proxy, all EE code, MCP, and every non-JS language runtime. A running server never gains a feature you didn't compile in: feature-gated routes 404 or return a `"requires "` stub. So if @@ -33,22 +33,30 @@ MUST **restart the backend with the appropriate features** for what you're worki ### Restarting the dev backend with the right features Restart in the **same pane**, so the relaunch inherits that pane's `DATABASE_URL`, `BACKEND_PORT` -and the rest of `runtime.env`. Scope every kill to this worktree — **never** +and the rest of the worktree environment. Scope every kill to this worktree — **never** `pkill -f target/debug/windmill`, which kills every sibling worktree's backend. -1. Find the backend pane by what it is running, not by index. The index depends on the webmux - profile: pane 1 is the backend under `full`, but the *frontend* under `frontendOnly`. +1. Find the backend pane by where it is running, not by index. Under herdr the two server panes + sit in the worktree's `backend/` and `frontend/`, so match on `cwd`: + + ```bash + herdr pane list --workspace "$HERDR_WORKSPACE_ID" + ``` + + Under webmux they are tmux panes, and the index depends on the profile: pane 1 is the backend + under `full`, but the *frontend* under `frontendOnly`. ```bash WIN=$(tmux display-message -p -t "$TMUX_PANE" '#{window_id}') tmux list-panes -t "$WIN" -F '#{pane_index} #{pane_current_command} #{pane_pid}' ``` -2. Read the feature set it is **actually** running. `CARGO_FEATURES` in `runtime.env` is only what - the pane started with, and goes stale the first time anyone restarts by hand: +2. Read the feature set it is **actually** running. The `CARGO_FEATURES` the worktree was created + with is only what the pane started with, and goes stale the first time anyone restarts by hand: ```bash - ps --ppid -o args= + herdr pane process-info --pane # foreground_processes[].argv + ps --ppid -o args= # under webmux # /home/hugo/.cargo/bin/cargo-watch watch -x run --features quickjs ``` @@ -56,22 +64,25 @@ and the rest of `runtime.env`. Scope every kill to this worktree — **never** explicitly: ```bash - tmux send-keys -t "$WIN." C-c - tmux send-keys -t "$WIN." 'PORT=$BACKEND_PORT cargo watch -x "run --features quickjs,private,parquet"' Enter + herdr pane send-keys C-c + herdr pane run 'PORT=$BACKEND_PORT cargo watch -x "run --features quickjs,private,parquet"' + # under webmux: tmux send-keys -t "$WIN." C-c, then the same command line with Enter ``` Carry over every feature the old command had unless you mean to drop one — rebuilding the list from memory is how a backend silently loses `quickjs`. -4. Persist the new set so a recreated pane starts with it: set `CARGO_FEATURES` in the - worktree's `.env.local`, which `scripts/post-create.sh` writes and webmux reads. Do **not** - edit `runtime.env` for this — webmux regenerates it from metadata and `.env.local` every time - the worktree is opened, so an edit there is lost on the next reopen. Either way the change - only affects a future pane; step 3 is what takes effect now. +4. Persist the new set so a recreated pane starts with it: set `CARGO_FEATURES` in the worktree's + `.env.local`, which the worktree hooks write. A herdr pane sources that file directly, so the + next pane picks the value up. A webmux pane sources `webmux/runtime.env` instead, which webmux + regenerates from metadata and `.env.local` when the worktree is *opened* — so the value lands + on the next reopen, not the next pane, and editing `runtime.env` by hand is lost at that same + reopen. Either way the change only affects a future pane; step 3 is what takes effect now. -5. Re-capture the pane until `health check completed` appears before hitting the API. A cold - rebuild takes ~60s, and the previous run's success line is still in the scrollback, so a - capture taken too early reads as ready when it isn't. +5. Re-read the pane (`herdr pane read --source recent-unwrapped`, or `tmux + capture-pane`) until `health check completed` appears before hitting the API. A cold rebuild + takes ~60s, and the previous run's success line is still in the scrollback, so a capture taken + too early reads as ready when it isn't. cargo-watch only re-runs on a file change, so after an idle/failed run `touch README.md` (from `backend/`, where the watch runs) is a cheap retrigger (touching a `.rs` forces a full rebuild). diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 5f5d5f2151..87bf7f64be 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -588,15 +588,15 @@ dependencies = [ [[package]] name = "astral-tokio-tar" -version = "0.5.6" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec179a06c1769b1e42e1e2cbe74c7dcdb3d6383c838454d063eaac5bbb7ebbe5" +checksum = "b18457efd137254e016bbde5e1d88df61c4e1a5ae2223746e56123bac6af2463" dependencies = [ - "filetime", "futures-core", "libc", "portable-atomic", "rustc-hash 2.1.3", + "rustix 1.1.5", "tokio", "tokio-stream", "xattr", @@ -928,9 +928,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-config" -version = "1.8.3" +version = "1.8.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0baa720ebadea158c5bda642ac444a2af0cdf7bb66b46d1e4533de5d1f449d0" +checksum = "8a8fc176d53d6fe85017f230405e3255cedb4a02221cb55ed6d76dccbbb099b2" dependencies = [ "aws-credential-types", "aws-runtime", @@ -938,8 +938,8 @@ dependencies = [ "aws-sdk-ssooidc", "aws-sdk-sts", "aws-smithy-async", - "aws-smithy-http 0.62.6", - "aws-smithy-json 0.61.9", + "aws-smithy-http 0.63.4", + "aws-smithy-json 0.62.4", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -1070,15 +1070,16 @@ dependencies = [ [[package]] name = "aws-sdk-config" -version = "1.68.0" +version = "1.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7de8bcff88ff2948bf2154204b7eba0ea30698c1c23770c4a917505fb9fcbd5" +checksum = "0360cf8f8ab0e328f89b5c60bdcc6203ebce83b8a018c05ed177c4f32f819aff" dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http 0.62.6", - "aws-smithy-json 0.61.9", + "aws-smithy-http 0.63.4", + "aws-smithy-json 0.62.4", + "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -1086,7 +1087,7 @@ dependencies = [ "bytes", "fastrand 2.5.0", "http 0.2.12", - "once_cell", + "http 1.5.0", "regex-lite", "tracing", ] @@ -1144,15 +1145,16 @@ dependencies = [ [[package]] name = "aws-sdk-sqs" -version = "1.77.0" +version = "1.94.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a6dcd63dffae1f7872df42ce9f1f0f785f8c9a3d3e880ef6c171d0415439df" +checksum = "0894909314920ab2c907110e7f91b9f7a6c826bdc830ccf36079c69af2b82335" dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http 0.62.6", - "aws-smithy-json 0.61.9", + "aws-smithy-http 0.63.4", + "aws-smithy-json 0.62.4", + "aws-smithy-observability", "aws-smithy-runtime", "aws-smithy-runtime-api", "aws-smithy-types", @@ -1160,6 +1162,7 @@ dependencies = [ "bytes", "fastrand 2.5.0", "http 0.2.12", + "http 1.5.0", "regex-lite", "tracing", ] @@ -1214,15 +1217,16 @@ dependencies = [ [[package]] name = "aws-sdk-sts" -version = "1.79.0" +version = "1.98.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317377afba3498fca4948c5d32b399ef9a5ad35561a1e8a6f2ac7273dabf802d" +checksum = "89c4f19655ab0856375e169865c91264de965bd74c407c7f1e403184b1049409" dependencies = [ "aws-credential-types", "aws-runtime", "aws-smithy-async", - "aws-smithy-http 0.62.6", - "aws-smithy-json 0.61.9", + "aws-smithy-http 0.63.4", + "aws-smithy-json 0.62.4", + "aws-smithy-observability", "aws-smithy-query", "aws-smithy-runtime", "aws-smithy-runtime-api", @@ -1231,6 +1235,7 @@ dependencies = [ "aws-types", "fastrand 2.5.0", "http 0.2.12", + "http 1.5.0", "regex-lite", "tracing", ] @@ -1340,7 +1345,7 @@ dependencies = [ "hyper 0.14.32", "hyper 1.11.1", "hyper-rustls 0.24.2", - "hyper-rustls 0.27.9", + "hyper-rustls 0.27.10", "hyper-util", "pin-project-lite", "rustls 0.21.12", @@ -2221,7 +2226,7 @@ dependencies = [ "memmap2", "num-traits", "num_cpus", - "rand 0.9.0", + "rand 0.9.5", "rand_distr", "rayon", "safetensors", @@ -2257,7 +2262,7 @@ dependencies = [ "candle-nn", "fancy-regex 0.17.0", "num-traits", - "rand 0.9.0", + "rand 0.9.5", "rayon", "serde", "serde_json", @@ -2311,9 +2316,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.4.6" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +checksum = "54413ede23c2daf518f35156dfde027feb2374004d63bd497f983c8db9c0e313" dependencies = [ "find-msvc-tools", "jobserver", @@ -3996,7 +4001,7 @@ dependencies = [ "http 1.5.0", "http-body-util", "hyper 1.11.1", - "hyper-rustls 0.27.9", + "hyper-rustls 0.27.10", "hyper-util", "ipnet", "percent-encoding", @@ -4199,7 +4204,7 @@ dependencies = [ "deno_tls", "http-body-util", "hyper 1.11.1", - "hyper-rustls 0.27.9", + "hyper-rustls 0.27.10", "hyper-util", "log", "once_cell", @@ -5055,9 +5060,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.12" +version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" [[package]] name = "fixedbitset" @@ -5101,7 +5106,7 @@ checksum = "719a903cc23e4a89e87962c2a80fdb45cdaad0983a89bd150bb57b4c8571a7d5" dependencies = [ "half", "num-traits", - "rand 0.9.0", + "rand 0.9.5", "rand_distr", ] @@ -5497,9 +5502,9 @@ dependencies = [ [[package]] name = "generator" -version = "0.8.9" +version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +checksum = "54ade96dc9003043bce7c035c85a9df5a858bfb2039c5a2e6fdf00f324f6c551" dependencies = [ "cc", "cfg-if", @@ -5833,7 +5838,7 @@ dependencies = [ "cfg-if", "crunchy", "num-traits", - "rand 0.9.0", + "rand 0.9.5", "rand_distr", "zerocopy", ] @@ -5975,7 +5980,7 @@ dependencies = [ "log", "native-tls", "num_cpus", - "rand 0.9.0", + "rand 0.9.5", "reqwest 0.12.28", "serde", "serde_json", @@ -6002,7 +6007,7 @@ dependencies = [ "idna", "ipnet", "once_cell", - "rand 0.9.0", + "rand 0.9.5", "serde", "thiserror 2.0.20", "tinyvec", @@ -6024,7 +6029,7 @@ dependencies = [ "moka", "once_cell", "parking_lot", - "rand 0.9.0", + "rand 0.9.5", "resolv-conf", "serde", "smallvec", @@ -6258,7 +6263,7 @@ dependencies = [ "headers", "http 1.5.0", "hyper 1.11.1", - "hyper-rustls 0.27.9", + "hyper-rustls 0.27.10", "hyper-util", "pin-project-lite", "tokio", @@ -6317,9 +6322,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http 1.5.0", "hyper 1.11.1", @@ -6949,13 +6954,14 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "8.3.0" +version = "9.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" dependencies = [ - "base64 0.21.7", - "pem 1.1.1", - "ring 0.16.20", + "base64 0.22.1", + "js-sys", + "pem", + "ring 0.17.14", "serde", "serde_json", "simple_asn1", @@ -6963,17 +6969,20 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "9.3.1" +version = "10.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ + "aws-lc-rs", "base64 0.22.1", + "getrandom 0.2.17", "js-sys", - "pem 3.0.6", - "ring 0.17.14", + "pem", "serde", "serde_json", + "signature", "simple_asn1", + "zeroize", ] [[package]] @@ -7062,13 +7071,13 @@ dependencies = [ "http-body-util", "hyper 1.11.1", "hyper-http-proxy", - "hyper-rustls 0.27.9", + "hyper-rustls 0.27.10", "hyper-timeout", "hyper-util", "jsonpath-rust", "k8s-openapi", "kube-core", - "pem 3.0.6", + "pem", "rustls 0.23.35", "secrecy", "serde", @@ -7308,9 +7317,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6480ccc157a1389bb2e4891b24751b0f798ba640d22386f23143fbcc89da195a" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "bitflags 2.13.2", "libc", @@ -7932,9 +7941,9 @@ dependencies = [ "lru 0.18.4", "mysql_common", "native-tls", - "pem 3.0.6", + "pem", "percent-encoding", - "rand 0.10.2", + "rand 0.10.3", "serde", "socket2 0.6.5", "thiserror 2.0.20", @@ -8440,7 +8449,7 @@ dependencies = [ "parking_lot", "percent-encoding", "quick-xml", - "rand 0.9.0", + "rand 0.9.5", "reqwest 0.12.28", "ring 0.17.14", "rustls-pemfile 2.2.0", @@ -8809,7 +8818,7 @@ dependencies = [ "futures-util", "opentelemetry 0.30.0", "percent-encoding", - "rand 0.9.0", + "rand 0.9.5", "serde_json", "thiserror 2.0.20", "tokio", @@ -8911,7 +8920,7 @@ dependencies = [ "hmac", "pkcs12", "pkcs5", - "rand 0.9.0", + "rand 0.9.5", "rc2", "sha1", "sha2 0.10.9", @@ -9075,15 +9084,6 @@ dependencies = [ "hmac", ] -[[package]] -name = "pem" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8" -dependencies = [ - "base64 0.13.1", -] - [[package]] name = "pem" version = "3.0.6" @@ -9123,9 +9123,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d45aeb61b4bf818e12d4205f2466f8c4748f85f4fce0146d1c03d69d753f0ad" +checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" dependencies = [ "memchr", "ucd-trie", @@ -9133,9 +9133,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89cc5a242e25ed4e7704d0be240f2cfbe20a8c27e7e252d94835be93d92dc39f" +checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" dependencies = [ "pest", "pest_generator", @@ -9143,9 +9143,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7abf21475cc3820fe4b2ca2dc2142902f67a02189f3b5b3a229f4febc01a43e5" +checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" dependencies = [ "pest", "pest_meta", @@ -9156,9 +9156,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adba4db388f687393c18c51348d44a41d870ca9df71a2c98172ea3035dc6936e" +checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" dependencies = [ "pest", ] @@ -9504,7 +9504,7 @@ dependencies = [ "hmac", "md-5 0.10.6", "memchr", - "rand 0.9.0", + "rand 0.9.5", "sha2 0.10.9", "stringprep", ] @@ -9879,7 +9879,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand 0.10.3", "rand_pcg", "ring 0.17.14", "rustc-hash 2.1.3", @@ -9959,20 +9959,19 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.0" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", - "zerocopy", ] [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -10049,7 +10048,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand 0.9.0", + "rand 0.9.5", ] [[package]] @@ -10125,7 +10124,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2" dependencies = [ - "pem 3.0.6", + "pem", "ring 0.17.14", "rustls-pki-types", "time", @@ -10339,7 +10338,7 @@ dependencies = [ "http-body 1.1.0", "http-body-util", "hyper 1.11.1", - "hyper-rustls 0.27.9", + "hyper-rustls 0.27.10", "hyper-tls", "hyper-util", "js-sys", @@ -10387,7 +10386,7 @@ dependencies = [ "http-body 1.1.0", "http-body-util", "hyper 1.11.1", - "hyper-rustls 0.27.9", + "hyper-rustls 0.27.10", "hyper-util", "js-sys", "log", @@ -10464,7 +10463,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc05fbf560421a0357a750cbe78c7ca19d4923918490daabba313d5dbc871e47" dependencies = [ - "rand 0.10.2", + "rand 0.10.3", ] [[package]] @@ -10552,7 +10551,7 @@ dependencies = [ "oauth2", "pastey", "pin-project-lite", - "rand 0.10.2", + "rand 0.10.3", "reqwest 0.13.5", "rmcp-macros", "schemars 1.2.2", @@ -11114,7 +11113,7 @@ dependencies = [ "openssl-sys", "pkg-config", "quick-xml", - "rand 0.9.0", + "rand 0.9.5", "serde", "thiserror 2.0.20", "url", @@ -13397,7 +13396,7 @@ dependencies = [ "pin-project-lite", "postgres-protocol", "postgres-types", - "rand 0.9.0", + "rand 0.9.5", "socket2 0.6.5", "tokio", "tokio-util", @@ -14089,7 +14088,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" dependencies = [ - "rand 0.9.0", + "rand 0.9.5", "uuid", "web-time", ] @@ -14172,9 +14171,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-id-start" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81b79ad29b5e19de4260020f8919b443b2ef0277d242ce532ec7b7a2cc8b6007" +checksum = "0825d2e09dcae814a4273c1e1435979d414ede25b2ee235b1545da6682483765" [[package]] name = "unicode-ident" @@ -14793,7 +14792,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-nats", @@ -14810,13 +14809,13 @@ dependencies = [ "git-version", "hex", "hmac", - "jsonwebtoken 8.3.0", + "jsonwebtoken 10.4.0", "lazy_static", "once_cell", "opentelemetry 0.30.0", "opentelemetry_sdk 0.30.0", "prometheus", - "rand 0.9.0", + "rand 0.9.5", "rdkafka", "reqwest 0.13.5", "rumqttc", @@ -14881,7 +14880,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.814.0" +version = "1.817.0" dependencies = [ "async-stream", "async-trait", @@ -14915,7 +14914,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.814.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14928,7 +14927,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "argon2", @@ -14961,7 +14960,7 @@ dependencies = [ "hyper 1.11.1", "indexmap 2.14.2", "itertools 0.14.0", - "jsonwebtoken 8.3.0", + "jsonwebtoken 10.4.0", "lazy_static", "libxml", "magic-crypt", @@ -14976,7 +14975,7 @@ dependencies = [ "prometheus", "quick-xml", "quick_cache", - "rand 0.9.0", + "rand 0.9.5", "regex", "reqwest 0.13.5", "rsa", @@ -15068,7 +15067,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.814.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15091,7 +15090,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.814.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15108,14 +15107,14 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "axum 0.8.9", "chrono", "http 1.5.0", "itertools 0.14.0", - "jsonwebtoken 8.3.0", + "jsonwebtoken 10.4.0", "lazy_static", "quick_cache", "reqwest 0.13.5", @@ -15134,7 +15133,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.814.0" +version = "1.817.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15144,7 +15143,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.814.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15161,7 +15160,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.814.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -15183,7 +15182,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15206,7 +15205,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.814.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15222,7 +15221,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.814.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15244,7 +15243,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.814.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15266,7 +15265,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.814.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15280,7 +15279,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-nats", @@ -15292,7 +15291,7 @@ dependencies = [ "futures", "hex", "hmac", - "rand 0.9.0", + "rand 0.9.5", "rdkafka", "reqwest 0.13.5", "rmcp", @@ -15315,7 +15314,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15340,7 +15339,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.814.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15368,7 +15367,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15390,7 +15389,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.814.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15410,8 +15409,9 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.814.0" +version = "1.817.0" dependencies = [ + "anyhow", "axum 0.8.9", "chrono", "futures", @@ -15436,6 +15436,9 @@ dependencies = [ "windmill-native-triggers", "windmill-object-store", "windmill-parser", + "windmill-parser-bash", + "windmill-parser-go", + "windmill-parser-graphql", "windmill-parser-py", "windmill-parser-py-asset", "windmill-parser-sql", @@ -15448,7 +15451,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15470,13 +15473,14 @@ dependencies = [ "windmill-ai", "windmill-alerting", "windmill-api-auth", + "windmill-audit", "windmill-common", "windmill-object-store", ] [[package]] name = "windmill-api-sse" -version = "1.814.0" +version = "1.817.0" dependencies = [ "lazy_static", "serde", @@ -15488,7 +15492,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.814.0" +version = "1.817.0" dependencies = [ "argon2", "axum 0.8.9", @@ -15512,7 +15516,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.814.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", @@ -15526,10 +15530,11 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.814.0" +version = "1.817.0" dependencies = [ "axum 0.8.9", "chrono", + "constant_time_eq 0.3.1", "futures", "hex", "http 1.5.0", @@ -15537,6 +15542,7 @@ dependencies = [ "lazy_static", "magic-crypt", "regex", + "reqwest 0.13.5", "serde", "serde_json", "sha2 0.10.9", @@ -15545,6 +15551,7 @@ dependencies = [ "tokio", "tokio-postgres", "tracing", + "url", "uuid", "windmill-api-auth", "windmill-api-jobs", @@ -15561,7 +15568,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.814.0" +version = "1.817.0" dependencies = [ "chrono", "lazy_static", @@ -15575,7 +15582,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -15594,7 +15601,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.814.0" +version = "1.817.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -15634,7 +15641,7 @@ dependencies = [ "hyper 1.11.1", "indexmap 2.14.2", "itertools 0.14.0", - "jsonwebtoken 8.3.0", + "jsonwebtoken 10.4.0", "lazy_static", "magic-crypt", "mail-send", @@ -15654,12 +15661,15 @@ dependencies = [ "postgres-native-tls 0.5.3", "prometheus", "quick_cache", - "rand 0.9.0", + "rand 0.9.5", + "rcgen", "regex", "reqwest 0.13.5", "reqwest-middleware", "reqwest-retry", "rsa", + "rustls 0.23.35", + "rustls-native-certs 0.8.4", "schemars 0.8.22", "semver 1.0.28", "serde", @@ -15680,6 +15690,7 @@ dependencies = [ "tikv-jemalloc-ctl", "tokio", "tokio-postgres", + "tokio-rustls 0.26.5", "tokio-stream", "tokio-util", "tonic 0.13.1", @@ -15690,6 +15701,7 @@ dependencies = [ "url", "urlencoding", "uuid", + "webpki-roots 1.0.9", "windmill-macros", "windmill-parser", "windmill-parser-py", @@ -15701,7 +15713,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.814.0" +version = "1.817.0" dependencies = [ "chrono", "futures", @@ -15721,7 +15733,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.814.0" +version = "1.817.0" dependencies = [ "regex", "serde", @@ -15738,7 +15750,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -15765,7 +15777,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "futures", @@ -15782,7 +15794,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.814.0" +version = "1.817.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -15798,7 +15810,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -15819,7 +15831,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -15850,7 +15862,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "arc-swap", @@ -15875,7 +15887,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-stream", @@ -15910,7 +15922,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "futures", @@ -15928,7 +15940,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.814.0" +version = "1.817.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -15937,7 +15949,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -15949,7 +15961,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "serde_json", @@ -15961,7 +15973,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "gosyn", @@ -15973,7 +15985,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -15985,7 +15997,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "serde_json", @@ -15997,7 +16009,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "nu-parser", @@ -16008,7 +16020,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16019,7 +16031,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16031,7 +16043,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16042,7 +16054,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-recursion", @@ -16064,7 +16076,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "serde_json", @@ -16076,7 +16088,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -16090,7 +16102,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16107,7 +16119,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -16120,7 +16132,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "serde", @@ -16132,7 +16144,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -16150,7 +16162,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16166,7 +16178,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "rustpython-ast", @@ -16182,7 +16194,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -16196,7 +16208,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-recursion", @@ -16215,7 +16227,7 @@ dependencies = [ "once_cell", "prometheus", "quick_cache", - "rand 0.9.0", + "rand 0.9.5", "regex", "reqwest 0.13.5", "serde", @@ -16235,7 +16247,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "const_format", @@ -16275,7 +16287,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.814.0" +version = "1.817.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -16286,7 +16298,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-recursion", @@ -16321,7 +16333,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16345,7 +16357,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16355,7 +16367,7 @@ dependencies = [ "hyper 1.11.1", "itertools 0.14.0", "lazy_static", - "rand 0.9.0", + "rand 0.9.5", "reqwest 0.13.5", "serde", "serde_json", @@ -16378,7 +16390,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16405,7 +16417,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16419,7 +16431,7 @@ dependencies = [ "itertools 0.14.0", "lazy_static", "quick_cache", - "rand 0.9.0", + "rand 0.9.5", "reqwest 0.13.5", "serde", "serde_json", @@ -16438,7 +16450,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16458,7 +16470,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16470,7 +16482,7 @@ dependencies = [ "google-cloud-pubsub", "http 1.5.0", "itertools 0.14.0", - "jsonwebtoken 8.3.0", + "jsonwebtoken 10.4.0", "lazy_static", "quick_cache", "reqwest 0.13.5", @@ -16492,7 +16504,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16528,7 +16540,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16551,7 +16563,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16575,7 +16587,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-nats", @@ -16599,7 +16611,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16614,7 +16626,7 @@ dependencies = [ "pg_escape", "postgres-native-tls 0.5.2", "quick_cache", - "rand 0.9.0", + "rand 0.9.5", "rust_decimal", "serde", "serde_json", @@ -16634,7 +16646,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16662,7 +16674,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-trait", @@ -16687,14 +16699,14 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "bitflags 2.13.2", "chrono", "hex", "itertools 0.14.0", - "rand 0.9.0", + "rand 0.9.5", "serde", "serde_json", "sqlx", @@ -16706,7 +16718,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-once-cell", @@ -16735,10 +16747,10 @@ dependencies = [ "hmac", "hudsucker", "hyper-http-proxy", - "hyper-rustls 0.27.9", + "hyper-rustls 0.27.10", "hyper-util", "itertools 0.14.0", - "jsonwebtoken 8.3.0", + "jsonwebtoken 10.4.0", "lazy_static", "libffi-sys", "libloading", @@ -16752,14 +16764,14 @@ dependencies = [ "opentelemetry-proto 0.30.0", "oracle", "parquet", - "pem 3.0.6", + "pem", "pep440_rs", "postgres-native-tls 0.5.3", "process-wrap", "prometheus", "prost", "quick_cache", - "rand 0.9.0", + "rand 0.9.5", "rcgen", "regex", "reqwest 0.13.5", @@ -16824,7 +16836,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.814.0" +version = "1.817.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 395e49afdc..2e81eb2637 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.814.0" +version = "1.817.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.814.0" +version = "1.817.0" authors = ["Ruben Fiszel "] edition = "2021" @@ -493,7 +493,7 @@ sql-builder = "^3" # behind a feature and changed `hash_password`), so a float breaks the build. argon2 = "0.6" quick_cache = "^0" -rand = "=0.9.0" +rand = "^0.9.3" rand_core = { version = "^0", features = ["std"] } arc-swap = "1" ed25519-dalek = { version = "2", features = ["rand_core"] } @@ -514,7 +514,7 @@ reqwest = { version = "^0.13", features = ["json", "stream", "gzip", "multipart" eventsource-stream = "0.2.3" time = "^0" serde_urlencoded = "^0" -astral-tokio-tar = "^0.5.6" +astral-tokio-tar = "^0.6.2" tempfile = "^3" x509-parser = "^0.16" tokio-util = { version = "=0.7.17", features = ["io"] } @@ -523,7 +523,7 @@ itertools = "^0.14.0" regex = "^1" semver = "^1" aws-sigv4 = "^1.3.4" -aws-sdk-config = "=1.68.0" +aws-sdk-config = "^1.94.0" aws-sdk-rds = "^1" async-trait = "0.1.88" @@ -637,7 +637,9 @@ libxml = { version = "=0.3.3" } samael = { git="https://github.com/njaremko/samael", rev="f879f1942ec1b34b6d3027ce7e4724ad95d15dfa", features = ["xmlsec"] } gcp_auth = "0.9.0" rust_decimal = { version = "^1", features = ["db-postgres", "serde-float"]} -jsonwebtoken = "8.3.0" +# aws_lc_rs, not rust_crypto: the RustCrypto backend refuses RSA keys above 4096 bits, and +# external/guest JWT issuers may sign with 8192-bit keys. +jsonwebtoken = { version = "^10.3", features = ["aws_lc_rs"] } pem = "3.0.1" nix = { version = "0.27.1", features = ["process", "signal"] } fs4 = "0.13" @@ -683,8 +685,8 @@ aws-sdk-bedrockruntime = "=1.122.0" aws-credential-types = "^1" aws-smithy-types = "^1" aws-sdk-secretsmanager = "^1" -aws-sdk-sqs = "=1.77.0" -aws-sdk-sts = "=1.79.0" +aws-sdk-sqs = "^1.89.0" +aws-sdk-sts = "^1.91.0" aws-sdk-sso = "=1.77.0" aws-sdk-ssooidc = "=1.78.0" rustls = "=0.23.35" @@ -755,4 +757,5 @@ hyper-http-proxy = { version = "1", default-features = false, features = ["rustl hyper-rustls = { version = "0.27", default-features = false, features = ["http1", "http2", "ring", "tls12"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] } rustls-native-certs = "0.8" +webpki-roots = "1" rcgen = "0.13" diff --git a/backend/THREAT_MODEL.md b/backend/THREAT_MODEL.md index af33877078..73a4b53227 100644 --- a/backend/THREAT_MODEL.md +++ b/backend/THREAT_MODEL.md @@ -97,7 +97,7 @@ published advisory history (73 GHSA advisories, several rated 9.9 critical). | id | threat | actor | surface | asset | impact | likelihood | status | controls | evidence | |---|---|---|---|---|---|---|---|---|---| | T1 | SQL injection in app/internal query builders and trigger clauses compromises the metadata DB and connected databases | remote_auth | EP8 | Database, downstream connected systems | critical | almost_certain | partially_mitigated | sqlx parameterized queries elsewhere; query-builder safety reviews | GHSA-225c-j3xq-g6x6, GHSA-78p7-jc72-gv66, GHSA-hvc7-f67h-jx3g, GHSA-wrrg-f89m-f84q, GHSA-79vf-3qwm-2w64, GHSA-55p6-fxj4-v983, GHSA-5g4v-49rj-r52r, GHSA-x6cq-7xr8-53x3, 2cf4bb180b | -| T2 | Server-side request forgery via proxies/executors reaches cloud metadata, internal network, and downstream credentials | remote_auth | EP6, EP7 | Cloud metadata, internal network, downstream connected systems, resource creds | critical | almost_certain | partially_mitigated | SSRF URL validation + redirect-following disabled added piecemeal; MCP private URL access requires the instance-wide `ALLOW_PRIVATE_MCP_SERVER_URLS` opt-in; WebSocket trigger URLs (stored, test, and runnable-resolved) are SSRF-validated at connect time behind the `ALLOW_PRIVATE_WEBSOCKET_URLS` opt-in, and the trigger test route now requires `:write` scope; SAML IdP metadata URLs are SSRF-validated at load time behind the `ALLOW_PRIVATE_SAML_METADATA_URLS` opt-in; outbound network isolation (`clone_newnet`) is opt-in and off by default | GHSA-3ggp-h37f-5qfw, GHSA-98qq-g8rh-xhff, GHSA-hfw8-27mx-63jm, GHSA-3r59-qvvc-774j, GHSA-4pj9-w5jc-g8w7, GHSA-8hh3-jf25-78j5, GHSA-3pjm-4w7f-3r2w, GHSA-f44c-x9hq-h68r, GHSA-j4h4-f8fj-3m3c, 4b06881918, 96a8eb63d4, dbd3942ef3 | +| T2 | Server-side request forgery via proxies/executors reaches cloud metadata, internal network, and downstream credentials | remote_auth | EP6, EP7 | Cloud metadata, internal network, downstream connected systems, resource creds | critical | almost_certain | partially_mitigated | SSRF URL validation + redirect-following disabled added piecemeal; MCP private URL access requires the instance-wide `ALLOW_PRIVATE_MCP_SERVER_URLS` opt-in; WebSocket trigger URLs (stored, test, and runnable-resolved) are SSRF-validated at connect time behind the `ALLOW_PRIVATE_WEBSOCKET_URLS` opt-in, and the trigger test route now requires `:write` scope; SAML IdP metadata URLs are SSRF-validated at load time behind the `ALLOW_PRIVATE_SAML_METADATA_URLS` opt-in; workspace webhook URLs are SSRF-checked when saved, not when sent, behind the `ALLOW_PRIVATE_WEBHOOK_URLS` opt-in; outbound network isolation (`clone_newnet`) is opt-in and off by default | GHSA-3ggp-h37f-5qfw, GHSA-98qq-g8rh-xhff, GHSA-hfw8-27mx-63jm, GHSA-3r59-qvvc-774j, GHSA-4pj9-w5jc-g8w7, GHSA-8hh3-jf25-78j5, GHSA-3pjm-4w7f-3r2w, GHSA-f44c-x9hq-h68r, GHSA-j4h4-f8fj-3m3c, 4b06881918, 96a8eb63d4, dbd3942ef3 | | T3 | Broken authorization / IDOR lets a scoped token or low-privilege member read scripts, job data, and secrets across folders and workspaces | remote_auth | EP5, EP2, EP1 | Scripts, job data, secrets, isolation | critical | almost_certain | partially_mitigated | RLS, token scopes, folder ACLs, view-token HMAC (added incrementally); on managed, sensitive tenants can opt into dedicated DB/worker/namespace, but the shared tier IS the software boundary | GHSA-qfg7-x243-5hg4, GHSA-8x8x-88qc-qp4r, GHSA-2ppx-66jv-wpw5, GHSA-x3x7-g97v-mp59, GHSA-j276-g4h8-g6h5, GHSA-8mv7-hmrg-96xv, GHSA-x2wf-f962-7frq, GHSA-qc7c-gcw6-h4xp, GHSA-vxc5-w28p-m9xw, GHSA-2g34-wfvr-5qqj, GHSA-w7p6-wpxm-pp66, 7edf3f0212, 89a7a37776, ab11c7747a, 664edcdfb7 | | T4 | Remote code execution by injecting attacker-controlled identifiers into generated worker wrappers | remote_auth | EP10 | Worker host, isolation, downstream | critical | likely | partially_mitigated | entrypoint/env-var-name validation added | GHSA-wxjq-w5pj-jqhx, GHSA-5f5q-2vg2-r2x4, GHSA-8q8j-mm3g-5c2q (CVE-2026-33881), bf93657fee, bd05bcadde, 22ec4da5f0 | | T5 | Worker compromise & cross-tenant access via weak-by-default isolation (nsjail off by default → user code runs with only PID-ns `unshare`); sandbox escape where nsjail/dind/podman is enabled | remote_auth | EP9, EP15 | Worker host, isolation, downstream | critical | likely | unmitigated | nsjail off by default everywhere (`DISABLE_NSJAIL=true`); shipped compose gives PID-ns `unshare` only (`FAVOR_UNSHARE_PID=true`), bare installs get no isolation. Where nsjail enabled: read-only remounts, jail-tmp refusal, podman socket gating | GHSA-6qr8-xhg4-453q, GHSA-3vpp-vf62-wqp6, f8467f38c8, df5aec0f5d, f1b6746e0e | diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 317ca12c5e..06b1b10a9a 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -d252afcc80e77fcc4f9a2a346b80908c8605a6c0 +c099af43bad922fd57ed0449ab717ffa047b5546 diff --git a/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py b/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py index 72feb5392e..4c78f0524e 100644 --- a/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py +++ b/backend/generate_mcp_endpoints_tools/generate_mcp_tools.py @@ -128,11 +128,16 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt r for r in body_schema['required'] if r in include_fields ] - # Apply opaque_fields: simplify listed properties to {"type": "object"} + # Apply opaque_fields: simplify listed properties to {"type": "object"}, + # keeping the description, which is all a caller learns about the field if body_schema and opaque_fields and 'properties' in body_schema: for field in opaque_fields: if field in body_schema['properties']: - body_schema['properties'][field] = {"type": "object"} + opaque = {"type": "object"} + description = body_schema['properties'][field].get('description') + if description: + opaque['description'] = description + body_schema['properties'][field] = opaque # If we have required fields specified and a body schema, update the required array if body_schema and required_fields: diff --git a/backend/migrations/20260908142148_datatable_role_catalog.down.sql b/backend/migrations/20260908142148_datatable_role_catalog.down.sql new file mode 100644 index 0000000000..8d3f12bc4a --- /dev/null +++ b/backend/migrations/20260908142148_datatable_role_catalog.down.sql @@ -0,0 +1,18 @@ +-- Refuse while the catalog holds anything. Each row is a live Postgres login with a password +-- only this table carries, so dropping it would leave credentials on the cluster that Windmill can +-- no longer disable, delete or even name — and re-applying could not recreate them, because the +-- role names would already be taken. Cleaning them up here is not an option either: dropping a +-- role means reassigning what it owns in *every* instance database, and a migration runs in one. +-- +-- Delete the roles through instance settings first; that path does the cluster work. +LOCK TABLE datatable_role IN ACCESS EXCLUSIVE MODE; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM datatable_role) THEN + RAISE EXCEPTION 'Cannot roll back: % data table role(s) still exist as Postgres logins. Delete them in instance settings first, which drops them from the cluster.', + (SELECT count(*) FROM datatable_role); + END IF; +END $$; + +DROP TABLE IF EXISTS datatable_role; diff --git a/backend/migrations/20260908142148_datatable_role_catalog.up.sql b/backend/migrations/20260908142148_datatable_role_catalog.up.sql new file mode 100644 index 0000000000..59cce4cc08 --- /dev/null +++ b/backend/migrations/20260908142148_datatable_role_catalog.up.sql @@ -0,0 +1,21 @@ +-- The instance's data table role catalog: one row per Postgres login Windmill created for data +-- table access. +-- +-- A table rather than a `global_settings` key, because the value is a set of live cluster +-- credentials and that table has generic read, list, write and CLI round-trip paths that know +-- nothing about what they are carrying. Every one of them is a way to leak the passwords or to +-- overwrite the catalog with a copy that has none, and a row nothing generic touches has none of +-- those. One row per role also makes two concurrent creates two inserts rather than a +-- read-modify-write over one document. +CREATE TABLE datatable_role ( + id VARCHAR(50) PRIMARY KEY, + -- The Postgres role name, verbatim. Unique because it is the cluster's own key. + name VARCHAR(63) NOT NULL UNIQUE, + enabled BOOLEAN NOT NULL DEFAULT true, + -- Generated by Windmill, never entered by anyone, and never leaves the server. + pwd TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +GRANT ALL ON datatable_role TO windmill_user; +GRANT ALL ON datatable_role TO windmill_admin; diff --git a/backend/migrations/20260914133558_draft_base_version.down.sql b/backend/migrations/20260914133558_draft_base_version.down.sql new file mode 100644 index 0000000000..07fc93ba30 --- /dev/null +++ b/backend/migrations/20260914133558_draft_base_version.down.sql @@ -0,0 +1 @@ +ALTER TABLE draft DROP COLUMN base; diff --git a/backend/migrations/20260914133558_draft_base_version.up.sql b/backend/migrations/20260914133558_draft_base_version.up.sql new file mode 100644 index 0000000000..835816d70c --- /dev/null +++ b/backend/migrations/20260914133558_draft_base_version.up.sql @@ -0,0 +1,14 @@ +-- The version a draft forked from, as one opaque text id whatever the kind: a +-- script hash (hex), a flow_version.id, an app_version.id. NULL for a draft that +-- was never forked from a deploy and for kinds that keep no lineage. +ALTER TABLE draft ADD COLUMN base TEXT; + +-- A U+0000 inside a `json` value makes `->>` raise 22P05; such rows keep NULL and +-- get their base on their next save. +UPDATE draft SET base = CASE typ::text + WHEN 'script' THEN value ->> 'parent_hash' + WHEN 'flow' THEN value ->> 'version_id' + ELSE value ->> 'parent_version' + END +WHERE typ::text IN ('script', 'flow', 'app', 'raw_app') + AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0; diff --git a/backend/migrations/20260914154128_draft_move.down.sql b/backend/migrations/20260914154128_draft_move.down.sql new file mode 100644 index 0000000000..6fff84e4bb --- /dev/null +++ b/backend/migrations/20260914154128_draft_move.down.sql @@ -0,0 +1 @@ +DROP TABLE draft_move; diff --git a/backend/migrations/20260914154128_draft_move.up.sql b/backend/migrations/20260914154128_draft_move.up.sql new file mode 100644 index 0000000000..e68e36c5ff --- /dev/null +++ b/backend/migrations/20260914154128_draft_move.up.sql @@ -0,0 +1,17 @@ +-- Where an item's drafts went when it moved, so a draft save still addressed to the +-- old path (an editor left open across the move, a chat, the CLI) lands on the moved +-- draft instead of starting a new item there. `email` NULL records a deployed item's +-- move and applies to every user; set, it records that user's draft-only move. +CREATE TABLE draft_move ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + typ DRAFT_KIND NOT NULL, + old_path VARCHAR(255) NOT NULL, + new_path VARCHAR(255) NOT NULL, + email VARCHAR(255) +); + +CREATE INDEX draft_move_old_path_idx ON draft_move (workspace_id, typ, old_path); + +GRANT ALL ON draft_move TO windmill_user; +GRANT ALL ON draft_move TO windmill_admin; diff --git a/backend/migrations/20260918092041_add_admins_and_developers_to_forks.down.sql b/backend/migrations/20260918092041_add_admins_and_developers_to_forks.down.sql new file mode 100644 index 0000000000..4e8ac47c69 --- /dev/null +++ b/backend/migrations/20260918092041_add_admins_and_developers_to_forks.down.sql @@ -0,0 +1 @@ +ALTER TABLE workspace_settings DROP COLUMN add_admins_and_developers_to_forks; diff --git a/backend/migrations/20260918092041_add_admins_and_developers_to_forks.up.sql b/backend/migrations/20260918092041_add_admins_and_developers_to_forks.up.sql new file mode 100644 index 0000000000..bb3502d362 --- /dev/null +++ b/backend/migrations/20260918092041_add_admins_and_developers_to_forks.up.sql @@ -0,0 +1 @@ +ALTER TABLE workspace_settings ADD COLUMN add_admins_and_developers_to_forks BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/migrations/20260919213810_remote_deploy.down.sql b/backend/migrations/20260919213810_remote_deploy.down.sql new file mode 100644 index 0000000000..ae26684ac6 --- /dev/null +++ b/backend/migrations/20260919213810_remote_deploy.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS remote_deploy_token; +ALTER TABLE workspace_settings + DROP COLUMN IF EXISTS remote_deploy_target, + DROP COLUMN IF EXISTS remote_deploy_target_changed_at; diff --git a/backend/migrations/20260919213810_remote_deploy.up.sql b/backend/migrations/20260919213810_remote_deploy.up.sql new file mode 100644 index 0000000000..c32f81fd88 --- /dev/null +++ b/backend/migrations/20260919213810_remote_deploy.up.sql @@ -0,0 +1,35 @@ +-- The workspace on another Windmill instance this workspace deploys into from the UI: +-- `{"base_url": ..., "workspace_id": ...}`, and when it last changed. A token connected under an +-- earlier change counts for nothing, even once the setting points back at the target it was for. +ALTER TABLE workspace_settings + ADD COLUMN remote_deploy_target JSONB, + ADD COLUMN remote_deploy_target_changed_at TIMESTAMPTZ; + +-- One user's token for the remote deploy target, encrypted with the workspace key. +-- `base_url`/`remote_workspace_id` name the target it was granted for: a token is only +-- ever sent to that target, so re-pointing the workspace setting cannot redirect it. +-- Keyed by the account rather than by membership, since a superadmin deploys from workspaces +-- it is not a member of. The account key cascades: whatever deletes or renames an account +-- takes its tokens along, so a later account with the same address cannot inherit them. +CREATE TABLE remote_deploy_token ( + workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE, + email VARCHAR(255) NOT NULL REFERENCES password(email) ON DELETE CASCADE ON UPDATE CASCADE, + base_url VARCHAR(1000) NOT NULL, + remote_workspace_id VARCHAR(50) NOT NULL, + token TEXT NOT NULL, + remote_email VARCHAR(255) NOT NULL, + -- Part of every proxy URL, and readable only by its owner through the API: a link from + -- elsewhere, which rides the session cookie, cannot know it and so cannot spend the token. + proxy_key VARCHAR(64) NOT NULL, + -- When the connect that wrote the row started, or when a disconnect emptied it. + connected_at TIMESTAMPTZ NOT NULL DEFAULT now(), + -- What the connect ran under: the `created_at` of the owner's membership (NULL for a + -- superadmin with none) and the target's `remote_deploy_target_changed_at`. The row counts + -- only while both are still the same, so a re-add or a target change voids it. + member_since TIMESTAMPTZ, + target_changed_at TIMESTAMPTZ, + PRIMARY KEY (workspace_id, email) +); + +GRANT ALL ON remote_deploy_token TO windmill_user; +GRANT ALL ON remote_deploy_token TO windmill_admin; diff --git a/backend/migrations/20260923173023_ai_instruction_resource_type.down.sql b/backend/migrations/20260923173023_ai_instruction_resource_type.down.sql new file mode 100644 index 0000000000..25b81b8c69 --- /dev/null +++ b/backend/migrations/20260923173023_ai_instruction_resource_type.down.sql @@ -0,0 +1,6 @@ +-- `created_by` tells this migration's row from one a user created by hand; a hub +-- sync updates in place and leaves it alone. +DELETE FROM resource_type +WHERE workspace_id = 'admins' + AND name = 'ai_instruction' + AND created_by = 'system'; diff --git a/backend/migrations/20260923173023_ai_instruction_resource_type.up.sql b/backend/migrations/20260923173023_ai_instruction_resource_type.up.sql new file mode 100644 index 0000000000..1b89469a91 --- /dev/null +++ b/backend/migrations/20260923173023_ai_instruction_resource_type.up.sql @@ -0,0 +1,15 @@ +-- Folder instructions for the AI chat: a markdown file resource whose +-- `value.content` applies to every item under the directory it lives in. +-- `format_extension` makes the resource editor render it as a plain .md file. +-- Seeded under 'admins' so every workspace sees it before the next hub sync. +INSERT INTO resource_type (workspace_id, name, schema, description, created_by, format_extension, edited_at) +VALUES ( + 'admins', + 'ai_instruction', + '{"type": "object", "properties": {"content": {"type": "string"}}}', + 'Instructions for the AI chat that apply to every item under the folder this resource lives in, like an AGENTS.md: an ai_instruction at f/billing/AGENTS is given to the assistant whenever it works on something under f/billing/. The file body is the instructions it follows.', + 'system', + 'md', + now() +) +ON CONFLICT (workspace_id, name) DO NOTHING; diff --git a/backend/parsers/windmill-parser-ts/src/lib.rs b/backend/parsers/windmill-parser-ts/src/lib.rs index 271ad4835b..ac75080994 100644 --- a/backend/parsers/windmill-parser-ts/src/lib.rs +++ b/backend/parsers/windmill-parser-ts/src/lib.rs @@ -16,11 +16,13 @@ use windmill_parser::{ use swc_common::{sync::Lrc, FileName, SourceMap, SourceMapper, Span, Spanned}; use swc_ecma_ast::{ - ArrayLit, AssignPat, BigInt, BindingIdent, Bool, Decl, ExportDecl, Expr, Ident, IdentName, Lit, - MemberExpr, MemberProp, ModuleDecl, ModuleItem, Number, ObjectLit, ObjectPat, Param, Pat, Stmt, - Str, TsArrayType, TsEntityName, TsInterfaceDecl, TsKeywordType, TsKeywordTypeKind, TsLit, - TsLitType, TsOptionalType, TsParenthesizedType, TsPropertySignature, TsType, TsTypeAliasDecl, - TsTypeAnn, TsTypeElement, TsTypeLit, TsTypeRef, TsUnionOrIntersectionType, TsUnionType, + ArrayLit, AssignPat, BigInt, BindingIdent, Bool, Decl, ExportDecl, Expr, ExprOrSpread, Ident, + IdentName, KeyValueProp, Lit, MemberExpr, MemberProp, ModuleDecl, ModuleItem, Number, + ObjectLit, ObjectPat, Param, Pat, Prop, PropName, PropOrSpread, Stmt, Str, TsArrayType, + TsEntityName, TsInterfaceDecl, TsKeywordType, TsKeywordTypeKind, TsLit, TsLitType, + TsOptionalType, TsParenthesizedType, TsPropertySignature, TsType, TsTypeAliasDecl, TsTypeAnn, + TsTypeElement, TsTypeLit, TsTypeRef, TsUnionOrIntersectionType, TsUnionType, UnaryExpr, + UnaryOp, }; use swc_ecma_parser::{lexer::Lexer, EsSyntax, Parser, StringInput, Syntax, TsSyntax}; @@ -578,6 +580,15 @@ fn parse_param( } }; + let literal = match &*right { + Expr::Object(_) | Expr::Array(_) if !skip_dflt => literal_json(&right), + _ => None, + }; + // Types an untyped object default that is not a plain literal (`{ a: x }`). + let literal_shape = match &*right { + Expr::Object(_) => Some(Typ::Object(ObjectType::new(None, None))), + _ => None, + }; let dflt = if skip_dflt { None } else { @@ -593,14 +604,19 @@ fn parse_param( Expr::Lit(Lit::Num(Number { value, .. })) => Some(serde_json::json!(value)), Expr::Lit(Lit::BigInt(BigInt { value, .. })) => Some(serde_json::json!(value)), Expr::Lit(Lit::Bool(Bool { value, .. })) => Some(Value::Bool(value)), - Expr::Object(ObjectLit { span, .. }) => eval_span(span, cm), - Expr::Array(ArrayLit { span, .. }) => eval_span(span, cm), + Expr::Object(ObjectLit { span, .. }) | Expr::Array(ArrayLit { span, .. }) => { + literal.or_else(|| eval_span(span, cm)) + } _ => None, } }; - if typ == Typ::Unknown && dflt.is_some() { - typ = json_to_typ(dflt.as_ref().unwrap(), false); + if typ == Typ::Unknown { + match &dflt { + Some(d) => typ = json_to_typ(d, false), + None if !skip_dflt => typ = literal_shape.unwrap_or(Typ::Unknown), + None => {} + } } Ok(Arg { otyp, @@ -636,6 +652,59 @@ fn parse_param( r } +/// A default written as a plain literal, read off the AST: `['a']` and `{ a: 1 }` are +/// not JSON, and outside wasm nothing can evaluate them (see `eval_sync`). +fn literal_json(expr: &Expr) -> Option { + let num = |v: f64| { + if v.fract() == 0.0 && v.abs() < 9007199254740992.0 { + Some(serde_json::json!(v as i64)) + } else { + serde_json::Number::from_f64(v).map(Value::Number) + } + }; + match expr { + Expr::Lit(Lit::Str(s)) => Some(Value::String(s.value.to_string())), + Expr::Lit(Lit::Num(n)) => num(n.value), + Expr::Lit(Lit::Bool(b)) => Some(Value::Bool(b.value)), + Expr::Lit(Lit::Null(_)) => Some(Value::Null), + Expr::Unary(UnaryExpr { op: UnaryOp::Minus, arg, .. }) => match &**arg { + Expr::Lit(Lit::Num(n)) => num(-n.value), + _ => None, + }, + Expr::Paren(p) => literal_json(&p.expr), + Expr::Array(a) => a + .elems + .iter() + .map(|e| match e { + Some(ExprOrSpread { spread: None, expr }) => literal_json(expr), + _ => None, + }) + .collect::>>() + .map(Value::Array), + Expr::Object(o) => o + .props + .iter() + .map(|p| match p { + PropOrSpread::Prop(p) => match &**p { + Prop::KeyValue(KeyValueProp { key, value }) => { + let key = match key { + PropName::Ident(i) => i.sym.to_string(), + PropName::Str(s) => s.value.to_string(), + PropName::Num(n) => n.value.to_string(), + _ => return None, + }; + Some((key, literal_json(value)?)) + } + _ => None, + }, + _ => None, + }) + .collect::>>() + .map(Value::Object), + _ => None, + } +} + fn eval_span(span: Span, cm: &Lrc) -> Option { let expr = cm .span_to_snippet(span) @@ -1361,5 +1430,7 @@ pub fn eval_sync(code: &str) -> Result { #[cfg(not(target_arch = "wasm32"))] pub fn eval_sync(_code: &str) -> Result { - panic!("eval_sync is only available in wasm32") + // The server parses signatures with defaults too: a default that is not JSON + // (`{ a: 1 }`, `['x']`) is then left out rather than failing the parse. + Err("evaluating a JavaScript default needs the wasm build".to_string()) } diff --git a/backend/parsers/windmill-parser-ts/tests/tests.rs b/backend/parsers/windmill-parser-ts/tests/tests.rs index 4206dd68f8..b4abc23488 100644 --- a/backend/parsers/windmill-parser-ts/tests/tests.rs +++ b/backend/parsers/windmill-parser-ts/tests/tests.rs @@ -7,6 +7,38 @@ mod tests { remove_pinned_import_specifiers, }; + /// The server parses defaults natively, where a non-JSON literal cannot be evaluated + /// but must still type and default the argument as the editor's wasm parse does. + #[test] + fn non_json_literal_defaults_parse_natively() { + let code = + "export function main(opts = { enabled: true, 'n': -1 }, tags = ['a'], o = { a: x }) {}"; + let sig = parse_deno_signature(code, false, false, None).unwrap(); + let defaults: Vec<_> = sig + .args + .iter() + .map(|a| (a.typ.clone(), a.default.clone())) + .collect(); + assert_eq!( + defaults, + vec![ + ( + Typ::Object(ObjectType::new( + None, + Some(vec![ + ObjectProperty::new("enabled".into(), Box::new(Typ::Bool)), + ObjectProperty::new("n".into(), Box::new(Typ::Int)), + ]) + )), + Some(json!({"enabled": true, "n": -1})) + ), + (Typ::List(Box::new(Typ::Str(None))), Some(json!(["a"]))), + // Not a plain literal: typed by its shape, with no default value. + (Typ::Object(ObjectType::new(None, None)), None), + ] + ); + } + #[test] fn test_imports_basic() { let code = r#" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 335b3f79cf..ccba27bcc5 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -267,6 +267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" dependencies = [ "aws-lc-sys", + "untrusted 0.7.1", "zeroize", ] @@ -374,12 +375,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.21.7" @@ -780,9 +775,9 @@ dependencies = [ [[package]] name = "cmov" -version = "0.5.3" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "colorchoice" @@ -1337,7 +1332,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin 0.9.8", + "spin", ] [[package]] @@ -1535,7 +1530,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -2186,16 +2181,20 @@ dependencies = [ [[package]] name = "jsonwebtoken" -version = "8.3.0" +version = "10.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" dependencies = [ - "base64 0.21.7", + "aws-lc-rs", + "base64 0.22.1", + "getrandom 0.2.17", + "js-sys", "pem", - "ring 0.16.20", "serde", "serde_json", + "signature", "simple_asn1", + "zeroize", ] [[package]] @@ -2225,7 +2224,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.8", + "spin", ] [[package]] @@ -2581,7 +2580,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -2598,7 +2597,7 @@ dependencies = [ "httparse", "memchr", "mime", - "spin 0.9.8", + "spin", "version_check", ] @@ -2817,7 +2816,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -2903,15 +2902,14 @@ checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" [[package]] name = "openssl" -version = "0.10.78" +version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38c4372413cdaaf3cc79dd92d29d7d9f5ab09b51b10dded508fb90bb70b9222" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ "bitflags", "cfg-if", "foreign-types", "libc", - "once_cell", "openssl-macros", "openssl-sys", ] @@ -2935,9 +2933,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-sys" -version = "0.9.114" +version = "0.9.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13ce1245cd07fcc4cfdb438f7507b0c7e4f3849a69fd84d52374c66d83741bb6" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", @@ -2994,11 +2992,12 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pem" -version = "1.1.1" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64 0.13.1", + "base64 0.22.1", + "serde_core", ] [[package]] @@ -3047,6 +3046,16 @@ dependencies = [ "phf_shared 0.12.1", ] +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", +] + [[package]] name = "phf_codegen" version = "0.11.3" @@ -3064,7 +3073,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -3098,6 +3107,15 @@ dependencies = [ "siphasher 1.0.2", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.2", +] + [[package]] name = "php-parser-rs" version = "0.1.3" @@ -3169,9 +3187,9 @@ dependencies = [ [[package]] name = "postgres-protocol" -version = "0.6.11" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" dependencies = [ "base64 0.22.1", "byteorder", @@ -3187,9 +3205,9 @@ dependencies = [ [[package]] name = "postgres-types" -version = "0.2.9" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613283563cd90e1dfc3518d548caee47e0e725455ed619881f5cf21f36de4b48" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" dependencies = [ "array-init", "bit-vec 0.6.3", @@ -3197,7 +3215,7 @@ dependencies = [ "chrono", "fallible-iterator", "postgres-protocol", - "serde", + "serde_core", "serde_json", "uuid", ] @@ -3394,16 +3412,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.0", - "ring 0.17.14", + "rand 0.9.5", + "ring", "rustc-hash 2.1.1", "rustls", "rustls-pki-types", @@ -3457,9 +3475,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -3468,13 +3486,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.0" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", - "zerocopy", ] [[package]] @@ -3752,22 +3769,7 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "503c78f59814e2664c9980b739b19e40f549233ecf39928040ee54fdd431f614" dependencies = [ - "rand 0.8.5", -] - -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin 0.5.2", - "untrusted 0.7.1", - "web-sys", - "winapi", + "rand 0.8.6", ] [[package]] @@ -3843,7 +3845,7 @@ dependencies = [ "borsh", "bytes", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "rkyv", "serde", "serde_json", @@ -3901,7 +3903,7 @@ checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ "aws-lc-rs", "once_cell", - "ring 0.17.14", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -3959,12 +3961,12 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", - "ring 0.17.14", + "ring", "rustls-pki-types", "untrusted 0.9.0", ] @@ -4199,15 +4201,16 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.143" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "indexmap", "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -4426,12 +4429,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - [[package]] name = "spin" version = "0.9.8" @@ -4594,7 +4591,7 @@ dependencies = [ "memchr", "once_cell", "percent-encoding", - "rand 0.8.5", + "rand 0.8.6", "rsa", "serde", "sha1", @@ -4605,7 +4602,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "uuid", - "whoami", + "whoami 1.6.1", ] [[package]] @@ -4636,7 +4633,7 @@ dependencies = [ "memchr", "num-bigint", "once_cell", - "rand 0.8.5", + "rand 0.8.6", "serde", "serde_json", "sha2 0.10.9", @@ -4646,7 +4643,7 @@ dependencies = [ "thiserror 2.0.18", "tracing", "uuid", - "whoami", + "whoami 1.6.1", ] [[package]] @@ -5063,9 +5060,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", @@ -5274,9 +5271,9 @@ dependencies = [ [[package]] name = "tokio-postgres" -version = "0.7.13" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c95d533c83082bb6490e0189acaa0bbeef9084e60471b696ca6988cd0541fb0" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" dependencies = [ "async-trait", "byteorder", @@ -5287,15 +5284,15 @@ dependencies = [ "log", "parking_lot", "percent-encoding", - "phf 0.11.3", + "phf 0.13.1", "pin-project-lite", "postgres-protocol", "postgres-types", - "rand 0.9.0", - "socket2 0.5.10", + "rand 0.10.1", + "socket2 0.6.3", "tokio", "tokio-util", - "whoami", + "whoami 2.1.0", ] [[package]] @@ -5797,7 +5794,7 @@ dependencies = [ "getopts", "log", "phf_codegen", - "rand 0.8.5", + "rand 0.8.6", ] [[package]] @@ -5919,6 +5916,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -5943,6 +5949,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "wasm-bindgen" version = "0.2.103" @@ -6154,7 +6169,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ "libredox", - "wasite", + "wasite 0.1.0", +] + +[[package]] +name = "whoami" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fae98cf96deed1b7572272dfc777713c249ae40aa1cf8862e091e8b745f5361" +dependencies = [ + "libredox", + "wasite 1.0.2", "web-sys", ] @@ -6191,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.814.0" +version = "1.817.0" dependencies = [ "aho-corasick", "anyhow", @@ -6233,9 +6258,10 @@ dependencies = [ "pep440_rs", "phf 0.11.3", "pin-project-lite", + "pkcs1", "postgres-native-tls", "quick_cache", - "rand 0.9.0", + "rand 0.9.5", "regex", "reqwest", "reqwest-middleware", @@ -6244,8 +6270,10 @@ dependencies = [ "serde", "serde_json", "serde_yml", + "sha1", "sha2 0.10.9", "size", + "spki", "sqlx", "strum 0.27.2", "strum_macros 0.27.2", @@ -6274,7 +6302,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.814.0" +version = "1.817.0" dependencies = [ "proc-macro2", "quote", @@ -6286,7 +6314,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.814.0" +version = "1.817.0" dependencies = [ "convert_case", "serde", @@ -6295,7 +6323,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -6307,7 +6335,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "serde_json", @@ -6319,7 +6347,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "gosyn", @@ -6331,7 +6359,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -6343,7 +6371,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "serde_json", @@ -6355,7 +6383,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "nu-parser", @@ -6366,7 +6394,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6377,7 +6405,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6389,7 +6417,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6400,7 +6428,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "async-recursion", @@ -6422,7 +6450,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "serde_json", @@ -6434,7 +6462,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -6448,7 +6476,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "convert_case", @@ -6465,7 +6493,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -6478,7 +6506,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "serde", @@ -6490,7 +6518,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -6508,7 +6536,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6524,7 +6552,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6540,7 +6568,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6572,7 +6600,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "lazy_static", @@ -6586,14 +6614,14 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.814.0" +version = "1.817.0" dependencies = [ "anyhow", "bitflags", "chrono", "hex", "itertools 0.14.0", - "rand 0.9.0", + "rand 0.9.5", "serde", "serde_json", "sqlx", @@ -7282,6 +7310,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" @@ -7315,3 +7357,9 @@ dependencies = [ "quote", "syn 2.0.117", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 1262be6a3b..0244b7b106 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.814.0" +version = "1.817.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/parsers/windmill-parser/src/asset_parser.rs b/backend/parsers/windmill-parser/src/asset_parser.rs index 30c6047255..70fe5aa28a 100644 --- a/backend/parsers/windmill-parser/src/asset_parser.rs +++ b/backend/parsers/windmill-parser/src/asset_parser.rs @@ -730,7 +730,12 @@ pub fn parse_asset_syntax( s: &str, enable_default_syntax: bool, ) -> Option<(AssetKind, Cow<'_, str>)> { - if enable_default_syntax && s == "datatable" { + // `datatable` and `datatable?role=analyst` both name the default data table: the role picks + // which Postgres login the connection is made as, not which data table is read. + if enable_default_syntax + && s.strip_prefix("datatable") + .is_some_and(|rest| rest.is_empty() || rest.starts_with('?')) + { return Some((AssetKind::DataTable, Cow::Borrowed("main"))); } else if enable_default_syntax && s == "ducklake" { return Some((AssetKind::Ducklake, Cow::Borrowed("main"))); @@ -741,6 +746,14 @@ pub fn parse_asset_syntax( if *kind == AssetKind::Dbt { return Some((*kind, Cow::Owned(canonicalize_table_asset_path(suffix)))); } + // Same reasoning as above, for the explicit form. Specific to data tables: a + // `Resource`'s `?table=` is part of what it names, and stripping it would merge two + // different assets. + if *kind == AssetKind::DataTable { + if let Some((path, _role)) = suffix.split_once('?') { + return Some((*kind, Cow::Borrowed(path))); + } + } // The suffix is kept verbatim. For S3 the path encodes the storage: // `s3:///`, with an EMPTY storage segment for the // workspace default — so `s3:///key` yields `/key` (leading slash @@ -1692,6 +1705,25 @@ fn parse_trigger_spec(s: &str) -> Option { mod pipeline_annotation_tests { use super::*; + #[test] + fn a_datatable_role_is_not_part_of_the_asset_it_names() { + // The role picks which Postgres login the connection is made as, so two references that + // differ only by role are the same asset and must land on one graph node. + assert_eq!( + parse_asset_syntax("datatable://sales?role=analytics", false), + Some((AssetKind::DataTable, Cow::Borrowed("sales"))) + ); + assert_eq!( + parse_asset_syntax("datatable?role=analytics", true), + Some((AssetKind::DataTable, Cow::Borrowed("main"))) + ); + // A resource's `?table=` is part of what it names, so it is kept. + assert_eq!( + parse_asset_syntax("$res:f/db/pg?table=users", false), + Some((AssetKind::Resource, Cow::Borrowed("f/db/pg?table=users"))) + ); + } + #[test] fn s3_path_keeps_storage_distinction() { // An S3 asset path is `/` with an empty storage segment diff --git a/backend/parsers/windmill-parser/src/json_schema.rs b/backend/parsers/windmill-parser/src/json_schema.rs new file mode 100644 index 0000000000..6ca216dd89 --- /dev/null +++ b/backend/parsers/windmill-parser/src/json_schema.rs @@ -0,0 +1,402 @@ +use serde_json::{json, Map, Value}; + +use crate::{MainArgSignature, ObjectProperty, Typ}; + +/// The run-form schema of a script's `main`, derived the way the editor derives it +/// (`inferArgs` in the frontend, `argSigToJsonSchemaType` in `windmill-utils-internal`), +/// so a script deployed without a schema gets the one the editor would have sent. +/// `previous` is the schema of the version being superseded: as in the editor, it is +/// updated in place, so what the user set on it (argument order, descriptions, ...) +/// survives wherever the code still agrees with it. +pub fn main_arg_schema(sig: &MainArgSignature, previous: Option<&Value>) -> Value { + let mut schema = match previous { + Some(Value::Object(p)) => p.clone(), + _ => json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + }) + .as_object() + .cloned() + .unwrap_or_default(), + }; + let mut old_props = match schema.remove("properties") { + Some(Value::Object(p)) => p, + _ => Map::new(), + }; + let mut properties = Map::new(); + let mut required = vec![]; + for arg in &sig.args { + let mut prop = match old_props.remove(&arg.name) { + Some(Value::Object(p)) => p, + _ => fresh_property(), + }; + if !matches!(arg.typ, Typ::OneOf(_)) { + prop.remove("oneOf"); + } + apply_typ(&arg.typ, &mut prop); + // `T | T[]` marks an argument debouncing accumulates into. + if let Some(otyp) = arg + .otyp + .as_deref() + .filter(|o| o.contains('[') && o.contains('|')) + { + prop.insert("originalType".into(), json!(otyp)); + } + match &arg.default { + Some(d) => prop.insert("default".into(), d.clone()), + None => prop.remove("default"), + }; + if !arg.has_default { + required.push(json!(arg.name)); + } + properties.insert(arg.name.clone(), Value::Object(prop)); + } + schema.insert("properties".into(), Value::Object(properties)); + schema.insert("required".into(), Value::Array(required)); + if sig.has_cmd_binding == Some(true) { + schema.insert("x-windmill-ps-cmd-binding".into(), json!(true)); + schema.insert( + "x-windmill-ps-supports-should-process".into(), + json!(sig.supports_should_process.unwrap_or(false)), + ); + } else { + schema.remove("x-windmill-ps-cmd-binding"); + schema.remove("x-windmill-ps-supports-should-process"); + } + Value::Object(schema) +} + +/// Keys a user sets on a property in the schema editor, kept whatever the code says. +const USER_FIELDS: &[&str] = &[ + "description", + "pattern", + "min", + "max", + "currency", + "currencyLocale", + "multiselect", + "customErrorMessage", + "required", + "showExpr", + "password", + "order", + "dateFormat", + "title", + "placeholder", +]; + +/// Item keys a list the parser cannot see into keeps from the previous schema. +const ITEMS_USER_FIELDS: &[&str] = &[ + "properties", + "required", + "additionalProperties", + "enum", + "resourceType", + "contentEncoding", + "description", +]; + +fn fresh_property() -> Map { + let mut p = Map::new(); + p.insert("description".into(), json!("")); + p.insert("type".into(), json!("")); + p +} + +fn object_properties( + props: &[ObjectProperty], + old: Option<&Map>, +) -> Map { + props + .iter() + .map(|p| { + let mut prop = old + .and_then(|o| o.get(&p.key)) + .and_then(Value::as_object) + .cloned() + .unwrap_or_else(fresh_property); + apply_typ(&p.typ, &mut prop); + (p.key.clone(), Value::Object(prop)) + }) + .collect() +} + +fn kept_items(old: &Map) -> Value { + let old_items = old.get("items").and_then(Value::as_object); + let mut items = Map::new(); + items.insert( + "type".into(), + old_items + .and_then(|i| i.get("type")) + .filter(|t| t.as_str().is_some_and(|t| !t.is_empty())) + .cloned() + .unwrap_or(json!("object")), + ); + if let Some(old_items) = old_items { + for f in ITEMS_USER_FIELDS { + if let Some(v) = old_items.get(*f) { + items.insert(f.to_string(), v.clone()); + } + } + } + Value::Object(items) +} + +/// Port of `argSigToJsonSchemaType`: rewrites `old` in place to carry `typ`. +fn apply_typ(typ: &Typ, old: &mut Map) { + let mut new = Map::new(); + // Keys the editor sets to `undefined`, which drops them from the stored JSON. + let mut unset: Vec<&str> = vec![]; + let mut set = |k: &str, v: Value| { + new.insert(k.to_string(), v); + }; + match typ { + Typ::Int => set("type", json!("integer")), + Typ::Float => set("type", json!("number")), + Typ::Bool => set("type", json!("boolean")), + Typ::Email => { + set("type", json!("string")); + set("format", json!("email")); + } + Typ::Sql => { + set("type", json!("string")); + set("format", json!("sql")); + } + Typ::Bytes => { + set("type", json!("string")); + set("contentEncoding", json!("base64")); + set("originalType", json!("bytes")); + } + Typ::Datetime => { + set("type", json!("string")); + set("format", json!("date-time")); + } + Typ::Date => { + set("type", json!("string")); + set("format", json!("date")); + } + Typ::OneOf(variants) => { + set("type", json!("object")); + let old_variants = old.get("oneOf").and_then(Value::as_array); + let one_of: Vec = variants + .iter() + .map(|v| { + let old_variant = old_variants + .and_then(|o| o.iter().find(|o| o.get("title") == Some(&json!(v.label)))) + .and_then(Value::as_object); + let old_props = old_variant + .and_then(|o| o.get("properties")) + .and_then(Value::as_object); + let mut variant = json!({ + "type": "object", + "title": v.label, + "properties": object_properties(&v.properties, old_props), + }); + if let Some(order) = old_variant.and_then(|o| o.get("order")) { + variant["order"] = order.clone(); + } + variant + }) + .collect(); + set("oneOf", Value::Array(one_of)); + } + Typ::Object(o) => { + set("type", json!("object")); + if let Some(name) = &o.name { + set("format", json!(format!("resource-{name}"))); + } + if let Some(props) = &o.props { + let old_props = old.get("properties").and_then(Value::as_object); + set( + "properties", + Value::Object(object_properties(props, old_props)), + ); + } + } + Typ::Str(variants) => { + set("type", json!("string")); + match variants { + Some(v) => { + set("originalType", json!("enum")); + set("enum", json!(v)); + } + None => { + set("originalType", json!("string")); + match old.get("enum") { + Some(e) if old.get("originalType") == Some(&json!("string")) => { + set("enum", e.clone()) + } + _ => unset.push("enum"), + } + } + } + } + Typ::Resource(name) => { + set("type", json!("object")); + set("format", json!(format!("resource-{name}"))); + } + Typ::DynSelect(name) => { + set("type", json!("object")); + set("format", json!(format!("dynselect-{name}"))); + } + Typ::DynMultiselect(name) => { + set("type", json!("object")); + set("format", json!(format!("dynmultiselect-{name}"))); + } + Typ::List(inner) => { + set("type", json!("array")); + let (items, original_type) = match inner.as_ref() { + Typ::Int | Typ::Float => (json!({"type": "number"}), "number[]"), + Typ::Bytes => ( + json!({"type": "string", "contentEncoding": "base64"}), + "bytes[]", + ), + Typ::Str(Some(v)) => (json!({"type": "string", "enum": v}), "enum[]"), + Typ::Str(None) => { + let mut items = json!({"type": "string"}); + if let Some(e) = old.get("items").and_then(|i| i.get("enum")) { + items["enum"] = e.clone(); + } + (items, "string[]") + } + Typ::Resource(r) => (json!({"type": "resource", "resourceType": r}), "resource[]"), + Typ::Object(o) => { + if let Some(name) = &o.name { + set("format", json!(format!("resource-{name}"))); + } + let items = match o.props.as_deref().filter(|p| !p.is_empty()) { + Some(props) => json!({ + "type": "object", + "properties": object_properties(props, None), + }), + None => kept_items(old), + }; + (items, "record[]") + } + _ => (kept_items(old), "object[]"), + }; + set("items", items); + set("originalType", json!(original_type)); + } + // The parser cannot tell: whatever type the user gave the argument stands. + Typ::Unknown => set("type", old.get("type").cloned().unwrap_or(json!("object"))), + } + + for f in USER_FIELDS { + if let Some(v) = old.get(*f) { + new.insert(f.to_string(), v.clone()); + } + } + + let old_type = old.get("type").cloned(); + let new_type = new.get("type").cloned(); + let old_format = old + .get("format") + .and_then(Value::as_str) + .map(str::to_string); + let new_format = new + .get("format") + .and_then(Value::as_str) + .map(str::to_string); + let is_date = |f: &Option| matches!(f.as_deref(), Some("date" | "date-time")); + let mut keep_old_format = false; + // A changed type takes every key `new` sets, so none of the adjustments below + // apply: running them would carry the old type's date format or items over. + if old_type != new_type { + } else if new_format.as_deref() == Some("date-time") && old_format.as_deref() == Some("date") { + new.insert("format".into(), json!("date")); + } else if is_date(&new_format) { + keep_old_format = true; + } else if old.get("items").and_then(|i| i.get("type")) + != new.get("items").and_then(|i| i.get("type")) + { + old.remove("items"); + } else if old_type == Some(json!("string")) && old_format.is_some() { + keep_old_format = true; + } + if keep_old_format { + match old.get("format") { + Some(f) => { + new.insert("format".into(), f.clone()); + } + None => { + new.remove("format"); + unset.push("format"); + } + } + } + if (old_type != new_type || new_type != Some(json!("string"))) && !new.contains_key("format") { + old.remove("format"); + } + + for k in unset { + if !new.contains_key(k) { + old.remove(k); + } + } + old.extend(new); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Arg, ObjectType}; + + fn arg(name: &str, typ: Typ, default: Option) -> Arg { + Arg { + name: name.to_string(), + has_default: default.is_some(), + default, + typ, + ..Default::default() + } + } + + #[test] + fn keeps_what_the_user_set_on_the_previous_version() { + let sig = MainArgSignature { + args: vec![ + arg("n", Typ::Int, None), + arg("db", Typ::Resource("postgresql".into()), None), + arg( + "tags", + Typ::List(Box::new(Typ::Str(None))), + Some(json!(["a"])), + ), + arg( + "opts", + Typ::Object(ObjectType::new(None, None)), + Some(json!({})), + ), + arg("any", Typ::Unknown, None), + ], + ..Default::default() + }; + let previous = json!({ + "type": "object", + "order": ["db", "n"], + "properties": { + "n": {"type": "integer", "description": "how many", "min": 1, "nullable": true}, + "tags": {"type": "array", "items": {"type": "string", "enum": ["a", "b"]}}, + "gone": {"type": "string"}, + }, + }); + let schema = main_arg_schema(&sig, Some(&previous)); + assert_eq!(schema["order"], json!(["db", "n"])); + assert_eq!(schema["required"], json!(["n", "db", "any"])); + assert_eq!( + schema["properties"]["n"], + json!({"type": "integer", "description": "how many", "min": 1, "nullable": true}) + ); + assert!(schema["properties"].get("gone").is_none()); + assert_eq!(schema["properties"]["db"]["format"], "resource-postgresql"); + assert_eq!( + schema["properties"]["tags"]["items"], + json!({"type": "string", "enum": ["a", "b"]}) + ); + assert_eq!(schema["properties"]["tags"]["default"], json!(["a"])); + assert_eq!(schema["properties"]["opts"]["type"], "object"); + // Untyped in the code and new: any JSON value, as the editor stores it. + assert_eq!(schema["properties"]["any"]["type"], ""); + } +} diff --git a/backend/parsers/windmill-parser/src/lib.rs b/backend/parsers/windmill-parser/src/lib.rs index 4270edcc79..7ca226b432 100644 --- a/backend/parsers/windmill-parser/src/lib.rs +++ b/backend/parsers/windmill-parser/src/lib.rs @@ -15,6 +15,7 @@ use serde_json::Value; pub mod asset_parser; pub mod duckdb_builtins; pub mod duckdb_macros; +pub mod json_schema; pub mod sql_materialize; /// S3 output format for SQL queries (moved here to avoid pulling sqlx into WASM via windmill-types) diff --git a/backend/src/main.rs b/backend/src/main.rs index 316ed099f2..9e7a93cfcd 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -52,7 +52,8 @@ use windmill_common::{ INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING, - MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING, + MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, + NO_DEFAULT_MAVEN_SETTING, NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, @@ -126,7 +127,8 @@ use windmill_worker::{ use crate::monitor::{ initial_load, load_concurrency_key_max_queued, load_disable_password_login, - load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, load_metrics_debug_enabled, + load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir, + load_mcp_disable_token_query_param, load_metrics_debug_enabled, load_preview_tags_override, load_require_preexisting_user, load_retention_period_overrides, load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces, load_workspace_fairness_duration_secs, load_workspace_fairness_enabled, @@ -2164,6 +2166,9 @@ async fn process_notify_event( DISABLE_PASSWORD_LOGIN_SETTING => { load_disable_password_login(db).await; } + MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING => { + load_mcp_disable_token_query_param(db).await; + } EXPOSE_METRICS_SETTING => { tracing::info!("Metrics setting changed, restarting"); spawn_graceful_killpill(tx, db, 30, "metrics setting change", server_mode) diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index e250302209..7e4dbb820a 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -45,7 +45,10 @@ use windmill_common::otel_oss::{ use windmill_common::{ agent_workers::DECODED_AGENT_TOKEN, apps::APP_WORKSPACED_ROUTE, - auth::{create_token_for_owner, ephemeral_script_token_label, job_token_expiry_secs}, + auth::{ + create_token_for_owner, ephemeral_script_token_label, job_token_expiry_secs, + TOKEN_EXPIRY_WARNING_DAYS, + }, ee_oss::CriticalErrorChannel, email_oss::send_email_if_possible, error, @@ -62,6 +65,7 @@ use windmill_common::{ FORK_WORKSPACE_TAG_APPEND_FORK_SUFFIX_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, + MCP_DISABLE_TOKEN_QUERY_PARAM, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, NSJAIL_TMPFS_SIZE_MB_SETTING, NSJAIL_TMP_BACKING_SETTING, NUGET_CONFIG_SETTING, OTEL_SETTING, OTEL_TRACES_RETENTION_SECS_SETTING, OTEL_TRACING_PROXY_SETTING, @@ -285,6 +289,15 @@ pub async fn initial_load( ); if let Some(db) = conn.as_sql() { + // Outside the `server_mode` block below: a `MODE=mcp` process serves the MCP routes + // with `server_mode` false and would otherwise never read this at all. That mode + // joins no monitor loop, so there — as for every global setting, `base_url` + // included — this pass is the only read, and a change lands on restart. + pass.setting( + MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, + false, + |v| async move { apply_mcp_disable_token_query_param(v) }, + ); pass.setting(DEFAULT_TAGS_PER_WORKSPACE_SETTING, false, |v| async move { apply_tag_per_workspace_enabled(v) }); @@ -1614,6 +1627,23 @@ pub fn apply_disable_password_login(value: Option) { }; } +pub async fn load_mcp_disable_token_query_param(db: &DB) { + match load_value_from_global_settings(db, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING).await { + Ok(v) => apply_mcp_disable_token_query_param(v), + Err(e) => tracing::error!("Error loading mcp_disable_token_query_param setting: {e:#}"), + }; +} + +pub fn apply_mcp_disable_token_query_param(value: Option) { + match value { + Some(serde_json::Value::Bool(t)) => { + MCP_DISABLE_TOKEN_QUERY_PARAM.store(t, Ordering::Relaxed) + } + None => MCP_DISABLE_TOKEN_QUERY_PARAM.store(false, Ordering::Relaxed), + _ => (), + }; +} + struct LogFile { file_path: String, hostname: String, @@ -1923,20 +1953,6 @@ pub async fn delete_expired_items(db: &DB) -> () { tracing::error!("Error deleting expired shared AI artifacts: {:?}", e); } - let audit_retention_days = audit_log_retention_days().await; - let audit_retention_secs: i64 = audit_retention_days * 60 * 60 * 24; - - // Clean up old (non-partitioned) audit table — will eventually be empty and dropped - if let Err(e) = sqlx::query_scalar!( - "DELETE FROM audit WHERE timestamp <= now() - ($1::bigint::text || ' s')::interval", - audit_retention_secs, - ) - .fetch_all(db) - .await - { - tracing::error!("Error deleting audit log: {:?}", e); - } - if let Err(e) = sqlx::query_scalar!( "DELETE FROM autoscaling_event WHERE applied_at <= now() - ($1::bigint::text || ' s')::interval", 30 * 24 * 60 * 60, // 30 days @@ -2198,7 +2214,7 @@ async fn cleanup_scheduled_job_deletions(db: &Pool) { } pub async fn check_expiring_tokens(db: &DB) { - // Find tokens expiring within 7 days that still have a pending notification row. + // Find tokens expiring within the warning window that still have a pending notification row. // The notification table stores token_hash (not plaintext) so the join works // even after the hash migration makes token.token nullable. let expiring_tokens_r = sqlx::query_as!( @@ -2207,8 +2223,9 @@ pub async fn check_expiring_tokens(db: &DB) { USING token t WHERE n.token_hash = t.token_hash AND n.expiration > now() - AND n.expiration <= now() + interval '7 days' + AND n.expiration <= now() + make_interval(days => $1) RETURNING t.token_prefix, t.label, t.email, t.workspace_id", + TOKEN_EXPIRY_WARNING_DAYS, ) .fetch_all(db) .await; diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index e27cb2893c..169eb81681 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -56,7 +56,7 @@ app_version_lite: id(bigint), value(jsonb) FK: (id) -> app_version(id) asset: workspace_id(char), path(char), kind(asset_kind), usage_access_type(asset_access_type), usage_path(char), usage_kind(asset_usage_kind), created_at(ts), id(bigint), columns(jsonb) FK: (workspace_id) -> workspace(id) -audit: workspace_id(char), id(bigint), timestamp(ts), username(char), operation(char), action_kind(action_kind), resource(char), parameters(jsonb), email(char), span(char) +audit_partitioned: workspace_id(char), id(bigint), timestamp(ts), username(char), operation(char), action_kind(action_kind), resource(char), parameters(jsonb), email(char), span(char) autoscaling_event: id(int), worker_group(text), event_type(autoscaling_event_type), desired_workers(int), applied_at(ts), reason(text) capture: workspace_id(char), path(char), created_at(ts), created_by(char), main_args(jsonb), is_flow(bool), trigger_kind(trigger_kind), preprocessor_args(jsonb), id(bigint) FK: (workspace_id) -> workspace(id) @@ -171,6 +171,9 @@ postgres_trigger: path(char), script_path(char), is_flow(bool), workspace_id(cha FK: (workspace_id) -> workspace(id) raw_app: path(char), version(int), workspace_id(char), summary(char), edited_at(ts), data(text), extra_perms(jsonb), labels(text[]) FK: (workspace_id) -> workspace(id) +remote_deploy_token: workspace_id(char), email(char), base_url(char), remote_workspace_id(char), token(text), remote_email(char), proxy_key(char), connected_at(timestamptz), member_since(timestamptz), target_changed_at(timestamptz) + FK: (email) -> password(email) + FK: (workspace_id) -> workspace(id) resource: workspace_id(char), path(char), value(jsonb), description(text), resource_type(char), extra_perms(jsonb), edited_at(ts), created_by(char), labels(text[]) FK: (workspace_id) -> workspace(id) resource_type: workspace_id(char), name(char), schema(jsonb), description(text), edited_at(ts), created_by(char), format_extension(char), is_fileset(bool), display_name(char) @@ -234,7 +237,7 @@ workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_gr FK: (workspace_id) -> workspace(id) workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint), runnable_is_agent(bool) FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id) -workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int) +workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool), remote_deploy_target(jsonb), remote_deploy_target_changed_at(ts) FK: (workspace_id) -> workspace(id) zombie_job_counter: job_id(uuid), counter(int) FK: (job_id) -> v2_job(id) diff --git a/backend/tests/app_guest_execution_mode.rs b/backend/tests/app_guest_execution_mode.rs index 7af2e0794e..105458847a 100644 --- a/backend/tests/app_guest_execution_mode.rs +++ b/backend/tests/app_guest_execution_mode.rs @@ -577,24 +577,13 @@ async fn guests_mode_needs_a_scopable_path(db: Pool) -> anyhow::Result .send() .await?; assert_eq!(resp.status(), 400, "renamed to a `,` path while in Guests"); - let resp = authed( - client().post(format!("{ws}/apps/update/{APP_PATH}")), - ADMIN_TOKEN, - ) - .json(&json!({ "path": "u/test-user/My App" })) - .send() - .await?; - assert_eq!( - resp.status(), - 200, - "a space is literal: {}", - resp.text().await? - ); - // Set on update: an app that already sits on such a path cannot be switched. + // Set on update: an app that already sits on such a path cannot be switched. Path + // validation now refuses these paths on every write, so plant the kind of app saved + // before it did. let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN) .json(&json!({ - "path": "u/test-user/x:y", + "path": "u/test-user/legacy", "summary": "App", "value": {}, "policy": { "execution_mode": "publisher", "triggerables_v2": {} } @@ -602,6 +591,12 @@ async fn guests_mode_needs_a_scopable_path(db: Pool) -> anyhow::Result .send() .await?; assert_eq!(resp.status(), 201, "{}", resp.text().await?); + sqlx::query( + "UPDATE app SET path = 'u/test-user/x:y' + WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/legacy'", + ) + .execute(&db) + .await?; let resp = authed( client().post(format!("{ws}/apps/update/u/test-user/x:y")), ADMIN_TOKEN, @@ -610,6 +605,8 @@ async fn guests_mode_needs_a_scopable_path(db: Pool) -> anyhow::Result .send() .await?; assert_eq!(resp.status(), 400, "switched to Guests on a `:` path"); + let body = resp.text().await?; + assert!(body.contains("cannot be set to Guests"), "{body}"); Ok(()) } diff --git a/backend/tests/app_version_order.rs b/backend/tests/app_version_order.rs new file mode 100644 index 0000000000..e3557ad55c --- /dev/null +++ b/backend/tests/app_version_order.rs @@ -0,0 +1,193 @@ +//! The deployed order of an app's versions is the order they were appended to +//! `app.versions`, not the order of their `created_at`. +//! +//! `app_version.created_at` defaults to `now()`, which in Postgres is the +//! transaction's start time, while the append happens under the app row's lock. +//! Two deploys that overlap therefore land in one order and carry timestamps in +//! the other. The head the editor guards against, and the sequence the diff +//! picker numbers, both have to follow the array. +//! +//! Users from the `base` fixture: test-user (admin, token SECRET_TOKEN). + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +const TOKEN: &str = "SECRET_TOKEN"; + +#[sqlx::test(fixtures("base"))] +async fn test_app_head_follows_the_append_order_not_the_timestamps( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let ws = format!( + "http://localhost:{}/api/w/test-workspace", + server.addr.port() + ); + let client = reqwest::Client::new(); + + let res = client + .post(format!("{ws}/apps/create")) + .header("Authorization", format!("Bearer {TOKEN}")) + .json(&json!({ + "path": "u/test-user/order_app", + "summary": "ordered", + "value": {}, + "policy": { "execution_mode": "publisher", "triggerables": {} } + })) + .send() + .await?; + assert!(res.status().is_success(), "{}", res.text().await?); + + let first: i64 = sqlx::query_scalar( + "SELECT versions[array_upper(versions, 1)] FROM app + WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/order_app'", + ) + .fetch_one(&db) + .await?; + + // The overlapping deploy: appended after `first`, so it is the version that + // landed, but stamped before it, so a timestamp sort puts it underneath. + let second: i64 = sqlx::query_scalar( + "INSERT INTO app_version (app_id, value, created_by, created_at, raw_app) + SELECT app_id, value, 'racer', created_at - interval '1 hour', raw_app + FROM app_version WHERE id = $1 + RETURNING id", + ) + .bind(first) + .fetch_one(&db) + .await?; + sqlx::query( + "UPDATE app SET versions = array_append(versions, $1::bigint) + WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/order_app'", + ) + .bind(second) + .execute(&db) + .await?; + + let head: serde_json::Value = client + .get(format!( + "{ws}/apps/get_latest_version/u/test-user/order_app" + )) + .header("Authorization", format!("Bearer {TOKEN}")) + .send() + .await? + .json() + .await?; + assert_eq!( + head["version"], second, + "the head is the version appended last, not the newest timestamp: {head}" + ); + + let history: Vec = client + .get(format!("{ws}/apps/history/p/u/test-user/order_app")) + .header("Authorization", format!("Bearer {TOKEN}")) + .send() + .await? + .json() + .await?; + let listed: Vec = history + .iter() + .map(|v| v["version"].as_i64().unwrap()) + .collect(); + assert_eq!( + listed, + vec![second, first], + "the history lists in deployed order, so it leads with the head" + ); + + // Enough versions that a page cannot hold them, so "asked for nothing" and "asked for + // a page" are visibly different answers. + let mut appended = vec![second, first]; + for _ in 0..24 { + let extra: i64 = sqlx::query_scalar( + "INSERT INTO app_version (app_id, value, created_by, created_at, raw_app) + SELECT app_id, value, 'bulk', created_at, raw_app FROM app_version WHERE id = $1 + RETURNING id", + ) + .bind(first) + .fetch_one(&db) + .await?; + sqlx::query( + "UPDATE app SET versions = array_append(versions, $1::bigint) + WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/order_app'", + ) + .bind(extra) + .execute(&db) + .await?; + appended.insert(0, extra); + } + + let versions_at = |query: &str| { + let url = format!("{ws}/apps/history/p/u/test-user/order_app{query}"); + let client = client.clone(); + async move { + let rows: Vec = client + .get(url) + .header("Authorization", format!("Bearer {TOKEN}")) + .send() + .await? + .json() + .await?; + Ok::<_, anyhow::Error>( + rows.iter() + .map(|v| v["version"].as_i64().unwrap()) + .collect::>(), + ) + } + }; + + // The deployment-history panel and the CLI read this endpoint without paging, so + // asking for no page has to keep answering with the whole history. + assert_eq!( + versions_at("").await?, + appended, + "an unpaginated request still answers whole" + ); + assert_eq!( + versions_at("?per_page=10").await?, + appended[..10], + "a page holds what was asked for, newest first" + ); + assert_eq!( + versions_at("?per_page=10&page=2").await?, + appended[10..20], + "the next page carries on where the first left off, skipping nothing" + ); + // A page past the end runs off it rather than overflowing into one. (The clamp on an + // asked-for size is pinned where it lives, in `paginate_optional`'s own test.) + assert!( + versions_at("?per_page=10&page=99999999") + .await? + .is_empty(), + "a page past the end is empty" + ); + // Naming only the page still asks for a page, rather than an offset into a listing + // sized to hold everything — which would answer nothing at all. + assert_eq!( + versions_at("?page=1").await?, + appended, + "a first page with no size named holds the history" + ); + + // A version that never entered the deployed sequence — one a restore or a fork copy + // could leave behind — still has to be reachable, after the ones that did. + let stray: i64 = sqlx::query_scalar( + "INSERT INTO app_version (app_id, value, created_by, created_at, raw_app) + SELECT app_id, value, 'stray', created_at, raw_app FROM app_version WHERE id = $1 + RETURNING id", + ) + .bind(first) + .fetch_one(&db) + .await?; + let mut with_stray = appended.clone(); + with_stray.push(stray); + assert_eq!( + versions_at("").await?, + with_stray, + "a version outside app.versions is listed, and after the deployed sequence" + ); + + Ok(()) +} diff --git a/backend/tests/bun_jobs.rs b/backend/tests/bun_jobs.rs index 63d5341ba3..14f8c422a4 100644 --- a/backend/tests/bun_jobs.rs +++ b/backend/tests/bun_jobs.rs @@ -1303,6 +1303,62 @@ async fn test_bun_wac_inline_task_cache_is_per_task(db: Pool) -> anyho Ok(()) } +/// A task that picks its own worker tag is held to CUSTOM_TAGS like a flow step: with none +/// set, only a superadmin may send it to `gpu`, so the workflow fails instead of queueing it. +#[sqlx::test(fixtures("base"))] +async fn test_bun_wac_task_tag_outside_custom_tags(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let content = r#" +import { workflow, task } from "windmill-client"; + +const onGpu = task(async (n: number) => { + return n * 2; +}, { tag: "gpu" }); + +export const main = workflow(async (n: number) => { + return await onGpu(n); +}); +"# + .to_owned(); + + let job = RunJob::from(JobPayload::Code(RawCode { + hash: None, + content, + path: None, + language: ScriptLang::Bun, + lock: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + modules: None, + tag: None, + })) + .arg("n", serde_json::json!(5)) + .as_user("test-user-2", "test2@windmill.dev") + .run_until_complete(&db, false, port) + .await; + + assert!(!job.success, "the workflow must fail"); + let result = job.json_result().unwrap(); + let message = result["error"]["message"].as_str().unwrap_or_default(); + assert!( + message.contains("task 'onGpu' cannot run on tag 'gpu'"), + "unexpected error: {result}" + ); + let children: i64 = sqlx::query_scalar("SELECT count(*) FROM v2_job WHERE parent_job = $1") + .bind(job.id) + .fetch_one(&db) + .await?; + assert_eq!(children, 0, "no child is queued on the refused tag"); + Ok(()) +} + // ============================================================================ // Environment Variable Tests // ============================================================================ diff --git a/backend/tests/datatable_migration_add_down.rs b/backend/tests/datatable_migration_add_down.rs index 3cd39e0fa3..47ea4c253b 100644 --- a/backend/tests/datatable_migration_add_down.rs +++ b/backend/tests/datatable_migration_add_down.rs @@ -142,3 +142,35 @@ async fn test_add_down_to_an_applied_migration(db: Pool) -> anyhow::Re Ok(()) } + +/// A definition saved before `-- role` annotations were parsed can open with a comment that now +/// reads as a malformed one. It never gets through a role check again, so an admin must still be +/// able to finish it rather than be refused on its annotation. +#[sqlx::test(fixtures("datatable_migrations_grants"))] +async fn test_add_down_to_a_migration_whose_comment_predates_role_annotations( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + setup_applied_migration_without_down(&db).await?; + let code_up = format!("-- Role table for widgets\n{CODE_UP}"); + sqlx::query("UPDATE datatable_migrations SET code_up = $1 WHERE workspace_id = 'dtmig-ws'") + .bind(&code_up) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let resp = authed(reqwest::Client::new().post(format!( + "http://localhost:{}/api/w/dtmig-ws/workspaces/upsert_datatable_migration/main", + server.addr.port() + ))) + .json(&json!({ + "timestamp": VERSION, + "name": "create_widgets", + "code_up": code_up, + "code_down": CODE_DOWN, + })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + Ok(()) +} diff --git a/backend/tests/dependency_map.rs b/backend/tests/dependency_map.rs index 4520cae848..f4b74525fb 100644 --- a/backend/tests/dependency_map.rs +++ b/backend/tests/dependency_map.rs @@ -521,10 +521,8 @@ def main(): .await .unwrap(); - assert_eq!( - res.text().await.unwrap(), - "app f/rel/root_app updated (npath: \"f/rel/root_app_renamed\")" - ); + let deployed: serde_json::Value = res.json().await.unwrap(); + assert_eq!(deployed["path"], "f/rel/root_app_renamed", "{deployed}"); let mut completed = listen_for_completed_jobs(&db).await; in_test_worker(&db, completed.next(), port).await; diff --git a/backend/tests/drafts_move_mirror.rs b/backend/tests/drafts_move_mirror.rs new file mode 100644 index 0000000000..d619381c46 --- /dev/null +++ b/backend/tests/drafts_move_mirror.rs @@ -0,0 +1,74 @@ +//! Moving a draft must carry both of its path keys. +//! +//! A draft value holds a typed path and a mirror the editors keep beside it while +//! it differs from the row's path (`path`/`draft_path`; which is which depends on +//! the kind). The loaders prefer the mirror, so a move that rewrote only the typed +//! key left the mirror naming the old location: reopening the item restored the +//! old path, and the next autosave wrote it back — undoing the move silently. +//! +//! This pins `move_draft`, including that a draft with no mirror never gains one. +//! A deploy's carry follows the keys only where they still name the old path +//! (`drafts_save_follows_move` pins that). + +use serde_json::Value; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +async fn move_to(port: u16, from: &str, to: &str) -> anyhow::Result { + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/move/script/{from}" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&serde_json::json!({ "new_path": to })) + .send() + .await?; + // Asserted here so a rejected move fails as itself, rather than as a JSON + // parse error in the read-back below. + let status = resp.status(); + let body = resp.text().await?; + assert!(status.is_success(), "move {from} -> {to} failed: {body}"); + Ok(body) +} + +/// The stored draft value at `path`, read back through the API so this test needs +/// no `sqlx::query!` (which would want an offline cache entry of its own). +async fn value_at(port: u16, path: &str) -> anyhow::Result { + let body: Value = reqwest::Client::new() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/{path}" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .send() + .await? + .json() + .await?; + Ok(body + .get("value") + .cloned() + .unwrap_or_else(|| panic!("no draft at {path}: {body}"))) +} + +#[sqlx::test(fixtures("base", "drafts_move_mirror"))] +async fn test_move_carries_both_path_keys(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + move_to(port, "u/test-user/draft_mirror", "u/test-user/renamed").await?; + move_to(port, "u/test-user/draft_plain", "u/test-user/plain2").await?; + + // The mirror follows: left at `u/test-user/friendly` it would win at load and + // walk the item back there. + let moved = value_at(port, "u/test-user/renamed").await?; + assert_eq!(moved["path"], "u/test-user/renamed"); + assert_eq!(moved["draft_path"], "u/test-user/renamed"); + + // A draft that never had a mirror must not be given one. + let plain = value_at(port, "u/test-user/plain2").await?; + assert_eq!(plain["path"], "u/test-user/plain2"); + assert_eq!(plain.get("draft_path"), None, "mirror injected: {plain}"); + + Ok(()) +} diff --git a/backend/tests/drafts_move_taken.rs b/backend/tests/drafts_move_taken.rs new file mode 100644 index 0000000000..40318ca2ef --- /dev/null +++ b/backend/tests/drafts_move_taken.rs @@ -0,0 +1,250 @@ +//! A rename onto a path that already holds a draft is refused. +//! +//! Nothing deployed can sit at a rename's destination (the deploy conflicts on +//! that), but a draft can: a never-deployed item, or a draft left on an archived +//! script. Moving onto it would merge two items or strand a row, so the rename +//! itself fails, in its own transaction, and the source stays deployed. The +//! destination draft here is the deployer's own, which is the same collision. + +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +/// Hex form of script hash 7010, the way the API takes a parent hash. +const HEAD_HASH: &str = "0000000000001b62"; + +#[sqlx::test(fixtures("base", "drafts_move_taken"))] +async fn test_rename_onto_a_draft_is_refused(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let client = reqwest::Client::new(); + + let resp = client + .post(format!( + "http://localhost:{port}/api/w/test-workspace/scripts/create" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ + "path": "u/test-user/mvtaken_b", + "parent_hash": HEAD_HASH, + "summary": "A", + "description": "", + "content": "export function main() { return 1 }", + "language": "deno", + "schema": {} + })) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!(status, 400, "rename onto a draft was not refused: {body}"); + assert!( + body.contains("already has a draft"), + "unexpected refusal: {body}" + ); + + // The whole deploy rolled back: the source is still the live head, and the + // draft at the destination is untouched. + let head: Value = client + .get(format!( + "http://localhost:{port}/api/w/test-workspace/scripts/get/p/u/test-user/mvtaken_a" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .send() + .await? + .json() + .await?; + assert_eq!(head["hash"], HEAD_HASH, "source was replaced: {head}"); + assert_eq!(head["archived"], false, "source was archived: {head}"); + + let draft: Value = client + .get(format!("http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/u/test-user/mvtaken_b")) + .header("Authorization", "Bearer SECRET_TOKEN") + .send() + .await? + .json() + .await?; + assert_eq!( + draft["value"]["summary"], "B", + "destination draft changed: {draft}" + ); + + Ok(()) +} + +/// A legacy (ownerless) draft occupies its path too: a deploy there deletes it together +/// with the caller's own row, so a move that parks a second draft beside it would discard +/// edits the caller never saw. Only an admin can clear it, so the refusal says so. +#[sqlx::test(fixtures("base", "drafts_move_taken"))] +async fn test_draft_move_refuses_a_legacy_destination(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/mvtaken_b" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "new_path": "u/test-user/mvtaken_legacy" })) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!(status, 400, "move onto a legacy draft was allowed: {body}"); + assert!( + body.contains("legacy workspace draft") && body.contains("workspace admin"), + "the refusal did not point at the one remedy: {body}" + ); + + // Both rows stayed where they were: the caller's own, and the legacy one the list + // synthesizes under the caller's name. + let list: Vec = reqwest::Client::new() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/list" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .send() + .await? + .json() + .await?; + let mut at = list + .iter() + .filter_map(|d| Some((d["kind"].as_str()?, d["path"].as_str()?))) + .filter(|(_, p)| p.starts_with("u/test-user/mvtaken_b") || p.ends_with("mvtaken_legacy")) + .collect::>(); + at.sort(); + assert_eq!( + at, + vec![ + ("script", "u/test-user/mvtaken_b"), + ("script", "u/test-user/mvtaken_legacy") + ], + "{list:?}" + ); + Ok(()) +} + +/// A classic app and a raw app deploy into the same table, so a draft-only move onto +/// the other kind's draft must be refused: deploying either path afterwards deletes +/// the caller's drafts of both kinds, taking the loser's item with it. +#[sqlx::test(fixtures("base", "drafts_move_taken"))] +async fn test_draft_move_refuses_the_other_app_kind(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/move/raw_app/u/test-user/mvtaken_raw" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "new_path": "u/test-user/mvtaken_app" })) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 400, + "move onto a classic app draft was allowed: {body}" + ); + assert!( + body.contains("already have a draft at 'u/test-user/mvtaken_app' (app)"), + "the refusal did not name the occupying kind: {body}" + ); + + // Both drafts are untouched. + let list: Vec = reqwest::Client::new() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/list" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .send() + .await? + .json() + .await?; + let mut at = list + .iter() + .filter(|d| matches!(d["kind"].as_str(), Some("app") | Some("raw_app"))) + .filter_map(|d| Some((d["kind"].as_str()?, d["path"].as_str()?))) + .collect::>(); + at.sort(); + assert_eq!( + at, + vec![ + ("app", "u/test-user/mvtaken_app"), + ("raw_app", "u/test-user/mvtaken_raw") + ], + "{list:?}" + ); + Ok(()) +} + +/// Teammates' drafts of one item share its path by design, so another user's row is no +/// obstacle — except across the app pair, where the two kinds are different items on one +/// deployed path: deploying either strands the other, and deleting the app takes both. +#[sqlx::test(fixtures("base", "drafts_move_taken"))] +async fn test_draft_move_refuses_another_users_other_app_kind( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let move_to = |kind: &'static str, from: &'static str, to: &'static str| async move { + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/move/{kind}/{from}" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "new_path": to })) + .send() + .await?; + Ok::<_, anyhow::Error>((resp.status(), resp.text().await?)) + }; + + let (status, body) = move_to( + "app", + "u/test-user/mvtaken_app", + "u/test-user/mvtaken_theirs", + ) + .await?; + assert_eq!( + status, 400, + "a classic app was moved onto another user's raw app: {body}" + ); + assert!( + body.contains("holds another user's raw app draft"), + "the refusal did not name the occupant: {body}" + ); + + // And the other direction, where the occupant reads as the classic kind. + let (status, body) = move_to( + "raw_app", + "u/test-user/mvtaken_raw", + "u/test-user/mvtaken_app_theirs", + ) + .await?; + assert_eq!( + status, 400, + "a raw app was moved onto another user's classic app: {body}" + ); + assert!( + body.contains("holds another user's app draft"), + "the refusal did not name the occupant: {body}" + ); + + // The same-kind case is the ordinary one: two users' drafts of one raw app. + let (status, body) = move_to( + "raw_app", + "u/test-user/mvtaken_raw", + "u/test-user/mvtaken_theirs", + ) + .await?; + assert!( + status.is_success(), + "a raw app was refused beside another user's raw-app draft: {body}" + ); + Ok(()) +} diff --git a/backend/tests/drafts_nul.rs b/backend/tests/drafts_nul.rs index d7314b8fb6..2a77fe772a 100644 --- a/backend/tests/drafts_nul.rs +++ b/backend/tests/drafts_nul.rs @@ -78,3 +78,46 @@ async fn test_draft_write_strips_nul(db: Pool) -> anyhow::Result<()> { Ok(()) } + +/// The lineage the server derives for `draft.base` comes from the same value: read before +/// the sanitizer, a NUL in it costs the draft its base, which then reads as up to date +/// whatever the head is. +#[sqlx::test(fixtures("drafts_nul"))] +async fn test_draft_base_is_derived_from_sanitized_value(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/dnul-ws"); + + let resp = authed(client().post(format!( + "{base}/drafts/update/script/u/dnul-admin/poisoned_base" + ))) + .json(&json!({ + "value": { + "summary": "s", + "path": "u/dnul-admin/poisoned_base", + "content": "x", + "parent_hash": "0000000000001b62\u{0}" + } + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "save refused: {}", + resp.text().await.unwrap_or_default() + ); + + let stored: Option = sqlx::query_scalar( + "SELECT base FROM draft WHERE workspace_id = 'dnul-ws' AND path = 'u/dnul-admin/poisoned_base'", + ) + .fetch_one(&db) + .await?; + assert_eq!( + stored.as_deref(), + Some("0000000000001b62"), + "the NUL cost the draft its base: {stored:?}" + ); + Ok(()) +} diff --git a/backend/tests/drafts_save_follows_move.rs b/backend/tests/drafts_save_follows_move.rs new file mode 100644 index 0000000000..afbec56c75 --- /dev/null +++ b/backend/tests/drafts_save_follows_move.rs @@ -0,0 +1,587 @@ +//! A draft save addressed to a path its item moved away from lands on the moved draft. +//! +//! A move carries every draft on the item to the new path and records where they +//! went. An editor left open across it still saves to the old path; the server puts +//! the save on the moved draft, keeps the path keys the move gave it, and names the +//! new path so the editor can follow. Without the record the save would plant a +//! phantom draft-only item at the old location. + +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +/// Hex form of script hash 7030, the way the API takes a parent hash. +const HEAD_HASH: &str = "0000000000001b76"; + +async fn own_draft_value(port: u16, path: &str) -> anyhow::Result { + let draft: Value = reqwest::Client::new() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/get_own/script/{path}" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .send() + .await? + .json() + .await?; + Ok(draft["value"].clone()) +} + +async fn own_draft_paths(port: u16) -> anyhow::Result> { + let list: Vec = reqwest::Client::new() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/list" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .send() + .await? + .json() + .await?; + Ok(list + .iter() + .filter(|d| d["kind"] == "script") + .filter_map(|d| d["path"].as_str().map(String::from)) + .filter(|p| p.starts_with("u/test-user/follow_")) + .collect()) +} + +#[sqlx::test(fixtures("base", "drafts_save_follows_move"))] +async fn test_save_follows_a_rename(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let client = reqwest::Client::new(); + + // Rename from Home: a redeploy of the deployed content at the new path that + // keeps the deployer's own draft, so the draft is carried rather than consumed. + let resp = client + .post(format!( + "http://localhost:{port}/api/w/test-workspace/scripts/create" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ + "path": "u/test-user/follow_b", + "parent_hash": HEAD_HASH, + "summary": "A", + "description": "", + "content": "export function main() { return 1 }", + "language": "deno", + "schema": {}, + "skip_draft_deletion": true + })) + .send() + .await?; + assert_eq!(resp.status(), 201, "rename failed: {}", resp.text().await?); + assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_b"]); + // A script draft's `path` is where deploying it lands, so it moves with the row. + assert_eq!( + own_draft_value(port, "u/test-user/follow_b").await?["path"], + "u/test-user/follow_b" + ); + + // The editor is still on the old path and writes that path back into the value. + let saved: Value = client + .post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/follow_a" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ + "value": { + "path": "u/test-user/follow_a", + "parent_hash": HEAD_HASH, + "summary": "A", + "content": "edited after the move" + } + })) + .send() + .await? + .json() + .await?; + assert_eq!(saved["status"], "saved", "save refused: {saved}"); + assert_eq!( + saved["path"], "u/test-user/follow_b", + "save did not follow the row: {saved}" + ); + + // The write landed on the carried row; nothing reappeared at the old path. + assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_b"]); + let draft = own_draft_value(port, "u/test-user/follow_b").await?; + assert_eq!(draft["content"], "edited after the move", "{draft}"); + assert_eq!(draft["path"], "u/test-user/follow_b", "{draft}"); + + Ok(()) +} + +/// A draft-only move rewrites both path keys. The owner's open editor still carries +/// the typed path it had, which names neither the old nor the new path; the moved +/// draft's own keys have to win, or the save walks the item back. +#[sqlx::test(fixtures("base", "drafts_save_follows_move"))] +async fn test_save_follows_a_draft_only_move(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let client = reqwest::Client::new(); + + let resp = client + .post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/draft_store" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "new_path": "u/test-user/moved" })) + .send() + .await?; + assert!( + resp.status().is_success(), + "move failed: {}", + resp.text().await? + ); + + let saved: Value = client + .post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/draft_store" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ + "value": { + "path": "u/test-user/friendly", + "draft_path": "u/test-user/friendly", + "summary": "D", + "content": "edited after the move" + } + })) + .send() + .await? + .json() + .await?; + assert_eq!(saved["path"], "u/test-user/moved", "{saved}"); + + let draft = own_draft_value(port, "u/test-user/moved").await?; + assert_eq!(draft["content"], "edited after the move", "{draft}"); + assert_eq!(draft["path"], "u/test-user/moved", "{draft}"); + assert_eq!(draft["draft_path"], "u/test-user/moved", "{draft}"); + Ok(()) +} + +/// Rename `from` to `to` the way Home does: redeploy the deployed content at the new +/// path, keeping the deployer's own draft so it is carried rather than consumed. +/// Returns the new head's hash. +async fn rename(port: u16, from_hash: &str, to: &str) -> anyhow::Result { + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/scripts/create" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ + "path": to, + "parent_hash": from_hash, + "summary": "A", + "description": "", + "content": "export function main() { return 1 }", + "language": "deno", + "schema": {}, + "skip_draft_deletion": true + })) + .send() + .await?; + let status = resp.status(); + let hash = resp.text().await?; + assert_eq!(status, 201, "rename to {to} failed: {hash}"); + Ok(hash) +} + +/// Save the draft as an editor still bound to `url_path` would. Returns the path the +/// save landed at. +async fn save_at(port: u16, url_path: &str, content: &str) -> anyhow::Result { + let saved: Value = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/update/script/{url_path}" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ + "value": { "path": url_path, "summary": "A", "content": content, "language": "deno" } + })) + .send() + .await? + .json() + .await?; + assert_eq!(saved["status"], "saved", "save refused: {saved}"); + Ok(saved["path"].as_str().unwrap_or_default().to_string()) +} + +/// A record is kept to one hop, and a move back to the path it left ends it: both are +/// three statements whose order decides the answer. +#[sqlx::test(fixtures("base", "drafts_save_follows_move"))] +async fn test_move_records_stay_one_hop(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let b = rename(port, HEAD_HASH, "u/test-user/follow_b").await?; + let _c = rename(port, &b, "u/test-user/follow_c").await?; + assert_eq!( + save_at(port, "u/test-user/follow_a", "after two moves").await?, + "u/test-user/follow_c", + "a save at the first path did not reach the last" + ); + assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_c"]); + Ok(()) +} + +#[sqlx::test(fixtures("base", "drafts_save_follows_move"))] +async fn test_move_back_ends_the_record(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let b = rename(port, HEAD_HASH, "u/test-user/follow_b").await?; + let _a = rename(port, &b, "u/test-user/follow_a").await?; + assert_eq!( + save_at(port, "u/test-user/follow_a", "after moving back").await?, + "u/test-user/follow_a", + "a save was routed off the path the item moved back to" + ); + assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_a"]); + Ok(()) +} + +/// A rename carries a teammate's row too: both its path keys follow, and the version +/// it forked from does not move. A restamp there would clear their out-of-date prompt +/// and let them deploy over the mover's version believing they were current. +#[sqlx::test(fixtures("base", "drafts_save_follows_move"))] +async fn test_a_teammates_draft_follows_with_its_base(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + rename(port, HEAD_HASH, "u/test-user/follow_b").await?; + + // Read from the pool: the teammate's row is another user's, and this asserts on + // `base`, which no endpoint exposes for someone else's draft. + let row: (String, String, Option) = sqlx::query_as( + "SELECT value::jsonb ->> 'path', value::jsonb ->> 'draft_path', base + FROM draft WHERE workspace_id = 'test-workspace' AND typ = 'script' + AND email = 'test2@windmill.dev'", + ) + .fetch_one(&db) + .await?; + assert_eq!(row.0, "u/test-user/follow_b", "typed path did not follow"); + assert_eq!(row.1, "u/test-user/follow_b", "mirror did not follow"); + assert_eq!( + row.2.as_deref(), + Some(HEAD_HASH), + "the teammate's base was restamped by someone else's rename" + ); + Ok(()) +} + +/// An item move and then the owner's own move of what is left: the two records have +/// different scopes, so the owner's move has to extend the chain in its own scope or +/// a save addressed to the first path stops at the abandoned middle one. +#[sqlx::test(fixtures("base", "drafts_save_follows_move"))] +async fn test_an_owner_move_extends_an_item_move(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + rename(port, HEAD_HASH, "u/test-user/follow_b").await?; + // Archiving the script at the new path leaves the carried draft as a draft-only + // item, which its owner can move through `/drafts/move`. + sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_b'") + .execute(&db) + .await?; + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_b" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "new_path": "u/test-user/follow_c" })) + .send() + .await?; + assert!( + resp.status().is_success(), + "move failed: {}", + resp.text().await? + ); + + assert_eq!( + save_at(port, "u/test-user/follow_a", "after both moves").await?, + "u/test-user/follow_c", + "a save at the first path stopped at the path the owner's move left" + ); + assert_eq!(own_draft_paths(port).await?, vec!["u/test-user/follow_c"]); + Ok(()) +} + +/// Redeploying at a path an owner's move routed away from ends that route: the live item +/// owns its path again, and its saves must not follow the draft that left. +#[sqlx::test(fixtures("base", "drafts_save_follows_move"))] +async fn test_redeploy_at_a_routed_path_ends_the_route(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // `move_draft` ignores archived rows, so an archived script's draft can be moved away. + sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'") + .execute(&db) + .await?; + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "new_path": "u/test-user/follow_b" })) + .send() + .await?; + assert!( + resp.status().is_success(), + "move failed: {}", + resp.text().await? + ); + + // Unarchiving redeploys at the same path, with the archived version as parent. + rename(port, HEAD_HASH, "u/test-user/follow_a").await?; + + assert_eq!( + save_at(port, "u/test-user/follow_a", "for the live script").await?, + "u/test-user/follow_a", + "a save for the redeployed script followed the draft that moved away" + ); + Ok(()) +} + +/// A draft written before the NUL sanitizer still has to follow a move: its path keys are +/// what a deploy of it would land on, so the carry rewrites them, sanitizing the value it +/// could not otherwise parse. +#[sqlx::test(fixtures("base", "drafts_save_follows_move"))] +async fn test_a_poisoned_draft_follows_a_rename(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // The teammate's row, rewritten the way a pre-sanitizer client left one: a real NUL + // escape in the content, both path keys naming the path the item is about to leave. + sqlx::query( + r#"UPDATE draft SET value = '{"path": "u/test-user/follow_a", "draft_path": "u/test-user/follow_a", + "parent_hash": "0000000000001b76", "summary": "A", "content": "a\u0000b"}' + WHERE email = 'test2@windmill.dev'"#, + ) + .execute(&db) + .await?; + + rename(port, HEAD_HASH, "u/test-user/follow_b").await?; + + let row: (String, String, String) = sqlx::query_as( + "SELECT value::jsonb ->> 'path', value::jsonb ->> 'draft_path', value::jsonb ->> 'content' + FROM draft WHERE email = 'test2@windmill.dev'", + ) + .fetch_one(&db) + .await?; + assert_eq!(row.0, "u/test-user/follow_b", "typed path did not follow"); + assert_eq!(row.1, "u/test-user/follow_b", "mirror did not follow"); + assert_eq!(row.2, "ab", "the NUL survived the rewrite"); + Ok(()) +} + +/// A discard whose answer is lost is retried, and the retry finds nothing to delete. It +/// still has to name where the item went: the editor asking is on the path it left, and a +/// reload there lands on nothing. +#[sqlx::test(fixtures("base", "drafts_save_follows_move"))] +async fn test_a_retried_discard_still_names_the_destination( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let client = reqwest::Client::new(); + + rename(port, HEAD_HASH, "u/test-user/follow_b").await?; + + let discard = |token: &'static str| { + let client = client.clone(); + async move { + client + .post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/follow_a" + )) + .header("Authorization", format!("Bearer {token}")) + .json(&json!({ "value": null })) + .send() + .await? + .json::() + .await + } + }; + + let first = discard("SECRET_TOKEN").await?; + assert_eq!(first["status"], "saved", "{first}"); + assert_eq!(first["path"], "u/test-user/follow_b", "{first}"); + + let retry = discard("SECRET_TOKEN").await?; + assert_eq!( + retry["status"], "saved", + "the retry was not idempotent: {retry}" + ); + assert_eq!(retry["path"], "u/test-user/follow_b", "{retry}"); + + // A third user has no draft on this item, so their discard deletes nothing and the + // destination is a move record and nothing else: it is answered only to a caller who + // can read that path. + let other = discard("SECRET_TOKEN_3").await?; + assert_eq!(other["status"], "saved", "{other}"); + assert_eq!( + other["path"], + Value::Null, + "a user who cannot read the destination was told where the item went: {other}" + ); + Ok(()) +} + +/// The legacy workspace-level row is carried by a rename like any other draft, and the +/// record that routes saves to it covers every caller — so discarding it from a page that +/// still names the old path has to reach it where it went. +#[sqlx::test(fixtures("base", "drafts_save_follows_move"))] +async fn test_a_legacy_discard_follows_a_rename(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + sqlx::query( + r#"INSERT INTO draft (workspace_id, path, typ, value, email) + VALUES ('test-workspace', 'u/test-user/follow_a', 'script', + '{"path": "u/test-user/follow_a", "summary": "legacy", "content": "x"}', NULL)"#, + ) + .execute(&db) + .await?; + + rename(port, HEAD_HASH, "u/test-user/follow_b").await?; + + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/update/script/u/test-user/follow_a" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "value": null, "legacy": true })) + .send() + .await?; + assert!( + resp.status().is_success(), + "discard failed: {}", + resp.text().await? + ); + + let left: i64 = sqlx::query_scalar( + "SELECT count(*) FROM draft WHERE workspace_id = 'test-workspace' AND email IS NULL", + ) + .fetch_one(&db) + .await?; + assert_eq!( + left, 0, + "the legacy draft survived a discard aimed at its old path" + ); + Ok(()) +} + +/// Deploying a draft that a move carried off an archived script: its parent is still the +/// version at the old path, so the deploy renames from there and carries what is left over +/// — onto the very draft being deployed. That row is this deploy's own, not an item in its +/// way, or the deploy is refused and every retry refuses again. +#[sqlx::test(fixtures("base", "drafts_save_follows_move"))] +async fn test_a_moved_draft_deploys_at_its_new_path(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'") + .execute(&db) + .await?; + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "new_path": "u/test-user/follow_b" })) + .send() + .await?; + assert!( + resp.status().is_success(), + "move failed: {}", + resp.text().await? + ); + + // Deploy it where it now lives, still parented on the archived version it forked from. + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/scripts/create" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ + "path": "u/test-user/follow_b", + "parent_hash": HEAD_HASH, + "summary": "A", + "description": "", + "content": "export function main() { return 2 }", + "language": "deno", + "schema": {} + })) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!(status, 201, "the moved draft could not be deployed: {body}"); + Ok(()) +} + +/// A route is only as good as the item it points at: when an unrelated item claims the +/// destination, a save still addressed to the old path must stay where it is rather than +/// land on that item's draft. +#[sqlx::test(fixtures("base", "drafts_save_follows_move"))] +async fn test_a_reused_destination_ends_the_route(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + sqlx::query("UPDATE script SET archived = true WHERE path = 'u/test-user/follow_a'") + .execute(&db) + .await?; + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/move/script/u/test-user/follow_a" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "new_path": "u/test-user/follow_b" })) + .send() + .await?; + assert!( + resp.status().is_success(), + "move failed: {}", + resp.text().await? + ); + // Someone else's item takes the destination, and the moved draft goes with the + // deploy that consumes it. + sqlx::query("DELETE FROM draft WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/follow_b'") + .execute(&db) + .await?; + let resp = reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/scripts/create" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ + "path": "u/test-user/follow_b", + "summary": "unrelated", + "description": "", + "content": "export function main() { return 3 }", + "language": "deno", + "schema": {} + })) + .send() + .await?; + assert_eq!(resp.status(), 201, "create failed: {}", resp.text().await?); + + assert_eq!( + save_at(port, "u/test-user/follow_a", "after the destination was reused").await?, + "u/test-user/follow_a", + "a save was routed onto the item that now owns the destination" + ); + Ok(()) +} diff --git a/backend/tests/fixtures/drafts_move_mirror.sql b/backend/tests/fixtures/drafts_move_mirror.sql new file mode 100644 index 0000000000..e96d985eae --- /dev/null +++ b/backend/tests/fixtures/drafts_move_mirror.sql @@ -0,0 +1,16 @@ +-- Fixture for the two-path-key move test. +-- +-- Two draft-only scripts owned by test-user, both parked at a generated storage +-- key the way a session-created draft is. One carries the `draft_path` mirror the +-- session editor writes while the typed path differs from that key; the other +-- carries no mirror at all, which is what a plain draft looks like. + +INSERT INTO draft (workspace_id, path, typ, value, email) VALUES + ('test-workspace', 'u/test-user/draft_mirror', 'script', + '{"path": "u/test-user/friendly", "draft_path": "u/test-user/friendly", + "content": "x", "language": "bun", "summary": "S"}', + 'test@windmill.dev'), + ('test-workspace', 'u/test-user/draft_plain', 'script', + '{"path": "u/test-user/draft_plain", + "content": "x", "language": "bun", "summary": "S"}', + 'test@windmill.dev'); diff --git a/backend/tests/fixtures/drafts_move_taken.sql b/backend/tests/fixtures/drafts_move_taken.sql new file mode 100644 index 0000000000..5fee11b942 --- /dev/null +++ b/backend/tests/fixtures/drafts_move_taken.sql @@ -0,0 +1,37 @@ +-- Fixture for refusing a rename onto a path a draft already occupies. +-- +-- A deployed script at `u/test-user/mvtaken_a` (hash 7010 = 0x1b62), and a +-- never-deployed draft of test-user's own at `u/test-user/mvtaken_b`, the path +-- the rename will target. Nothing deployed lives at the target, so only the +-- draft can refuse the move. + +INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, + schema, summary, description, lock, extra_perms) +VALUES ('test-workspace', 7010, 'u/test-user/mvtaken_a', + 'export function main() { return 1 }', + 'deno', 'script', 'test-user', '{}', 'A', '', '', '{}'); + +INSERT INTO draft (workspace_id, path, typ, value, email) +VALUES ('test-workspace', 'u/test-user/mvtaken_b', 'script', + '{"path": "u/test-user/mvtaken_b", "summary": "B", "content": ""}', + 'test@windmill.dev'); + +-- An ownerless (pre per-user drafts) row at a third path: a deploy there deletes it +-- together with the caller's, so it occupies the path just as an own draft does. +INSERT INTO draft (workspace_id, path, typ, value, email) +VALUES ('test-workspace', 'u/test-user/mvtaken_legacy', 'script', + '{"path": "u/test-user/mvtaken_legacy", "summary": "legacy", "content": ""}', + NULL); + +-- A draft-only classic app and a draft-only raw app of the same owner. They share +-- the `app` table, so one occupies the other's path. The third is another user's raw +-- app: a different item at its own path, not a second copy of anyone's. +INSERT INTO draft (workspace_id, path, typ, value, email) VALUES + ('test-workspace', 'u/test-user/mvtaken_app', 'app', + '{"summary": "classic", "value": {}}', 'test@windmill.dev'), + ('test-workspace', 'u/test-user/mvtaken_raw', 'raw_app', + '{"summary": "raw", "files": {}}', 'test@windmill.dev'), + ('test-workspace', 'u/test-user/mvtaken_theirs', 'raw_app', + '{"summary": "theirs", "files": {}}', 'test2@windmill.dev'), + ('test-workspace', 'u/test-user/mvtaken_app_theirs', 'app', + '{"summary": "theirs", "value": {}}', 'test2@windmill.dev'); diff --git a/backend/tests/fixtures/drafts_save_follows_move.sql b/backend/tests/fixtures/drafts_save_follows_move.sql new file mode 100644 index 0000000000..16a41feb1a --- /dev/null +++ b/backend/tests/fixtures/drafts_save_follows_move.sql @@ -0,0 +1,29 @@ +-- Fixture for saving a draft to the path its item moved away from. +-- +-- A deployed script at `u/test-user/follow_a` (hash 7030 = 0x1b76) with the +-- deployer's own draft on it. The test renames the script and then saves the +-- draft from an editor still bound to the old path. + +INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, + schema, summary, description, lock, extra_perms) +VALUES ('test-workspace', 7030, 'u/test-user/follow_a', + 'export function main() { return 1 }', + 'deno', 'script', 'test-user', '{}', 'A', '', '', '{}'); + +INSERT INTO draft (workspace_id, path, typ, value, email, base) +VALUES ('test-workspace', 'u/test-user/follow_a', 'script', + '{"path": "u/test-user/follow_a", "parent_hash": "0000000000001b76", "summary": "A", "content": "draft"}', + 'test@windmill.dev', '0000000000001b76'); + +-- A draft-only script parked at a generated storage key, its typed path elsewhere. +INSERT INTO draft (workspace_id, path, typ, value, email) +VALUES ('test-workspace', 'u/test-user/draft_store', 'script', + '{"path": "u/test-user/friendly", "draft_path": "u/test-user/friendly", "summary": "D", "content": "draft"}', + 'test@windmill.dev'); + +-- A teammate's draft on the same deployed script, forked from the same head. The +-- rename must carry it too, without touching the version it forked from. +INSERT INTO draft (workspace_id, path, typ, value, email, base) +VALUES ('test-workspace', 'u/test-user/follow_a', 'script', + '{"path": "u/test-user/follow_a", "draft_path": "u/test-user/follow_a", "parent_hash": "0000000000001b76", "summary": "A", "content": "teammate draft"}', + 'test2@windmill.dev', '0000000000001b76'); diff --git a/backend/tests/flow_engine_parity.rs b/backend/tests/flow_engine_parity.rs index 78e45e404b..7ea221c2e8 100644 --- a/backend/tests/flow_engine_parity.rs +++ b/backend/tests/flow_engine_parity.rs @@ -2877,6 +2877,131 @@ async fn test_flow_env_imported_flow_with_nested_branch(db: Pool) -> a Ok(()) } +// A branch/loop sub-flow of a deployed flow is marked at push when its parent has no flow_env, +// own or inherited, so its worker skips `fetch_root_flow_env`. With an env two scopes up (root +// → loop → imported flow without env → parallel loop → leaf) nothing may be marked and the +// root's value must reach the leaf; with no env anywhere every loop iteration is marked. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_flow_env_marks_sub_flows_only_without_ancestor_env( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Deployed, so their loops run as `FlowNode` sub-flows. + let deploy = |path: &'static str, version: i64, value: serde_json::Value| { + let db = db.clone(); + async move { + sqlx::query( + "INSERT INTO flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES ('test-workspace', '', '', $1, ARRAY[$2]::bigint[], '{}'::jsonb, $3, 'system')", + ) + .bind(path) + .bind(version) + .bind(&value) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO flow_version(id, workspace_id, path, schema, value, created_by) VALUES ($1, 'test-workspace', $2, '{}'::jsonb, $3, 'system')", + ) + .bind(version) + .bind(path) + .bind(&value) + .execute(&db) + .await?; + RunJob::from(JobPayload::FlowDependencies { + path: path.to_string(), + dedicated_worker: None, + version, + debouncing_settings: Default::default(), + }) + .run_until_complete(&db, false, port) + .await; + anyhow::Ok(()) + } + }; + deploy( + "f/system/imported_loop_no_env", + 9991004, + json!({ "modules": [{ "id": "inner", "value": { + "type": "forloopflow", + "iterator": { "type": "javascript", "expr": "[1, 2]" }, + "skip_failures": false, + "parallel": true, + "modules": [{ "id": "leaf", "value": { + "type": "rawscript", + "language": "deno", + "content": "export function main(key: any) { return key ?? null; }", + "input_transforms": { "key": { "type": "javascript", "expr": "flow_env.KEY" } } + }}, { "id": "tail", "value": { + // A single simple step is inlined rather than stored as a flow node. + "type": "rawscript", + "language": "deno", + "content": "export function main(prev: any) { return prev; }", + "input_transforms": { "prev": { "type": "javascript", "expr": "results.leaf" } } + }}] + }}]}), + ) + .await?; + let root = |flow_env: serde_json::Value| { + json!({ "flow_env": flow_env, "modules": [{ "id": "outer", "value": { + "type": "forloopflow", + "iterator": { "type": "javascript", "expr": "[1]" }, + "skip_failures": false, + "parallel": false, + "modules": [{ "id": "import", "value": { + "type": "flow", + "path": "f/system/imported_loop_no_env", + "input_transforms": {} + }}] + }}]}) + }; + deploy( + "f/system/root_with_env", + 9991005, + root(json!({ "KEY": "from-root" })), + ) + .await?; + deploy("f/system/root_no_env", 9991006, root(json!(null))).await?; + + let run = |path: &'static str, version: i64| { + let db = db.clone(); + async move { + let completed = RunJob::from(JobPayload::Flow { + path: path.to_string(), + dedicated_worker: None, + apply_preprocessor: false, + version, + labels: None, + }) + .run_until_complete(&db, false, port) + .await; + // The mark of every loop iteration pushed under the run. + let marks = sqlx::query_as::<_, (String, Option)>( + "SELECT j.kind::text, (c.flow_status->>'no_inherited_flow_env')::bool + FROM v2_job j JOIN v2_job_completed c ON c.id = j.id + WHERE COALESCE(j.root_job, j.parent_job) = $1 + AND j.flow_step_id IN ('outer', 'inner')", + ) + .bind(completed.id) + .fetch_all(&db) + .await?; + anyhow::Ok((completed.json_result().unwrap(), marks)) + } + }; + + let (result, marks) = run("f/system/root_with_env", 9991005).await?; + assert_eq!(result, json!([["from-root", "from-root"]])); + assert_eq!(marks, vec![("flownode".to_string(), None); 3]); + + let (result, marks) = run("f/system/root_no_env", 9991006).await?; + assert_eq!(result, json!([[null, null]])); + assert_eq!(marks, vec![("flownode".to_string(), Some(true)); 3]); + + Ok(()) +} + // stop_after_if predicate sees flow_env. Regression for the eval at line 614 // of `update_flow_status_after_job_completion_internal` which used to pass // `None` for flow_env unconditionally. @@ -3312,3 +3437,60 @@ export function main(i: number) { Ok(()) } + +// A `$flow_expr[...]` step tag is resolved from the flow's state before the step is pushed, and +// one that cannot be resolved fails the step instead of queueing it on a tag no worker serves. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_flow_expr_step_tag(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + + let step = |id: &str, tag: Option<&str>| { + flow_module( + id, + FlowModuleValue::RawScript { + input_transforms: Default::default(), + language: ScriptLang::Deno, + content: "export function main() { return { lang: 'bun' } }".to_string(), + path: None, + lock: None, + tag: tag.map(str::to_string), + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ) + }; + let flow = FlowValue { + modules: vec![ + step("a", None), + step("b", Some("$flow_expr[results.a.lang]")), + step("c", Some("nobody-serves-$flow_expr[a.lang]")), + ], + same_worker: false, + ..Default::default() + }; + + let job = RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None }) + .run_until_complete(&db, false, server.addr.port()) + .await; + + let b_tag = sqlx::query_scalar::<_, String>( + "SELECT tag FROM v2_job WHERE parent_job = $1 AND flow_step_id = 'b'", + ) + .bind(job.id) + .fetch_one(&db) + .await?; + assert_eq!(b_tag, "bun"); + + assert!(!job.success); + let result = job.json_result().unwrap(); + let message = result["error"]["message"].as_str().unwrap_or_default(); + assert!( + message.contains("Could not resolve the step tag `nobody-serves-$flow_expr[a.lang]`"), + "got {result:?}" + ); + + Ok(()) +} diff --git a/backend/tests/instance_config.rs b/backend/tests/instance_config.rs index 64d7854173..26a46d272e 100644 --- a/backend/tests/instance_config.rs +++ b/backend/tests/instance_config.rs @@ -1530,6 +1530,31 @@ async fn declarative_sync_rejects_an_unusable_instance_banner(db: Pool ); } +#[sqlx::test(fixtures("base"))] +async fn declarative_sync_rejects_a_malformed_max_token_expiration(db: Pool) { + clear_settings_and_configs(&db).await; + let before = count_global_settings(&db).await; + + let mut desired = BTreeMap::new(); + desired.insert( + "max_token_expiration_days".to_string(), + serde_json::json!("7.0"), + ); + + let err = windmill_common::instance_config::sync_global_settings_declarative( + &db, + &BTreeMap::new(), + &desired, + ) + .await + .expect_err("a ceiling the token routes cannot read must fail the sync"); + assert!( + err.to_string().contains("max_token_expiration_days"), + "the error should name the offending setting, got: {err}" + ); + assert_eq!(count_global_settings(&db).await, before); +} + #[sqlx::test(fixtures("base"))] async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool) { // The declarative writers (the sync-config CLI, the operator's ConfigMap @@ -1583,3 +1608,34 @@ async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool

) { + clear_settings_and_configs(&db).await; + + let mut desired = BTreeMap::new(); + desired.insert( + "accent_color".to_string(), + serde_json::json!("#000;}body{display:none"), + ); + let err = windmill_common::instance_config::sync_global_settings_declarative( + &db, + &BTreeMap::new(), + &desired, + ) + .await + .expect_err("a non-hex accent color must fail the sync"); + assert!(err.to_string().contains("accent_color"), "got: {err}"); + assert!(get_global_setting(&db, "accent_color").await.is_none()); + + desired.insert("accent_color".to_string(), serde_json::json!("#1f9d55")); + windmill_common::instance_config::sync_global_settings_declarative( + &db, + &BTreeMap::new(), + &desired, + ) + .await + .expect("a hex accent color must sync"); +} diff --git a/backend/tests/job_payload.rs b/backend/tests/job_payload.rs index c538cbe6e1..1a973f6fe6 100644 --- a/backend/tests/job_payload.rs +++ b/backend/tests/job_payload.rs @@ -239,6 +239,7 @@ mod job_payload { let result = RunJob::from(JobPayload::FlowNode { id, path: "f/system/hello_with_nodes_flow/forloop-0".into(), + no_inherited_flow_env: false, }) .arg("iter", json!({ "value": "tests", "index": 0 })) .run_until_complete(&db, false, port) diff --git a/backend/tests/job_token_log_masking.rs b/backend/tests/job_token_log_masking.rs new file mode 100644 index 0000000000..5d7de27218 --- /dev/null +++ b/backend/tests/job_token_log_masking.rs @@ -0,0 +1,108 @@ +/* + * The job's own token (`$WM_TOKEN`) stays valid well past the job it was minted + * for, and job logs are persisted to `job_logs` and optionally to object storage, + * so a script that echoes the token would otherwise park a live credential in + * durable storage. `run_worker` registers the token with `sensitive_log_masks` + * for the job it pulled; this pins that the persisted log carries the masked form. + */ + +use sqlx::{Pool, Postgres}; +use windmill_common::{ + jobs::{JobPayload, RawCode}, + scripts::ScriptLang, +}; +use windmill_test_utils::*; + +/// Prefix of a serialized job token: `jwt_` plus the base64 of a JWT header. +/// The masked form keeps only `jwt` + the last three characters, so it never matches. +const RAW_TOKEN_PREFIX: &str = "jwt_ey"; + +#[sqlx::test(fixtures("base"))] +async fn test_job_token_masked_in_persisted_logs(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let job = RunJob::from(JobPayload::Code(RawCode { + hash: None, + content: "echo \"running with --token $WM_TOKEN\"".to_string(), + path: None, + lock: None, + language: ScriptLang::Bash, + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, + tag: None, + })) + .run_until_complete(&db, false, port) + .await; + assert!(job.success, "job should have succeeded"); + + let logs = + sqlx::query_scalar::<_, Option>("SELECT logs FROM job_logs WHERE job_id = $1") + .bind(job.id) + .fetch_one(&db) + .await? + .unwrap_or_default(); + + assert!( + !logs.contains(RAW_TOKEN_PREFIX), + "an unmasked job token reached the persisted logs: {logs}" + ); + assert!( + logs.contains("secret value was masked"), + "expected the masking notice in logs: {logs}" + ); + Ok(()) +} + +/// nativets runs V8 in-process and persists `console.log` output through its own +/// channel, so it is masked by a different mechanism than the bash case above and +/// needs its own guard. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_job_token_masked_in_nativets_logs(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let job = RunJob::from(JobPayload::Code(RawCode { + hash: None, + content: "export async function main() {\n console.log('running with --token ' + process.env.WM_TOKEN);\n return 'ok';\n}".to_string(), + path: None, + lock: None, + language: ScriptLang::Nativets, + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default() + .into(), + debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(), + modules: None, + tag: None, + })) + .run_until_complete(&db, false, port) + .await; + assert!(job.success, "job should have succeeded"); + + let logs = + sqlx::query_scalar::<_, Option>("SELECT logs FROM job_logs WHERE job_id = $1") + .bind(job.id) + .fetch_one(&db) + .await? + .unwrap_or_default(); + + assert!( + !logs.contains(RAW_TOKEN_PREFIX), + "an unmasked job token reached the persisted logs: {logs}" + ); + assert!( + logs.contains("secret value was masked"), + "expected the masking notice in logs: {logs}" + ); + Ok(()) +} diff --git a/backend/tests/jobs_read_auth.rs b/backend/tests/jobs_read_auth.rs index c8c154c250..8c5bfee0eb 100644 --- a/backend/tests/jobs_read_auth.rs +++ b/backend/tests/jobs_read_auth.rs @@ -516,10 +516,14 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul Some("SECRET_TOKEN_2"), ) .await; - assert!(status.is_success(), "owner must mint a resume secret: {secret}"); + assert!( + status.is_success(), + "owner must mint a resume secret: {secret}" + ); let secret = secret.trim().trim_matches('"').to_string(); - let approval_result = - format!("completed/get_result/{STEP_JOB}?suspended_job={STEP_JOB}&resume_id=0&secret={secret}"); + let approval_result = format!( + "completed/get_result/{STEP_JOB}?suspended_job={STEP_JOB}&resume_id=0&secret={secret}" + ); let (status, body) = get(&base, &approval_result, None).await; assert!( status.is_success(), @@ -991,6 +995,89 @@ async fn test_single_job_read_authorization(db: Pool) -> anyhow::Resul !status.is_success(), "a public token must not let an anonymous caller cancel the run (got {status}): {body}" ); + // ---- RUN_NOW is gated like cancel, and only moves a job still waiting for a + // future start. ---- + let run_now = format!("queue/run_now/{RUNNING_JOB}"); + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_3")).await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "viewer must not start another user's job early (got {status}): {body}" + ); + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await; + assert_eq!( + status, + reqwest::StatusCode::BAD_REQUEST, + "a running job has no future start to move (got {status}): {body}" + ); + sqlx::query( + "UPDATE v2_job_queue SET running = false, scheduled_for = now() + interval '1 hour' + WHERE id = $1::uuid", + ) + .bind(RUNNING_JOB) + .execute(&db) + .await?; + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await; + assert!( + status.is_success(), + "owner must start their deferred job now (got {status}): {body}" + ); + let due: bool = + sqlx::query_scalar("SELECT scheduled_for <= now() FROM v2_job_queue WHERE id = $1::uuid") + .bind(RUNNING_JOB) + .fetch_one(&db) + .await?; + assert!(due, "run_now must make the job due immediately"); + + // A schedule tick that is not due yet is refused: the schedule would queue that same + // tick again on completion and run twice. + sqlx::query( + "INSERT INTO schedule (workspace_id, path, edited_by, schedule, script_path, permissioned_as) + VALUES ('test-workspace', 'u/test-user-2/daily', 'test-user-2', '0 0 3 * * *', + 'u/test-user-2/running_secret', 'u/test-user-2')", + ) + .execute(&db) + .await?; + sqlx::query( + "UPDATE v2_job SET trigger_kind = 'schedule', trigger = 'u/test-user-2/daily' + WHERE id = $1::uuid", + ) + .bind(RUNNING_JOB) + .execute(&db) + .await?; + sqlx::query( + "UPDATE v2_job_queue SET scheduled_for = + (date_trunc('day', now() AT TIME ZONE 'UTC') + interval '1 day 3 hours') AT TIME ZONE 'UTC' + WHERE id = $1::uuid", + ) + .bind(RUNNING_JOB) + .execute(&db) + .await?; + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await; + assert_eq!( + status, + reqwest::StatusCode::BAD_REQUEST, + "an upcoming schedule tick must not be started early (got {status}): {body}" + ); + // A tick a concurrency limit pushed past its time is off the cron occurrences and + // may still be started early. + sqlx::query( + "UPDATE v2_job_queue SET scheduled_for = now() + interval '1 hour 37 seconds 123 milliseconds' + WHERE id = $1::uuid", + ) + .bind(RUNNING_JOB) + .execute(&db) + .await?; + let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await; + assert!( + status.is_success(), + "a deferred schedule tick must still start now (got {status}): {body}" + ); + sqlx::query("UPDATE v2_job SET trigger_kind = NULL, trigger = NULL WHERE id = $1::uuid") + .bind(RUNNING_JOB) + .execute(&db) + .await?; + // The owner still cancels their own job (no over-blocking). Keep this last: it // takes RUNNING_JOB out of the queue. let (status, body) = post( diff --git a/backend/tests/perpetual_cancel_race.rs b/backend/tests/perpetual_cancel_race.rs new file mode 100644 index 0000000000..df239eb7ab --- /dev/null +++ b/backend/tests/perpetual_cancel_race.rs @@ -0,0 +1,183 @@ +//! A cancel reaches the queue row first and the worker only later: one landing after the worker's +//! last read of that row completes the run carrying no `canceled_by` of its own, and a perpetual +//! script must still stop rather than restart as if nothing had been cancelled. + +use serde_json::json; +use sqlx::{types::Json, Pool, Postgres}; +use uuid::Uuid; +use windmill_queue::{add_completed_job, get_mini_completed_job}; + +const W_ID: &str = "test-workspace"; +const PATH: &str = "u/test-user/loop"; + +async fn start_perpetual_run(db: &Pool) -> anyhow::Result { + sqlx::query( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, \ + language, lock, restart_unless_cancelled) \ + VALUES ($1, 1, $2, '', '', 'echo', 'test-user', 'bash', '', true)", + ) + .bind(W_ID) + .bind(PATH) + .execute(db) + .await?; + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, created_at, permissioned_as, \ + permissioned_as_email, kind, runnable_id, runnable_path, script_lang, tag, args, \ + visible_to_owner) \ + VALUES ($1, $2, 'test-user', now(), 'u/test-user', 'test@windmill.dev', 'script', 1, \ + $3, 'bash', 'bash', '{\"n\": 1}', true)", + ) + .bind(id) + .bind(W_ID) + .bind(PATH) + .execute(db) + .await?; + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, started_at, tag) \ + VALUES ($1, $2, now(), true, now(), 'bash')", + ) + .bind(id) + .bind(W_ID) + .execute(db) + .await?; + Ok(id) +} + +/// Waits for another connection to be stuck on the queue row of `id`, which is where the +/// completion's delete lands while the cancel is uncommitted. +async fn wait_until_blocked_on(db: &Pool, id: Uuid) -> anyhow::Result<()> { + for _ in 0..100 { + let blocked: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM pg_locks l JOIN v2_job_queue q \ + ON q.ctid = ('(' || l.page || ',' || l.tuple || ')')::tid \ + WHERE NOT l.granted AND l.locktype = 'tuple' AND q.id = $1) \ + OR EXISTS (SELECT 1 FROM pg_stat_activity \ + WHERE wait_event_type = 'Lock' AND query LIKE '%v2_job_queue%')", + ) + .bind(id) + .fetch_one(db) + .await?; + if blocked { + return Ok(()); + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + anyhow::bail!("the completion never reached the delete, so the race was not exercised") +} + +/// The same cancel, one step earlier: still being written when the completion reads the queue row, +/// which reads it without a lock. Only the delete that follows waits for the writer. +#[sqlx::test(fixtures("base"))] +async fn a_cancel_still_being_written_is_kept(db: Pool) -> anyhow::Result<()> { + let running = start_perpetual_run(&db).await?; + let job = get_mini_completed_job(&running, W_ID, &db).await?.unwrap(); + + let mut cancel = db.begin().await?; + sqlx::query( + "UPDATE v2_job_queue SET canceled_by = 'test-user', canceled_reason = 'stop' WHERE id = $1", + ) + .bind(running) + .execute(&mut *cancel) + .await?; + + let completing = tokio::spawn({ + let db = db.clone(); + async move { + add_completed_job( + &db, + &job, + true, + false, + Json(&json!("done")), + None, + 0, + None, + false, + None, + false, + ) + .await + } + }); + // Committing on a timer would let a slow completion read the cancel through the insert + // instead, leaving the half this test is for unexercised and green. Waiting for the delete to + // block on the row is what puts it there. + wait_until_blocked_on(&db, running).await?; + cancel.commit().await?; + completing.await??; + + let (status, canceled_by): (String, Option) = + sqlx::query_as("SELECT status::text, canceled_by FROM v2_job_completed WHERE id = $1") + .bind(running) + .fetch_one(&db) + .await?; + assert_eq!(status, "canceled", "the run is recorded as canceled"); + assert_eq!(canceled_by.as_deref(), Some("test-user")); + + let queued: Vec = sqlx::query_scalar( + "SELECT q.id FROM v2_job_queue q JOIN v2_job j USING (id) \ + WHERE j.workspace_id = $1 AND j.runnable_path = $2", + ) + .bind(W_ID) + .bind(PATH) + .fetch_all(&db) + .await?; + assert!( + queued.is_empty(), + "a canceled perpetual run queues no next one: {queued:?}" + ); + Ok(()) +} + +#[sqlx::test(fixtures("base"))] +async fn a_cancel_landing_after_the_worker_read_its_row_is_kept( + db: Pool, +) -> anyhow::Result<()> { + let running = start_perpetual_run(&db).await?; + sqlx::query( + "UPDATE v2_job_queue SET canceled_by = 'test-user', canceled_reason = 'stop' WHERE id = $1", + ) + .bind(running) + .execute(&db) + .await?; + + let job = get_mini_completed_job(&running, W_ID, &db).await?.unwrap(); + add_completed_job( + &db, + &job, + true, + false, + Json(&json!("done")), + None, + 0, + // What the worker carries when the cancel landed after it last read the row. + None, + false, + None, + false, + ) + .await?; + + let (status, canceled_by): (String, Option) = + sqlx::query_as("SELECT status::text, canceled_by FROM v2_job_completed WHERE id = $1") + .bind(running) + .fetch_one(&db) + .await?; + assert_eq!(status, "canceled", "the run is recorded as canceled"); + assert_eq!(canceled_by.as_deref(), Some("test-user")); + + let queued: Vec = sqlx::query_scalar( + "SELECT q.id FROM v2_job_queue q JOIN v2_job j USING (id) \ + WHERE j.workspace_id = $1 AND j.runnable_path = $2", + ) + .bind(W_ID) + .bind(PATH) + .fetch_all(&db) + .await?; + assert!( + queued.is_empty(), + "a canceled perpetual run queues no next one: {queued:?}" + ); + Ok(()) +} diff --git a/backend/tests/raw_apps_list_scope.rs b/backend/tests/raw_apps_list_scope.rs new file mode 100644 index 0000000000..503bca7577 --- /dev/null +++ b/backend/tests/raw_apps_list_scope.rs @@ -0,0 +1,76 @@ +//! `GET /raw_apps/list` must honor a path-scoped token. The route layer only checks +//! the scope domain and RLS knows nothing of token scopes, so the per-path filter +//! lives in the handler, ahead of the page's LIMIT. + +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +async fn list_paths(port: u16, token: &str, query: &str) -> anyhow::Result> { + let resp = reqwest::Client::new() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/raw_apps/list?{query}" + )) + .header("Authorization", format!("Bearer {token}")) + .send() + .await?; + assert_eq!(resp.status(), 200, "list: {}", resp.text().await?); + let body: Vec = resp.json().await?; + let mut paths: Vec = body + .iter() + .map(|a| a["path"].as_str().unwrap().to_string()) + .collect(); + paths.sort(); + Ok(paths) +} + +#[sqlx::test(fixtures("base"))] +async fn test_raw_apps_list_filters_by_token_scope(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + // The list sorts by edited_at desc, so the out-of-scope apps fill the first page + // unless the scope is applied before the LIMIT. + for (path, age_minutes) in [ + ("f/other/c", 0), + ("u/test-userx/d", 1), + ("u/test-user/a", 2), + ("u/test-user/sub/b", 3), + // A `prefix/*` grant also covers the path at the prefix itself. + ("u/test-user", 4), + ] { + sqlx::query( + "INSERT INTO raw_app (path, workspace_id, data, edited_at) + VALUES ($1, 'test-workspace', '', now() - make_interval(mins => $2))", + ) + .bind(path) + .bind(age_minutes) + .execute(&db) + .await?; + } + sqlx::query( + "INSERT INTO token (token_hash, token_prefix, token, email, label, scopes) + VALUES (encode(sha256('SCOPED_TOKEN'::bytea), 'hex'), 'SCOPED_TOK', 'SCOPED_TOKEN', + 'test@windmill.dev', 'scoped', ARRAY['raw_apps:read:u/test-user/*'])", + ) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // The same user through an unscoped token sees every row. + assert_eq!( + list_paths(port, "SECRET_TOKEN", "").await?, + [ + "f/other/c", + "u/test-user", + "u/test-user/a", + "u/test-user/sub/b", + "u/test-userx/d" + ] + ); + assert_eq!( + list_paths(port, "SCOPED_TOKEN", "per_page=3").await?, + ["u/test-user", "u/test-user/a", "u/test-user/sub/b"] + ); + Ok(()) +} diff --git a/backend/tests/script_schema_inference.rs b/backend/tests/script_schema_inference.rs new file mode 100644 index 0000000000..083716f8df --- /dev/null +++ b/backend/tests/script_schema_inference.rs @@ -0,0 +1,115 @@ +use std::collections::HashMap; + +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; +use windmill_api_client::types::{NewScript, ScriptLang}; +use windmill_test_utils::init_client; + +const PATH: &str = "u/test-user/inferred_schema"; + +fn bun_ns(content: &str, parent_hash: Option) -> NewScript { + NewScript { + draft_only: None, + content: content.into(), + language: ScriptLang::Bun, + lock: None, + parent_hash, + path: PATH.into(), + concurrent_limit: None, + concurrency_time_window_s: None, + cache_ttl: None, + dedicated_worker: None, + description: "".to_string(), + envs: vec![], + is_template: None, + kind: None, + summary: "".to_string(), + tag: None, + // What this client sends for "no schema". + schema: HashMap::new(), + ws_error_handler_muted: Some(false), + priority: None, + delete_after_secs: None, + timeout: None, + restart_unless_cancelled: None, + deployment_message: None, + concurrency_key: None, + visible_to_runner_only: None, + auto_kind: None, + codebase: None, + has_preprocessor: None, + on_behalf_of_email: None, + assets: vec![], + modules: None, + } +} + +async fn head(db: &Pool) -> anyhow::Result<(i64, Value)> { + Ok(sqlx::query_as( + "SELECT hash, schema::jsonb FROM script \ + WHERE workspace_id = 'test-workspace' AND path = $1 AND archived = false", + ) + .bind(PATH) + .fetch_one(db) + .await?) +} + +/// A deploy without a schema (MCP, a bare API call) gets the one the editor would +/// have sent, and a new version keeps what the user wrote on the previous one's. +#[sqlx::test(fixtures("base"))] +async fn schemaless_deploy_infers_schema_keeping_annotations( + db: Pool, +) -> anyhow::Result<()> { + let (client, _port, _s) = init_client(db.clone()).await; + + client + .create_script( + "test-workspace", + &bun_ns( + "export function main(n: number, name = \"x\") { return n }", + None, + ), + ) + .await?; + let (hash, schema) = head(&db).await?; + assert_eq!(schema["required"], json!(["n"])); + assert_eq!(schema["properties"]["n"]["type"], "number"); + assert_eq!(schema["properties"]["name"]["default"], "x"); + + sqlx::query( + "UPDATE script SET schema = jsonb_set(schema::jsonb, '{properties,n,description}', \ + '\"how many\"')::json WHERE hash = $1", + ) + .bind(hash) + .execute(&db) + .await?; + + client + .create_script( + "test-workspace", + &bun_ns( + "export function main(n: number, flag: boolean) { return n }", + Some(format!("{hash:016x}")), + ), + ) + .await?; + let (hash, schema) = head(&db).await?; + assert_eq!(schema["required"], json!(["n", "flag"])); + assert_eq!(schema["properties"]["n"]["description"], "how many"); + assert_eq!(schema["properties"]["flag"]["type"], "boolean"); + assert!(schema["properties"].get("name").is_none()); + + // Code that does not parse keeps the schema rather than losing it for good. + client + .create_script( + "test-workspace", + &bun_ns( + "export function main(n: number {", + Some(format!("{hash:016x}")), + ), + ) + .await?; + assert_eq!(head(&db).await?.1, schema); + + Ok(()) +} diff --git a/backend/tests/worker.rs b/backend/tests/worker.rs index b8bd6b30a7..d7fb992280 100644 --- a/backend/tests/worker.rs +++ b/backend/tests/worker.rs @@ -5439,7 +5439,7 @@ async fn test_duckdb_ffi(db: Pool) -> anyhow::Result<()> { } /// Test that flow substeps with tags that are not available for the workspace fail. -/// This validates that `check_tag_available_for_workspace_internal` is properly called +/// This validates that `check_tag_available_for_push` is properly called /// when pushing jobs from worker_flow. #[sqlx::test(fixtures("base"))] #[serial] @@ -5512,17 +5512,178 @@ async fn test_flow_substep_tag_availability_check(db: Pool) -> anyhow: Ok(()) } +/// A step's `$flow_expr[...]` tag is checked on the queue it resolves to, not on its text: the +/// custom tags list `bun` alone, which admits the step resolving to it and refuses the one that +/// lands elsewhere. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +#[serial] +async fn test_flow_substep_tag_checked_on_resolved_value(db: Pool) -> anyhow::Result<()> { + use windmill_common::worker::{CustomTags, CUSTOM_TAGS_PER_WORKSPACE}; + + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + + CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::from(vec![ + "bun".to_string() + ]))); + + let step = |id: &str, tag: Option<&str>| { + json!({ + "id": id, + "value": { + "type": "rawscript", + "language": "deno", + "content": "export function main() { return { lang: 'bun' } }", + "tag": tag, + }, + }) + }; + let flow: FlowValue = serde_json::from_value(json!({ + "modules": [ + step("a", None), + step("b", Some("$flow_expr[results.a.lang]")), + step("c", Some("$flow_expr[results.a.lang]-gpu")), + ], + }))?; + + // A non-superadmin, so the custom tags apply. + let job = RunJob::from(JobPayload::RawFlow { value: flow, path: None, restarted_from: None }) + .as_user("test-user-2", "test2@windmill.dev") + .run_until_complete(&db, false, server.addr.port()) + .await; + CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::default())); + + let b_tag = sqlx::query_scalar::<_, String>( + "SELECT tag FROM v2_job WHERE parent_job = $1 AND flow_step_id = 'b'", + ) + .bind(job.id) + .fetch_one(&db) + .await?; + assert_eq!(b_tag, "bun"); + + assert!(!job.success); + let result = job.json_result().unwrap(); + let message = result["error"]["message"].as_str().unwrap_or_default(); + assert!( + message.contains("(resolved to bun-gpu) is not included in the allowed CUSTOM_TAGS"), + "got {result:?}" + ); + + Ok(()) +} + +/// A flow whose preprocessor runs lands on its tag filled in from the preprocessor's output, so +/// running it judges the tag as written: even raw args that fill it in to a listed tag do not +/// admit a template nobody listed. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base", "hello"))] +#[serial] +async fn test_flow_tag_judged_as_written_before_preprocessor( + db: Pool, +) -> anyhow::Result<()> { + use windmill_common::worker::{CustomTags, CUSTOM_TAGS_PER_WORKSPACE}; + + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let set_flow_tag = |tag: &'static str| { + let db = db.clone(); + async move { + sqlx::query("UPDATE flow SET tag = $1 WHERE path = 'f/system/hello_with_preprocessor'") + .bind(tag) + .execute(&db) + .await?; + // Flow info is cached per version, which a real redeploy would bump. + windmill_common::FLOW_INFO_CACHE + .remove(&("test-workspace".to_string(), 1443253234253456)); + anyhow::Ok(()) + } + }; + // As a non-superadmin, so the custom tags apply. + let run_as_user = |entries: &[&str], query: &str| { + CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::from( + entries.iter().map(|e| e.to_string()).collect(), + ))); + reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/test-workspace/jobs/run/f/f/system/hello_with_preprocessor{query}" + )) + .bearer_auth("SECRET_TOKEN_2") + .json(&json!({ "foo": "bar" })) + .send() + }; + + sqlx::query( + "UPDATE flow SET extra_perms = '{\"u/test-user-2\": false}' + WHERE path = 'f/system/hello_with_preprocessor'", + ) + .execute(&db) + .await?; + set_flow_tag("pp-$args[foo]").await?; + let refused = run_as_user(&["pp-bar"], "").await?; + let refused_status = refused.status(); + let refused_body = refused.text().await?; + // `push` drops a `?tag=` of such a flow, so it does not stand in for the flow's own tag. + let overridden = run_as_user(&["pp-bar"], "?tag=pp-bar").await?.status(); + let admitted = run_as_user(&["pp-$args[foo]"], "").await?.status(); + // A tag the preprocessor cannot change is known now, so patterns apply to it. + set_flow_tag("pp-large").await?; + let static_admitted = run_as_user(&["pp-$args[size]"], "").await?.status(); + + // Nothing a flow's tag can read resolves `$flow_expr[...]`, so the flow keeps its tag. + set_flow_tag("$flow_expr[flow_input.foo]").await?; + CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::default())); + let unresolvable = RunJob::from(JobPayload::Flow { + path: "f/system/hello_with_preprocessor".to_string(), + dedicated_worker: None, + apply_preprocessor: true, + version: 1443253234253456, + labels: None, + }) + .as_user("test-user-2", "test2@windmill.dev") + .run_until_complete(&db, false, port) + .await; + + assert_eq!(refused_status, 400, "got {refused_body}"); + assert!( + refused_body.contains("Tag pp-$args[foo] is not included"), + "got {refused_body}" + ); + assert_eq!(overridden, 400); + assert!(admitted.is_success(), "got {admitted}"); + assert!(static_admitted.is_success(), "got {static_admitted}"); + + assert!(unresolvable.success, "got {:?}", unresolvable.json_result()); + let tag = sqlx::query_scalar::<_, String>("SELECT tag FROM v2_job WHERE id = $1") + .bind(unresolvable.id) + .fetch_one(&db) + .await?; + assert_eq!(tag, "flow"); + + Ok(()) +} + /// The `*` fork marker only grants through a real `parent_workspace_id` lineage lookup, which the /// parse-level unit tests cannot reach: they hand `applies_to_workspace` a synthetic chain, so a /// regression in the lookup or in the `is_fork_scoped()` gate that skips it would pass them. #[sqlx::test(fixtures("base"))] #[serial] async fn test_fork_marker_tag_admission_through_lineage(db: Pool) -> anyhow::Result<()> { - use windmill_common::jobs::check_tag_available_for_workspace_internal; use windmill_common::worker::{CustomTags, CUSTOM_TAGS_PER_WORKSPACE}; initialize_tracing().await; + // For a non-superadmin caller (a superadmin would bypass the scope check entirely). + async fn allowed(db: &Pool, w_id: &str, tag: &str) -> bool { + let no_args = std::collections::HashMap::new(); + let args = windmill_queue::PushArgs::from(&no_args); + windmill_queue::check_tag_available_for_push(db, w_id, tag, &args, false, None) + .await + .is_ok() + } + // The ancestor chain is cached process-wide by workspace id, so use one no other test takes. let fork = "wm-fork-tagmarker"; sqlx::query!( @@ -5538,27 +5699,18 @@ async fn test_fork_marker_tag_admission_through_lineage(db: Pool) -> a "bare(test-workspace)".to_string(), ]))); - // A non-superadmin caller (a superadmin would bypass the scope check entirely). - let is_super_admin = false; - for (w_id, tag) in [("test-workspace", "bare"), ("test-workspace", "forky")] { assert!( - check_tag_available_for_workspace_internal(&db, w_id, tag, is_super_admin, None) - .await - .is_ok(), + allowed(&db, w_id, tag).await, "{tag} should be available in the workspace it names" ); } assert!( - check_tag_available_for_workspace_internal(&db, fork, "forky", is_super_admin, None) - .await - .is_ok(), + allowed(&db, fork, "forky").await, "a `*` tag must be granted to a fork through its parent lineage" ); assert!( - check_tag_available_for_workspace_internal(&db, fork, "bare", is_super_admin, None) - .await - .is_err(), + !allowed(&db, fork, "bare").await, "an unmarked tag must not reach a fork of the workspace it names" ); @@ -5567,6 +5719,122 @@ async fn test_fork_marker_tag_admission_through_lineage(db: Pool) -> a Ok(()) } +/// A workspace-scoped pattern admits the tags in its range only from the workspaces its scope +/// allows, and a tag listed by its own name with a scope stays inside that scope whatever pattern +/// it fits. The fork lineage and `$workspace` are resolved for a matching pattern. +#[sqlx::test(fixtures("base"))] +#[serial] +async fn test_scoped_custom_tag_pattern_admission(db: Pool) -> anyhow::Result<()> { + use windmill_common::worker::{CustomTags, CUSTOM_TAGS_PER_WORKSPACE}; + + initialize_tracing().await; + + // For a non-superadmin caller (a superadmin would bypass the scope check entirely). + async fn check(db: &Pool, w_id: &str, tag: &str, size: &str) -> Result<(), String> { + let args = std::collections::HashMap::from([( + "size".to_string(), + serde_json::value::to_raw_value(size).unwrap(), + )]); + let args = windmill_queue::PushArgs::from(&args); + windmill_queue::check_tag_available_for_push(db, w_id, tag, &args, false, None) + .await + .map_err(|e| e.to_string()) + } + async fn allowed(db: &Pool, w_id: &str, tag: &str, size: &str) -> bool { + check(db, w_id, tag, size).await.is_ok() + } + async fn allowed_as_written(db: &Pool, w_id: &str, tag: &str) -> bool { + windmill_common::jobs::check_tag_available_for_workspace_internal( + db, + w_id, + tag, + None, + std::future::ready(w_id.to_string()), + false, + None, + ) + .await + .is_ok() + } + + // The ancestor chain is cached process-wide by workspace id, so use one no other test takes. + let fork = "wm-fork-tagpattern"; + sqlx::query!( + "INSERT INTO workspace (id, name, owner, parent_workspace_id) + VALUES ($1, $1, 'test-user', 'test-workspace')", + fork + ) + .execute(&db) + .await?; + + CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::from(vec![ + "gpu-$args[size]".to_string(), + "$args[size]-secret(test-workspace)".to_string(), + "gpu-secret(other)".to_string(), + "$workspace-$args[size](test-workspace*)".to_string(), + "urgent".to_string(), + "urgent(other)".to_string(), + ]))); + + let confined = check(&db, "test-workspace", "gpu-$args[size]", "secret").await; + let checks = [ + ( + "scoped pattern from its workspace", + allowed(&db, "test-workspace", "cpu-secret", "").await, + ), + ( + "scoped pattern refused elsewhere", + !allowed(&db, "other", "cpu-secret", "").await, + ), + ("confined tag refused past the patterns it fits", confined.is_err()), + ( + "global pattern for the tags nothing confines", + allowed(&db, "test-workspace", "gpu-large", "").await, + ), + ( + "confined tag from its workspace", + allowed(&db, "other", "gpu-secret", "").await, + ), + // Listing the tag by name without a scope as well keeps it open everywhere. + ( + "tag listed both globally and scoped", + allowed(&db, "test-workspace", "urgent", "").await, + ), + // The fork's `$workspace` is its parent's, whose tags its lineage reaches. Written as the + // resolved tag, not as the entry, so only the pattern can admit it. + ( + "fork-scoped `$workspace` pattern from a fork", + allowed(&db, fork, "test-workspace-large", "").await, + ), + ( + "fork-scoped `$workspace` pattern refused elsewhere", + !allowed(&db, "other", "$workspace-$args[size]", "large").await, + ), + ( + "as written from its workspace", + allowed_as_written(&db, "test-workspace", "$args[size]-secret").await, + ), + ( + "as written refused elsewhere", + !allowed_as_written(&db, "other", "$args[size]-secret").await, + ), + ]; + CUSTOM_TAGS_PER_WORKSPACE.store(std::sync::Arc::new(CustomTags::default())); + + for (what, ok) in checks { + assert!(ok, "{what}"); + } + let confined = confined.unwrap_err(); + assert!( + confined.contains("the custom tag gpu-secret(other) restricts it to other workspaces") + && confined.contains("gpu-$args[size]") + && confined.contains("$args[size]-secret(test-workspace)"), + "got {confined}" + ); + + Ok(()) +} + #[cfg(all(feature = "quickjs", feature = "python"))] #[sqlx::test(fixtures("base"))] async fn test_whileloop_propagates_inner_iterator_eval_failure( diff --git a/backend/windmill-ai/src/ai_types.rs b/backend/windmill-ai/src/ai_types.rs index 69c8ac4538..599cf0cca3 100644 --- a/backend/windmill-ai/src/ai_types.rs +++ b/backend/windmill-ai/src/ai_types.rs @@ -210,7 +210,9 @@ pub fn validate_model_pricing_json(ai_config: &serde_json::Value) -> Result<(), return Err(format!("Price override for {} is not an object", key)); }; for field in ["input", "output", "cache_read", "cache_write"] { - let Some(rate) = price.get(field) else { continue }; + let Some(rate) = price.get(field) else { + continue; + }; let rate = rate .as_f64() .filter(|r| r.is_finite() && *r >= 0.0 && *r <= MAX_MODEL_RATE); @@ -223,9 +225,98 @@ pub fn validate_model_pricing_json(ai_config: &serde_json::Value) -> Result<(), } for required in ["input", "output"] { if !price.contains_key(required) { - return Err(format!("Price override for {} is missing {}", key, required)); + return Err(format!( + "Price override for {} is missing {}", + key, required + )); } } } Ok(()) } + +// ============================================================================ +// Per-model token maps +// ============================================================================ + +/// One `provider:model` -> tokens map of the AI config, with the bounds the API +/// documents for it. Both maps are validated the same way, so the bounds live here +/// rather than at each call site. +pub struct TokenMap { + pub field: &'static str, + pub label: &'static str, + pub min: i64, + pub max: i64, +} + +/// The chat compacts its history as it nears the window, so a value below any real +/// model's window would compact every turn; the ceiling is far above any shipped +/// model and only catches typos. +pub const CONTEXT_WINDOWS: TokenMap = TokenMap { + field: "context_window_per_model", + label: "Context window", + min: 1024, + max: 10_000_000, +}; + +/// The completion cap the chat asks the provider for. +pub const OUTPUT_LIMITS: TokenMap = + TokenMap { field: "max_tokens_per_model", label: "Output limit", min: 1, max: 2_000_000 }; + +pub const TOKEN_MAPS: [TokenMap; 2] = [CONTEXT_WINDOWS, OUTPUT_LIMITS]; + +pub fn validate_token_limit(map: &TokenMap, key: &str, tokens: i64) -> Result<(), String> { + if (map.min..=map.max).contains(&tokens) { + Ok(()) + } else { + Err(format!( + "{} for {} must be between {} and {} tokens", + map.label, key, map.min, map.max + )) + } +} + +/// Bound these maps in the untyped instance AI config, for the reason given on +/// `validate_model_pricing_json`. +pub fn validate_token_maps_json(ai_config: &serde_json::Value) -> Result<(), String> { + for map in TOKEN_MAPS.iter() { + let entries = match ai_config.get(map.field) { + None | Some(serde_json::Value::Null) => continue, + Some(v) => v + .as_object() + .ok_or_else(|| format!("{} must be an object", map.field))?, + }; + for (key, tokens) in entries { + let tokens = tokens + .as_i64() + .ok_or_else(|| format!("{} for {} must be an integer", map.label, key))?; + validate_token_limit(map, key, tokens)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn instance_token_maps_are_bounded_integers() { + assert!(validate_token_maps_json(&json!({})).is_ok()); + for map in TOKEN_MAPS.iter() { + let entry = |value| json!({ map.field: { "customai:m": value } }); + assert!(validate_token_maps_json(&entry(json!(map.min))).is_ok()); + assert!(validate_token_maps_json(&entry(json!(map.max))).is_ok()); + for bad in [ + json!(map.min - 1), + json!(map.max + 1), + json!(4096.5), + json!("4096"), + ] { + assert!(validate_token_maps_json(&entry(bad)).is_err()); + } + assert!(validate_token_maps_json(&json!({ map.field: [4096] })).is_err()); + } + } +} diff --git a/backend/windmill-api-auth/src/auth.rs b/backend/windmill-api-auth/src/auth.rs index 6e5aa6b1da..869f40e14c 100644 --- a/backend/windmill-api-auth/src/auth.rs +++ b/backend/windmill-api-auth/src/auth.rs @@ -1073,7 +1073,8 @@ fn no_auth_admin_authed() -> ApiAuthed { } /// Resolves OptJobAuthed from request parts. -/// Takes ownership of Parts and returns them back. +/// Takes ownership of Parts and returns them back, memoized into the extensions so a +/// second extraction in the same request is free. #[allow(unreachable_code, unused_mut)] pub async fn resolve_opt_job_authed( mut parts: Parts, @@ -1089,10 +1090,11 @@ pub async fn resolve_opt_job_authed( )); } - let already_authed = parts.extensions.get::().cloned(); - - if let Some(authed) = already_authed { - return Ok((authed, parts)); + // Safe only while what the route checks below read is fixed before the first + // extraction: path and method always are, `workspace_id` only while every + // `GatewayWorkspaceId` writer stays layered outside auth extraction. + if let Some(opt_job_authed) = parts.extensions.get::().cloned() { + return Ok((opt_job_authed, parts)); } let already_tokened = parts.extensions.get::().cloned(); @@ -1185,6 +1187,9 @@ pub async fn resolve_opt_job_authed( if let Some(workspace_id) = workspace_id { Span::current().record("workspace_id", &workspace_id); } + // Separate from the ApiAuthed insert above, which handlers taking + // `Extension` read. + parts.extensions.insert(opt_job_authed.clone()); return Ok((opt_job_authed, parts)); } } @@ -1311,3 +1316,69 @@ pub async fn list_tokens_internal( Ok(Json(tokens)) } + +#[cfg(test)] +mod tests { + use super::*; + + /// Dropping the `AUTH_CACHE` entry between the two resolutions is what makes a memo + /// hit observable: only a working memo can answer the second one. + #[tokio::test] + async fn resolve_opt_job_authed_is_memoized() { + // Never connected: an AUTH_CACHE hit resolves without a query, and AuthCache + // only needs the handle to exist. Without the timeout a regression — which + // falls through to a real lookup — spends sqlx's 30s default failing. + let db = sqlx::postgres::PgPoolOptions::new() + .acquire_timeout(std::time::Duration::from_millis(50)) + .connect_lazy("postgres://memo@127.0.0.1:1/memo") + .expect("lazy pool"); + + let token = "memo_guard_token"; + // Workspace-scoped: a job token on a workspace-less route is refused by + // check_job_token_for_global_route before the memo is ever taken. + let key = ("memo_workspace".to_string(), token.to_string()); + let job_id = uuid::Uuid::from_u128(0x6d656d6f); + AUTH_CACHE.insert( + key.clone(), + ExpiringAuthCache { + authed: ApiAuthed { + email: "memo@windmill.dev".to_string(), + username: "memo".to_string(), + ..Default::default() + }, + expiry: chrono::Utc::now() + chrono::Duration::hours(1), + job_id: Some(job_id), + }, + ); + + #[cfg(feature = "enterprise")] + let cache = AuthCache::new(db, None, None); + #[cfg(not(feature = "enterprise"))] + let cache = AuthCache::new(db, None); + + let mut parts = http::Request::builder() + .uri("/api/w/memo_workspace/jobs/list") + .header(http::header::AUTHORIZATION, format!("Bearer {token}")) + .body(()) + .unwrap() + .into_parts() + .0; + parts.extensions.insert(Arc::new(cache)); + + let (_, parts) = resolve_opt_job_authed(parts) + .await + .map_err(|(e, _)| e) + .expect("first resolution"); + + AUTH_CACHE.remove(&key); + + let (second, _) = resolve_opt_job_authed(parts) + .await + .map_err(|(e, _)| e) + .expect("second resolution must hit the memo, not re-resolve"); + assert_eq!(second.authed.username, "memo"); + // The wrapper must survive whole: memoizing only the inner ApiAuthed and + // rebuilding around it drops the provenance forbid_elevated_job_token gates on. + assert_eq!(second.job_id, Some(job_id)); + } +} diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index 0e26c2a8b8..bb1586db68 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -1249,6 +1249,10 @@ pub async fn register_token_expiry_notification( if !windmill_common::auth::is_user_token(label) { return; } + let warning_days = windmill_common::auth::TOKEN_EXPIRY_WARNING_DAYS; + if expiration <= chrono::Utc::now() + chrono::Duration::days(warning_days.into()) { + return; + } if let Err(e) = sqlx::query!( "INSERT INTO token_expiry_notification (token_hash, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING", token_hash, diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 5ac0e0588c..c7df69879e 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -51,7 +51,10 @@ use windmill_common::{ jobs::JobPayload, schedule::Schedule, triggers::MovedNativeTrigger, - utils::{http_get_from_hub, not_found_if_none, paginate, Pagination, RunnableKind, StripPath}, + utils::{ + http_get_from_hub, not_found_if_none, paginate, paginate_optional, Pagination, + RunnableKind, StripPath, + }, }; use windmill_dep_map::scoped_dependency_map::ScopedDependencyMap; use windmill_git_sync::{handle_deployment_metadata, DeployedObject}; @@ -747,6 +750,14 @@ async fn create_flow( .execute(&mut *tx) .await?; } + windmill_common::user_drafts::clear_draft_moves_from( + &mut tx, + &w_id, + &[UserDraftItemKind::Flow], + &nf.path, + None, + ) + .await?; audit_log( &mut *tx, @@ -915,25 +926,36 @@ pub struct FlowVersion { pub created_at: chrono::DateTime, #[serde(skip_serializing_if = "Option::is_none")] pub deployment_msg: Option, + /// Who deployed this version — the diff's version picker names them so a reader + /// can tell their own deploys from a teammate's. + #[serde(skip_serializing_if = "Option::is_none")] + pub created_by: Option, } async fn get_flow_history( authed: ApiAuthed, Extension(user_db): Extension, Path((w_id, path)): Path<(String, StripPath)>, + Query(pagination): Query, ) -> JsonResult> { let path = path.to_path(); check_scopes(&authed, || format!("flows:read:{}", path))?; + // Unasked-for, this listing stays whole: the history panels, the restart picker and + // the CLI all read it without paging. The diff picker asks for a page. + let (per_page, offset) = paginate_optional(pagination); let mut tx = user_db.begin(&authed).await?; let flows = sqlx::query_as!( FlowVersion, - "SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version + "SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version - WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 - ORDER BY flow_version.created_at DESC", + WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 + ORDER BY flow_version.created_at DESC + LIMIT $3 OFFSET $4", path, - w_id + w_id, + per_page, + offset, ) .fetch_all(&mut *tx) .await?; @@ -953,7 +975,7 @@ async fn get_latest_version( let version = sqlx::query_as!( FlowVersion, - "SELECT flow_version.id, flow_version.created_at, deployment_metadata.deployment_msg FROM flow_version + "SELECT flow_version.id, flow_version.created_at, flow_version.created_by, deployment_metadata.deployment_msg FROM flow_version LEFT JOIN deployment_metadata ON flow_version.id = deployment_metadata.flow_version WHERE flow_version.path = $1 AND flow_version.workspace_id = $2 ORDER BY flow_version.created_at DESC", @@ -1438,6 +1460,20 @@ async fn update_flow( .await?; } + if is_new_path { + // Everything left at the old path is a draft this deploy didn't consume + // — teammates' rows, and the deployer's own when the caller asked us to + // keep it. Carry them rather than strand them. + windmill_common::user_drafts::move_drafts_for_path( + &mut tx, + &w_id, + &[UserDraftItemKind::Flow], + flow_path, + &nf.path, + ) + .await?; + } + audit_log( &mut *tx, &authed, diff --git a/backend/windmill-api-groups/src/folders.rs b/backend/windmill-api-groups/src/folders.rs index 646f764ec6..c52b8a6316 100644 --- a/backend/windmill-api-groups/src/folders.rs +++ b/backend/windmill-api-groups/src/folders.rs @@ -800,6 +800,14 @@ async fn delete_folder( not_found_if_none(get_folderopt(&mut tx, &w_id, &name).await?, "Folder", &name)?; + // See the same call in `delete_group`: a freed name must not stay in a tenant list. + windmill_common::workspaces::remove_datatable_tenant_in_workspace( + &mut tx, + &w_id, + &format!("f/{name}"), + ) + .await?; + let del = sqlx::query_scalar!( "DELETE FROM folder WHERE name = $1 AND workspace_id = $2 RETURNING 1", name, diff --git a/backend/windmill-api-groups/src/groups.rs b/backend/windmill-api-groups/src/groups.rs index c931cf2255..0209af8585 100644 --- a/backend/windmill-api-groups/src/groups.rs +++ b/backend/windmill-api-groups/src/groups.rs @@ -797,6 +797,15 @@ async fn delete_group( } not_found_if_none(get_group_opt(&mut tx, &w_id, &name).await?, "Group", &name)?; + // A tenant list names a principal, so a freed name must not linger in one: a later group + // reusing it would silently inherit the data table access this one had. + windmill_common::workspaces::remove_datatable_tenant_in_workspace( + &mut tx, + &w_id, + &format!("g/{name}"), + ) + .await?; + sqlx::query!( "DELETE FROM usr_to_group WHERE group_ = $1 AND workspace_id = $2", name, diff --git a/backend/windmill-api-integration-tests/tests/datatable_acl.rs b/backend/windmill-api-integration-tests/tests/datatable_acl.rs new file mode 100644 index 0000000000..4310830ae2 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/datatable_acl.rs @@ -0,0 +1,108 @@ +//! Who may read and change a data table's grants and owners. On the Enterprise Edition: its +//! administrators, from the workspace that governs it. Without it: nobody. Each refusal is decided +//! before anything connects to the data table, so the fixture's database never has to exist. + +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +fn grant_select_on_public() -> Value { + json!({ + "target": {"kind": "schema", "schema": "public"}, + "change": {"type": "grant", "role": "analytics", "privileges": ["SELECT"], + "scope": "all_tables"}, + "statements": [r#"GRANT SELECT ON ALL TABLES IN SCHEMA "public" TO "analytics""#] + }) +} + +async fn post_acl( + port: u16, + w_id: &str, + action: &str, + token: &str, +) -> anyhow::Result { + Ok(reqwest::Client::new() + .post(format!( + "http://localhost:{port}/api/w/{w_id}/workspaces/datatable_acl/main/{action}" + )) + .header("Authorization", format!("Bearer {token}")) + .json(&grant_select_on_public()) + .send() + .await?) +} + +/// A fork reaches the data table through a pointer: it may use it, never change what each role may +/// touch on it — not even as an admin of the fork. +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_fork_cannot_change_access_on_the_data_table_it_points_at( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + for action in ["plan", "apply"] { + let resp = post_acl(port, "wm-fork-dt", action, "SECRET_TOKEN_2").await?; + assert_eq!(resp.status(), 401, "{action}: {}", resp.text().await?); + } + Ok(()) +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_member_who_is_not_an_admin_cannot_change_access( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + for action in ["plan", "apply"] { + let resp = post_acl(port, "test-workspace", action, "SECRET_TOKEN_2").await?; + assert_eq!(resp.status(), 401, "{action}: {}", resp.text().await?); + } + Ok(()) +} + +/// Not even reading, and not even on a data table that is not under roles — which any member +/// reaches, so only the edition stands between them and the instance's credentials. +#[cfg(not(all(feature = "private", feature = "enterprise")))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn only_the_enterprise_edition_has_the_access_editor( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + sqlx::query( + "UPDATE workspace_settings + SET datatable = datatable #- '{datatables,main,permissions}' + WHERE workspace_id = 'test-workspace'", + ) + .execute(&db) + .await?; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let read = reqwest::Client::new() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/workspaces/datatable_acl/main?kind=database" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .send() + .await?; + let mut responses = vec![("read", read)]; + for action in ["plan", "apply"] { + responses.push(( + action, + post_acl(port, "test-workspace", action, "SECRET_TOKEN").await?, + )); + } + for (action, resp) in responses { + assert_eq!(resp.status(), 400, "{action}"); + let body = resp.text().await?; + assert!( + body.contains("Data table roles are a Windmill Enterprise Edition feature"), + "{action}: {body}" + ); + } + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/datatable_roles.rs b/backend/windmill-api-integration-tests/tests/datatable_roles.rs new file mode 100644 index 0000000000..4948a0098a --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/datatable_roles.rs @@ -0,0 +1,1758 @@ +//! Who may connect to a data table as which role, across the two shapes an entry can take: one +//! that owns its database, and a fork's pointer at it. + +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {token}")) +} + +/// The `analytics` role's tenant list as stored, so a cascade can be observed directly. +async fn tenants(db: &Pool, w_id: &str) -> Vec { + let value: Option = sqlx::query_scalar( + "SELECT datatable->'datatables'->'main'->'permissions'->'roles'->'role1'->'tenants' + FROM workspace_settings WHERE workspace_id = $1", + ) + .bind(w_id) + .fetch_one(db) + .await + .unwrap(); + serde_json::from_value(value.unwrap_or(json!([]))).unwrap() +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn freeing_a_principal_takes_its_datatable_tenant(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace"); + + assert_eq!( + tenants(&db, "test-workspace").await, + vec!["u/test-user-2", "g/analysts", "f/finance"] + ); + + let resp = authed( + client().delete(format!("{base}/groups/delete/analysts")), + "SECRET_TOKEN", + ) + .send() + .await?; + assert_eq!(resp.status(), 200, "delete group: {}", resp.text().await?); + + let resp = authed( + client().delete(format!("{base}/folders/delete/finance")), + "SECRET_TOKEN", + ) + .send() + .await?; + assert_eq!(resp.status(), 200, "delete folder: {}", resp.text().await?); + + let resp = authed( + client().delete(format!("{base}/users/delete/test-user-2")), + "SECRET_TOKEN", + ) + .send() + .await?; + assert_eq!(resp.status(), 200, "delete user: {}", resp.text().await?); + + // Leaving is the other way a membership ends, and there are two `/leave` routes — the one the + // UI and the generated client call is this one. A tenant left behind here comes back with the + // person on rejoin, or attaches to whoever takes the username next. + sqlx::query( + r#"UPDATE workspace_settings SET datatable = jsonb_set(datatable, + '{datatables,main,permissions,roles,role1,tenants}', '["u/test-user-3"]'::jsonb) + WHERE workspace_id = 'test-workspace'"#, + ) + .execute(&db) + .await?; + let resp = authed( + client().post(format!("{base}/workspaces/leave")), + "SECRET_TOKEN_3", + ) + .send() + .await?; + assert_eq!(resp.status(), 200, "leave: {}", resp.text().await?); + // Nothing left naming a principal that no longer exists: a later group or account reusing one + // of those names must not inherit the access this one had. + assert!( + tenants(&db, "test-workspace").await.is_empty(), + "leaving kept the tenant: {:?}", + tenants(&db, "test-workspace").await + ); + Ok(()) +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_fork_uses_the_data_table_it_points_at_but_never_administers_it( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let fork = format!("http://localhost:{port}/api/w/wm-fork-dt/workspaces"); + + // `test-user-2` is an admin of the fork and a plain member of the parent. The roles they can + // use are the ones the parent's tenants give them there, not what their fork admin bit says. + let resp = authed( + client().get(format!("{fork}/datatable_usable_roles/main")), + "SECRET_TOKEN_2", + ) + .send() + .await?; + assert_eq!(resp.status(), 200); + let body: Value = resp.json().await?; + assert_eq!(body["roles"], json!(["analytics"]), "{body}"); + assert_eq!(body["default_role"], "analytics"); + + // The drawer names the workspace that decides, and refuses to let the fork edit it. + let resp = authed( + client().get(format!("{fork}/datatable_permissions/main")), + "SECRET_TOKEN_2", + ) + .send() + .await?; + let body: Value = resp.json().await?; + assert_eq!(body["governing_workspace_id"], "test-workspace"); + assert_eq!(body["editable"], false, "{body}"); + + let resp = authed( + client().post(format!("{fork}/datatable_permissions/main")), + "SECRET_TOKEN_2", + ) + .json(&json!({"permissioned": true, "default_role": "admin", + "roles": [{"id": "admin", "tenants": ["*"]}]})) + .send() + .await?; + assert_eq!( + resp.status(), + 401, + "a fork admin widened the parent's access" + ); + + // Nor by saving the settings form: the pointer is server-owned, so a payload naming the + // parent's database leaves the entry exactly as it was. + let resp = authed( + client().post(format!("{fork}/edit_datatable_config")), + "SECRET_TOKEN_2", + ) + .json(&json!({ + "settings": {"datatables": {"main": { + "database": {"resource_type": "instance", "resource_path": "dt_main"} + }}}, + "renames": [], "deleted_datatables": [] + })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + let entry: Option = sqlx::query_scalar( + "SELECT datatable->'datatables'->'main' FROM workspace_settings WHERE workspace_id = $1", + ) + .bind("wm-fork-dt") + .fetch_one(&db) + .await?; + let entry = entry.unwrap(); + assert_eq!( + entry["reference"]["workspace_id"], "test-workspace", + "{entry}" + ); + assert!(entry["database"].is_null(), "{entry}"); + Ok(()) +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_second_entry_on_the_same_database_is_reported_rather_than_governed( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + // A copy of the parent's entry, as a fork created before data table roles would hold. It keeps + // its own access, so the owner is told about it instead of being told it is covered. + sqlx::query( + r#"UPDATE workspace_settings SET datatable = '{"datatables": {"copy": { + "database": {"resource_type": "instance", "resource_path": "dt_main"}}}}'::jsonb + WHERE workspace_id = 'wm-fork-dt'"#, + ) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let resp = authed( + client().get(format!( + "http://localhost:{port}/api/w/test-workspace/workspaces/datatable_permissions/main" + )), + "SECRET_TOKEN", + ) + .send() + .await?; + let body: Value = resp.json().await?; + assert_eq!( + body["ungoverned_reachers"], + json!([{"workspace_id": "wm-fork-dt", "datatable": "copy"}]), + "{body}" + ); + Ok(()) +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_resource_backed_data_table_cannot_be_put_under_roles( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + // A role is a login on Windmill's own cluster. A resource-backed data table dials a host the + // workspace admin chose, so accepting one here would hand that host a real cluster credential. + sqlx::query( + r#"UPDATE workspace_settings SET datatable = '{"datatables": {"byo": { + "database": {"resource_type": "postgresql", "resource_path": "u/test-user/pg"}}}}'::jsonb + WHERE workspace_id = 'test-workspace'"#, + ) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces"); + + let resp = authed( + client().get(format!("{base}/datatable_permissions/byo")), + "SECRET_TOKEN", + ) + .send() + .await?; + let body: Value = resp.json().await?; + assert_eq!(body["supported"], false, "{body}"); + + let resp = authed( + client().post(format!("{base}/datatable_permissions/byo")), + "SECRET_TOKEN", + ) + .json(&json!({"permissioned": true, "default_role": "role1", + "roles": [{"id": "role1", "tenants": ["*"]}]})) + .send() + .await?; + assert_eq!(resp.status(), 400, "{}", resp.text().await?); + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_fork_renaming_its_own_entry_leaves_the_governing_bookkeeping_alone( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + // The parent's migration definitions. A rename or delete through the fork's settings form + // resolves through the pointer, so without a guard it would relabel or wipe these. + sqlx::query( + "INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up) + VALUES ('test-workspace', 'main', 1, 'init', 'SELECT 1')", + ) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/w/wm-fork-dt/workspaces/edit_datatable_config" + )), + "SECRET_TOKEN_2", + ) + .json(&json!({ + "settings": {"datatables": {}}, + "renames": [], + "deleted_datatables": ["main"] + })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + let left: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM datatable_migrations WHERE workspace_id = 'test-workspace'", + ) + .fetch_one(&db) + .await?; + assert_eq!(left, 1, "the fork's delete reached the parent's migrations"); + Ok(()) +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_caller_who_is_not_a_member_of_the_governing_workspace_reaches_nothing( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + // A fork member who was never added to the parent. Their fork membership says nothing there, + // and the email lookup that would evaluate them as a member of it finds no row. + sqlx::query( + "INSERT INTO usr (workspace_id, email, username, is_admin, role) + VALUES ('wm-fork-dt', 'test3@windmill.dev', 'test-user-3', false, 'User')", + ) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let resp = authed( + client().get(format!( + "http://localhost:{port}/api/w/wm-fork-dt/workspaces/datatable_usable_roles/main" + )), + "SECRET_TOKEN_3", + ) + .send() + .await?; + assert_eq!(resp.status(), 200); + let body: Value = resp.json().await?; + assert_eq!(body["roles"], json!([]), "{body}"); + Ok(()) +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_caller_with_no_identity_reaches_a_permissioned_data_table_not_at_all( + db: Pool, +) -> anyhow::Result<()> { + use windmill_common::workspaces::{get_datatable_resource_from_db, DatatableAccess}; + + initialize_tracing().await; + // The compatibility story for an agent worker that predates data table roles and sends no job + // id: it keeps resolving an unpermissioned data table, and is refused on a permissioned one + // rather than handed an unattributed admin connection. + let refused = get_datatable_resource_from_db( + &db, + "test-workspace", + "main", + None, + DatatableAccess::NoIdentity, + ) + .await; + assert!(refused.is_err(), "an unidentified caller was let in"); + + sqlx::query( + "UPDATE workspace_settings + SET datatable = datatable #- '{datatables,main,permissions}' + WHERE workspace_id = 'test-workspace'", + ) + .execute(&db) + .await?; + let resolved = get_datatable_resource_from_db( + &db, + "test-workspace", + "main", + None, + DatatableAccess::NoIdentity, + ) + .await?; + assert_eq!(resolved["dbname"], "dt_main", "{resolved}"); + Ok(()) +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn concurrent_role_creations_both_survive(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Postgres roles are cluster-wide and this cluster is shared with every other test database, + // so the names have to be unique to this run. + let suffix: String = uuid::Uuid::new_v4().simple().to_string()[..8].to_string(); + let names = [format!("wmtest_a_{suffix}"), format!("wmtest_b_{suffix}")]; + + // The cluster DDL is not visible to another transaction until commit, so without the lock both + // of these pass their `pg_roles` existence check and one loses — leaving a live cluster login + // the catalog never recorded. + let create = |name: String| async move { + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/settings/datatable_roles" + )), + "SECRET_TOKEN", + ) + .json(&json!({ "name": name })) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + Ok::<_, anyhow::Error>((status, body)) + }; + let outcome = async { + let (a, b) = tokio::join!(create(names[0].clone()), create(names[1].clone())); + let (a, b) = (a?, b?); + assert_eq!(a.0, 200, "{}", a.1); + assert_eq!(b.0, 200, "{}", b.1); + + let catalog = windmill_common::datatable_roles::read_role_catalog(&db).await?; + let recorded: Vec<&str> = catalog.values().map(|r| r.name.as_str()).collect(); + for name in &names { + assert!( + recorded.contains(&name.as_str()), + "{name} is a live cluster login the catalog forgot: {recorded:?}" + ); + } + Ok::<_, anyhow::Error>(()) + } + .await; + + // Roles are cluster-wide, so they outlive this test's throwaway database. Dropped whatever + // happened above — a failing run is exactly the one that created them and did not record them. + for name in &names { + let _ = sqlx::query(&format!("DROP ROLE IF EXISTS \"{name}\"")) + .execute(&db) + .await; + } + outcome +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_role_delete_that_fails_part_way_leaves_the_role_disabled( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let suffix: String = uuid::Uuid::new_v4().simple().to_string()[..8].to_string(); + let name = format!("wmtest_del_{suffix}"); + + let outcome = async { + let created: Value = authed( + client().post(format!( + "http://localhost:{port}/api/settings/datatable_roles" + )), + "SECRET_TOKEN", + ) + .json(&json!({ "name": name })) + .send() + .await? + .error_for_status()? + .json() + .await?; + let id = created["id"].as_str().unwrap().to_string(); + + // Each database's pass commits on its own, so one that cannot be reached fails the delete + // after the others may already have stripped the role. + sqlx::query( + "UPDATE global_settings SET value = jsonb_set(value, '{databases,wm_unreachable}', '{}') + WHERE name = 'custom_instance_pg_databases'", + ) + .execute(&db) + .await?; + + let resp = authed( + client().delete(format!( + "http://localhost:{port}/api/settings/datatable_roles/{id}" + )), + "SECRET_TOKEN", + ) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!(status, 400, "{body}"); + + let catalog = windmill_common::datatable_roles::read_role_catalog(&db).await?; + let role = catalog + .get(&id) + .expect("a failed delete keeps the entry to retry"); + assert!( + !role.enabled, + "a half-deleted role is still enabled in the catalog" + ); + let can_login: bool = + sqlx::query_scalar("SELECT rolcanlogin FROM pg_roles WHERE rolname = $1") + .bind(&name) + .fetch_one(&db) + .await?; + assert!(!can_login, "a half-deleted role can still log in"); + Ok::<_, anyhow::Error>(()) + } + .await; + + let _ = sqlx::query(&format!("DROP ROLE IF EXISTS \"{name}\"")) + .execute(&db) + .await; + outcome +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn renaming_a_governing_data_table_carries_its_forks( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // A pointer names the governing data table by name, so a rename that does not follow leaves + // every fork resolving to nothing — the data table vanishes from their pickers and their jobs + // stop, with nothing in the renaming workspace to suggest why. + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/w/test-workspace/workspaces/edit_datatable_config" + )), + "SECRET_TOKEN", + ) + .json(&json!({ + "settings": {"datatables": {"renamed": { + "database": {"resource_type": "instance", "resource_path": "dt_main"} + }}}, + "renames": [{"from": "main", "to": "renamed"}], + "deleted_datatables": [] + })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + let entry: Option = sqlx::query_scalar( + "SELECT datatable->'datatables'->'main' FROM workspace_settings WHERE workspace_id = $1", + ) + .bind("wm-fork-dt") + .fetch_one(&db) + .await?; + let entry = entry.unwrap(); + assert_eq!(entry["reference"]["datatable"], "renamed", "{entry}"); + + // And it still resolves, which is the thing the fork actually cares about. + let resp = authed( + client().get(format!( + "http://localhost:{port}/api/w/wm-fork-dt/workspaces/datatable_usable_roles/main" + )), + "SECRET_TOKEN_2", + ) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_rename_has_to_match_the_save_it_claims_to_describe( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let url = + format!("http://localhost:{port}/api/w/test-workspace/workspaces/edit_datatable_config"); + + let instance = + |path: &str| json!({"database": {"resource_type": "instance", "resource_path": path}}); + + // Fork pointers are rewritten from the rename list, so a rename nobody performed moves every + // fork of one data table onto another. `main` survives this save, so it was not renamed. + let resp = authed(client().post(&url), "SECRET_TOKEN") + .json(&json!({ + "settings": {"datatables": {"main": instance("dt_main"), "decoy": instance("dt_two")}}, + "renames": [{"from": "main", "to": "decoy"}], + "deleted_datatables": [] + })) + .send() + .await?; + assert_eq!(resp.status(), 400, "a forged rename was accepted"); + + let entry: Option = sqlx::query_scalar( + "SELECT datatable->'datatables'->'main'->'reference' FROM workspace_settings + WHERE workspace_id = $1", + ) + .bind("wm-fork-dt") + .fetch_one(&db) + .await?; + assert_eq!( + entry.unwrap()["datatable"], + "main", + "the fork was repointed anyway" + ); + + // A swap is two renames whose sources and targets cross. It cannot be done one at a time — + // `datatables` is keyed by name — so refusing it would be a regression, and applying the two + // in order without a temporary name would carry `main`'s pointers back to `main`. + let resp = authed(client().post(&url), "SECRET_TOKEN") + .json(&json!({ + "settings": {"datatables": {"main": instance("dt_two"), "other": instance("dt_main")}}, + "renames": [{"from": "main", "to": "other"}, {"from": "other", "to": "main"}], + "deleted_datatables": [] + })) + .send() + .await?; + // `other` does not exist yet, so this particular pair is still refused — the swap shape is + // covered by the pair below, which starts from two real data tables. + assert_eq!(resp.status(), 400, "{}", resp.text().await?); + + sqlx::query( + r#"UPDATE workspace_settings SET datatable = jsonb_set(datatable, '{datatables,other}', + '{"database": {"resource_type": "instance", "resource_path": "dt_two"}}'::jsonb) + WHERE workspace_id = 'test-workspace'"#, + ) + .execute(&db) + .await?; + + let resp = authed(client().post(&url), "SECRET_TOKEN") + .json(&json!({ + "settings": {"datatables": {"main": instance("dt_two"), "other": instance("dt_main")}}, + "renames": [{"from": "main", "to": "other"}, {"from": "other", "to": "main"}], + "deleted_datatables": [] + })) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "a swap was refused: {}", + resp.text().await? + ); + + // The fork named `main`, which is now called `other`. + let entry: Option = sqlx::query_scalar( + "SELECT datatable->'datatables'->'main'->'reference' FROM workspace_settings + WHERE workspace_id = $1", + ) + .bind("wm-fork-dt") + .fetch_one(&db) + .await?; + assert_eq!( + entry.unwrap()["datatable"], + "other", + "the swap did not carry the pointer" + ); + Ok(()) +} + +async fn database_exists(db: &Pool, name: &str) -> anyhow::Result { + Ok( + sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM pg_database WHERE datname = $1)") + .bind(name) + .fetch_one(db) + .await?, + ) +} + +async fn workspace_exists(db: &Pool, id: &str) -> anyhow::Result { + Ok( + sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM workspace WHERE id = $1)") + .bind(id) + .fetch_one(db) + .await?, + ) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_data_table_under_roles_is_cloned_only_with_its_grants( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + // The database a fork's copy of `main` goes into is named after the fork. Every request below + // is refused before it is created, so the cluster-wide name never collides with a sibling run. + let target = "wm_fork_copy__main"; + let fork = |token: &str, forked: Value| { + authed( + client().post(format!( + "http://localhost:{port}/api/w/test-workspace/workspaces/create_fork" + )), + token, + ) + .json(&json!({"id": "wm-fork-copy", "name": "copy", "forked_datatables": [forked]})) + }; + + // Rows are a workspace admin's to copy, as they were before roles. + let resp = fork( + "SECRET_TOKEN_2", + json!({"name": "main", "new_dbname": target, "fork_behavior": "schema_and_data"}), + ) + .send() + .await?; + assert_eq!(resp.status(), 403, "{}", resp.text().await?); + assert!(!database_exists(&db, target).await?); + + // Even the schema alone lists every table, which a member covered by no role cannot read in + // the parent. + let resp = fork( + "SECRET_TOKEN_3", + json!({"name": "main", "new_dbname": target, "fork_behavior": "schema_only"}), + ) + .send() + .await?; + assert_eq!(resp.status(), 401, "{}", resp.text().await?); + assert!(!database_exists(&db, target).await?); + + // Refused in the first phase too, before a git branch is created for a fork that cannot be. + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/w/test-workspace/workspaces/create_workspace_fork_branch" + )), + "SECRET_TOKEN_3", + ) + .json( + &json!({"id": "wm-fork-copy", "name": "copy", "forked_datatables": [ + {"name": "main", "new_dbname": target, "fork_behavior": "schema_only"} + ]}), + ) + .send() + .await?; + assert_eq!(resp.status(), 401, "{}", resp.text().await?); + + // A database the request did not create — whatever the data table, under roles or not — may be + // a copy left behind by a deleted fork, which the entry would reach without its governance. + let resp = fork( + "SECRET_TOKEN", + json!({"name": "other", "new_dbname": "wm_fork_copy__other"}), + ) + .send() + .await?; + assert_eq!(resp.status(), 400); + assert!( + resp.text().await?.contains("fork_behavior"), + "a database the fork did not create was taken" + ); + assert!(!workspace_exists(&db, "wm-fork-copy").await?); + + // The copy an admin asks for goes ahead in an edition that replays grants, and is refused + // before any database exists in one that does not. The fixture's database does not exist, so + // the dump fails either way — and leaves neither a database nor a fork behind. + let resp = fork( + "SECRET_TOKEN", + json!({"name": "main", "new_dbname": target, "fork_behavior": "schema_only"}), + ) + .send() + .await?; + assert_ne!(resp.status(), 200); + let body = resp.text().await?; + #[cfg(all(feature = "private", feature = "enterprise"))] + assert!(body.contains("pg_dump"), "{body}"); + #[cfg(not(all(feature = "private", feature = "enterprise")))] + assert!(body.contains("Enterprise Edition"), "{body}"); + assert!(!database_exists(&db, target).await?); + assert!(!workspace_exists(&db, "wm-fork-copy").await?); + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_clone_takes_its_roles_from_the_data_table_it_was_cloned_from( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + sqlx::query( + r#"UPDATE workspace_settings SET datatable = jsonb_set(datatable, '{datatables,copy}', '{ + "database": {"resource_type": "instance", "resource_path": "wm_fork_dt__copy"}, + "governed_by": {"workspace_id": "test-workspace", "datatable": "main"}, + "forked_from": {} + }'::jsonb) WHERE workspace_id = 'wm-fork-dt'"#, + ) + .execute(&db) + .await?; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let fork = format!("http://localhost:{port}/api/w/wm-fork-dt/workspaces"); + + // `test-user-2` administers the fork, and is only a tenant of `analytics` in the parent. + let resp = authed( + client().get(format!("{fork}/datatable_usable_roles/copy")), + "SECRET_TOKEN_2", + ) + .send() + .await?; + let body: Value = resp.json().await?; + assert_eq!(body["roles"], json!(["analytics"]), "{body}"); + + let resp = authed( + client().get(format!("{fork}/datatable_permissions/copy")), + "SECRET_TOKEN_2", + ) + .send() + .await?; + let body: Value = resp.json().await?; + assert_eq!(body["editable"], false, "{body}"); + assert_eq!(body["clone_of"]["workspace_id"], "test-workspace", "{body}"); + + // Nobody changes a clone's roles, a superadmin included: they are the source's. + for token in ["SECRET_TOKEN_2", "SECRET_TOKEN"] { + let resp = authed( + client().post(format!("{fork}/datatable_permissions/copy")), + token, + ) + .json(&json!({"permissioned": false})) + .send() + .await?; + assert_eq!(resp.status(), 400, "{token} changed a clone's roles"); + } + + // A settings save cannot clear the link. + let resp = authed( + client().post(format!("{fork}/edit_datatable_config")), + "SECRET_TOKEN_2", + ) + .json(&json!({ + "settings": {"datatables": {"main": {}, "copy": { + "database": {"resource_type": "instance", "resource_path": "wm_fork_dt__copy"} + }}}, + "renames": [], "deleted_datatables": [] + })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let governed_by: Option = sqlx::query_scalar( + "SELECT datatable->'datatables'->'copy'->'governed_by' FROM workspace_settings + WHERE workspace_id = 'wm-fork-dt'", + ) + .fetch_one(&db) + .await?; + assert_eq!(governed_by.unwrap()["datatable"], "main"); + + // Nor move it, a superadmin included: its grants were replayed into that database alone. + let resp = authed( + client().post(format!("{fork}/edit_datatable_config")), + "SECRET_TOKEN", + ) + .json(&json!({ + "settings": {"datatables": {"main": {}, "copy": { + "database": {"resource_type": "instance", "resource_path": "wm_fork_dt__elsewhere"} + }}}, + "renames": [], "deleted_datatables": [] + })) + .send() + .await?; + assert_eq!(resp.status(), 400, "{}", resp.text().await?); + + // Nor reach its database through a second entry without roles, which would connect as `admin`. + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/w/test-workspace/workspaces/edit_datatable_config" + )), + "SECRET_TOKEN", + ) + .json(&json!({ + "settings": {"datatables": { + "main": {"database": {"resource_type": "instance", "resource_path": "dt_main"}}, + "alias": {"database": {"resource_type": "instance", "resource_path": "wm_fork_dt__copy"}} + }}, + "renames": [], "deleted_datatables": [] + })) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!(status, 400, "{body}"); + assert!(body.contains("without carrying those roles"), "{body}"); + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_data_table_under_roles_is_not_copied_without_its_grants( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/w/test-workspace/workspaces/create_pg_database" + )), + "SECRET_TOKEN", + ) + .json(&json!({"source": "datatable://main", "target_dbname": "wm_fork_dt_copy"})) + .send() + .await?; + assert_eq!(resp.status(), 400); + assert!( + resp.text().await?.contains("under roles"), + "the fork's database was created for a copy that cannot happen" + ); + + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/w/test-workspace/workspaces/import_pg_database" + )), + "SECRET_TOKEN", + ) + .json( + &json!({"source": "datatable://main", "target": "datatable://main", + "fork_behavior": "schema_only"}), + ) + .send() + .await?; + assert_eq!(resp.status(), 400); + assert!( + resp.text().await?.contains("under roles"), + "the copy was refused for some other reason" + ); + Ok(()) +} + +/// The fork's `forked_from` for one of its entries; `None` whether it is absent or `null`. +async fn forked_from_of(db: &Pool, name: &str) -> Option { + sqlx::query_scalar::<_, Option>( + "SELECT datatable->'datatables'->$1::text->'forked_from' + FROM workspace_settings WHERE workspace_id = 'wm-fork-dt'", + ) + .bind(name) + .fetch_one(db) + .await + .unwrap() + .filter(|v| !v.is_null()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_clone_stamp_is_carried_but_its_schema_baseline_advances( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + // Whether an entry is a clone is what marks its database droppable, so a save can neither + // stamp nor unstamp one. The schema baseline inside the stamp is what the fork's schema diff + // advances after applying a change; dropping it would offer that same change again. + sqlx::query( + r#"UPDATE workspace_settings SET datatable = '{"datatables": { + "clone": {"database": {"resource_type": "instance", "resource_path": "wm_fork_dt__clone"}, + "forked_from": {"schema": {}}}, + "plain": {"database": {"resource_type": "instance", "resource_path": "dt_plain"}}}}'::jsonb + WHERE workspace_id = 'wm-fork-dt'"#, + ) + .execute(&db) + .await?; + let server = ApiServer::start(db.clone()).await?; + let url = format!( + "http://localhost:{}/api/w/wm-fork-dt/workspaces/edit_datatable_config", + server.addr.port() + ); + let clone_db = json!({"resource_type": "instance", "resource_path": "wm_fork_dt__clone"}); + let plain_db = json!({"resource_type": "instance", "resource_path": "dt_plain"}); + let baseline = json!({"schema": {"public": {"orders": {"id": "int4"}}}}); + + let resp = authed(client().post(&url), "SECRET_TOKEN_2") + .json(&json!({"settings": {"datatables": { + "clone": {"database": clone_db, "forked_from": baseline}, + "plain": {"database": plain_db, "forked_from": {"schema": {}}} + }}})) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + assert_eq!( + forked_from_of(&db, "clone").await, + Some(baseline.clone()), + "the schema diff's baseline did not advance" + ); + assert_eq!( + forked_from_of(&db, "plain").await, + None, + "a save stamped a clone" + ); + + let resp = authed(client().post(&url), "SECRET_TOKEN_2") + .json(&json!({"settings": {"datatables": { + "clone": {"database": clone_db}, "plain": {"database": plain_db} + }}})) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + assert_eq!( + forked_from_of(&db, "clone").await, + Some(baseline), + "a save unstamped a clone" + ); + Ok(()) +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn roles_cannot_be_turned_on_while_a_trigger_streams_the_data_table( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + // A replication stream reads every row whatever the roles grant, so a data table carries one + // or the other. An enabled trigger on it — here a fork's, through its pointer — keeps roles + // from being turned on, and disabling it is what lets them on. + sqlx::query( + "UPDATE workspace_settings SET datatable = datatable #- '{datatables,main,permissions}' + WHERE workspace_id = 'test-workspace'", + ) + .execute(&db) + .await?; + sqlx::query( + r#"INSERT INTO postgres_trigger (path, script_path, is_flow, workspace_id, edited_by, + postgres_resource_path, replication_slot_name, publication_name, permissioned_as, mode) + VALUES ('u/test-user-2/fork_stream', 'u/test-user-2/s', false, 'wm-fork-dt', + 'test-user-2', 'datatable://main', 'slot_fork', 'pub_fork', 'u/test-user-2', + 'enabled')"#, + ) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let url = format!( + "http://localhost:{}/api/w/test-workspace/workspaces/datatable_permissions/main", + server.addr.port() + ); + let turn_on = json!({"permissioned": true, "default_role": "admin", + "roles": [{"id": "admin", "tenants": ["*"]}]}); + + let resp = authed(client().post(&url), "SECRET_TOKEN") + .json(&turn_on) + .send() + .await?; + assert_eq!(resp.status(), 400); + assert!( + resp.text() + .await? + .contains("wm-fork-dt/u/test-user-2/fork_stream"), + "the refusal does not name the trigger to disable" + ); + + // Disabled, but its listener pinged just now and stops only at its next heartbeat. + sqlx::query( + "UPDATE postgres_trigger SET mode = 'disabled', server_id = NULL, last_server_ping = now() + WHERE path = 'u/test-user-2/fork_stream'", + ) + .execute(&db) + .await?; + let resp = authed(client().post(&url), "SECRET_TOKEN") + .json(&turn_on) + .send() + .await?; + assert_eq!( + resp.status(), + 400, + "roles went on while a disabled trigger's listener was still attached: {}", + resp.text().await? + ); + + sqlx::query( + "UPDATE postgres_trigger SET last_server_ping = now() - interval '20 seconds' + WHERE path = 'u/test-user-2/fork_stream'", + ) + .execute(&db) + .await?; + let resp = authed(client().post(&url), "SECRET_TOKEN") + .json(&turn_on) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + Ok(()) +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn roles_going_on_wait_for_a_trigger_being_enabled(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + sqlx::query( + "UPDATE workspace_settings SET datatable = datatable #- '{datatables,main,permissions}' + WHERE workspace_id = 'test-workspace'", + ) + .execute(&db) + .await?; + + // A trigger enable in flight: it holds the stream lock and its row is not committed yet, so a + // roles save that looked for streams now would miss it and its listener would connect to a + // data table it is about to be refused. + let mut enabling = db.begin().await?; + windmill_common::datatable_roles::lock_datatable_streams(&mut *enabling, false).await?; + sqlx::query( + r#"INSERT INTO postgres_trigger (path, script_path, is_flow, workspace_id, edited_by, + postgres_resource_path, replication_slot_name, publication_name, permissioned_as, mode) + VALUES ('u/test-user-2/racing_stream', 'u/test-user-2/s', false, 'wm-fork-dt', + 'test-user-2', 'datatable://main', 'slot_race', 'pub_race', 'u/test-user-2', + 'enabled')"#, + ) + .execute(&mut *enabling) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let url = format!( + "http://localhost:{}/api/w/test-workspace/workspaces/datatable_permissions/main", + server.addr.port() + ); + let save = tokio::spawn( + authed(client().post(&url), "SECRET_TOKEN") + .json(&json!({"permissioned": true, "default_role": "admin", + "roles": [{"id": "admin", "tenants": ["*"]}]})) + .send(), + ); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + assert!( + !save.is_finished(), + "roles went on while a trigger was being enabled" + ); + enabling.commit().await?; + + let resp = save.await??; + assert_eq!(resp.status(), 400); + assert!( + resp.text() + .await? + .contains("wm-fork-dt/u/test-user-2/racing_stream"), + "the roles save missed the trigger enabled while it waited" + ); + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_stored_name_containing_a_question_mark_resolves_as_itself( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + // Names could contain `?` before they were restricted, and such an entry is still stored. + sqlx::query( + "UPDATE workspace_settings + SET datatable = jsonb_set(datatable, '{datatables,legacy?dt}', datatable->'datatables'->'main') + WHERE workspace_id = 'test-workspace'", + ) + .execute(&db) + .await?; + + let resolve = |reference: &'static str| { + let db = db.clone(); + async move { + windmill_common::workspaces::parse_datatable_ref_for(&db, "test-workspace", reference) + .await + } + }; + assert_eq!(resolve("legacy?dt").await?, ("legacy?dt".to_string(), None)); + assert_eq!( + resolve("main?role=analytics").await?, + ("main".to_string(), Some("analytics".to_string())) + ); + assert!( + resolve("main?dt").await.is_err(), + "an unknown parameter was ignored" + ); + + sqlx::query( + "UPDATE workspace_settings + SET datatable = jsonb_set(datatable, '{datatables,main?role=analytics}', datatable->'datatables'->'main') + WHERE workspace_id = 'test-workspace'", + ) + .execute(&db) + .await?; + assert!( + resolve("main?role=analytics").await.is_err(), + "a reference naming both a stored data table and a role on another resolved to one of them" + ); + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_settings_save_dropping_a_governing_entry_names_the_forks_it_strands( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + // The whole map and no `deleted_datatables`, as a settings sync sends it. + let resp = authed( + client().post(format!( + "http://localhost:{}/api/w/test-workspace/workspaces/edit_datatable_config", + server.addr.port() + )), + "SECRET_TOKEN", + ) + .json(&json!({ "settings": { "datatables": {} } })) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!(status, 200, "{body}"); + let result: Value = serde_json::from_str(&body)?; + assert!( + result["stranded_references"] + .as_array() + .is_some_and(|refs| refs.iter().any(|r| r["workspace_id"] == "wm-fork-dt")), + "the fork left pointing at nothing was not named: {body}" + ); + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn an_entry_without_roles_cannot_newly_reach_a_database_under_roles( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + sqlx::query( + r#"UPDATE workspace_settings SET datatable = jsonb_set(datatable, '{datatables,other}', + '{"database": {"resource_type": "instance", "resource_path": "dt_other"}}') + WHERE workspace_id = 'test-workspace'"#, + ) + .execute(&db) + .await?; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + // Whole-map saves with no `renames`, as a settings sync sends them. + let dt_main = + json!({ "database": { "resource_type": "instance", "resource_path": "dt_main" } }); + let dt_other = + json!({ "database": { "resource_type": "instance", "resource_path": "dt_other" } }); + for (case, w_id, datatables) in [ + ( + "a rename to a new name", + "test-workspace", + json!({ "main_renamed": dt_main, "other": dt_other }), + ), + ( + "an existing name repointed", + "test-workspace", + json!({ "other": dt_main }), + ), + ( + "another workspace's entry", + "wm-fork-dt", + json!({ "direct": dt_main }), + ), + ] { + let resp = authed( + client().post(format!( + "http://localhost:{port}/api/w/{w_id}/workspaces/edit_datatable_config" + )), + "SECRET_TOKEN", + ) + .json(&json!({ "settings": { "datatables": datatables } })) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert!( + status == 400 && body.contains("which a data table under roles uses"), + "{case} reached the database under roles without them ({status}): {body}" + ); + } + + let still_governed: bool = sqlx::query_scalar( + "SELECT (datatable->'datatables'->'main') ? 'permissions' FROM workspace_settings + WHERE workspace_id = 'test-workspace'", + ) + .fetch_one(&db) + .await?; + assert!(still_governed, "the refused save still took effect"); + Ok(()) +} + +/// Browsing names the role it connects as, and a role the caller may not use is refused rather +/// than quietly listed as the default. The refusal is decided before connecting, so the fixture's +/// database never has to exist. +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn browsing_as_a_role_the_caller_may_not_use_is_refused( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces"); + + // `test-user-2` is a tenant of `analytics` only. + let resp = authed( + client().get(format!( + "{base}/list_datatable_tables?role_for=main&role=admin" + )), + "SECRET_TOKEN_2", + ) + .send() + .await?; + assert_eq!(resp.status(), 200); + let body: Value = resp.json().await?; + let entry = body + .as_array() + .and_then(|a| a.iter().find(|e| e["datatable_name"] == "main")) + .expect("main is listed"); + assert_eq!(entry["usable_roles"], json!(["analytics"]), "{entry}"); + assert_eq!(entry["default_role"], "analytics", "{entry}"); + assert_eq!(entry["permissioned"], true, "{entry}"); + assert_eq!(entry["instance"], true, "{entry}"); + let error = entry["error"].as_str().unwrap_or_default(); + assert!( + error.contains("Not allowed to use role 'admin'"), + "listed as another role than the one asked for: {entry}" + ); + + let resp = authed( + client().get(format!( + "{base}/get_datatable_table_schema?datatable_name=main&schema_name=public&table_name=t&role=admin" + )), + "SECRET_TOKEN_2", + ) + .send() + .await?; + let status = resp.status(); + let text = resp.text().await?; + assert!( + text.contains("Not allowed to use role 'admin'"), + "{status}: {text}" + ); + + // A role means nothing without the data table it belongs to. + let resp = authed( + client().get(format!("{base}/list_datatable_tables?role=analytics")), + "SECRET_TOKEN_2", + ) + .send() + .await?; + assert_eq!(resp.status(), 400, "{}", resp.text().await?); + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn an_alias_saved_elsewhere_waits_for_roles_going_on_for_its_database( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + sqlx::query( + r#"UPDATE workspace_settings SET datatable = jsonb_set(datatable, '{datatables,other}', + '{"database": {"resource_type": "instance", "resource_path": "dt_other"}}') + WHERE workspace_id = 'test-workspace'"#, + ) + .execute(&db) + .await?; + + // Roles going on for `dt_other`, not committed yet: it holds only its own workspace's settings + // row, so an alias saved from another workspace that looked for roles now would miss them. + let enabling = { + let mut tx = db.begin().await?; + windmill_common::datatable_roles::lock_instance_databases_governance( + &mut *tx, + ["dt_other"], + ) + .await?; + sqlx::query( + r#"UPDATE workspace_settings SET datatable = jsonb_set(datatable, + '{datatables,other,permissions}', + '{"default_role": "admin", "roles": {"admin": {"tenants": ["*"]}}}') + WHERE workspace_id = 'test-workspace'"#, + ) + .execute(&mut *tx) + .await?; + tx + }; + + let server = ApiServer::start(db.clone()).await?; + let url = format!( + "http://localhost:{}/api/w/wm-fork-dt/workspaces/edit_datatable_config", + server.addr.port() + ); + let save = tokio::spawn( + authed(client().post(&url), "SECRET_TOKEN") + .json(&json!({ "settings": { "datatables": { + "direct": { "database": { "resource_type": "instance", "resource_path": "dt_other" } } + } } })) + .send(), + ); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + assert!( + !save.is_finished(), + "an alias was saved while roles were going on for its database" + ); + enabling.commit().await?; + + let resp = save.await??; + let status = resp.status(); + let body = resp.text().await?; + assert!( + status == 400 && body.contains("which a data table under roles uses"), + "the alias reached the database whose roles went on while it waited ({status}): {body}" + ); + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_fork_waits_for_a_rename_of_the_data_table_it_keeps( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + // A rename in flight holds the parent's settings row and moves only the pointers it can see; a + // fork being created is invisible to it, so the fork has to read the name the rename commits. + let mut renaming = db.begin().await?; + sqlx::query( + "SELECT 1 FROM workspace_settings WHERE workspace_id = 'test-workspace' FOR UPDATE", + ) + .execute(&mut *renaming) + .await?; + sqlx::query( + "UPDATE workspace_settings SET datatable = jsonb_set(datatable #- '{datatables,main}', + '{datatables,renamed}', datatable->'datatables'->'main') + WHERE workspace_id = 'test-workspace'", + ) + .execute(&mut *renaming) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let url = format!( + "http://localhost:{}/api/w/test-workspace/workspaces/create_fork", + server.addr.port() + ); + let fork = tokio::spawn( + authed(client().post(&url), "SECRET_TOKEN") + .json(&json!({ "id": "wm-fork-race", "name": "race", "color": "#0000ff" })) + .send(), + ); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + assert!( + !fork.is_finished(), + "the fork copied the parent's data tables while a rename held them" + ); + renaming.commit().await?; + + let resp = fork.await??; + assert!(resp.status().is_success(), "{}", resp.text().await?); + let datatables: Option = sqlx::query_scalar( + "SELECT datatable->'datatables' FROM workspace_settings WHERE workspace_id = 'wm-fork-race'", + ) + .fetch_one(&db) + .await?; + let datatables = datatables.unwrap(); + assert_eq!( + datatables["renamed"]["reference"], + json!({ "workspace_id": "test-workspace", "datatable": "renamed" }), + "{datatables}" + ); + Ok(()) +} + +async fn enable_migrations_on_main(db: &Pool) -> anyhow::Result<()> { + sqlx::query( + "UPDATE workspace_settings + SET datatable = jsonb_set(datatable, '{datatables,main,migrations_enabled}', 'true') + WHERE workspace_id = 'test-workspace'", + ) + .execute(db) + .await?; + Ok(()) +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn a_migration_is_written_only_by_who_may_run_it(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + enable_migrations_on_main(&db).await?; + let server = ApiServer::start(db.clone()).await?; + let base = format!( + "http://localhost:{}/api/w/test-workspace/workspaces", + server.addr.port() + ); + let create = |token: &str, body: Value| { + authed( + client() + .post(format!("{base}/create_datatable_migration/main")) + .json(&body), + token, + ) + .send() + }; + let upsert = |token: &str, code_up: &str| { + authed( + client() + .post(format!("{base}/upsert_datatable_migration/main")) + .json(&json!({"timestamp": 20260101000000i64, "name": "m", "code_up": code_up})), + token, + ) + .send() + }; + + // `test-user-2` holds `analytics` and nothing else, so of a migration they may store only + // what they could run: SQL that declares no role runs as admin, in the up or the down. + for body in [ + json!({"name": "m", "code_up": "DROP TABLE t"}), + json!({"name": "m", "code_up": "-- role analytics\nSELECT 1", "code_down": "DROP TABLE t"}), + ] { + let resp = create("SECRET_TOKEN_2", body.clone()).await?; + assert_eq!(resp.status(), 401, "{body}: {}", resp.text().await?); + } + let resp = create( + "SECRET_TOKEN_2", + json!({"name": "m", "code_up": "-- role analytics\nSELECT 1"}), + ) + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + // An admin's migration re-pushed as stored writes nothing, so it passes. Replacing it removes + // SQL they could not run, even with SQL they could, so it is refused like deleting it. + let resp = upsert("SECRET_TOKEN", "DROP TABLE t").await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + let resp = upsert("SECRET_TOKEN_2", "DROP TABLE t").await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + for code_up in ["DROP TABLE u", "-- role analytics\nSELECT 1"] { + let resp = upsert("SECRET_TOKEN_2", code_up).await?; + assert_eq!(resp.status(), 401, "{code_up}: {}", resp.text().await?); + } + let resp = authed( + client().delete(format!( + "{base}/delete_datatable_migration/main/20260101000000" + )), + "SECRET_TOKEN_2", + ) + .send() + .await?; + assert_eq!(resp.status(), 401, "{}", resp.text().await?); + + let code_up: String = sqlx::query_scalar( + "SELECT code_up FROM datatable_migrations + WHERE workspace_id = 'test-workspace' AND timestamp = 20260101000000", + ) + .fetch_one(&db) + .await?; + assert_eq!(code_up, "DROP TABLE t"); + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn an_operator_neither_writes_nor_runs_a_migration(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + enable_migrations_on_main(&db).await?; + // Off roles, where every role check passes everyone, so only the operator guard is left to + // refuse. + sqlx::query( + "UPDATE workspace_settings SET datatable = datatable #- '{datatables,main,permissions}' + WHERE workspace_id = 'test-workspace'", + ) + .execute(&db) + .await?; + // A token of its own: the auth cache is process-wide, and the other tests here authenticate + // `SECRET_TOKEN_3` in this workspace as a non-operator. + sqlx::raw_sql( + "UPDATE usr SET operator = true, role = 'Operator' + WHERE workspace_id = 'test-workspace' AND username = 'test-user-3'; + INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin) + VALUES (encode(sha256('DT_OPERATOR_TOKEN'::bytea), 'hex'), 'DT_OPERATO', + 'DT_OPERATOR_TOKEN', 'test3@windmill.dev', 'operator', false);", + ) + .execute(&db) + .await?; + let server = ApiServer::start(db.clone()).await?; + let base = format!( + "http://localhost:{}/api/w/test-workspace/workspaces", + server.addr.port() + ); + let migration = json!({"timestamp": 20260101000000i64, "name": "m", "code_up": "SELECT 1"}); + + for request in [ + client() + .post(format!("{base}/create_datatable_migration/main")) + .json(&migration), + client() + .post(format!("{base}/upsert_datatable_migration/main")) + .json(&migration), + client().delete(format!( + "{base}/delete_datatable_migration/main/20260101000000" + )), + client().post(format!("{base}/run_datatable_migrations/main")), + client().post(format!("{base}/rollback_datatable_migrations/main")), + client().post(format!("{base}/generate_initial_datatable_migration/main")), + ] { + let resp = authed(request, "DT_OPERATOR_TOKEN").send().await?; + let status = resp.status(); + let text = resp.text().await?; + assert!( + status == 401 && text.contains("Operators cannot"), + "{status}: {text}" + ); + } + + // The same write from a developer is accepted: what refused the operator is theirs alone. + let resp = authed( + client() + .post(format!("{base}/upsert_datatable_migration/main")) + .json(&migration), + "SECRET_TOKEN_2", + ) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + Ok(()) +} + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +const ENTERPRISE_REFUSAL: &str = "Data table roles are a Windmill Enterprise Edition feature"; + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn every_roles_route_is_an_enterprise_feature(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let api = format!("http://localhost:{}/api", server.addr.port()); + let dt = format!("{api}/w/test-workspace/workspaces"); + + for (method, url, body) in [ + ( + reqwest::Method::GET, + format!("{api}/settings/datatable_roles"), + None, + ), + ( + reqwest::Method::POST, + format!("{api}/settings/datatable_roles"), + Some(json!({ "name": "wmtest_ce" })), + ), + ( + reqwest::Method::POST, + format!("{api}/settings/datatable_roles/role1"), + Some(json!({ "enabled": false })), + ), + ( + reqwest::Method::DELETE, + format!("{api}/settings/datatable_roles/role1"), + None, + ), + ( + reqwest::Method::GET, + format!("{dt}/datatable_permissions/main"), + None, + ), + ( + reqwest::Method::POST, + format!("{dt}/datatable_permissions/main"), + Some(json!({ "permissioned": false })), + ), + ( + reqwest::Method::GET, + format!("{dt}/datatable_usable_roles/main"), + None, + ), + ] { + let mut request = authed(client().request(method.clone(), &url), "SECRET_TOKEN"); + if let Some(body) = body { + request = request.json(&body); + } + let resp = request.send().await?; + let status = resp.status(); + let text = resp.text().await?; + assert!( + status == 400 && text.contains(ENTERPRISE_REFUSAL), + "{method} {url} answered {status}: {text}" + ); + } + + // Refused, not acted on: the catalog row and the data table's roles are where they were. + let untouched: (i64, bool) = sqlx::query_as( + "SELECT (SELECT count(*) FROM datatable_role), + (datatable->'datatables'->'main') ? 'permissions' + FROM workspace_settings WHERE workspace_id = 'test-workspace'", + ) + .fetch_one(&db) + .await?; + assert_eq!(untouched, (1, true)); + Ok(()) +} + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn without_the_enterprise_edition_a_data_table_under_roles_is_refused_a_connection( + db: Pool, +) -> anyhow::Result<()> { + use windmill_common::workspaces::{get_datatable_resource_from_db, DatatableAccess}; + initialize_tracing().await; + + // Saved under roles, as an enterprise build left it: refused whoever asks, never `admin`. + for access in [DatatableAccess::Unchecked, DatatableAccess::NoIdentity] { + let err = get_datatable_resource_from_db(&db, "test-workspace", "main", None, access) + .await + .expect_err("a data table under roles resolved"); + assert!(err.to_string().contains(ENTERPRISE_REFUSAL), "{err}"); + } + + // Not under roles, it resolves as it always has, including when `admin` is named — which every + // migration does; naming any other role on it is refused. + sqlx::query( + "UPDATE workspace_settings SET datatable = datatable #- '{datatables,main,permissions}' + WHERE workspace_id = 'test-workspace'", + ) + .execute(&db) + .await?; + for role in [None, Some("admin")] { + let resolved = get_datatable_resource_from_db( + &db, + "test-workspace", + "main", + role, + DatatableAccess::NoIdentity, + ) + .await?; + assert_eq!(resolved["dbname"], "dt_main", "{resolved}"); + } + let err = get_datatable_resource_from_db( + &db, + "test-workspace", + "main", + Some("analytics"), + DatatableAccess::Unchecked, + ) + .await + .expect_err("a named role resolved"); + assert!(err.to_string().contains(ENTERPRISE_REFUSAL), "{err}"); + Ok(()) +} + +/// A save blocked on an instance database's lock is not a user of it until it commits one: the +/// cleanup for a database whose setup failed lets that save through and reads what it left. +#[sqlx::test(migrations = "../migrations", fixtures("base", "datatable_roles"))] +async fn cleanup_waits_out_a_save_racing_it_for_an_instance_database( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let key = "instance_database:dt_probe"; + + // Queues cleanup behind `holder`, then a save behind cleanup, so cleanup takes the lock with + // the save already waiting on it — the ordering the waiter check is for. + let race = |commit: bool| { + let db = db.clone(); + async move { + let mut holder = db.acquire().await?; + sqlx::query("SELECT pg_advisory_lock(hashtext($1))") + .bind(key) + .execute(&mut *holder) + .await?; + + let cleanup = tokio::spawn({ + let db = db.clone(); + async move { windmill_common::drop_unused_instance_database(&db, "dt_probe").await } + }); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + let save = tokio::spawn({ + let db = db.clone(); + async move { + let mut tx = db.begin().await?; + windmill_common::lock_instance_databases(&mut tx, ["dt_probe"]).await?; + sqlx::query( + r#"UPDATE workspace_settings SET datatable = jsonb_set(datatable, + '{datatables,probe}', + '{"database": {"resource_type": "instance", "resource_path": "dt_probe"}}') + WHERE workspace_id = 'test-workspace'"#, + ) + .execute(&mut *tx) + .await?; + if commit { + tx.commit().await?; + } else { + tx.rollback().await?; + } + Ok::<_, anyhow::Error>(()) + } + }); + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + + sqlx::query("SELECT pg_advisory_unlock(hashtext($1))") + .bind(key) + .execute(&mut *holder) + .await?; + save.await??; + Ok::<_, anyhow::Error>(cleanup.await??) + } + }; + + assert!( + matches!(race(false).await?, windmill_common::Cleanup::Dropped), + "a save that rolled back kept the database, whose name then blocks every retry" + ); + let kept = race(true).await?; + let windmill_common::Cleanup::InUse(users) = kept else { + anyhow::bail!("the database was dropped under a save that committed a reference to it") + }; + assert_eq!(users, vec!["test-workspace".to_string()]); + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/fixtures/datatable_roles.sql b/backend/windmill-api-integration-tests/tests/fixtures/datatable_roles.sql new file mode 100644 index 0000000000..55d50d212c --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/fixtures/datatable_roles.sql @@ -0,0 +1,45 @@ +-- A data table under roles in `test-workspace`, and a fork whose entry points at it rather than +-- carrying a copy. `test-user-2` is a non-admin of the parent and an admin of the fork: the shape +-- the pointer exists for. + +-- Empty registry: role provisioning grants CONNECT on every database named here, and the data +-- table's `dt_main` is a name in workspace settings, not a database that exists. +INSERT INTO global_settings (name, value) VALUES + ('custom_instance_pg_databases', '{"user_pwd": "pw", "databases": {}}'::jsonb) + ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value; + +INSERT INTO datatable_role (id, name, enabled, pwd) VALUES ('role1', 'analytics', true, 'pw'); + +UPDATE workspace_settings SET datatable = '{ + "datatables": { + "main": { + "database": {"resource_type": "instance", "resource_path": "dt_main"}, + "permissions": { + "default_role": "role1", + "roles": { + "admin": {"tenants": []}, + "role1": {"tenants": ["u/test-user-2", "g/analysts", "f/finance"]} + } + } + } + } +}'::jsonb WHERE workspace_id = 'test-workspace'; + +INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES + ('test-workspace', 'analysts', 'Analysts', '{}'); +INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms) VALUES + ('test-workspace', 'finance', 'finance', '{}', '{}'); + +INSERT INTO workspace (id, name, owner, parent_workspace_id) VALUES + ('wm-fork-dt', 'fork of test-workspace', 'test2@windmill.dev', 'test-workspace'); +INSERT INTO workspace_key (workspace_id, kind, key) VALUES ('wm-fork-dt', 'cloud', 'test-key'); +INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES + ('wm-fork-dt', 'all', 'All users', '{}'); +INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES + ('wm-fork-dt', 'test2@windmill.dev', 'test-user-2', true, 'Admin'); + +INSERT INTO workspace_settings (workspace_id, datatable) VALUES ('wm-fork-dt', '{ + "datatables": { + "main": {"reference": {"workspace_id": "test-workspace", "datatable": "main"}} + } +}'::jsonb); diff --git a/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs b/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs index dae02f3e5a..bc6c10f1ee 100644 --- a/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs +++ b/backend/windmill-api-integration-tests/tests/fork_clone_on_behalf_of.rs @@ -311,3 +311,130 @@ async fn test_fork_keeps_only_resolvable_on_behalf_of(db: Pool) -> any Ok(()) } + +/// Apps, schedules, triggers and their drafts cannot drop an identity the way scripts and flows +/// do, so one naming nobody in the fork goes to its creator while one that still resolves stays. +/// Forked as an admin, whose app policies the clone otherwise keeps. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_fork_repoints_unresolvable_identities(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let base_url = format!("http://localhost:{}/api", server.addr.port()); + + let stranger = json!({ + "on_behalf_of": "u/test-user-2", + "on_behalf_of_email": "test2@windmill.dev", + "execution_mode": "publisher", + }); + sqlx::query( + "INSERT INTO app (workspace_id, path, summary, policy, versions) + VALUES ('test-workspace', 'u/test-user/stranger', '', $1, '{}'), + ('test-workspace', 'u/test-user/group', '', $2, '{}')", + ) + .bind(&stranger) + .bind(json!({ + "on_behalf_of": "g/all", + "on_behalf_of_email": "group-all@windmill.dev", + "execution_mode": "publisher", + })) + .execute(&db) + .await?; + // The clone re-aggregates `versions` from `app_version`, and the column is NOT NULL. + sqlx::query( + "WITH v AS ( + INSERT INTO app_version (app_id, value, created_by) + SELECT id, '{}'::json, 'test-user' FROM app WHERE workspace_id = 'test-workspace' + RETURNING id, app_id + ) + UPDATE app SET versions = ARRAY[v.id] FROM v WHERE app.id = v.app_id", + ) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO draft (workspace_id, path, typ, value, created_at, email) + VALUES ('test-workspace', 'u/test-user/stranger', 'raw_app', $1::json, NOW(), 'test@windmill.dev'), + ('test-workspace', 'u/test-user/stranger', 'trigger_websocket', $2::json, NOW(), 'test@windmill.dev'), + ('test-workspace', 'u/test-user/nul', 'raw_app', $3::json, NOW(), 'test@windmill.dev')", + ) + .bind(json!({ "policy": stranger })) + .bind(json!({ "permissioned_as": "u/test-user-2" })) + // Saved before drafts were stripped of NULs: any jsonb parse of it raises, so it must be + // skipped rather than abort the fork. Built from parts because a NUL escape can't sit in source. + .bind(format!( + r#"{{"policy":{{"on_behalf_of":"u/test-user-2"}},"files":{{"f":"a{}u0000"}}}}"#, + "\\" + )) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO schedule (workspace_id, path, edited_by, schedule, script_path, email, permissioned_as, enabled) + VALUES ('test-workspace', 'u/test-user/stranger', 'test-user', '0 0 * * * *', 'u/test-user/s', 'test2@windmill.dev', 'u/test-user-2', false)", + ) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO websocket_trigger (workspace_id, path, url, script_path, is_flow, edited_by, permissioned_as, mode) + VALUES ('test-workspace', 'u/test-user/stranger', 'ws://localhost', 'u/test-user/s', false, 'test-user', 'u/test-user-2', 'disabled')", + ) + .execute(&db) + .await?; + + let resp = reqwest::Client::new() + .post(format!( + "{base_url}/w/test-workspace/workspaces/create_fork" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "id": "wm-fork-repoint", "name": "Fork", "color": "#0000ff" })) + .send() + .await?; + assert!( + resp.status().is_success(), + "creating the fork: {}", + resp.text().await? + ); + + let text = |sql: &'static str| sqlx::query_scalar::<_, String>(sql).fetch_one(&db); + assert_eq!( + text("SELECT (policy->>'on_behalf_of') || ' ' || (policy->>'on_behalf_of_email') FROM app WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/stranger'").await?, + "u/test-user test@windmill.dev" + ); + assert_eq!( + text("SELECT policy->>'on_behalf_of' FROM app WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/group'").await?, + "g/all" + ); + assert_eq!( + text("SELECT value->'policy'->>'on_behalf_of' FROM draft WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/stranger' AND typ = 'raw_app'").await?, + "u/test-user" + ); + // `clone_drafts` strips a NUL escape as it copies, so the row reaches the fork + // parseable and the repoint below reaches it like any other draft's. The rule this + // guards is that the fork completes and no identity naming nobody survives it; the + // skip only ever existed because `to_jsonb` raises on a value still holding one. + assert_eq!( + text("SELECT CASE WHEN strpos(value::text, 'u/test-user-2') > 0 THEN 'kept' ELSE 'rewritten' END FROM draft WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/nul'").await?, + "rewritten" + ); + // And it arrives without the poison that made it a special case. + assert_eq!( + text("SELECT CASE WHEN position(chr(92) || 'u0000' in value::text) > 0 THEN 'poisoned' ELSE 'clean' END FROM draft WHERE workspace_id = 'wm-fork-repoint' AND path = 'u/test-user/nul'").await?, + "clean" + ); + assert_eq!( + text("SELECT value->>'permissioned_as' FROM draft WHERE workspace_id = 'wm-fork-repoint' AND typ = 'trigger_websocket'").await?, + "u/test-user" + ); + assert_eq!( + text("SELECT permissioned_as || ' ' || email FROM schedule WHERE workspace_id = 'wm-fork-repoint'").await?, + "u/test-user test@windmill.dev" + ); + assert_eq!( + text( + "SELECT permissioned_as FROM websocket_trigger WHERE workspace_id = 'wm-fork-repoint'" + ) + .await?, + "u/test-user" + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/fork_members.rs b/backend/windmill-api-integration-tests/tests/fork_members.rs new file mode 100644 index 0000000000..5f4baced1f --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/fork_members.rs @@ -0,0 +1,75 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; + +use windmill_test_utils::*; + +/// With `add_admins_and_developers_to_forks` on, a fork starts with the parent's admins and +/// developers at their parent role, even when a developer forks it; operators are left out. The +/// copies are manual members: a parent membership that came from an instance group must not carry +/// that provenance into a fork that does not configure the group. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_fork_adds_parent_admins_and_developers(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base_url = format!( + "http://localhost:{}/api/w/test-workspace/workspaces", + server.addr.port() + ); + let client = reqwest::Client::new(); + + sqlx::query( + "UPDATE usr SET operator = true WHERE workspace_id = 'test-workspace' AND username = 'test-user-3'", + ) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO usr (workspace_id, email, username, is_admin, added_via) + VALUES ('test-workspace', 'test4@windmill.dev', 'test-user-4', false, + '{\"source\": \"instance_group\", \"group\": \"devs\"}')", + ) + .execute(&db) + .await?; + + let resp = client + .post(format!( + "{base_url}/edit_add_admins_and_developers_to_forks" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "add_admins_and_developers_to_forks": true })) + .send() + .await?; + assert!( + resp.status().is_success(), + "enabling the setting: {}", + resp.text().await? + ); + + let resp = client + .post(format!("{base_url}/create_fork")) + .header("Authorization", "Bearer SECRET_TOKEN_2") + .json(&json!({ "id": "wm-fork-team", "name": "Team fork" })) + .send() + .await?; + assert!( + resp.status().is_success(), + "creating the fork: {}", + resp.text().await? + ); + + let members: Vec<(String, bool, bool)> = sqlx::query_as( + "SELECT username, is_admin, added_via IS NULL FROM usr + WHERE workspace_id = 'wm-fork-team' ORDER BY username", + ) + .fetch_all(&db) + .await?; + assert_eq!( + members, + vec![ + ("test-user".to_string(), true, true), + ("test-user-2".to_string(), false, true), + ("test-user-4".to_string(), false, true), + ] + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/max_token_expiration.rs b/backend/windmill-api-integration-tests/tests/max_token_expiration.rs new file mode 100644 index 0000000000..b748ce49a1 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/max_token_expiration.rs @@ -0,0 +1,239 @@ +//! `max_token_expiration_days`: the instance-wide ceiling on how far ahead a token a caller +//! picks the lifetime of may expire, and the service-account exemption. + +use serde_json::json; +use sqlx::types::chrono::{DateTime, Utc}; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +const DAY: u64 = 24 * 60 * 60; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn from_now(secs: u64) -> DateTime { + Utc::now() + std::time::Duration::from_secs(secs) +} + +async fn set_max(db: &Pool, value: serde_json::Value) { + sqlx::query( + "INSERT INTO global_settings (name, value) VALUES ('max_token_expiration_days', $1) + ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value", + ) + .bind(value) + .execute(db) + .await + .unwrap(); +} + +/// Mints as `test2@windmill.dev`, a plain member of `test-workspace`. +async fn create_token(port: u16, body: serde_json::Value) -> reqwest::Response { + client() + .post(format!("http://localhost:{port}/api/users/tokens/create")) + .header("Authorization", "Bearer SECRET_TOKEN_2") + .json(&body) + .send() + .await + .unwrap() +} + +async fn stored_expiration(db: &Pool, label: &str) -> Option> { + sqlx::query_scalar::<_, Option>>("SELECT expiration FROM token WHERE label = $1") + .bind(label) + .fetch_one(db) + .await + .unwrap() +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_max_token_expiration_days_shortens_user_tokens( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + let resp = create_token(port, json!({ "label": "unset" })).await; + assert_eq!(resp.status(), 201); + assert_eq!( + stored_expiration(&db, "unset").await, + None, + "with no setting a token may still have no expiration" + ); + + // Refused at write time: the token routes can only read a value they cannot parse as no + // ceiling at all. + let resp = client() + .post(format!( + "http://localhost:{port}/api/settings/global/max_token_expiration_days" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "value": 7.5 })) + .send() + .await?; + assert_eq!(resp.status(), 400); + + set_max(&db, json!(7)).await; + + // The token form reads the ceiling as whoever is creating the token, usually not a + // superadmin, so it can offer only expirations the server would keep. + let resp = client() + .get(format!( + "http://localhost:{port}/api/settings/global/max_token_expiration_days" + )) + .header("Authorization", "Bearer SECRET_TOKEN_2") + .send() + .await?; + assert_eq!(resp.status(), 200); + assert_eq!(resp.text().await?, "7"); + + let resp = create_token(port, json!({ "label": "none asked" })).await; + assert_eq!(resp.status(), 201); + let expiration = stored_expiration(&db, "none asked") + .await + .expect("a token asking for no expiration gets the ceiling"); + assert!( + expiration > from_now(6 * DAY) && expiration <= from_now(7 * DAY), + "expected the 7 day ceiling, got {expiration}" + ); + + let resp = create_token( + port, + json!({ "label": "past the ceiling", "expiration": from_now(30 * DAY) }), + ) + .await; + assert_eq!(resp.status(), 201); + let expiration = stored_expiration(&db, "past the ceiling").await.unwrap(); + assert!( + expiration > from_now(6 * DAY) && expiration <= from_now(7 * DAY), + "expected an expiration past the ceiling to be shortened to it, got {expiration}" + ); + + let resp = create_token( + port, + json!({ "label": "within", "expiration": from_now(3 * DAY) }), + ) + .await; + assert_eq!(resp.status(), 201); + let expiration = stored_expiration(&db, "within").await.unwrap(); + assert!( + expiration <= from_now(3 * DAY), + "an expiration within the ceiling must be kept, got {expiration}" + ); + + // The settings UI stores an integer, but the YAML instance config and config sync can write + // the same whole number as a string or as `5.0`. Reading either as "unset" would silently + // drop the ceiling, while the token form (`parseMaxTokenExpirationDays`) still showed it. + for (stored, label) in [ + (json!("5"), "string setting"), + (json!(5.0), "float setting"), + ] { + set_max(&db, stored).await; + let resp = create_token(port, json!({ "label": label })).await; + assert_eq!(resp.status(), 201); + let expiration = stored_expiration(&db, label).await; + assert!( + expiration.is_some_and(|e| e > from_now(4 * DAY) && e <= from_now(5 * DAY)), + "{label}: expected the 5 day ceiling, got {expiration:?}" + ); + } + + // A superadmin impersonating a user picks the lifetime too, so the ceiling applies there; + // left out, it would be the one way to mint a token that never expires. + let resp = client() + .post(format!( + "http://localhost:{port}/api/users/tokens/impersonate" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "label": "impersonated", "impersonate_email": "test3@windmill.dev" })) + .send() + .await?; + assert_eq!(resp.status(), 201); + assert!( + stored_expiration(&db, "impersonated").await.is_some(), + "an impersonation token asking for no expiration gets the ceiling" + ); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_service_accounts_are_exempt(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + set_max(&db, json!(7)).await; + // The same email is a service account in one workspace and an ordinary user in another. + sqlx::query( + "UPDATE usr SET is_service_account = true + WHERE email = 'test2@windmill.dev' AND workspace_id = 'test-workspace'", + ) + .execute(&db) + .await?; + sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('other', 'other', 'test-user')") + .execute(&db) + .await?; + sqlx::query("INSERT INTO workspace_settings (workspace_id) VALUES ('other')") + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO usr (workspace_id, email, username, is_admin, role) + VALUES ('other', 'test2@windmill.dev', 'test-user-2', false, 'User')", + ) + .execute(&db) + .await?; + + // The token form decides the exemption from this list, so it must carry each membership's flag. + let memberships: serde_json::Value = client() + .get(format!("http://localhost:{port}/api/workspaces/users")) + .header("Authorization", "Bearer SECRET_TOKEN_2") + .send() + .await? + .json() + .await?; + for (workspace, is_service_account) in [("test-workspace", true), ("other", false)] { + let membership = memberships["workspaces"] + .as_array() + .and_then(|ws| ws.iter().find(|w| w["id"] == workspace)) + .unwrap_or_else(|| panic!("{workspace} missing from {memberships}")); + assert_eq!(membership["is_service_account"], json!(is_service_account)); + } + + for (label, workspace_id, exempt) in [ + ("own workspace", Some("test-workspace"), true), + ("other workspace", Some("other"), false), + // A workspace-less token has no workspace to match, so a service account anywhere counts. + ("global", None, true), + ] { + let resp = create_token( + port, + json!({ "label": label, "workspace_id": workspace_id }), + ) + .await; + assert_eq!(resp.status(), 201); + assert_eq!( + stored_expiration(&db, label).await.is_none(), + exempt, + "{label}: expected exempt = {exempt}" + ); + } + + // Impersonation checks the impersonated account, not the superadmin minting the token. + let resp = client() + .post(format!( + "http://localhost:{port}/api/users/tokens/impersonate" + )) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&json!({ "label": "impersonated service account", "impersonate_email": "test2@windmill.dev" })) + .send() + .await?; + assert_eq!(resp.status(), 201); + assert_eq!( + stored_expiration(&db, "impersonated service account").await, + None + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/mcp_token_query_param.rs b/backend/windmill-api-integration-tests/tests/mcp_token_query_param.rs new file mode 100644 index 0000000000..d0d6e070f6 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/mcp_token_query_param.rs @@ -0,0 +1,102 @@ +//! The `mcp_disable_token_query_param` switch closes the URL-borne credential path. +//! +//! The rejection is a middleware layered between the `WWW-Authenticate` decorator and +//! everything that reads a token, on both the workspaced and the gateway mount. Each half of +//! that sandwich is pinned: the `WWW-Authenticate` header on the refusal catches the layer +//! being moved outward (a client would lose the pointer that starts OAuth discovery), and +//! refusing a token that was never valid catches it being moved inward past authentication +//! (the URL-borne token would be hashed and looked up before anything refused it). +#![cfg(feature = "mcp")] + +use std::sync::atomic::Ordering; + +use serde_json::json; +use sqlx::{Pool, Postgres}; + +use windmill_common::global_settings::MCP_DISABLE_TOKEN_QUERY_PARAM; +use windmill_test_utils::*; + +/// Workspace-less with an `mcp:` scope, which is what the gateway mount requires; the +/// workspaced mount takes its workspace from the path, so one token reaches both. +async fn insert_mcp_token(db: &Pool) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes) + VALUES (encode(sha256('MCP_TOKEN'::bytea), 'hex'), 'MCP_TOK', 'MCP_TOKEN', 'test@windmill.dev', 'mcp token', true, ARRAY['mcp:all'])", + ) + .execute(db) + .await?; + Ok(()) +} + +/// A token that is not in `token` at all. Authentication would refuse it on its own, so a +/// refusal carrying the middleware's own wording is evidence nothing looked it up first. +const BOGUS_TOKEN: &str = "NOT_A_REAL_TOKEN"; + +async fn tools_list(url: &str) -> anyhow::Result { + Ok(reqwest::Client::new() + .post(url) + .header("Accept", "application/json, text/event-stream") + .json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} })) + .send() + .await?) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_mcp_token_query_param_switch(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + set_jwt_secret().await; + insert_mcp_token(&db).await?; + let server = ApiServer::start_mcp(db.clone()).await?; + let port = server.addr.port(); + let workspaced = + format!("http://localhost:{port}/api/mcp/w/test-workspace/mcp?token=MCP_TOKEN"); + let gateway = format!("http://localhost:{port}/api/mcp/gateway?token=MCP_TOKEN"); + + assert_eq!( + tools_list(&workspaced).await?.status(), + 200, + "a URL-borne token is the documented default and must keep working while the switch is off" + ); + assert_eq!(tools_list(&gateway).await?.status(), 200); + + MCP_DISABLE_TOKEN_QUERY_PARAM.store(true, Ordering::Relaxed); + + for url in [&workspaced, &gateway] { + let resp = tools_list(url).await?; + assert_eq!( + resp.status(), + 401, + "{url} still admitted a token in the URL" + ); + // What sends the client into the OAuth flow rather than leaving it stuck on a 401. + assert!( + resp.headers().contains_key("www-authenticate"), + "{url} rejected without pointing at the authorization server" + ); + } + + // Refused before authentication, not after: an invalid token gets the middleware's own + // message rather than the generic 401 that looking it up would produce. + let resp = tools_list(&format!( + "http://localhost:{port}/api/mcp/w/test-workspace/mcp?token={BOGUS_TOKEN}" + )) + .await?; + assert_eq!(resp.status(), 401); + assert!( + resp.text().await?.contains("does not accept a token in the MCP URL"), + "an invalid URL token was answered by authentication, so the token was read before \ + the switch refused it" + ); + + // The header stays open: it is the channel the OAuth flow itself hands tokens over on. + let resp = reqwest::Client::new() + .post(format!("http://localhost:{port}/api/mcp/gateway")) + .header("Accept", "application/json, text/event-stream") + .header("Authorization", "Bearer MCP_TOKEN") + .json(&json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} })) + .send() + .await?; + assert_eq!(resp.status(), 200); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/native_triggers.rs b/backend/windmill-api-integration-tests/tests/native_triggers.rs index 059b3aa2d2..e7f0147323 100644 --- a/backend/windmill-api-integration-tests/tests/native_triggers.rs +++ b/backend/windmill-api-integration-tests/tests/native_triggers.rs @@ -12,7 +12,7 @@ use serde_json::json; use sqlx::{Pool, Postgres}; use axum::http::StatusCode; -use windmill_api_auth::ApiAuthed; +use windmill_api_auth::{ApiAuthed, ScopePathFilter}; use windmill_common::{ error::Error, variables::{build_crypt, encrypt}, @@ -661,6 +661,7 @@ async fn test_rename_moves_native_trigger(db: Pool) -> anyhow::Result< None, Some("f/test/after"), Some(false), + ScopePathFilter::AllowAll, ) .await?; assert_eq!( @@ -672,6 +673,55 @@ async fn test_rename_moves_native_trigger(db: Pool) -> anyhow::Result< Ok(()) } +// ============================================================================ +// 6. Token scope filtering on list +// ============================================================================ + +/// A `native_triggers:read:f/test/*` token must only list triggers under `f/test/`, on the `/` +/// boundary (`f/testx/` is out of scope). +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_list_native_triggers_scope_filter(db: Pool) -> anyhow::Result<()> { + for (i, path) in ["f/testx/out", "f/test/in"].into_iter().enumerate() { + insert_test_script(&db, path).await?; + store_native_trigger( + &db, + "test-workspace", + ServiceName::Nextcloud, + &format!("ext-{i}"), + &NativeTriggerConfig { + script_path: path.to_string(), + is_flow: false, + webhook_token: format!("abcdefghij123456789{i}"), + }, + json!({"event": "OCP\\Files\\Events\\Node\\NodeCreatedEvent"}), + None, + true, + ) + .await?; + } + + let triggers = list_native_triggers( + &db, + "test-workspace", + ServiceName::Nextcloud, + None, + None, + None, + None, + ScopePathFilter::Restricted { exact: vec![], prefix: vec!["f/test".to_string()] }, + ) + .await?; + assert_eq!( + triggers + .iter() + .map(|t| t.script_path.as_str()) + .collect::>(), + vec!["f/test/in"] + ); + + Ok(()) +} + // --- parse_stop_channel_params --- #[test] diff --git a/backend/windmill-api-integration-tests/tests/remote_deploy.rs b/backend/windmill-api-integration-tests/tests/remote_deploy.rs new file mode 100644 index 0000000000..27447c3095 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/remote_deploy.rs @@ -0,0 +1,297 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; + +use windmill_common::variables::{build_crypt, encrypt}; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +/// The deploy target is this very server, which the proxy has no way to tell from another +/// instance: it only ever knows the configured URL, the caller's stored token, and the path. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_remote_deploy_proxy(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/remote_deploy"); + + // Planted rather than set through the route, which is enterprise-gated. Unlike the route, it + // deletes no token for another target, which is what a connect landing just after the + // route's cleanup leaves behind. + let set_target = |base_url: String, workspace_id: &'static str| { + let db = db.clone(); + async move { + sqlx::query!( + "UPDATE workspace_settings + SET remote_deploy_target = $1, remote_deploy_target_changed_at = now() + WHERE workspace_id = 'test-workspace'", + json!({ "base_url": base_url, "workspace_id": workspace_id }) + ) + .execute(&db) + .await + .unwrap(); + } + }; + set_target(format!("http://localhost:{port}"), "test-workspace").await; + + let resp = authed(client().get(format!("{base}/target"))) + .send() + .await?; + assert_eq!(resp.status(), 200); + let status = resp.json::().await?; + assert_eq!(status["target"]["workspace_id"], "test-workspace"); + assert!(status["connection"].is_null()); + + // Deploying before connecting names the step that is missing, rather than reaching the target + // with the caller's credentials for this instance. + let resp = authed(client().get(format!("{base}/proxy/none/users/whoami"))) + .send() + .await?; + assert_eq!(resp.status(), 400); + assert!(resp.text().await?.contains("Connect to")); + + let target = + json!({ "base_url": format!("http://localhost:{port}"), "workspace_id": "test-workspace" }); + + // A token obtained for another target is refused before it is sent anywhere. + let resp = authed(client().post(format!("{base}/connect"))) + .json(&json!({"token": "SECRET_TOKEN", "target": { "base_url": format!("http://localhost:{port}"), "workspace_id": "elsewhere" }})) + .send() + .await?; + assert_eq!(resp.status(), 400); + + // A token the target refuses is not stored. + let resp = authed(client().post(format!("{base}/connect"))) + .json(&json!({"token": "not-a-token", "target": target})) + .send() + .await?; + assert_eq!(resp.status(), 400); + + let resp = authed(client().post(format!("{base}/connect"))) + .json(&json!({"token": "SECRET_TOKEN", "target": target})) + .send() + .await?; + assert_eq!(resp.status(), 200); + let connection = resp.json::().await?; + assert_eq!(connection["remote_email"], "test@windmill.dev"); + let key = connection["proxy_key"].as_str().unwrap().to_string(); + + let resp = authed(client().get(format!("{base}/proxy/{key}/users/whoami"))) + .send() + .await?; + assert_eq!(resp.status(), 200); + // Whatever the remote returns is served from this origin, so it must never render as a page. + let csp = resp.headers()["content-security-policy"] + .to_str()? + .to_string(); + assert!(csp.starts_with("sandbox"), "{csp}"); + assert_eq!( + resp.json::().await?["email"], + "test@windmill.dev" + ); + + // A link from elsewhere rides the session cookie but cannot know the key, so it cannot spend + // the stored token. + // Nor can a credential that may not use the proxy read the key, to build such a link itself. + sqlx::query!( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, read_only) + VALUES (encode(sha256('READ_ONLY_TOKEN'::bytea), 'hex'), 'READ_ONLY_', 'READ_ONLY_TOKEN', + 'test@windmill.dev', 'read only', false, true)" + ) + .execute(&db) + .await?; + let resp = client() + .get(format!("{base}/target")) + .header("Authorization", "Bearer READ_ONLY_TOKEN") + .send() + .await?; + assert_eq!(resp.status(), 200); + assert!(resp.json::().await?["connection"].is_null()); + + let guessed = "x".repeat(key.len()); + let resp = authed(client().get(format!("{base}/proxy/{guessed}/users/whoami"))) + .send() + .await?; + assert_eq!(resp.status(), 400); + + // A connect locks nothing against a key rotation, so its row can land under the old key: that + // is no connection, which the drawer offers to replace, rather than an error on every deploy. + sqlx::query!( + "UPDATE remote_deploy_token SET token = 'not-under-this-key' + WHERE workspace_id = 'test-workspace'" + ) + .execute(&db) + .await?; + let resp = authed(client().get(format!("{base}/target"))) + .send() + .await?; + assert!(resp.json::().await?["connection"].is_null()); + + // A target that refuses the stored token must not answer 401: the browser reads an + // unhandled 401 as its own session having expired and logs the user out of this instance. + let mc = build_crypt(&db, "test-workspace").await?; + sqlx::query!( + "UPDATE remote_deploy_token SET token = $1 WHERE workspace_id = 'test-workspace'", + encrypt(&mc, "revoked-token") + ) + .execute(&db) + .await?; + let resp = authed(client().get(format!("{base}/proxy/{key}/users/whoami"))) + .send() + .await?; + assert_eq!(resp.status(), 502); + + // Nor does a connect lock anything against a removal from the workspace, so its row can land + // after the removal cleared the table: a row from an earlier membership must not come back + // with a re-add, even one whose `created_at` reads earlier than the connect (it is the start + // of the re-adding transaction). + let as_test2 = + |builder: reqwest::RequestBuilder| builder.header("Authorization", "Bearer SECRET_TOKEN_2"); + let resp = as_test2(client().post(format!("{base}/connect"))) + .json(&json!({"token": "SECRET_TOKEN_2", "target": target})) + .send() + .await?; + assert_eq!(resp.status(), 200); + let key2 = resp.json::().await?["proxy_key"] + .as_str() + .unwrap() + .to_string(); + let resp = as_test2(client().get(format!("{base}/target"))) + .send() + .await?; + assert_eq!( + resp.json::().await?["connection"]["proxy_key"], + key2.as_str() + ); + sqlx::query!( + "UPDATE usr SET created_at = created_at - interval '1 hour' + WHERE workspace_id = 'test-workspace' AND email = 'test2@windmill.dev'" + ) + .execute(&db) + .await?; + let resp = as_test2(client().get(format!("{base}/target"))) + .send() + .await?; + assert!(resp.json::().await?["connection"].is_null()); + let resp = as_test2(client().get(format!("{base}/proxy/{key2}/users/whoami"))) + .send() + .await?; + assert_eq!(resp.status(), 400); + assert!(resp.text().await?.contains("Connect to")); + + // Deleting the account must take its token with it, or the next account created with that + // address would act on the remote as this one. + let resp = authed(client().delete(format!( + "http://localhost:{port}/api/users/delete/test2@windmill.dev" + ))) + .send() + .await?; + assert_eq!(resp.status(), 200); + let left = sqlx::query_scalar!( + "SELECT count(*) FROM remote_deploy_token WHERE email = 'test2@windmill.dev'" + ) + .fetch_one(&db) + .await?; + assert_eq!(left, Some(0)); + + // The token was granted for one target and is never sent to another, so re-pointing the + // workspace leaves the caller unconnected instead of handing its token to the new target. + set_target(format!("http://localhost:{port}"), "other-workspace").await; + let resp = authed(client().get(format!("{base}/target"))) + .send() + .await?; + assert!(resp.json::().await?["connection"].is_null()); + let resp = authed(client().get(format!("{base}/proxy/{key}/users/whoami"))) + .send() + .await?; + assert_eq!(resp.status(), 400); + + // The row for the old target outlived the change, as one from a connect in flight across it + // would: pointing the setting back must not revive it, even with a stamp that reads earlier + // than the connect. + set_target(format!("http://localhost:{port}"), "test-workspace").await; + sqlx::query!( + "UPDATE workspace_settings SET remote_deploy_target_changed_at = '2000-01-01' + WHERE workspace_id = 'test-workspace'" + ) + .execute(&db) + .await?; + let resp = authed(client().get(format!("{base}/target"))) + .send() + .await?; + assert!(resp.json::().await?["connection"].is_null()); + + // A connect in flight across a disconnect must not undo it when it lands. The disconnect is + // stamped an hour ahead, as if every connect starting now had started before it. + let resp = authed(client().post(format!("{base}/connect"))) + .json(&json!({"token": "SECRET_TOKEN", "target": target})) + .send() + .await?; + assert_eq!(resp.status(), 200); + let resp = authed(client().post(format!("{base}/disconnect"))) + .send() + .await?; + assert_eq!(resp.status(), 200); + sqlx::query!( + "UPDATE remote_deploy_token SET connected_at = clock_timestamp() + interval '1 hour' + WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'" + ) + .execute(&db) + .await?; + let resp = authed(client().post(format!("{base}/connect"))) + .json(&json!({"token": "SECRET_TOKEN", "target": target})) + .send() + .await?; + assert_eq!(resp.status(), 400); + let resp = authed(client().get(format!("{base}/target"))) + .send() + .await?; + assert!(resp.json::().await?["connection"].is_null()); + + // A superadmin outside the workspace has no membership to bind to, so the row is bound to the + // account by the credential making the request: an account deleted during the remote call, + // and its address taken by another, must not inherit it. A deleted credential that auth still + // holds in its cache stands in for one whose account went away mid-call. + sqlx::query!( + "DELETE FROM usr WHERE workspace_id = 'test-workspace' AND email = 'test@windmill.dev'" + ) + .execute(&db) + .await?; + sqlx::query!("DELETE FROM remote_deploy_token WHERE email = 'test@windmill.dev'") + .execute(&db) + .await?; + sqlx::query!( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin) + VALUES (encode(sha256('GONE_TOKEN'::bytea), 'hex'), 'GONE_TOKEN', 'GONE_TOKEN', + 'test@windmill.dev', 'gone', true)" + ) + .execute(&db) + .await?; + let as_gone = + |builder: reqwest::RequestBuilder| builder.header("Authorization", "Bearer GONE_TOKEN"); + let resp = as_gone(client().get(format!("{base}/target"))) + .send() + .await?; + assert_eq!(resp.status(), 200); + sqlx::query!("DELETE FROM token WHERE token_prefix = 'GONE_TOKEN'") + .execute(&db) + .await?; + let resp = as_gone(client().post(format!("{base}/connect"))) + .json(&json!({"token": "SECRET_TOKEN", "target": target})) + .send() + .await?; + assert_eq!(resp.status(), 400); + let resp = authed(client().post(format!("{base}/connect"))) + .json(&json!({"token": "SECRET_TOKEN", "target": target})) + .send() + .await?; + assert_eq!(resp.status(), 200); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/token_expiry_warning.rs b/backend/windmill-api-integration-tests/tests/token_expiry_warning.rs new file mode 100644 index 0000000000..667e064cca --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/token_expiry_warning.rs @@ -0,0 +1,49 @@ +//! Which user tokens get an "expiring soon" warning queued when they are created. + +use serde_json::json; +use sqlx::types::chrono::Utc; +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +const DAY: u64 = 24 * 60 * 60; + +async fn warning_queued(db: &Pool, label: &str) -> bool { + sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM token_expiry_notification n + JOIN token t ON t.token_hash = n.token_hash WHERE t.label = $1)", + ) + .bind(label) + .fetch_one(db) + .await + .unwrap() +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_short_lived_tokens_get_no_expiry_warning(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + for (label, lifetime) in [("short", DAY), ("long", 30 * DAY)] { + let resp = reqwest::Client::new() + .post(format!("http://localhost:{port}/api/users/tokens/create")) + .header("Authorization", "Bearer SECRET_TOKEN_2") + .json(&json!({ + "label": label, + "expiration": Utc::now() + std::time::Duration::from_secs(lifetime), + })) + .send() + .await?; + assert_eq!(resp.status(), 201); + } + + assert!( + !warning_queued(&db, "short").await, + "a token whose whole lifetime fits in the warning window must not be warned about" + ); + assert!( + warning_queued(&db, "long").await, + "a longer-lived token still gets its warning" + ); + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/workspace_encryption_key_git_sync.rs b/backend/windmill-api-integration-tests/tests/workspace_encryption_key_git_sync.rs index 8edfd39d61..a22ad974c3 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_encryption_key_git_sync.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_encryption_key_git_sync.rs @@ -275,6 +275,67 @@ async fn test_encryption_key_rotation_dispatches_batched_git_sync( Ok(()) } +/// Stored repository tokens and webhook secrets are encrypted under the +/// workspace key but never synced, so a rotation has to carry them over even +/// when the caller skips re-encrypting variables. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_encryption_key_rotation_reencrypts_git_sync_secrets( + db: Pool, +) -> anyhow::Result<()> { + use windmill_common::variables::{build_crypt, crypt_from_key_with_suffix, decrypt, encrypt}; + initialize_tracing().await; + + create_folder(&db, "28103").await?; + create_git_repo_resource(&db).await?; + let sync_script_path = "f/28103/test_sync_script_git_secrets"; + create_sync_script(&db, sync_script_path).await?; + setup_git_sync_config(&db, sync_script_path).await?; + + let mc = build_crypt(&db, "test-workspace").await?; + sqlx::query( + r#" + UPDATE workspace_settings SET + git_credentials = jsonb_build_array(jsonb_build_object( + 'token', $1::text, 'repo_identity', 'https://gitlab.example.com/grp/proj')), + git_sync = jsonb_set(git_sync, '{repositories,0,auto_pull}', jsonb_build_object( + 'enabled', true, 'mode', 'webhook', 'webhook_id', 1, 'webhook_secret', $2::text)) + WHERE workspace_id = 'test-workspace' + "#, + ) + .bind(encrypt(&mc, "stored-token")) + .bind(encrypt(&mc, "hook-secret")) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces"); + + let new_key = "c".repeat(64); + let resp = authed(client().post(format!("{base}/encryption_key"))) + .json(&json!({"new_key": new_key, "skip_reencrypt": true})) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "set_encryption_key failed: {}", + resp.text().await? + ); + + let (token, secret): (String, String) = sqlx::query_as( + "SELECT git_credentials->0->>'token', git_sync#>>'{repositories,0,auto_pull,webhook_secret}' + FROM workspace_settings WHERE workspace_id = 'test-workspace'", + ) + .fetch_one(&db) + .await?; + let new_mc = crypt_from_key_with_suffix(&new_key, ""); + assert_eq!(decrypt(&new_mc, token)?, "stored-token"); + assert_eq!(decrypt(&new_mc, secret)?, "hook-secret"); + + Ok(()) +} + /// Regression test for the non-debouncing fallback: a workspace whose sync /// script predates hub version 28103 must still receive git-sync jobs for the /// encryption_key entry and every re-encrypted secret. Before the fallback was diff --git a/backend/windmill-api-integration-tests/tests/workspaces.rs b/backend/windmill-api-integration-tests/tests/workspaces.rs index 25312cae27..29b8a0a611 100644 --- a/backend/windmill-api-integration-tests/tests/workspaces.rs +++ b/backend/windmill-api-integration-tests/tests/workspaces.rs @@ -319,6 +319,14 @@ async fn test_workspace_endpoints(db: Pool) -> anyhow::Result<()> { .unwrap(); assert_eq!(resp.status(), 200); + let resp = authed(client().post(format!("{base}/edit_webhook"))) + .json(&json!({"webhook": "http://127.0.0.1/hook"})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400); + assert!(resp.text().await?.contains("ALLOW_PRIVATE_WEBHOOK_URLS")); + // --- edit_auto_invite (EE-gated) --- let resp = authed(client().post(format!("{base}/edit_auto_invite"))) .json(&json!({"operator": false, "invite_all": false, "auto_add": false})) @@ -1166,3 +1174,47 @@ async fn test_create_service_account_drops_orphaned_group_memberships( Ok(()) } + +#[cfg(feature = "private")] +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_edit_auto_invite_preserves_instance_groups(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + sqlx::query( + r#"UPDATE workspace_settings + SET auto_invite = '{"instance_groups": ["eng"], "instance_groups_roles": {"eng": "developer"}}' + WHERE workspace_id = 'test-workspace'"#, + ) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces"); + + // enable, then disable + for body in [ + json!({"operator": false, "invite_all": true, "auto_add": false}), + json!({}), + ] { + let resp = authed(client().post(format!("{base}/edit_auto_invite"))) + .json(&body) + .send() + .await?; + assert_eq!(resp.status(), 200, "{body}: {}", resp.text().await?); + + let auto_invite: serde_json::Value = sqlx::query_scalar( + "SELECT auto_invite FROM workspace_settings WHERE workspace_id = 'test-workspace'", + ) + .fetch_one(&db) + .await?; + assert_eq!(auto_invite["instance_groups"], json!(["eng"]), "{body}"); + assert_eq!( + auto_invite["instance_groups_roles"], + json!({"eng": "developer"}), + "{body}" + ); + } + + Ok(()) +} diff --git a/backend/windmill-api-jobs/src/execution.rs b/backend/windmill-api-jobs/src/execution.rs index 7184f8ecec..9ad7189434 100644 --- a/backend/windmill-api-jobs/src/execution.rs +++ b/backend/windmill-api-jobs/src/execution.rs @@ -40,8 +40,8 @@ use windmill_common::{ FlowVersionInfo, DB, }; use windmill_queue::{ - cancel_job, get_result_and_success_by_id_from_flow, parse_result_object, push, PushArgs, - PushArgsOwned, PushIsolationLevel, + cancel_job, check_tag_available_for_push, get_result_and_success_by_id_from_flow, + parse_result_object, push, tag_reads_args, PushArgs, PushArgsOwned, PushIsolationLevel, }; use crate::types::RunJobQuery; @@ -50,7 +50,27 @@ use crate::types::RunJobQuery; // Tag / license checks // --------------------------------------------------------------------------- +/// `args` must be the ones the job is pushed with, see [`windmill_queue::check_tag_available_for_push`]. pub async fn check_tag_available_for_workspace( + db: &DB, + w_id: &str, + tag: &Option, + args: &PushArgs<'_>, + authed: &ApiAuthed, +) -> error::Result<()> { + if let Some(tag) = tag.as_deref().filter(|t| !t.is_empty()) { + let tags = get_scope_tags(authed); + // Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags. + let is_super_admin = windmill_api_auth::is_super_admin_authed(db, authed).await?; + check_tag_available_for_push(db, w_id, tag, args, is_super_admin, tags).await + } else { + Ok(()) + } +} + +/// [`check_tag_available_for_workspace`] for a tag filled in from values nothing can check yet, +/// such as a flow's preprocessor output: only an entry spelled exactly like it admits it. +pub async fn check_tag_as_written_available_for_workspace( db: &DB, w_id: &str, tag: &Option, @@ -60,7 +80,16 @@ pub async fn check_tag_available_for_workspace( let tags = get_scope_tags(authed); // Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags. let is_super_admin = windmill_api_auth::is_super_admin_authed(db, authed).await?; - check_tag_available_for_workspace_internal(db, w_id, tag, is_super_admin, tags).await + check_tag_available_for_workspace_internal( + db, + w_id, + tag, + None, + std::future::ready(w_id.to_string()), + is_super_admin, + tags, + ) + .await } else { Ok(()) } @@ -760,9 +789,25 @@ pub async fn run_flow<'c>( .. } = flow_version_info; - let tag = run_query.tag.clone().or(tag); + let flow_tag = tag; + let tag = run_query.tag.clone().or(flow_tag.clone()); + let push_args = PushArgs { args: &args.args, extra: args.extra }; + let apply_preprocessor = + !run_query.skip_preprocessor.unwrap_or(false) && has_preprocessor.unwrap_or(false); - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; + if !apply_preprocessor || run_query.tag.is_some() { + check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; + } + // `push` drops the tag of a flow whose preprocessor runs, and the worker then puts the flow on + // its own tag filled in from the preprocessor's output, after this caller's identity is gone. + // So that tag is the one to check, and an `$args[...]` in it is judged as written. + if apply_preprocessor { + if flow_tag.as_deref().is_some_and(tag_reads_args) { + check_tag_as_written_available_for_workspace(&db, &w_id, &flow_tag, &authed).await?; + } else { + check_tag_available_for_workspace(&db, &w_id, &flow_tag, &push_args, &authed).await?; + } + } let scheduled_for = run_query.get_scheduled_for(&db).await?; let return_tx = tx_o.is_some(); @@ -798,11 +843,10 @@ pub async fn run_flow<'c>( path: flow_path.to_string(), dedicated_worker, version, - apply_preprocessor: !run_query.skip_preprocessor.unwrap_or(false) - && has_preprocessor.unwrap_or(false), + apply_preprocessor, labels, }, - PushArgs { args: &args.args, extra: args.extra }, + push_args, authed.display_username(), email, permissioned_as, @@ -988,7 +1032,8 @@ pub async fn push_script_job_by_path_into_queue<'c>( let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(tag); - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; + let push_args = PushArgs { args: &args.args, extra: args.extra }; + check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; let return_tx = tx_o.is_some(); @@ -1020,7 +1065,7 @@ pub async fn push_script_job_by_path_into_queue<'c>( tx, &w_id, job_payload, - PushArgs { args: &args.args, extra: args.extra }, + push_args, authed.display_username(), email, permissioned_as, diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index dfd4a6e3ba..78cd6eb378 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -619,6 +619,7 @@ async fn edit_schedule( dynamic_skip = $23, email = $24, edited_by = $25, + edited_at = now(), permissioned_as = $26, labels = COALESCE($27, labels) WHERE path = $19 AND workspace_id = $20 diff --git a/backend/windmill-api-scripts/Cargo.toml b/backend/windmill-api-scripts/Cargo.toml index 8b34512872..72353c6f1b 100644 --- a/backend/windmill-api-scripts/Cargo.toml +++ b/backend/windmill-api-scripts/Cargo.toml @@ -30,7 +30,11 @@ windmill-parser-ts-asset.workspace = true windmill-parser-sql-asset.workspace = true windmill-parser-sql.workspace = true windmill-parser-yaml.workspace = true +windmill-parser-bash.workspace = true +windmill-parser-go.workspace = true +windmill-parser-graphql.workspace = true +anyhow.workspace = true axum.workspace = true futures.workspace = true http.workspace = true diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index a4667e4876..631e8606e6 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -55,7 +55,7 @@ use windmill_common::{ min_version_supports_runnable_settings_v0, RunnableSettings, RunnableSettingsTrait, }, scripts::{hash_script, ScriptRunnableSettingsHandle, ScriptRunnableSettingsInline}, - utils::{paginate_without_limits, WarnAfterExt}, + utils::{paginate_optional, paginate_without_limits, WarnAfterExt}, worker::CLOUD_HOSTED, }; use windmill_object_store::upload_artifact_to_store; @@ -1132,6 +1132,96 @@ async fn validate_dbt_relation( }) } +/// The schema the editor would have derived from `content`, for a deploy that sends +/// none (MCP, a bare API call). `None` for a language whose parser this build lacks, +/// or code that does not parse. +fn infer_main_schema( + language: &ScriptLang, + kind: Option<&ScriptKind>, + content: &str, + previous: Option<&serde_json::Value>, +) -> Option { + let entrypoint = + matches!(kind, Some(ScriptKind::Preprocessor)).then(|| "preprocessor".to_string()); + // The database a SQL script runs against is an argument unless the code names + // it (`-- database f/...`), as in the editor. + let with_db = |sig: anyhow::Result, resource: &str| { + sig.map(|mut sig| { + if windmill_parser_sql::parse_db_resource(content).is_none() { + sig.args.insert( + 0, + windmill_parser::Arg { + name: "database".to_string(), + typ: windmill_parser::Typ::Resource(resource.to_string()), + ..Default::default() + }, + ); + } + sig + }) + }; + let sig = match language { + ScriptLang::Bun | ScriptLang::Bunnative | ScriptLang::Deno | ScriptLang::Nativets => { + windmill_parser_ts::parse_deno_signature(content, false, false, entrypoint) + } + #[cfg(feature = "python")] + ScriptLang::Python3 => { + windmill_parser_py::parse_python_signature(content, entrypoint, false) + } + ScriptLang::Go => windmill_parser_go::parse_go_sig(content), + ScriptLang::Bash => windmill_parser_bash::parse_bash_sig(content), + ScriptLang::Powershell => windmill_parser_bash::parse_powershell_sig(content), + ScriptLang::Postgresql => { + with_db(windmill_parser_sql::parse_pgsql_sig(content), "postgresql") + } + ScriptLang::Mysql => with_db(windmill_parser_sql::parse_mysql_sig(content), "mysql"), + ScriptLang::Bigquery => { + with_db(windmill_parser_sql::parse_bigquery_sig(content), "bigquery") + } + ScriptLang::Snowflake => with_db( + windmill_parser_sql::parse_snowflake_sig(content), + "snowflake", + ), + ScriptLang::Mssql => with_db( + windmill_parser_sql::parse_mssql_sig(content), + "ms_sql_server", + ), + ScriptLang::OracleDB => { + with_db(windmill_parser_sql::parse_oracledb_sig(content), "oracledb") + } + ScriptLang::DuckDb => windmill_parser_sql::parse_duckdb_sig(content), + ScriptLang::Graphql => { + windmill_parser_graphql::parse_graphql_sig(content).map(|mut sig| { + sig.args.insert( + 0, + windmill_parser::Arg { + name: "api".to_string(), + typ: windmill_parser::Typ::Resource("graphql".to_string()), + ..Default::default() + }, + ); + sig + }) + } + ScriptLang::Ansible => windmill_parser_yaml::parse_ansible_sig(content), + _ => return None, + }; + match sig { + Ok(sig) => serde_json::value::to_raw_value(&windmill_parser::json_schema::main_arg_schema( + &sig, previous, + )) + .ok() + .map(|v| Schema(sqlx::types::Json(v))), + Err(e) => { + tracing::warn!( + "could not infer the schema of a {} script: {e:#}", + language.as_str() + ); + None + } + } +} + async fn create_script_internal<'c>( mut ns: NewScript, w_id: String, @@ -1359,6 +1449,40 @@ async fn create_script_internal<'c>( parent_adopted_from_retired_path = ns.parent_hash.is_some(); } + // Before hashing, so the hash and the no-op check see the schema that gets stored. + // `{}` counts as absent: an agent filling every tool argument sends it for "none". + let schema_absent = ns.schema.as_ref().is_none_or(|s| { + serde_json::from_str::>(s.0.get()) + .is_ok_and(|m| m.is_empty()) + }); + if schema_absent && !matches!(ns.language, ScriptLang::Dbt) { + let previous = match &ns.parent_hash { + Some(p_hash) => sqlx::query_scalar::<_, Option>>( + "SELECT schema FROM script WHERE hash = $1 AND workspace_id = $2", + ) + .bind(p_hash.0) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await? + .flatten() + .map(|s| s.0), + None => None, + }; + // Code the server cannot parse keeps the previous version's schema, as it + // does in the editor, rather than losing it for good. + ns.schema = infer_main_schema( + &ns.language, + ns.kind.as_ref(), + &ns.content, + previous.as_ref(), + ) + .or_else(|| { + previous + .and_then(|p| serde_json::value::to_raw_value(&p).ok()) + .map(|v| Schema(sqlx::types::Json(v))) + }); + } + // Must stay below the parent resolution above: an auto_parent deploy hashed before // it carries a first deploy's lineage, so redeploying content the path has held // before collides with that archived version instead of superseding it. The @@ -2312,6 +2436,20 @@ async fn create_script_internal<'c>( .await?; } + if p_path != &ns.path { + // Everything left at the old path is a draft this deploy didn't + // consume — teammates' rows, and the deployer's own when the caller + // asked us to keep it. Carry them rather than strand them. + windmill_common::user_drafts::move_drafts_for_path( + &mut tx, + &w_id, + &[UserDraftItemKind::Script], + p_path, + &ns.path, + ) + .await?; + } + sqlx::query!( "UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE", ns.path, @@ -2389,19 +2527,32 @@ async fn create_script_internal<'c>( tx = push_scheduled_job(&db, tx, &schedule, None, None).await?; } } - } else if !skip_draft_deletion { - // See the matching branch above — only wipe the deployer's own - // draft (plus the legacy NULL-email row). - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \ - AND (email = $3 OR email IS NULL)", - ns.path, - &w_id, - &authed.email, - ) - .execute(&mut *tx) - .await?; + } else { + if !skip_draft_deletion { + // See the matching branch above — only wipe the deployer's own + // draft (plus the legacy NULL-email row). + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script' \ + AND (email = $3 OR email IS NULL)", + ns.path, + &w_id, + &authed.email, + ) + .execute(&mut *tx) + .await?; + } } + // Every deploy, not only a new script: an archived script's draft can be moved away + // (`move_draft` ignores archived rows), and unarchiving redeploys at the same path, + // where a route left behind would send the live script's saves to the moved draft. + windmill_common::user_drafts::clear_draft_moves_from( + &mut tx, + &w_id, + &[UserDraftItemKind::Script], + &ns.path, + p_path_opt.as_deref(), + ) + .await?; if p_hashes.is_some() && !p_hashes.unwrap().is_empty() { audit_log( &mut *tx, @@ -3061,17 +3212,24 @@ async fn get_script_history( authed: ApiAuthed, Extension(user_db): Extension, Path((w_id, path)): Path<(String, StripPath)>, + Query(pagination): Query, ) -> JsonResult> { let path = path.to_path(); check_scopes(&authed, || format!("scripts:read:{}", path))?; + // Unasked-for, this listing stays whole: the deployment-history panels, the restart + // picker and the CLI all read it without paging. The diff picker asks for a page. + let (per_page, offset) = paginate_optional(pagination); let mut tx = user_db.begin(&authed).await?; let query_result = sqlx::query!( - "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at + "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash WHERE s.workspace_id = $1 AND s.path = $2 - ORDER by s.created_at DESC", + ORDER by s.created_at DESC + LIMIT $3 OFFSET $4", w_id, path, + per_page, + offset, ) .fetch_all(&mut *tx) .await?; @@ -3083,6 +3241,7 @@ async fn get_script_history( script_hash: ScriptHash(row.hash), deployment_msg: row.deployment_msg, created_at: Some(row.created_at), + created_by: Some(row.created_by), }) .collect(); return Ok(Json(result)); @@ -3097,7 +3256,7 @@ async fn get_latest_version( check_scopes(&authed, || format!("scripts:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; let row_o = sqlx::query!( - "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at + "SELECT s.hash as hash, dm.deployment_msg as deployment_msg, s.created_at as created_at, s.created_by as created_by FROM script s LEFT JOIN deployment_metadata dm ON s.hash = dm.script_hash WHERE s.workspace_id = $1 AND s.path = $2 ORDER by s.created_at DESC LIMIT 1", @@ -3113,6 +3272,7 @@ async fn get_latest_version( script_hash: ScriptHash(row.hash), deployment_msg: row.deployment_msg, created_at: Some(row.created_at), + created_by: Some(row.created_by), }; return Ok(Json(Some(result))); } else { diff --git a/backend/windmill-api-settings/Cargo.toml b/backend/windmill-api-settings/Cargo.toml index bc5078bce4..0ec99ef6d0 100644 --- a/backend/windmill-api-settings/Cargo.toml +++ b/backend/windmill-api-settings/Cargo.toml @@ -11,7 +11,7 @@ path = "src/lib.rs" [features] default = [] enterprise = ["license"] -private = ["windmill-common/private"] +private = ["windmill-common/private", "windmill-audit/private"] parquet = ["windmill-common/parquet", "windmill-object-store/parquet"] license = ["dep:rsa"] @@ -19,6 +19,7 @@ license = ["dep:rsa"] windmill-ai = { workspace = true, default-features = false } windmill-alerting.workspace = true windmill-api-auth.workspace = true +windmill-audit.workspace = true windmill-common = { workspace = true, default-features = false } axum.workspace = true anyhow.workspace = true diff --git a/backend/windmill-api-settings/src/datatable_roles_oss.rs b/backend/windmill-api-settings/src/datatable_roles_oss.rs new file mode 100644 index 0000000000..885d13ee9d --- /dev/null +++ b/backend/windmill-api-settings/src/datatable_roles_oss.rs @@ -0,0 +1,44 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Where the data table role catalog endpoints come from: the enterprise implementation, or a +//! refusal. Roles are an Enterprise Edition feature; see `windmill_common::datatable_roles_oss`. + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub(crate) use crate::datatable_roles_ee::{ + create_datatable_role, delete_datatable_role, list_datatable_roles, update_datatable_role, +}; + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +pub(crate) use ce::*; + +// The routes stay registered so the API has one shape; each answers after authentication, before +// anything is read. +#[cfg(not(all(feature = "private", feature = "enterprise")))] +mod ce { + use windmill_api_auth::ApiAuthed; + use windmill_common::{ + datatable_roles_oss::datatable_roles_unavailable as unavailable, error::Result, + }; + + pub(crate) async fn list_datatable_roles(_authed: ApiAuthed) -> Result { + Err(unavailable()) + } + + pub(crate) async fn create_datatable_role(_authed: ApiAuthed) -> Result { + Err(unavailable()) + } + + pub(crate) async fn update_datatable_role(_authed: ApiAuthed) -> Result { + Err(unavailable()) + } + + pub(crate) async fn delete_datatable_role(_authed: ApiAuthed) -> Result { + Err(unavailable()) + } +} diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 2e2b3290fa..bc52d85d54 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -17,6 +17,9 @@ mod audit_logs_s3; mod audit_logs_s3_backfill; #[cfg(feature = "parquet")] mod background_task; +#[cfg(all(feature = "private", feature = "enterprise"))] +mod datatable_roles_ee; +mod datatable_roles_oss; #[cfg(feature = "private")] mod ee; pub mod ee_oss; @@ -55,12 +58,13 @@ use windmill_common::{ error::{self, pg_error_message, JsonResult, Result}, get_database_url, global_settings::{ - AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING, - CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, - DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, + ACCENT_COLOR_SETTING, AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, + AUTOMATE_USERNAME_CREATION_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, + DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, INSTANCE_BANNER_SETTING, MAX_RETENTION_OVERRIDE_WORKSPACES, + MAX_TOKEN_EXPIRATION_DAYS_SETTING, MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING, RETENTION_PERIOD_SECS_OVERRIDES_SETTING, RUFF_CONFIG_SETTING, UNIQUE_ID_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, @@ -111,6 +115,17 @@ async fn get_ruff_config_unauthed(Extension(db): Extension) -> error::Result .unwrap()) } +/// The announcement banner and accent color, which every signed-in session reads on each +/// full page load. +async fn get_instance_ui( + Extension(db): Extension, + _authed: ApiAuthed, +) -> JsonResult { + Ok(Json( + windmill_common::global_settings::get_instance_ui(&db).await?, + )) +} + pub fn global_service() -> Router { #[warn(unused_mut)] let r = Router::new() @@ -122,6 +137,7 @@ pub fn global_service() -> Router { "/global/{key}", post(set_global_setting).get(get_global_setting), ) + .route("/instance_ui", get(get_instance_ui)) .route("/list_global", get(list_global_settings)) .route("/github_app_stale_webhooks", get(github_app_stale_webhooks)) .route( @@ -151,6 +167,16 @@ pub fn global_service() -> Router { "/list_custom_instance_pg_databases", post(list_custom_instance_pg_databases), ) + .route( + "/datatable_roles", + get(datatable_roles_oss::list_datatable_roles) + .post(datatable_roles_oss::create_datatable_role), + ) + .route( + "/datatable_roles/{id}", + post(datatable_roles_oss::update_datatable_role) + .delete(datatable_roles_oss::delete_datatable_role), + ) .route( "/refresh_custom_instance_user_pwd", post(refresh_custom_instance_user_pwd), @@ -931,6 +957,8 @@ async fn run_setting_pre_write_hook( AI_CONFIG_SETTING => { windmill_ai::ai_types::validate_model_pricing_json(value) .map_err(error::Error::BadRequest)?; + windmill_ai::ai_types::validate_token_maps_json(value) + .map_err(error::Error::BadRequest)?; } AUTOMATE_USERNAME_CREATION_SETTING => { if value.as_bool().unwrap_or(false) { @@ -1182,6 +1210,12 @@ async fn run_setting_pre_write_hook( } } } + MAX_TOKEN_EXPIRATION_DAYS_SETTING => { + windmill_common::global_settings::parse_max_token_expiration_days(Some(value)) + .map_err(|e| { + error::Error::BadRequest(format!("{MAX_TOKEN_EXPIRATION_DAYS_SETTING}: {e}")) + })?; + } INSTANCE_BANNER_SETTING => { match value { // Clearing (delete row) is handled by the caller; allow it through. @@ -1194,6 +1228,12 @@ async fn run_setting_pre_write_hook( } } } + ACCENT_COLOR_SETTING => match value { + serde_json::Value::Null => {} + serde_json::Value::String(s) if s.trim().is_empty() => {} + v => windmill_common::global_settings::validate_accent_color(v) + .map_err(|e| error::Error::BadRequest(format!("{ACCENT_COLOR_SETTING}: {e}")))?, + }, _ => {} } Ok(()) @@ -1343,6 +1383,12 @@ pub async fn get_global_setting( && key != HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING && key != WS_BASE_URL_SETTING && key != INSTANCE_BANNER_SETTING + // The token form reads it to stop offering expirations the server would shorten. + && key != MAX_TOKEN_EXPIRATION_DAYS_SETTING + // Whoever is wiring up an MCP client reads it to know whether a URL-borne token + // would be refused, and they are usually not a superadmin. Not a secret: pointing + // any MCP client at the instance discovers the same answer. + && key != MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING { require_super_admin(&db, &authed).await?; } @@ -1654,6 +1700,8 @@ struct CustomInstanceDbLogs { replication_user: String, #[serde(skip_serializing_if = "Option::is_none")] replication_user_error: Option, + #[serde(skip_serializing_if = "String::is_empty")] + user_connect: String, } async fn list_custom_instance_pg_databases( @@ -1871,9 +1919,57 @@ async fn setup_custom_instance_pg_database_inner( } } + // Everything above logged in as the DATABASE_URL user, but whatever uses the database logs + // in as custom_instance_user. A proxy that routes on the login name can accept one and + // refuse the other, which would otherwise surface only once a data table first connects. + let user_creds = PgDatabase { + user: Some(windmill_common::datatable_roles::CUSTOM_INSTANCE_USER.to_string()), + password: Some(windmill_common::utils::get_custom_pg_instance_password(db).await?), + ..pg_creds + }; + let (client, connection) = user_creds + .connect(Some(db)) + .await + .map_err(|e| custom_instance_user_connect_error(&user_creds.host, dbname, e))?; + let join_handle = tokio::spawn(async move { connection.await }); + logs.user_connect = "OK".to_string(); + drop(client); // /!\ Drop before joining to avoid deadlock + windmill_common::shutdown_pg_connection(join_handle).await?; + Ok(()) } +fn custom_instance_user_connect_error(host: &str, dbname: &str, e: error::Error) -> error::Error { + let cause = match &e { + error::Error::Anyhow { error, .. } => format!("{error:#}"), + e => e.to_string(), + }; + // Supavisor, Supabase's pooler, reads the tenant to route to from the login + // (`.`), and only knows the logins configured for that tenant. + let lower = cause.to_lowercase(); + let routing_refused = [ + "enoidentifier", + "tenant identifier", + "tenant or user", + "tenant/user", + ] + .iter() + .any(|signature| lower.contains(signature)); + if routing_refused { + error::Error::BadConfig(format!( + "DATABASE_URL reaches Postgres through a connection pooler ({host}) that picks the \ + server to route to from the login name, and it refused custom_instance_user, the \ + role Windmill uses for instance databases ({cause}). Instance databases cannot be \ + used through this pooler: use your own Postgres database instead, or point \ + DATABASE_URL at the Postgres server directly rather than at the pooler." + )) + } else { + error::Error::ExecutionErr(format!( + "Could not connect to {dbname} as custom_instance_user: {cause}" + )) + } +} + async fn drop_custom_instance_pg_database( authed: ApiAuthed, Extension(db): Extension, @@ -2276,6 +2372,34 @@ mod tests { use std::collections::BTreeMap; use windmill_common::instance_config::{GlobalSettings, InstanceConfig, WorkerGroupConfig}; + #[test] + fn supavisor_refusing_custom_instance_user_is_named() { + use windmill_common::error::{to_anyhow, Error}; + let connect_error = |message: &str| { + let e = Error::from(to_anyhow(std::io::Error::other(message.to_string()))); + super::custom_instance_user_connect_error( + "aws-0-eu-west-1.pooler.supabase.com", + "dt", + e, + ) + }; + for supavisor in [ + "db error: FATAL: (ENOIDENTIFIER) no tenant identifier provided", + "db error: FATAL: Tenant or user not found", + ] { + assert!( + matches!(connect_error(supavisor), Error::BadConfig(m) if m.contains("connection pooler")), + "{supavisor}" + ); + } + assert!(matches!( + connect_error( + "db error: FATAL: password authentication failed for user \"custom_instance_user\"" + ), + Error::ExecutionErr(_) + )); + } + #[test] fn instance_config_yaml_round_trip() { let config = InstanceConfig { diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index b7d30eaa4b..a1b1054a25 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -47,7 +47,10 @@ use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; use windmill_common::audit::AuditAuthor; use windmill_common::auth::{hash_token, safe_token_prefix, TOKEN_PREFIX_LEN}; -use windmill_common::global_settings::AUTOMATE_USERNAME_CREATION_SETTING; +use windmill_common::global_settings::{ + load_value_from_global_settings, parse_max_token_expiration_days, + AUTOMATE_USERNAME_CREATION_SETTING, MAX_TOKEN_EXPIRATION_DAYS_SETTING, +}; use windmill_common::oauth2::InstanceEvent; use windmill_common::per_minute_counter::PerMinuteCounter; use windmill_common::users::truncate_token; @@ -524,7 +527,7 @@ async fn list_users_as_super_admin( let rows = if active_only.is_some_and(|x| x) { sqlx::query_as!( GlobalUserInfo, - r#"WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')), + r#"WITH active_users AS (SELECT distinct username as email FROM audit_partitioned WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')), authors as (SELECT distinct email FROM usr WHERE usr.operator IS false) SELECT email as "email!", (email NOT IN (SELECT email FROM authors)) as operator_only, NULL::bool as is_workspace_admin, login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id FROM password @@ -1703,14 +1706,25 @@ async fn delete_user( .await?; windmill_common::user_drafts::delete_drafts_of_email(&mut *tx, &email_to_delete).await?; - let usernames = sqlx::query_scalar!( - "DELETE FROM usr WHERE email = $1 RETURNING username", + let memberships = sqlx::query!( + "DELETE FROM usr WHERE email = $1 RETURNING username, workspace_id", &email_to_delete ) .fetch_all(&mut *tx) .await?; - for username in usernames { + for row in memberships { + let username = row.username; + // A tenant list names a principal of its workspace, so the name has to be freed in every + // workspace this account belonged to: a later account taking the username would otherwise + // inherit the data table access it had. + windmill_common::workspaces::remove_datatable_tenant_in_workspace( + &mut tx, + &row.workspace_id, + &format!("u/{username}"), + ) + .await?; + sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete) .execute(&mut *tx) .await?; @@ -2456,6 +2470,15 @@ pub async fn delete_workspace_user_internal( tx: &mut Transaction<'_, Postgres>, authed: Option<&ApiAuthed>, // None for system operations ) -> Result<()> { + // Same reasoning as the `extra_perms` sweep below: a freed username must not stay named + // anywhere that grants access, tenant lists included. + windmill_common::workspaces::remove_datatable_tenant_in_workspace( + tx, + w_id, + &format!("u/{username_to_delete}"), + ) + .await?; + // ---- Clean up extra_perms referencing this user ---- let extra_perms_tables = [ "script", @@ -2540,6 +2563,14 @@ pub async fn delete_workspace_user_internal( .execute(&mut **tx) .await?; + sqlx::query!( + "DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2", + email_to_delete, + w_id + ) + .execute(&mut **tx) + .await?; + sqlx::query!( "DELETE FROM usr_to_group WHERE usr = $1 AND workspace_id = $2", username_to_delete, @@ -3072,11 +3103,68 @@ pub async fn create_guest_session_token<'c>( // create_token_internal is re-exported from windmill-api-auth above +/// Applies the instance-wide ceiling on how long a token a caller picks the lifetime of may +/// live (`create_token`, and `impersonate` for superadmins), returning the expiration to store: +/// the requested one while it fits, the ceiling otherwise, and the ceiling as well when none was +/// requested. Only the stored expiration is capped: tokens already stored when the setting is +/// turned on or lowered keep theirs, since the auth lookup never reads the setting. +/// +/// It shortens rather than refuses because most callers do not comply on their own. The CLI +/// authorization page, `wmill user create-token` and the editor's language-server token each +/// pick a lifetime, often none at all, without reading the setting (and CLIs already installed +/// never will), so refusing would break logging in and the editor instead of the long-lived +/// tokens the setting is aimed at. +/// +/// Read from `global_settings` on each call rather than cached: token creation is rare +/// enough that the round trip costs nothing, and the ceiling is then never served stale. +/// +/// A token owned by a service account is exempt: in the workspace the token names, or in any +/// workspace for a workspace-less token, which has none to match. Service accounts are the +/// identity automation that needs a long-lived credential runs as. The cost is that any +/// workspace admin can create and impersonate one to hold an uncapped token, so the ceiling +/// bounds personal tokens rather than what an admin can obtain. +async fn cap_token_expiration( + db: &DB, + owner_email: &str, + workspace_id: Option<&str>, + requested: Option>, +) -> Result>> { + let value = load_value_from_global_settings(db, MAX_TOKEN_EXPIRATION_DAYS_SETTING).await?; + let max_days = match parse_max_token_expiration_days(value.as_ref()) { + Ok(Some(max_days)) => max_days, + Ok(None) => return Ok(requested), + // Both write paths reject this, so only a row written around them gets here. + Err(e) => { + tracing::warn!("ignoring {MAX_TOKEN_EXPIRATION_DAYS_SETTING}: {e}"); + return Ok(requested); + } + }; + let max = chrono::Utc::now() + chrono::Duration::days(max_days); + + let is_service_account = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM usr WHERE email = $1 AND is_service_account IS true + AND ($2::varchar IS NULL OR workspace_id = $2))", + owner_email, + workspace_id, + ) + .fetch_one(db) + .await? + .unwrap_or(false); + if is_service_account { + return Ok(requested); + } + + Ok(Some(match requested { + Some(expiration) if expiration < max => expiration, + _ => max, + })) +} + async fn create_token( Extension(db): Extension, authed: ApiAuthed, OptJobAuthed { job_id, .. }: OptJobAuthed, - Json(token_config): Json, + Json(mut token_config): Json, ) -> Result<(StatusCode, String)> { forbid_elevated_job_token(&db, &authed.email, job_id).await?; check_token_create_rate_limit(&authed.username)?; @@ -3098,6 +3186,14 @@ async fn create_token( windmill_api_auth::ensure_scopes_within_caller(&authed, token_config.scopes.as_deref())?; + token_config.expiration = cap_token_expiration( + &db, + &authed.email, + token_config.workspace_id.as_deref(), + token_config.expiration, + ) + .await?; + let mut tx = db.begin().await?; let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?; @@ -3154,6 +3250,7 @@ async fn impersonate( .fetch_optional(&db) .await? .unwrap_or(false); + let expiration = cap_token_expiration(&db, &impersonated, None, new_token.expiration).await?; let mut tx = db.begin().await?; sqlx::query!( @@ -3165,7 +3262,7 @@ async fn impersonate( plaintext as Option<&str>, impersonated, new_token.label, - new_token.expiration, + expiration, is_super_admin ) .execute(&mut *tx) @@ -3175,7 +3272,7 @@ async fn impersonate( &mut *tx, &t_hash, new_token.label.as_deref(), - new_token.expiration, + expiration, ) .await; @@ -3925,6 +4022,8 @@ async fn update_token_label( AND NOT starts_with(label, 'embed_app:') AND NOT starts_with(label, 'sdk_app:') AND NOT starts_with(label, 'impersonation:') + AND NOT starts_with(label, 'cli-login:') + AND NOT starts_with(label, 'remote-deploy:') )) RETURNING token_prefix", req.label.as_deref(), @@ -3965,13 +4064,31 @@ async fn leave_workspace( ) -> Result { forbid_job_token_account_destruction(&authed)?; let mut tx = db.begin().await?; - sqlx::query!( + windmill_common::workspaces::remove_datatable_tenant_in_workspace( + &mut tx, + &w_id, + &format!("u/{}", authed.username), + ) + .await?; + let left = sqlx::query!( "DELETE FROM usr WHERE workspace_id = $1 AND username = $2", &w_id, authed.username ) .execute(&mut *tx) - .await?; + .await? + .rows_affected(); + // A superadmin deploys from workspaces it is no member of; leaving none is no reason to drop + // its connection. + if left > 0 { + sqlx::query!( + "DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2", + &authed.email, + &w_id + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, diff --git a/backend/windmill-api-workers/src/lib.rs b/backend/windmill-api-workers/src/lib.rs index 07bef364c8..0102f1b033 100644 --- a/backend/windmill-api-workers/src/lib.rs +++ b/backend/windmill-api-workers/src/lib.rs @@ -436,11 +436,6 @@ async fn get_workspace_fairness_events( WITH capped AS ( SELECT timestamp, operation, resource, parameters FROM audit_partitioned - WHERE workspace_id = 'admins' - AND operation = 'workspace_fairness.capped' - UNION ALL - SELECT timestamp, operation, resource, parameters - FROM audit WHERE workspace_id = 'admins' AND operation = 'workspace_fairness.capped' ORDER BY timestamp DESC @@ -448,11 +443,6 @@ async fn get_workspace_fairness_events( ), uncapped AS ( SELECT timestamp, operation, resource, parameters FROM audit_partitioned - WHERE workspace_id = 'admins' - AND operation = 'workspace_fairness.uncapped' - UNION ALL - SELECT timestamp, operation, resource, parameters - FROM audit WHERE workspace_id = 'admins' AND operation = 'workspace_fairness.uncapped' ORDER BY timestamp DESC diff --git a/backend/windmill-api-workspaces/Cargo.toml b/backend/windmill-api-workspaces/Cargo.toml index 52a58aa1a3..7ca1f692ad 100644 --- a/backend/windmill-api-workspaces/Cargo.toml +++ b/backend/windmill-api-workspaces/Cargo.toml @@ -35,6 +35,7 @@ windmill-store.workspace = true axum.workspace = true chrono.workspace = true +constant_time_eq.workspace = true futures = { workspace = true, optional = true } hex.workspace = true magic-crypt.workspace = true @@ -42,6 +43,7 @@ http.workspace = true hyper.workspace = true lazy_static.workspace = true regex.workspace = true +reqwest.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true @@ -49,5 +51,6 @@ sqlx.workspace = true tokio.workspace = true tokio-postgres.workspace = true tracing.workspace = true +url.workspace = true uuid.workspace = true strum.workspace = true diff --git a/backend/windmill-api-workspaces/src/datatable_acl.rs b/backend/windmill-api-workspaces/src/datatable_acl.rs new file mode 100644 index 0000000000..04dc3d4e80 --- /dev/null +++ b/backend/windmill-api-workspaces/src/datatable_acl.rs @@ -0,0 +1,2034 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Ownership and grants on the objects of an instance data table. +//! +//! [`datatable_permissions`](crate::datatable_permissions) decides who may connect as which role; +//! this decides what each role may then touch. Every change is a real `GRANT`, `REVOKE`, +//! `ALTER ... OWNER TO` or `ALTER DEFAULT PRIVILEGES`, so Postgres is what enforces it. +//! +//! Reading is open to anyone who reaches the data table. Planning and applying are for those who +//! administer it — admins of the workspace that governs it, and superadmins. All of it is +//! Enterprise Edition ([`crate::datatable_acl_oss`]). + +use std::collections::BTreeMap; + +use axum::{ + extract::{Extension, Path, Query}, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; +use tokio_postgres::error::{DbError, SqlState}; +use tokio_postgres::AsyncMessage; + +use windmill_api_auth::ApiAuthed; +use windmill_audit::audit_oss::audit_log; +use windmill_audit::ActionKind; +use windmill_common::datatable_roles::{ + lock_role_catalog, quote_ident, read_role_catalog, read_role_catalog_tx, DatatableRoleCatalog, + ADMIN_DATATABLE_ROLE, CUSTOM_INSTANCE_USER, +}; +use windmill_common::error::{pg_error_message, Error, JsonResult, Result}; +use windmill_common::workspaces::{resolve_governing_datatable, DataTable, GoverningDatatable}; +use windmill_common::{PgDatabase, DB}; + +use crate::datatable_permissions::{ensure_governs_datatable, ensure_reaches_governing_datatable}; + +pub(crate) fn routes() -> Router { + Router::new() + .route("/datatable_acl/{datatable_name}", get(get_datatable_acl)) + .route( + "/datatable_acl/{datatable_name}/plan", + post(plan_datatable_acl), + ) + .route( + "/datatable_acl/{datatable_name}/apply", + post(apply_datatable_acl), + ) +} + +/// What a read or a change is about. +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AclTarget { + /// The data table's own database — where the privilege to create schemas lives. + Database, + Schema { + schema: String, + }, + Table { + schema: String, + table: String, + }, +} + +impl AclTarget { + /// The schema the target is in, absent for the database itself. + pub(crate) fn schema(&self) -> Option<&str> { + match self { + AclTarget::Database => None, + AclTarget::Schema { schema } => Some(schema), + AclTarget::Table { schema, .. } => Some(schema), + } + } + + /// What it is called in a message. + pub(crate) fn label(&self, dbname: &str) -> String { + match self { + AclTarget::Database => dbname.to_string(), + AclTarget::Schema { schema } => schema.clone(), + AclTarget::Table { schema, table } => format!("{schema}.{table}"), + } + } +} + +#[derive(Deserialize, Debug)] +pub struct AclTargetQuery { + kind: String, + schema: Option, + table: Option, +} + +impl TryFrom for AclTarget { + type Error = Error; + fn try_from(q: AclTargetQuery) -> Result { + match (q.kind.as_str(), q.schema, q.table) { + ("database", _, _) => Ok(AclTarget::Database), + ("schema", Some(schema), _) => Ok(AclTarget::Schema { schema }), + ("table", Some(schema), Some(table)) => Ok(AclTarget::Table { schema, table }), + ("schema" | "table", None, _) => { + Err(Error::BadRequest("This target needs a schema".to_string())) + } + ("table", _, None) => Err(Error::BadRequest( + "A table target needs a table".to_string(), + )), + (kind, _, _) => Err(Error::BadRequest(format!("Unknown ACL target '{kind}'"))), + } + } +} + +/// Where a set of privileges applies, relative to the target. +/// +/// `Future*` covers what does not exist yet: those become `ALTER DEFAULT PRIVILEGES`, which only +/// binds objects created by the roles it names. +#[derive(Deserialize, Serialize, Debug, Clone, Copy, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum GrantScope { + /// The target itself — the database, the schema, or the table. + Target, + AllTables, + AllSequences, + AllFunctions, + FutureTables, + FutureSequences, + FutureFunctions, +} + +impl GrantScope { + pub(crate) fn is_future(&self) -> bool { + matches!( + self, + GrantScope::FutureTables | GrantScope::FutureSequences | GrantScope::FutureFunctions + ) + } +} + +/// A change to plan. One at a time: each is confirmed against its own SQL. +#[derive(Deserialize, Serialize, Debug, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum AclChange { + /// Hand the target — and, for a schema, everything already in it but an extension's members, + /// which stay with the extension — to another role. + SetOwner { + role: String, + }, + Grant { + role: String, + privileges: Vec, + scope: GrantScope, + }, + Revoke { + role: String, + privileges: Vec, + scope: GrantScope, + /// Objects inside the target, empty for the target itself. `ON ALL TABLES` grants read + /// back per object, so they are revoked per object — and the same privileges on several + /// of them are revoked together. + #[serde(default)] + objects: Vec, + }, +} + +impl AclChange { + /// The role the change is about, as the editor names it. + fn role(&self) -> &str { + match self { + AclChange::SetOwner { role } + | AclChange::Grant { role, .. } + | AclChange::Revoke { role, .. } => role, + } + } +} + +#[derive(Deserialize, Debug)] +pub struct AclChangeRequest { + pub target: AclTarget, + pub change: AclChange, + /// The statements the plan showed. An apply runs only those: it plans again and refuses if the + /// result differs. + #[serde(default)] + pub statements: Option>, +} + +/// An object inside a schema, named the way `REVOKE ... ON ` needs it. +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct AclObject { + pub name: String, + /// `TABLE`, `SEQUENCE`, `FUNCTION`, `PROCEDURE` or `TYPE`: what the object is. + /// [`object_keyword`] turns it into the keyword a revoke takes; a type has none. + pub kind: String, + /// A routine is identified by its argument types, not by its name: two `f` in one schema are + /// two objects. Absent for everything else. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub args: Option, +} + +/// A grant as the database has it, under the role names the editor uses. +#[derive(Serialize, Debug, PartialEq)] +pub struct AclGrant { + /// A data table role's name, `admin` for `custom_instance_user`, else the raw Postgres role + /// (`PUBLIC` included). + pub grantee: String, + pub privileges: Vec, + /// `None` for the target itself, else the object inside it. + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option, + /// `TABLES` / `SEQUENCES` / `FUNCTIONS` / `TYPES` (or, on the database, `SCHEMAS`) when this + /// is a default privilege, which applies to objects that do not exist yet. + #[serde(skip_serializing_if = "Option::is_none")] + pub future: Option, + /// Where the grant comes from, each role once: who granted it, or for a default privilege the + /// role whose future objects it covers. A revoke of some of the privileges takes them back from + /// every source that gave them. + pub sources: Vec, +} + +/// One role a grant comes from. +#[derive(Serialize, Debug, PartialEq)] +pub struct AclSource { + /// Under the same names as [`AclGrant::grantee`]. + pub role: String, + /// What `role` gave of the grant's privileges. A revoke is held back only by a source out of + /// reach that gave some of what it takes back. + pub privileges: Vec, + /// Whether this data table's connection can take back what `role` gave: on an object only the + /// owner's grants when it acts for the owner, or else its own (`grant_source!`); for a default + /// privilege, a creating role it acts for. What a source out of reach gave is not revocable + /// from here; privileges only other sources gave still are. + pub reachable: bool, +} + +#[derive(Serialize, Debug)] +pub struct DatatableAclInfo { + /// Under the same names as [`AclGrant::grantee`]. + pub owner: String, + /// The roles a change may name: `admin`, then every role of the instance catalog. Only for a + /// caller who may change anything, as the catalog is in the permissions drawer. + pub roles: Vec, + /// Whether this caller may plan and apply changes: they administer the data table, on an + /// edition that has the planner. + pub editable: bool, + /// Whether the data table is a clone, whose grants stay as they were copied from its source. + pub clone: bool, + /// Whether the server is Postgres 17 or later, which added the `MAINTAIN` table privilege. + pub supports_maintain: bool, + /// The database the target lives in, which no target carries itself. + pub dbname: String, + pub grants: Vec, + /// What the target holds that is a target of its own: a database's schemas, a schema's tables. + pub children: Vec, +} + +#[derive(Serialize, Debug)] +pub struct AclPlan { + pub statements: Vec, + pub warnings: Vec, +} + +/// The Postgres role a role name stands for. A data table role is a login named exactly like the +/// role, so this is the identity — except `admin`, which is `custom_instance_user`. +/// +/// Anything else is refused, never resolved to some default: every statement a plan writes names +/// the role it is about. +pub(crate) fn pg_role_of(name: &str, catalog: &DatatableRoleCatalog) -> Result { + if name == ADMIN_DATATABLE_ROLE { + return Ok(CUSTOM_INSTANCE_USER.to_string()); + } + if catalog.values().any(|r| r.name == name) { + return Ok(name.to_string()); + } + Err(Error::BadRequest(format!( + "'{name}' is not a data table role of this instance" + ))) +} + +/// The reverse of [`pg_role_of`], for display. A role that is not a data table role reads back as +/// itself. +pub(crate) fn role_name_of(pg_role: &str) -> String { + if pg_role == CUSTOM_INSTANCE_USER { + ADMIN_DATATABLE_ROLE.to_string() + } else { + pg_role.to_string() + } +} + +/// Every role a change may name: `admin` first, then the catalog. +fn role_names(catalog: &DatatableRoleCatalog) -> Vec { + let mut names: Vec = catalog.values().map(|r| r.name.clone()).collect(); + names.sort(); + names.insert(0, ADMIN_DATATABLE_ROLE.to_string()); + names +} + +fn ensure_instance(governing: &GoverningDatatable) -> Result<()> { + if governing.is_instance() { + return Ok(()); + } + Err(Error::BadRequest(format!( + "Data table '{}' is backed by a Postgres resource, so its access is managed on that \ + server directly. Only a data table on the Windmill instance's own database has data \ + table roles to grant to.", + governing.name + ))) +} + +/// The data table's `admin` connection, and the notices Postgres sends on it. +/// +/// Authorization: connects as `custom_instance_user` with the instance's own credentials and checks +/// nothing. Callers MUST have authorized the request first — a request about to be refused must +/// not get as far as this connection. +async fn connect_as_admin_unchecked( + db: &DB, + governing: &GoverningDatatable, +) -> Result<( + tokio_postgres::Client, + mpsc::UnboundedReceiver, + String, +)> { + ensure_instance(governing)?; + // Built from the authorized entry, never by resolving the settings again: a save in between + // could point the entry at a resource on another server and back, and this connection would + // then alter a database the later checks of the entry never see. + let mut pg = PgDatabase::parse_uri(&windmill_common::get_database_url().await?.as_str().await)?; + pg.dbname = governing + .datatable + .database + .as_ref() + .expect("a governing entry owns a database") + .resource_path + .clone(); + pg.user = Some(CUSTOM_INSTANCE_USER.to_string()); + pg.password = Some(windmill_common::utils::get_custom_pg_instance_password(db).await?); + let dbname = pg.dbname.clone(); + let (client, notices) = connect_with_notices(db, &pg).await?; + Ok((client, notices, dbname)) +} + +/// A connection to `pg`, and the notices Postgres sends on it — which is where a grant or revoke +/// that changed nothing is reported ([`execute_acl_statements`]). +pub(crate) async fn connect_with_notices( + db: &DB, + pg: &PgDatabase, +) -> Result<(tokio_postgres::Client, mpsc::UnboundedReceiver)> { + let (client, connection) = pg.connect(Some(db)).await?; + Ok(( + client, + drive_with_notices(connection, windmill_common::TokioPgConnection::poll_message), + )) +} + +type PollMessage = + fn( + &mut C, + &mut std::task::Context<'_>, + ) -> std::task::Poll>>; + +/// Drive `connection` in the background with `poll`, forwarding its notices. +pub(crate) fn drive_with_notices( + mut connection: C, + poll: PollMessage, +) -> mpsc::UnboundedReceiver { + // Unbounded: the driver must never wait on the receiver, which only drains once the statement + // the driver is carrying has completed. + let (notices_tx, notices) = mpsc::unbounded_channel(); + tokio::spawn(async move { + loop { + match std::future::poll_fn(|cx| poll(&mut connection, cx)).await { + Some(Ok(AsyncMessage::Notice(notice))) => { + let _ = notices_tx.send(notice); + } + Some(Ok(_)) => {} + Some(Err(e)) => { + tracing::error!("Datatable ACL connection error: {e}"); + break; + } + None => break, + } + } + }); + notices +} + +/// Run `statements` in order on `tx`, failing on the first that errors or that Postgres only warns +/// about. A privilege the connection cannot pass on is a warning to Postgres (`01007` / `01006`), +/// which then carries on having changed nothing; returning drops the transaction, rolling back +/// everything before it. +/// +/// Authorization: none. Runs `statements` as the connection `tx` is on; callers MUST have authorized +/// changing that database's access, and built the statements themselves. +pub(crate) async fn execute_acl_statements( + tx: &tokio_postgres::Transaction<'_>, + notices: &mut mpsc::UnboundedReceiver, + statements: &[String], +) -> Result<()> { + while notices.try_recv().is_ok() {} + for statement in statements { + tx.batch_execute(statement).await.map_err(|e| { + Error::ExecutionErr(format!( + "Failed to run `{statement}`: {}", + pg_error_message(&e) + )) + })?; + while let Ok(notice) = notices.try_recv() { + if *notice.code() == SqlState::WARNING_PRIVILEGE_NOT_GRANTED + || *notice.code() == SqlState::WARNING_PRIVILEGE_NOT_REVOKED + { + return Err(Error::ExecutionErr(format!( + "`{statement}` did not take effect ({}), so nothing was applied", + notice.message() + ))); + } + } + } + Ok(()) +} + +/// An object whose ownership follows the schema's. +#[derive(Debug, PartialEq)] +pub(crate) struct OwnedObject { + /// The keyword `ALTER ... OWNER TO` takes for this kind of object. + pub(crate) keyword: &'static str, + /// How Postgres names the object (`pg_identify_object`): schema-qualified, quoted where + /// needed, with a routine's arguments or an operator class's access method. It goes into the + /// statement as it is. + pub(crate) identity: String, +} + +/// Everything a schema's change of owner takes along, in schema `$1`, as (kind, identity, owner +/// oid), the first two as `pg_identify_object` gives them. The plan, the check of what this +/// connection may move and the check after the move all read this one list, so what is moved and +/// what is checked cannot differ. +/// +/// Read from what depends on the schema rather than catalog by catalog, so no kind of object is +/// left out by omission; array types, row types and indexes depend on another object instead. +/// Left out: an object's internal parts (a range type's constructors and multirange, an identity +/// column's sequence), a sequence tied to a column (it follows its table, and refuses an owner of +/// its own), an extension's members, and what has no `ALTER ... OWNER` at all (an extension, a text +/// search parser or template). Postgres records no owner for the bootstrap superuser, oid 10. +macro_rules! schema_owned_objects { + () => { + "SELECT o.type AS kind, o.identity, COALESCE(s.refobjid, 10::oid) AS owner + FROM pg_depend d + CROSS JOIN LATERAL pg_identify_object(d.classid, d.objid, 0) o + LEFT JOIN pg_shdepend s + ON s.dbid = (SELECT oid FROM pg_database WHERE datname = current_database()) + AND s.classid = d.classid AND s.objid = d.objid AND s.deptype = 'o' + WHERE d.refclassid = 'pg_namespace'::regclass AND d.deptype = 'n' + AND d.refobjid = (SELECT oid FROM pg_namespace WHERE nspname = $1) + AND d.classid <> ALL(ARRAY['pg_extension'::regclass, 'pg_ts_parser'::regclass, + 'pg_ts_template'::regclass]::oid[]) + AND NOT EXISTS ( + SELECT 1 FROM pg_depend x + WHERE x.classid = d.classid AND x.objid = d.objid AND x.objsubid = 0 + AND (x.deptype IN ('i', 'e') + OR (x.deptype = 'a' AND d.classid = 'pg_class'::regclass + AND x.refobjsubid <> 0)))" + }; +} +#[allow(unused_imports)] +pub(crate) use schema_owned_objects; + +/// The keyword `ALTER ... OWNER TO` takes for a kind of object, as `pg_identify_object` names the +/// kind. A kind missing here is refused rather than skipped, which would leave it behind. +pub(crate) fn owned_keyword(kind: &str) -> Option<&'static str> { + Some(match kind { + "table" => "TABLE", + "view" => "VIEW", + "materialized view" => "MATERIALIZED VIEW", + "sequence" => "SEQUENCE", + "foreign table" => "FOREIGN TABLE", + "type" => "TYPE", + "function" | "procedure" | "aggregate" => "ROUTINE", + "collation" => "COLLATION", + "conversion" => "CONVERSION", + "operator" => "OPERATOR", + "operator class" => "OPERATOR CLASS", + "operator family" => "OPERATOR FAMILY", + "statistics object" => "STATISTICS", + "text search dictionary" => "TEXT SEARCH DICTIONARY", + "text search configuration" => "TEXT SEARCH CONFIGURATION", + _ => return None, + }) +} + +async fn read_owned_objects( + client: &tokio_postgres::Client, + schema: &str, +) -> Result> { + let rows = client + .query( + concat!( + "SELECT kind, identity FROM (", + schema_owned_objects!(), + ") o ORDER BY kind, identity" + ), + &[&schema], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to list the objects of schema '{schema}': {}", + pg_error_message(&e) + )) + })?; + rows.into_iter() + .map(|row| { + let kind: &str = row.get(0); + let identity: String = row.get(1); + match owned_keyword(kind) { + Some(keyword) => Ok(OwnedObject { keyword, identity }), + None => Err(Error::BadRequest(format!( + "{identity} is a {kind}, whose owner cannot be changed from here, and schema \ + {schema} would change hands without it. Move it to another schema first." + ))), + } + }) + .collect() +} + +/// Default privileges set database-wide (no `IN SCHEMA`), as `(defaclrole, defaclobjtype, grantee, +/// privilege_type)`. Postgres stores such an entry as a whole acl, the creator's own privileges +/// included, so only what goes beyond the built-in default is a grant. It applies in every schema +/// on top of the schema's own defaults, which cannot take it back. +macro_rules! database_wide_defaults { + () => { + "SELECT d.defaclrole, d.defaclobjtype, a.grantee, a.privilege_type + FROM pg_default_acl d, aclexplode(d.defaclacl) a + WHERE d.defaclnamespace = 0 + AND NOT EXISTS ( + SELECT 1 FROM aclexplode(acldefault( + CASE d.defaclobjtype WHEN 'S' THEN 's' ELSE d.defaclobjtype END, d.defaclrole)) x + WHERE x.grantee = a.grantee AND x.privilege_type = a.privilege_type)" + }; +} + +/// `TABLES`, `SEQUENCES`, `FUNCTIONS` or `TYPES`, as a `defaclobjtype` names them. +fn default_objects_keyword(objtype: &str) -> &'static str { + match objtype { + "r" => "TABLES", + "S" => "SEQUENCES", + "T" => "TYPES", + _ => "FUNCTIONS", + } +} + +/// What a schema's owner holds on what gets created there later. A change of owner hands the new +/// owner the same and takes these back: otherwise every former owner keeps reaching whatever the +/// other roles create there. +#[derive(Debug, PartialEq)] +pub(crate) struct FormerOwnerDefaults { + pub(crate) pg_role: String, + /// (creating role, `TABLES` / `SEQUENCES` / `FUNCTIONS` / `TYPES`), one per default privilege + /// it holds. + pub(crate) defaults: Vec<(String, &'static str)>, +} + +/// The default privileges schema `schema`'s owner holds there — `None` when it holds none, or is +/// `new_owner` already. Refused when one was set by a role this connection cannot act for: only +/// a member of the creating role may change its defaults, so the revoke would fail at apply. Also +/// refused when the owner holds defaults set database-wide, which no change to this schema takes +/// back. +async fn read_former_owner_defaults( + client: &tokio_postgres::Client, + schema: &str, + new_owner: &str, +) -> Result> { + let database_wide = client + .query_opt( + concat!( + "SELECT pg_get_userbyid(n.nspowner), pg_get_userbyid(g.defaclrole), + g.defaclobjtype::text + FROM pg_namespace n, (", + database_wide_defaults!(), + ") g + WHERE n.nspname = $1 AND g.grantee = n.nspowner + AND g.defaclobjtype IN ('r', 'S', 'f', 'T') + AND n.nspowner <> (SELECT oid FROM pg_roles WHERE rolname = $2) + ORDER BY 2, 3 + LIMIT 1" + ), + &[&schema, &new_owner], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to read the database-wide default privileges: {}", + pg_error_message(&e) + )) + })?; + if let Some(row) = database_wide { + let pg_role: String = row.get(0); + let creator: String = row.get(1); + return Err(Error::BadRequest(format!( + "{} holds default privileges set database-wide by {}, which no default of schema \ + {schema} takes back, so it would keep reaching what is created here after the change \ + of owner. Revoke them database-wide first: ALTER DEFAULT PRIVILEGES FOR ROLE {} \ + REVOKE ALL PRIVILEGES ON {} FROM {}", + role_name_of(&pg_role), + role_name_of(&creator), + quote_ident(&creator), + default_objects_keyword(row.get(2)), + quote_ident(&pg_role) + ))); + } + + let rows = client + .query( + "SELECT DISTINCT pg_get_userbyid(n.nspowner), pg_get_userbyid(d.defaclrole), + d.defaclobjtype::text, pg_has_role(d.defaclrole, 'USAGE') + FROM pg_namespace n + JOIN pg_default_acl d ON d.defaclnamespace = n.oid + CROSS JOIN LATERAL aclexplode(d.defaclacl) a + WHERE n.nspname = $1 AND a.grantee = n.nspowner + -- What the owner gives itself is about the objects it creates, not about owning + -- the schema, so a move leaves it alone. + AND a.grantee <> d.defaclrole + AND d.defaclobjtype IN ('r', 'S', 'f', 'T') + AND n.nspowner <> (SELECT oid FROM pg_roles WHERE rolname = $2) + ORDER BY 2, 3", + &[&schema, &new_owner], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to read the default privileges of schema '{schema}': {}", + pg_error_message(&e) + )) + })?; + let Some(first) = rows.first() else { + return Ok(None); + }; + let pg_role: String = first.get(0); + let plural = |row: &tokio_postgres::Row| default_objects_keyword(row.get(2)); + if let Some(row) = rows.iter().find(|row| !row.get::<_, bool>(3)) { + let creator: String = row.get(1); + return Err(Error::BadRequest(format!( + "{} holds default privileges in schema {schema} from {creator}, which this data \ + table's connection cannot act for, so they would outlive the change of owner. \ + Revoke them as {creator} first: ALTER DEFAULT PRIVILEGES FOR ROLE {} IN SCHEMA {} \ + REVOKE ALL PRIVILEGES ON {} FROM {}", + role_name_of(&pg_role), + quote_ident(&creator), + quote_ident(schema), + plural(row), + quote_ident(&pg_role) + ))); + } + Ok(Some(FormerOwnerDefaults { + defaults: rows + .iter() + .map(|row| (row.get::<_, String>(1), plural(row))) + .collect(), + pg_role, + })) +} + +/// What the catalog holds that a plan depends on, read before planning so the planner stays pure. +#[derive(Debug, Default, PartialEq)] +pub(crate) struct CatalogFacts { + /// Every role that may create objects here, but the one the change is about. + pub(crate) other_pg_roles: Vec, + /// For a schema's change of owner: what moves along with it. + pub(crate) existing_objects: Vec, + /// For a schema's change of owner: the defaults it takes back from the owner it replaces. + pub(crate) former_owner: Option, + /// For a revoke: each grant it takes back. + pub(crate) revoked_grants: Vec, +} + +/// A grant a revoke takes back, as the catalog records it: what `source` gave on `object` (the +/// target itself when `None`), or for a default privilege on what `source` creates later. +#[derive(Debug, PartialEq)] +pub(crate) struct RevokedGrant { + pub(crate) object: Option, + /// The role that made the grant: its grantor, or the creating role of a default privilege. + pub(crate) source: String, + pub(crate) privileges: Vec, +} + +/// An `aclexplode` row's source and whether this connection can take back what it gave, on an +/// object owned by `$owner`. A REVOKE speaks for the grantor Postgres picks itself — the owner, +/// when the connection acts for the owner, and otherwise the connection — and `GRANTED BY` names +/// nobody else, so a grant any other role made stays whatever the connection runs. +macro_rules! grant_source { + ($owner:literal) => { + concat!( + "pg_get_userbyid(a.grantor), a.grantor = CASE WHEN pg_has_role(", + $owner, + ", 'USAGE') THEN ", + $owner, + " ELSE (SELECT oid FROM pg_roles WHERE rolname = current_user) END" + ) + }; +} + +/// What relation `$2` of schema `$1` grants role `$3`, as (source, whether this connection can +/// take it back, privilege). +const RELATION_GRANTS: &str = concat!( + "SELECT ", + grant_source!("c.relowner"), + ", a.privilege_type + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace, + aclexplode(COALESCE(c.relacl, acldefault( + CASE c.relkind WHEN 'S' THEN 's' ELSE 'r' END::\"char\", c.relowner))) a + WHERE n.nspname = $1 AND c.relname = $2 + AND c.relkind = ANY(ARRAY['r','p','v','m','S','f']::\"char\"[]) + AND a.grantee = (SELECT oid FROM pg_roles WHERE rolname = $3)" +); + +/// The grants a revoke takes back, read from the catalog rather than from the request: one per +/// object and source that gave `pg_role` any of `privileges`. Refused when a source's grant is out +/// of this connection's reach (`grant_source!`, or for a default privilege a creating role it does +/// not act for): the revoke would leave that grant in place. +async fn read_revoked_grants( + client: &tokio_postgres::Client, + dbname: &str, + target: &AclTarget, + scope: GrantScope, + objects: &[AclObject], + privileges: &[String], + pg_role: &str, +) -> Result> { + let read_error = |e: tokio_postgres::Error| { + Error::internal_err(format!( + "Failed to read what the revoke takes back: {}", + pg_error_message(&e) + )) + }; + let wanted: Vec = privileges.iter().map(|p| p.to_uppercase()).collect(); + // (object, how it reads in a refusal, one row per source and privilege) + let mut read = Vec::new(); + match (scope, target) { + (scope, AclTarget::Schema { schema }) if scope.is_future() => { + let (objtype, plural) = match scope { + GrantScope::FutureTables => ("r", "tables"), + GrantScope::FutureSequences => ("S", "sequences"), + _ => ("f", "functions"), + }; + let database_wide = client + .query( + concat!( + "SELECT pg_get_userbyid(g.defaclrole), g.privilege_type FROM (", + database_wide_defaults!(), + ") g + WHERE g.defaclobjtype::text = $1 + AND g.grantee = (SELECT oid FROM pg_roles WHERE rolname = $2) + ORDER BY 1, 2" + ), + &[&objtype, &pg_role], + ) + .await + .map_err(read_error)?; + let mut still_granted: BTreeMap> = BTreeMap::new(); + for row in database_wide { + let privilege: String = row.get(1); + if wanted.contains(&privilege) { + still_granted.entry(row.get(0)).or_default().push(privilege); + } + } + if let Some((creator, taken)) = still_granted.into_iter().next() { + return Err(Error::BadRequest(format!( + "{} also receives {} on {plural} {} creates through a default privilege set \ + database-wide, which no default of schema {schema} takes back. Revoke it \ + database-wide first: ALTER DEFAULT PRIVILEGES FOR ROLE {} REVOKE {} ON {} \ + FROM {}", + role_name_of(pg_role), + taken.join(", "), + role_name_of(&creator), + quote_ident(&creator), + taken.join(", "), + default_objects_keyword(objtype), + quote_ident(pg_role) + ))); + } + let rows = client + .query( + "SELECT pg_get_userbyid(d.defaclrole), pg_has_role(d.defaclrole, 'USAGE'), + a.privilege_type + FROM pg_default_acl d + JOIN pg_namespace n ON n.oid = d.defaclnamespace, + aclexplode(d.defaclacl) a + WHERE n.nspname = $1 AND d.defaclobjtype::text = $2 + AND a.grantee = (SELECT oid FROM pg_roles WHERE rolname = $3)", + &[schema, &objtype, &pg_role], + ) + .await + .map_err(read_error)?; + read.push(( + None, + format!("{plural} created later in schema {schema}"), + rows, + )); + } + (GrantScope::Target, _) if objects.is_empty() => { + let rows = match target { + AclTarget::Database => { + client + .query( + concat!( + "SELECT ", + grant_source!("d.datdba"), + ", a.privilege_type + FROM pg_database d, + aclexplode(COALESCE(d.datacl, acldefault('d', d.datdba))) a + WHERE d.datname = current_database() + AND a.grantee = (SELECT oid FROM pg_roles WHERE rolname = $1)" + ), + &[&pg_role], + ) + .await + } + AclTarget::Schema { schema } => { + client + .query( + concat!( + "SELECT ", + grant_source!("n.nspowner"), + ", a.privilege_type + FROM pg_namespace n, + aclexplode(COALESCE(n.nspacl, acldefault('n', n.nspowner))) a + WHERE n.nspname = $1 + AND a.grantee = (SELECT oid FROM pg_roles WHERE rolname = $2)" + ), + &[schema, &pg_role], + ) + .await + } + AclTarget::Table { schema, table } => { + client + .query(RELATION_GRANTS, &[schema, table, &pg_role]) + .await + } + } + .map_err(read_error)?; + read.push((None, target.label(dbname), rows)); + } + (GrantScope::Target, AclTarget::Schema { schema }) => { + for object in objects { + let rows = match object_keyword(&object.kind)? { + "ROUTINE" => { + client + .query( + concat!( + "SELECT ", + grant_source!("p.proowner"), + ", a.privilege_type + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace, + aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) a + WHERE n.nspname = $1 AND p.proname = $2 + AND pg_get_function_identity_arguments(p.oid) = $3 + AND a.grantee = (SELECT oid FROM pg_roles WHERE rolname = $4)" + ), + &[ + schema, + &object.name, + &object.args.as_deref().unwrap_or(""), + &pg_role, + ], + ) + .await + } + _ => { + client + .query(RELATION_GRANTS, &[schema, &object.name, &pg_role]) + .await + } + } + .map_err(read_error)?; + read.push(( + Some(object.clone()), + format!("{} {schema}.{}", object.kind.to_lowercase(), object.name), + rows, + )); + } + } + // Every other scope and target is the planner's to refuse. + _ => {} + } + + let mut revoked = Vec::new(); + for (object, label, rows) in read { + let mut by_source: BTreeMap)> = BTreeMap::new(); + for row in rows { + let privilege: String = row.get(2); + if wanted.contains(&privilege) { + by_source + .entry(row.get(0)) + .or_insert_with(|| (row.get(1), vec![])) + .1 + .push(privilege); + } + } + for (source, (reachable, mut privileges)) in by_source { + if !reachable { + return Err(Error::BadRequest(format!( + "{} on {label} was granted to {} by {source}, and Postgres takes a grant \ + back only through the role that made it, which this data table's \ + connection cannot speak for here. Revoke it as {source}.", + privileges.join(", "), + role_name_of(pg_role), + ))); + } + privileges.sort(); + privileges.dedup(); + revoked.push(RevokedGrant { object: object.clone(), source, privileges }); + } + } + Ok(revoked) +} + +/// The keyword a `REVOKE ... ON` takes for one object, checked rather than interpolated: it lands +/// in SQL unquoted. +pub(crate) fn object_keyword(kind: &str) -> Result<&'static str> { + match kind.to_uppercase().as_str() { + "TABLE" | "VIEW" | "MATERIALIZED VIEW" | "FOREIGN TABLE" => Ok("TABLE"), + "SEQUENCE" => Ok("SEQUENCE"), + // `FUNCTION` names no procedure; `ROUTINE` names either. + "FUNCTION" | "PROCEDURE" | "ROUTINE" => Ok("ROUTINE"), + other => Err(Error::BadRequest(format!("Unknown object kind '{other}'"))), + } +} + +/// Every object of a schema, named the way the catalog names it. +async fn read_schema_objects( + client: &tokio_postgres::Client, + schema: &str, +) -> Result> { + let rows = client + .query( + "SELECT CASE c.relkind WHEN 'S' THEN 'SEQUENCE' ELSE 'TABLE' END, c.relname, NULL::text + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relkind = ANY(ARRAY['r','p','v','m','S','f']::\"char\"[]) + UNION ALL + SELECT CASE p.prokind WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END, p.proname, + pg_get_function_identity_arguments(p.oid) + FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = $1", + &[&schema], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to list the objects of schema '{schema}': {}", + pg_error_message(&e) + )) + })?; + Ok(rows + .into_iter() + .map(|row| AclObject { kind: row.get(0), name: row.get(1), args: row.get(2) }) + .collect()) +} + +/// Replace the objects a revoke names with the catalog's own entry for each. +/// +/// A routine is identified by its argument types, and those go into the statement as written — +/// there is no quoting for them — so the request may name an object but never spell one: what +/// reaches the SQL is read back from Postgres. An object that resolves to nothing is refused rather +/// than dropped, since a revoke that silently covers less than it says is worse than an error. +async fn resolve_acl_objects( + client: &tokio_postgres::Client, + target: &AclTarget, + objects: &[AclObject], +) -> Result> { + if objects.is_empty() { + return Ok(vec![]); + } + let Some(schema) = target.schema() else { + return Err(Error::BadRequest( + "A database has no objects of its own to revoke on".to_string(), + )); + }; + let known = read_schema_objects(client, schema).await?; + objects + .iter() + .map(|requested| { + let keyword = object_keyword(&requested.kind)?; + known + .iter() + .find(|k| { + k.name == requested.name + && k.args == requested.args + && object_keyword(&k.kind).is_ok_and(|k| k == keyword) + }) + .cloned() + .ok_or_else(|| { + Error::NotFound(format!( + "'{}' is not an object of schema '{schema}'", + requested.name + )) + }) + }) + .collect() +} + +async fn read_owner(client: &tokio_postgres::Client, target: &AclTarget) -> Result> { + let row = match target { + AclTarget::Database => { + client + .query_opt( + "SELECT pg_get_userbyid(datdba) FROM pg_database WHERE datname = current_database()", + &[], + ) + .await + } + AclTarget::Schema { schema } => { + client + .query_opt( + // `public` is owned by `pg_database_owner`, a placeholder role whose membership + // is whoever owns the database — naming it back would say nothing, so resolve + // it to that owner. + "SELECT pg_get_userbyid(owner) FROM ( + SELECT CASE WHEN n.nspowner = (SELECT oid FROM pg_roles WHERE rolname = 'pg_database_owner') + THEN (SELECT d.datdba FROM pg_database d WHERE d.datname = current_database()) + ELSE n.nspowner END AS owner + FROM pg_namespace n WHERE n.nspname = $1 + ) o", + &[schema], + ) + .await + } + AclTarget::Table { schema, table } => { + client + .query_opt( + "SELECT pg_get_userbyid(c.relowner) + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relname = $2", + &[schema, table], + ) + .await + } + } + .map_err(|e| Error::internal_err(format!("Failed to read the owner: {}", pg_error_message(&e))))?; + Ok(row.map(|row| row.get(0))) +} + +async fn read_children(client: &tokio_postgres::Client, target: &AclTarget) -> Result> { + let rows = match target { + AclTarget::Database => { + client + .query( + "SELECT nspname::text FROM pg_namespace + WHERE nspname <> 'information_schema' AND nspname NOT LIKE 'pg\\_%' + ORDER BY nspname", + &[], + ) + .await + } + AclTarget::Schema { schema } => { + client + .query( + "SELECT c.relname::text + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relkind = ANY(ARRAY['r','p']::\"char\"[]) + ORDER BY c.relname", + &[schema], + ) + .await + } + AclTarget::Table { .. } => return Ok(vec![]), + } + .map_err(|e| { + Error::internal_err(format!( + "Failed to list what the target holds: {}", + pg_error_message(&e) + )) + })?; + Ok(rows.into_iter().map(|row| row.get(0)).collect()) +} + +async fn get_datatable_acl( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, datatable_name)): Path<(String, String)>, + Query(query): Query, +) -> JsonResult { + crate::datatable_acl_oss::ensure_datatable_acl_available()?; + let target: AclTarget = query.try_into()?; + let governing = resolve_governing_datatable(&db, &w_id, &datatable_name).await?; + ensure_reaches_governing_datatable(&db, &w_id, &datatable_name, &governing, &authed).await?; + ensure_instance(&governing)?; + let editable = ensure_governs_datatable(&db, &authed, &w_id, &governing) + .await + .is_ok(); + let roles = if editable { + role_names(&read_role_catalog(&db).await?) + } else { + vec![] + }; + + let (client, _notices, dbname) = connect_as_admin_unchecked(&db, &governing).await?; + let owner = read_owner(&client, &target) + .await? + .ok_or_else(|| Error::NotFound(format!("{} not found", target.label(&dbname))))?; + let grants = read_grants(&client, &target).await?; + let supports_maintain: bool = client + .query_one( + "SELECT current_setting('server_version_num')::int >= 170000", + &[], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to read the server version: {}", + pg_error_message(&e) + )) + })? + .get(0); + let children = read_children(&client, &target).await?; + + Ok(Json(DatatableAclInfo { + owner: role_name_of(&owner), + roles, + editable, + clone: governing.governor.is_some(), + supports_maintain, + dbname, + grants, + children, + })) +} + +async fn read_grants(client: &tokio_postgres::Client, target: &AclTarget) -> Result> { + // `aclexplode` turns an acl array into one row per (grantee, privilege); grantee 0 is PUBLIC, + // which has no name to resolve. A NULL acl is not "no access" but Postgres's built-in default — + // the owner holds everything and, on a routine, PUBLIC may EXECUTE — hence `acldefault`. The + // owner's own entries are left out: what it holds comes with ownership, which the owner shows, + // not with a grant a revoke here could take back. Each row ends with its source — the grantor, + // or a default privilege's creating role — and whether this connection can take back what it + // gave. + // Column-level grants (`pg_attribute.attacl`) are not supported yet: they are neither read here + // nor revocable from the editor. + let mut rows = match target { + AclTarget::Database => { + let mut out = client + .query( + concat!( + "SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END, + a.privilege_type, NULL::text, NULL::text, NULL::text, NULL::text, ", + grant_source!("d.datdba"), + " FROM pg_database d, aclexplode(COALESCE(d.datacl, acldefault('d', d.datdba))) a + WHERE d.datname = current_database() AND a.grantee <> d.datdba" + ), + &[], + ) + .await + .map_err(grant_read_error)?; + out.extend( + client + .query( + // Default privileges set database-wide reach what is created in every + // schema, so they are the database's to show rather than any schema's. + concat!( + "SELECT CASE WHEN g.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(g.grantee) END, + g.privilege_type, NULL::text, + CASE g.defaclobjtype + WHEN 'r' THEN 'TABLES' WHEN 'S' THEN 'SEQUENCES' + WHEN 'f' THEN 'FUNCTIONS' WHEN 'n' THEN 'SCHEMAS' + ELSE 'TYPES' END, NULL::text, NULL::text, + pg_get_userbyid(g.defaclrole), pg_has_role(g.defaclrole, 'USAGE') + FROM (", + database_wide_defaults!(), + ") g" + ), + &[], + ) + .await + .map_err(grant_read_error)?, + ); + out + } + AclTarget::Schema { schema } => { + let mut out = client + .query( + concat!( + "SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END, + a.privilege_type, NULL::text, NULL::text, NULL::text, NULL::text, ", + grant_source!("n.nspowner"), + " FROM pg_namespace n, aclexplode(COALESCE(n.nspacl, acldefault('n', n.nspowner))) a + WHERE n.nspname = $1 AND a.grantee <> n.nspowner" + ), + &[schema], + ) + .await + .map_err(grant_read_error)?; + out.extend( + client + .query( + concat!( + "SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END, + a.privilege_type, c.relname, NULL::text, + CASE c.relkind WHEN 'S' THEN 'SEQUENCE' ELSE 'TABLE' END, + NULL::text, ", + grant_source!("c.relowner"), + " FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace, + aclexplode(COALESCE(c.relacl, acldefault( + CASE c.relkind WHEN 'S' THEN 's' ELSE 'r' END::\"char\", c.relowner))) a + WHERE n.nspname = $1 + AND c.relkind = ANY(ARRAY['r','p','v','m','S','f']::\"char\"[]) + AND a.grantee <> c.relowner" + ), + &[schema], + ) + .await + .map_err(grant_read_error)?, + ); + out.extend( + client + .query( + // Routines carry their own acl in `pg_proc`; without this a grant made here + // would vanish on the next read and could never be revoked back. + concat!( + "SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END, + a.privilege_type, p.proname, NULL::text, + CASE p.prokind WHEN 'p' THEN 'PROCEDURE' ELSE 'FUNCTION' END, + pg_get_function_identity_arguments(p.oid), ", + grant_source!("p.proowner"), + " FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace, + aclexplode(COALESCE(p.proacl, acldefault('f', p.proowner))) a + WHERE n.nspname = $1 AND a.grantee <> p.proowner" + ), + &[schema], + ) + .await + .map_err(grant_read_error)?, + ); + out.extend( + client + .query( + // `USAGE` on a type is what lets a role use it in a column. Only a type the + // schema holds in its own right has an acl: an array, a row type and a + // multirange answer to their element, table or range. + concat!( + "SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END, + a.privilege_type, t.typname, NULL::text, 'TYPE', NULL::text, ", + grant_source!("t.typowner"), + " FROM pg_type t + JOIN pg_depend d ON d.classid = 'pg_type'::regclass AND d.objid = t.oid + AND d.refclassid = 'pg_namespace'::regclass AND d.deptype = 'n', + aclexplode(COALESCE(t.typacl, acldefault('T', t.typowner))) a + WHERE d.refobjid = (SELECT oid FROM pg_namespace WHERE nspname = $1) + AND a.grantee <> t.typowner + AND NOT EXISTS ( + SELECT 1 FROM pg_depend x + WHERE x.classid = 'pg_type'::regclass AND x.objid = t.oid + AND x.objsubid = 0 AND x.deptype = 'i')" + ), + &[schema], + ) + .await + .map_err(grant_read_error)?, + ); + out.extend( + client + .query( + // What a creating role set is taken back `FOR ROLE` that role, which only + // a role acting for it may do. + "SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END, + a.privilege_type, NULL::text, + CASE d.defaclobjtype + WHEN 'r' THEN 'TABLES' WHEN 'S' THEN 'SEQUENCES' + WHEN 'f' THEN 'FUNCTIONS' ELSE 'TYPES' END, NULL::text, NULL::text, + pg_get_userbyid(d.defaclrole), pg_has_role(d.defaclrole, 'USAGE') + FROM pg_default_acl d + JOIN pg_namespace n ON n.oid = d.defaclnamespace, + aclexplode(d.defaclacl) a + WHERE n.nspname = $1", + &[schema], + ) + .await + .map_err(grant_read_error)?, + ); + out + } + AclTarget::Table { schema, table } => client + .query( + concat!( + "SELECT CASE WHEN a.grantee = 0 THEN 'PUBLIC' ELSE pg_get_userbyid(a.grantee) END, + a.privilege_type, NULL::text, NULL::text, NULL::text, NULL::text, ", + grant_source!("c.relowner"), + " FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace, + aclexplode(COALESCE(c.relacl, acldefault( + CASE c.relkind WHEN 'S' THEN 's' ELSE 'r' END::\"char\", c.relowner))) a + WHERE n.nspname = $1 AND c.relname = $2 AND a.grantee <> c.relowner" + ), + &[schema, table], + ) + .await + .map_err(grant_read_error)?, + }; + + // One row per privilege and source — and, for default privileges, per creating role. Fold them + // back into one entry per grantee and object that keeps every source and what each gave: a + // revoke takes a privilege back from every source that gave it. + let mut folded: BTreeMap< + ( + String, + Option<(String, String, Option)>, + Option, + ), + (Vec, BTreeMap)>), + > = BTreeMap::new(); + for row in rows.drain(..) { + let grantee: String = row.get(0); + let privilege: String = row.get(1); + let object: Option = row.get(2); + let future: Option = row.get(3); + let object_kind: Option = row.get(4); + let object_args: Option = row.get(5); + let source: String = row.get(6); + let reachable: bool = row.get(7); + let (privileges, sources) = folded + .entry(( + role_name_of(&grantee), + object.map(|name| { + ( + name, + object_kind.unwrap_or_else(|| "TABLE".to_string()), + object_args, + ) + }), + future, + )) + .or_default(); + sources + .entry(role_name_of(&source)) + .or_insert_with(|| (reachable, vec![])) + .1 + .push(privilege.clone()); + privileges.push(privilege); + } + Ok(folded + .into_iter() + .map(|((grantee, object, future), (mut privileges, sources))| { + privileges.sort(); + privileges.dedup(); + AclGrant { + grantee, + privileges, + object: object.map(|(name, kind, args)| AclObject { name, kind, args }), + future, + sources: sources + .into_iter() + .map(|(role, (reachable, mut privileges))| { + privileges.sort(); + privileges.dedup(); + AclSource { role, privileges, reachable } + }) + .collect(), + } + }) + .collect()) +} + +fn grant_read_error(e: tokio_postgres::Error) -> Error { + Error::internal_err(format!("Failed to read grants: {}", pg_error_message(&e))) +} + +/// Changing a data table's access is administering it. Checked in full before anything connects +/// with the instance's credentials. +async fn authorize_acl_change( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + datatable_name: &str, +) -> Result { + let governing = resolve_governing_datatable(db, w_id, datatable_name).await?; + ensure_governs_datatable(db, authed, w_id, &governing).await?; + ensure_instance(&governing)?; + Ok(governing) +} + +static APPLY_SLOT: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(1); + +/// A role passes on only privileges it holds with grant option, and an instance database +/// provisioned before data table roles gave `custom_instance_user` none. Adds that option to its +/// database and `public` privileges, and nothing else: default privileges are left alone, since a +/// schema's change of owner is planned against them. Best-effort, as a grant it fails to enable is +/// refused when it runs. +async fn ensure_grant_options(client: &tokio_postgres::Client, db: &DB, dbname: &str) { + let held = client + .query_one( + "SELECT has_database_privilege(current_database(), 'CONNECT WITH GRANT OPTION') + AND has_database_privilege(current_database(), 'CREATE WITH GRANT OPTION') + AND (to_regnamespace('public') IS NULL + OR (has_schema_privilege('public', 'USAGE WITH GRANT OPTION') + AND has_schema_privilege('public', 'CREATE WITH GRANT OPTION')))", + &[], + ) + .await + .is_ok_and(|row| row.get::<_, bool>(0)); + if held { + return; + } + if let Err(e) = grant_options_as_server(db, dbname).await { + tracing::warn!("Could not enable grant options on '{dbname}': {e}"); + } +} + +/// Only the database's owner, the server's own Postgres user, can hand out an option it holds. +async fn grant_options_as_server(db: &DB, dbname: &str) -> Result<()> { + let server = PgDatabase::parse_uri(&windmill_common::get_database_url().await?.as_str().await)?; + let creds = PgDatabase { dbname: dbname.to_string(), ..server }; + let (client, connection) = creds.connect(Some(db)).await?; + let join_handle = tokio::spawn(async move { connection.await }); + let role = quote_ident(CUSTOM_INSTANCE_USER); + let result = client + .batch_execute(&format!( + "GRANT CONNECT, CREATE ON DATABASE {} TO {role} WITH GRANT OPTION; + DO $$ BEGIN + IF to_regnamespace('public') IS NOT NULL THEN + GRANT USAGE, CREATE ON SCHEMA public TO {role} WITH GRANT OPTION; + END IF; + END $$;", + quote_ident(dbname) + )) + .await; + drop(client); + windmill_common::shutdown_pg_connection(join_handle).await?; + result.map_err(|e| { + Error::internal_err(format!( + "Failed to grant options on '{dbname}': {}", + pg_error_message(&e) + )) + }) +} + +/// Whether the governing entry an apply was authorized on is still the one in the settings, read +/// under the lock: a save in between could have pointed it at another database or changed its roles. +fn entry_unchanged(governing: &GoverningDatatable, entry_now: Option) -> bool { + let Some(Ok(now)) = entry_now.map(serde_json::from_value::) else { + return false; + }; + match ( + serde_json::to_value(&now), + serde_json::to_value(&governing.datatable), + ) { + (Ok(now), Ok(authorized)) => now == authorized, + _ => false, + } +} + +/// Plan one change against the catalog and the database as they are now. +async fn build_plan( + client: &tokio_postgres::Client, + dbname: &str, + catalog: &DatatableRoleCatalog, + target: &AclTarget, + change: &AclChange, +) -> Result { + let role = change.role(); + // `admin` is the login the data table itself reaches Postgres through, and the one every + // change here runs as: a revoke that lands leaves nothing able to grant it back. + if matches!(change, AclChange::Revoke { .. }) && role == ADMIN_DATATABLE_ROLE { + return Err(Error::BadRequest(format!( + "'{ADMIN_DATATABLE_ROLE}' is how this data table reaches its database; \ + its own access is not revocable from here" + ))); + } + let pg_role = pg_role_of(role, catalog)?; + let change = match change { + AclChange::Revoke { role, privileges, scope, objects } => AclChange::Revoke { + role: role.clone(), + privileges: privileges.clone(), + scope: *scope, + objects: resolve_acl_objects(client, target, objects).await?, + }, + change => change.clone(), + }; + // Default privileges are recorded per creating role, and a schema's new owner is kept in reach + // of what the others create there, so both are written for every role there is. + let other_pg_roles = role_names(catalog) + .iter() + .map(|name| pg_role_of(name, catalog)) + .filter(|r| r.as_ref().map_or(true, |r| *r != pg_role)) + .collect::>>()?; + let (existing_objects, former_owner) = match (&change, target) { + (AclChange::SetOwner { .. }, AclTarget::Schema { schema }) => ( + read_owned_objects(client, schema).await?, + read_former_owner_defaults(client, schema, &pg_role).await?, + ), + _ => (vec![], None), + }; + let revoked_grants = match &change { + AclChange::Revoke { privileges, scope, objects, .. } => { + read_revoked_grants( + client, dbname, target, *scope, objects, privileges, &pg_role, + ) + .await? + } + _ => vec![], + }; + if matches!(change, AclChange::SetOwner { .. }) { + if let Some((object, owner)) = unmanaged_owner(client, target).await? { + return Err(Error::BadRequest(format!( + "{object} is owned by {owner}, which this data table's connection cannot act \ + for, so its owner cannot be changed from here" + ))); + } + } + let facts = CatalogFacts { other_pg_roles, existing_objects, former_owner, revoked_grants }; + let mut plan = + crate::datatable_acl_oss::plan_statements(target, &change, dbname, &pg_role, &facts)?; + if matches!(change, AclChange::SetOwner { .. }) { + if let Some(missing) = missing_owner_privilege(client, target, &pg_role).await? { + plan.warnings.push(format!( + "{role} does not have {missing}, which Postgres requires of a new owner, so this \ + will be refused. Grant it first." + )); + } + } + Ok(plan) +} + +/// Postgres only hands an object to a role that could have created it: a table to one with +/// `CREATE` on its schema, a schema to one with `CREATE` on the database. +async fn missing_owner_privilege( + client: &tokio_postgres::Client, + target: &AclTarget, + pg_role: &str, +) -> Result> { + let (row, missing) = match target { + AclTarget::Table { schema, .. } => ( + client + .query_one( + "SELECT has_schema_privilege($1::name, $2::text, 'CREATE')", + &[&pg_role, schema], + ) + .await, + format!("CREATE on schema {schema}"), + ), + AclTarget::Schema { .. } => ( + client + .query_one( + "SELECT has_database_privilege($1::name, current_database(), 'CREATE')", + &[&pg_role], + ) + .await, + "CREATE on the database".to_string(), + ), + AclTarget::Database => return Ok(None), + }; + let has: bool = row + .map_err(|e| { + Error::internal_err(format!( + "Failed to read the new owner's privileges: {}", + pg_error_message(&e) + )) + })? + .get(0); + Ok((!has).then_some(missing)) +} + +/// The first thing a change of owner would move that this connection cannot act for, with its +/// owner. Postgres lets only a member of the current owner move an object, and every change runs as +/// `custom_instance_user`, so an object it does not hold the owner of — `public`, owned by the +/// database's owner, above all — is refused here rather than at apply. Running as the instance's +/// own user instead would reach objects Windmill never created. +async fn unmanaged_owner( + client: &tokio_postgres::Client, + target: &AclTarget, +) -> Result> { + let read_error = |e: tokio_postgres::Error| { + Error::internal_err(format!( + "Failed to read who owns what the change moves: {}", + pg_error_message(&e) + )) + }; + match target { + AclTarget::Database => Ok(None), + AclTarget::Table { schema, table } => { + let row = client + .query_opt( + "SELECT pg_get_userbyid(c.relowner) + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = $1 AND c.relname = $2 AND NOT pg_has_role(c.relowner, 'USAGE')", + &[schema, table], + ) + .await + .map_err(read_error)?; + Ok(row.map(|row| (format!("{schema}.{table}"), row.get(0)))) + } + AclTarget::Schema { schema } => { + let row = client + .query_opt( + concat!( + "SELECT ord, identity, pg_get_userbyid(owner) FROM ( + SELECT 0 AS ord, NULL::text AS identity, n.nspowner AS owner + FROM pg_namespace n WHERE n.nspname = $1 + UNION ALL + SELECT 1, identity, owner FROM (", + schema_owned_objects!(), + ") m + ) o WHERE NOT pg_has_role(owner, 'USAGE') ORDER BY ord, identity LIMIT 1" + ), + &[schema], + ) + .await + .map_err(read_error)?; + Ok(row.map(|row| { + let label = row + .get::<_, Option>(1) + .unwrap_or_else(|| format!("schema {schema}")); + (label, row.get(2)) + })) + } + } +} + +async fn plan_datatable_acl( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, datatable_name)): Path<(String, String)>, + Json(req): Json, +) -> JsonResult { + crate::datatable_acl_oss::ensure_datatable_acl_available()?; + let governing = authorize_acl_change(&db, &authed, &w_id, &datatable_name).await?; + let catalog = read_role_catalog(&db).await?; + let (client, _notices, dbname) = connect_as_admin_unchecked(&db, &governing).await?; + Ok(Json( + build_plan(&client, &dbname, &catalog, &req.target, &req.change).await?, + )) +} + +async fn apply_datatable_acl( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, datatable_name)): Path<(String, String)>, + Json(req): Json, +) -> Result { + crate::datatable_acl_oss::ensure_datatable_acl_available()?; + let confirmed = req.statements.as_ref().ok_or_else(|| { + Error::BadRequest( + "An apply runs exactly the statements its plan showed; plan the change first" + .to_string(), + ) + })?; + // Everything that needs the pool happens before the locks: once `tx` holds them, a second pool + // connection could wait forever on a pool that concurrent applies, queued on the same locks, + // have exhausted. + let governing = authorize_acl_change(&db, &authed, &w_id, &datatable_name).await?; + // Applies queue on an instance-wide lock while each holds a direct connection to the instance's + // Postgres; unbounded, the queue alone could exhaust its connection limit. One at a time per + // server, and the ones waiting hold no connection at all. + let _slot = APPLY_SLOT + .acquire() + .await + .map_err(|e| Error::internal_err(format!("ACL apply slot closed: {e}")))?; + let (mut client, mut notices, dbname) = connect_as_admin_unchecked(&db, &governing).await?; + ensure_grant_options(&client, &db, &dbname).await; + + // Held until the change is committed: a role renamed or dropped meanwhile would change what + // the plan names, and a settings save could move the entry onto another database. Taken in the + // same order as the permissions save, so the two cannot deadlock. + let mut tx = db.begin().await?; + lock_role_catalog(&mut tx).await?; + let entry_now = sqlx::query_scalar::<_, Option>( + "SELECT datatable->'datatables'->$2 FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE", + ) + .bind(&governing.workspace_id) + .bind(&governing.name) + .fetch_optional(&mut *tx) + .await? + .flatten(); + let catalog = read_role_catalog_tx(&mut tx).await?; + + let plan = build_plan(&client, &dbname, &catalog, &req.target, &req.change).await?; + if !entry_unchanged(&governing, entry_now) || &plan.statements != confirmed { + return Err(Error::BadRequest( + "The data table or its roles changed since this was planned, so it would no longer \ + run what was confirmed. Plan it again." + .to_string(), + )); + } + + // One transaction: a half-applied ownership transfer leaves one schema's objects owned by two + // different roles. + let pg_tx = client.transaction().await.map_err(|e| { + Error::internal_err(format!( + "Failed to open a transaction on the data table: {}", + pg_error_message(&e) + )) + })?; + execute_acl_statements(&pg_tx, &mut notices, &plan.statements).await?; + + // A schema's objects were listed before the transaction opened; one committed since would stay + // with its old owner. One created while this transaction is still open can still slip past, as + // Postgres has no lock that holds creation in a schema back. That is benign: it stays with its + // creator, like anything created there later, and moving that table fixes it. + if let (AclChange::SetOwner { role }, AclTarget::Schema { schema }) = (&req.change, &req.target) + { + let new_owner = pg_role_of(role, &catalog)?; + let straggler = pg_tx + .query_opt( + concat!( + "SELECT identity FROM (", + schema_owned_objects!(), + ") m WHERE owner <> (SELECT oid FROM pg_roles WHERE rolname = $2) + ORDER BY 1 LIMIT 1" + ), + &[schema, &new_owner], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to check what the schema holds: {}", + pg_error_message(&e) + )) + })?; + if let Some(row) = straggler { + return Err(Error::BadRequest(format!( + "{} appeared while this ran and would keep its old owner, so nothing was \ + applied. Plan it again.", + row.get::<_, String>(0) + ))); + } + } + + let target_label = req.target.label(&dbname); + audit_log( + &mut *tx, + &authed, + "workspaces.datatable_acl", + ActionKind::Update, + &governing.workspace_id, + Some(&governing.name), + Some( + [ + ("target", target_label.as_str()), + ("change", change_kind(&req.change)), + ("role", req.change.role()), + ] + .into(), + ), + ) + .await?; + pg_tx.commit().await.map_err(|e| { + Error::internal_err(format!( + "Failed to commit the changes: {}", + pg_error_message(&e) + )) + })?; + tx.commit().await?; + + windmill_common::feature_usage::log_feature_usage( + "datatable", + "acl_applied", + change_kind(&req.change), + ); + + Ok(format!("Updated access on {target_label}")) +} + +/// What kind of change, never what it named: the telemetry key and the audit's summary. +fn change_kind(change: &AclChange) -> &'static str { + match change { + AclChange::SetOwner { .. } => "owner", + AclChange::Grant { scope, .. } | AclChange::Revoke { scope, .. } if scope.is_future() => { + "default_privileges" + } + AclChange::Grant { .. } => "grant", + AclChange::Revoke { .. } => "revoke", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use windmill_common::datatable_roles::InstanceDatatableRole; + + #[test] + fn a_role_is_its_own_postgres_role_except_admin() { + let catalog: DatatableRoleCatalog = BTreeMap::from([( + "role1".to_string(), + InstanceDatatableRole { name: "analytics".to_string(), enabled: true, pwd: None }, + )]); + assert_eq!( + pg_role_of("admin", &catalog).unwrap(), + "custom_instance_user" + ); + assert_eq!(pg_role_of("analytics", &catalog).unwrap(), "analytics"); + assert_eq!(role_name_of("custom_instance_user"), "admin"); + assert_eq!(role_name_of("analytics"), "analytics"); + // A catalog id, a name the catalog lacks, or the admin login spelled out never stands for + // some other role. + for unknown in ["role1", "operator", "custom_instance_user", "PUBLIC", ""] { + assert!( + matches!(pg_role_of(unknown, &catalog), Err(Error::BadRequest(_))), + "{unknown}" + ); + } + } + + /// A connection to the test's own database, the way the handlers reach a data table's. + async fn catalog_client(pool: &sqlx::PgPool) -> tokio_postgres::Client { + let mut config: tokio_postgres::Config = + std::env::var("DATABASE_URL").unwrap().parse().unwrap(); + config.dbname(pool.connect_options().get_database().unwrap()); + let (client, connection) = config.connect(tokio_postgres::NoTls).await.unwrap(); + tokio::spawn(connection); + client + } + + /// What a revoke takes back is read from the catalog, per object and source, and only the + /// privileges it asks for: the planner renders exactly this, and refuses an empty read. + #[sqlx::test(migrations = false)] + async fn a_revoke_reads_back_only_what_it_asks_for(pool: sqlx::PgPool) { + let client = catalog_client(&pool).await; + // A predefined role, so that nothing is granted outside the test's own database. + client + .batch_execute( + "CREATE SCHEMA granted; + CREATE TABLE granted.g (id int); + GRANT SELECT, INSERT ON granted.g TO pg_read_all_data;", + ) + .await + .unwrap(); + let target = AclTarget::Table { schema: "granted".to_string(), table: "g".to_string() }; + let revoked = read_revoked_grants( + &client, + "db", + &target, + GrantScope::Target, + &[], + &["select".to_string()], + "pg_read_all_data", + ) + .await + .unwrap(); + assert_eq!(revoked.len(), 1, "{revoked:?}"); + assert_eq!(revoked[0].object, None); + assert_eq!(revoked[0].privileges, ["SELECT"]); + let held_none = read_revoked_grants( + &client, + "db", + &target, + GrantScope::Target, + &[], + &["update".to_string()], + "pg_read_all_data", + ) + .await + .unwrap(); + assert!(held_none.is_empty(), "{held_none:?}"); + // Each source says what it gave: that, and not the whole row, is what a revoke of some of + // its privileges is held back by. + let grants = read_grants( + &client, + &AclTarget::Schema { schema: "granted".to_string() }, + ) + .await + .unwrap(); + let on_g = grants + .iter() + .find(|g| { + g.grantee == "pg_read_all_data" && g.object.as_ref().is_some_and(|o| o.name == "g") + }) + .unwrap(); + assert_eq!(on_g.sources.len(), 1, "{:?}", on_g.sources); + assert_eq!(on_g.sources[0].privileges, ["INSERT", "SELECT"]); + assert!(on_g.sources[0].reachable); + } + + /// A default privilege set database-wide applies in every schema on top of the schema's own, + /// which cannot take it back: the database shows it, and neither a schema's revoke of it nor a + /// change of owner away from its grantee may go ahead as if it were gone. + #[sqlx::test(migrations = false)] + async fn a_schema_cannot_take_back_a_database_wide_default(pool: sqlx::PgPool) { + let client = catalog_client(&pool).await; + client + .batch_execute( + "CREATE SCHEMA owned AUTHORIZATION pg_read_all_data; + ALTER DEFAULT PRIVILEGES GRANT SELECT ON TABLES TO pg_read_all_data; + ALTER DEFAULT PRIVILEGES IN SCHEMA owned + GRANT SELECT, INSERT ON TABLES TO pg_read_all_data; + CREATE SCHEMA creators AUTHORIZATION pg_monitor; + ALTER DEFAULT PRIVILEGES FOR ROLE pg_monitor + GRANT SELECT ON TABLES TO pg_read_all_stats;", + ) + .await + .unwrap(); + let creator: String = client + .query_one("SELECT current_user::text", &[]) + .await + .unwrap() + .get(0); + + let grants = read_grants(&client, &AclTarget::Database).await.unwrap(); + let database_wide = grants + .iter() + .find(|g| g.grantee == "pg_read_all_data" && g.future.as_deref() == Some("TABLES")) + .unwrap_or_else(|| panic!("{grants:?}")); + assert_eq!(database_wide.privileges, ["SELECT"]); + // A database-wide entry also holds its creator's own privileges, which come with creating + // and are no grant: neither a row nor a refusal may stem from them. + assert!( + !grants + .iter() + .any(|g| g.future.is_some() && (g.grantee == creator || g.grantee == "pg_monitor")), + "{grants:?}" + ); + assert_eq!( + database_wide.sources.len(), + 1, + "{:?}", + database_wide.sources + ); + assert_eq!(database_wide.sources[0].role, creator); + + let schema = AclTarget::Schema { schema: "owned".to_string() }; + let still_granted = read_revoked_grants( + &client, + "db", + &schema, + GrantScope::FutureTables, + &[], + &["select".to_string()], + "pg_read_all_data", + ) + .await; + assert!( + matches!(&still_granted, Err(Error::BadRequest(m)) if m.contains("database-wide")), + "{still_granted:?}" + ); + let schema_only = read_revoked_grants( + &client, + "db", + &schema, + GrantScope::FutureTables, + &[], + &["insert".to_string()], + "pg_read_all_data", + ) + .await + .unwrap(); + assert_eq!(schema_only.len(), 1, "{schema_only:?}"); + assert_eq!(schema_only[0].privileges, ["INSERT"]); + + let moved = read_former_owner_defaults(&client, "owned", "pg_write_all_data").await; + assert!( + matches!(&moved, Err(Error::BadRequest(m)) if m.contains("database-wide")), + "{moved:?}" + ); + let moved_from_creator = + read_former_owner_defaults(&client, "creators", "pg_write_all_data").await; + assert!( + matches!(moved_from_creator, Ok(None)), + "{moved_from_creator:?}" + ); + } + + /// Defaults on types go with a schema's owner like the other kinds: once a database-wide + /// default takes PUBLIC's USAGE on types away, they are all that reaches a new type. + #[sqlx::test(migrations = false)] + async fn a_schemas_former_owner_defaults_include_types(pool: sqlx::PgPool) { + let client = catalog_client(&pool).await; + client + .batch_execute( + "CREATE SCHEMA typed AUTHORIZATION pg_read_all_data; + ALTER DEFAULT PRIVILEGES IN SCHEMA typed GRANT USAGE ON TYPES TO pg_read_all_data; + ALTER DEFAULT PRIVILEGES FOR ROLE pg_read_all_data IN SCHEMA typed + GRANT SELECT ON TABLES TO pg_read_all_data;", + ) + .await + .unwrap(); + let creator: String = client + .query_one("SELECT current_user::text", &[]) + .await + .unwrap() + .get(0); + let former = read_former_owner_defaults(&client, "typed", "pg_write_all_data") + .await + .unwrap(); + assert_eq!( + former, + Some(FormerOwnerDefaults { + pg_role: "pg_read_all_data".to_string(), + defaults: vec![(creator, "TYPES")], + }) + ); + } + + /// A kind of object the list misses stays with its old owner while the schema changes hands, + /// which only a real catalog shows. + #[sqlx::test(migrations = false)] + async fn a_schemas_owner_change_takes_every_object_in_it(pool: sqlx::PgPool) { + let client = catalog_client(&pool).await; + client + .batch_execute( + "CREATE SCHEMA moved; + CREATE TABLE moved.t (id serial PRIMARY KEY, a int, b int); + CREATE STATISTICS moved.st ON a, b FROM moved.t; + CREATE TABLE moved.pt (id int) PARTITION BY RANGE (id); + CREATE TABLE moved.pt1 PARTITION OF moved.pt FOR VALUES FROM (0) TO (10); + CREATE TYPE moved.r AS RANGE (subtype = float8); + CREATE TYPE moved.pair AS (a int, b int); + CREATE TYPE moved.mood AS ENUM ('ok'); + CREATE DOMAIN moved.tags AS text[]; + CREATE COLLATION moved.coll (provider = libc, locale = 'C');", + ) + .await + .unwrap(); + let owned = read_owned_objects(&client, "moved").await.unwrap(); + // Not the serial's sequence, the range's constructors and multirange, or any array or row + // type: each follows the object it belongs to. + assert_eq!( + owned + .iter() + .map(|o| (o.keyword, o.identity.as_str())) + .collect::>(), + [ + ("COLLATION", "moved.coll"), + ("STATISTICS", "moved.st"), + ("TABLE", "moved.pt"), + ("TABLE", "moved.pt1"), + ("TABLE", "moved.t"), + ("TYPE", "moved.mood"), + ("TYPE", "moved.pair"), + ("TYPE", "moved.r"), + ("TYPE", "moved.tags"), + ] + ); + } +} diff --git a/backend/windmill-api-workspaces/src/datatable_acl_oss.rs b/backend/windmill-api-workspaces/src/datatable_acl_oss.rs new file mode 100644 index 0000000000..59eb8cdbe0 --- /dev/null +++ b/backend/windmill-api-workspaces/src/datatable_acl_oss.rs @@ -0,0 +1,44 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Where the ACL planner comes from: the enterprise one, or a refusal. +//! +//! Data table roles are an Enterprise Edition feature, and so is everything here — reading who +//! owns what included. `private` alone is not that edition — community builds carry it — so the +//! planner is behind `enterprise` as well. + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub(crate) use crate::datatable_acl_ee::plan_statements; + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub(crate) fn ensure_datatable_acl_available() -> windmill_common::error::Result<()> { + Ok(()) +} + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +use { + crate::datatable_acl::{AclChange, AclPlan, AclTarget, CatalogFacts}, + windmill_common::{datatable_roles_oss::datatable_roles_unavailable, error::Result}, +}; + +/// Checked first by every ACL route, before anything is read or connected to. +#[cfg(not(all(feature = "private", feature = "enterprise")))] +pub(crate) fn ensure_datatable_acl_available() -> Result<()> { + Err(datatable_roles_unavailable()) +} + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +pub(crate) fn plan_statements( + _target: &AclTarget, + _change: &AclChange, + _dbname: &str, + _pg_role: &str, + _facts: &CatalogFacts, +) -> Result { + Err(datatable_roles_unavailable()) +} diff --git a/backend/windmill-api-workspaces/src/datatable_clone.rs b/backend/windmill-api-workspaces/src/datatable_clone.rs new file mode 100644 index 0000000000..a57a2216e5 --- /dev/null +++ b/backend/windmill-api-workspaces/src/datatable_clone.rs @@ -0,0 +1,566 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Copying a data table's database for the fork being created. +//! +//! The fork request makes its copies before it writes the fork: each is created, filled and — for a +//! data table under roles — given the source's owners and grants. What each copy was made from +//! stays in the request ([`MadeCopy`]), so the fork's transaction checks it against the source as it +//! is then, and a fork that fails drops the instance copies it made ([`drop_copies_after`]) and names +//! the others, which live on servers of the workspace's own. + +use std::collections::BTreeSet; + +use windmill_api_auth::ApiAuthed; +use windmill_common::datatable_roles::{lock_role_catalog, read_role_catalog_tx}; +use windmill_common::error::{pg_error_message, Error, Result}; +use windmill_common::utils::require_admin; +use windmill_common::worker::CLOUD_HOSTED; +use windmill_common::workspaces::{ + get_datatable_resource_from_db_unchecked, DataTableDatabase, DataTableForkBehavior, + GoverningDatatable, +}; +use windmill_common::{PgDatabase, DB}; + +use crate::datatable_acl::connect_with_notices; +use crate::datatable_permissions::ensure_reaches_governing_datatable; +use crate::workspaces::{ + create_database_on_server, ensure_datatable_is_clonable, pg_dump_database, pg_import_dump, + DumpFile, PgDumpOptions, +}; + +/// A database this request created and filled for one data table of the fork. +pub(crate) struct MadeCopy { + /// The data table's name, in the parent and in the fork. + pub(crate) name: String, + pub(crate) dbname: String, + pub(crate) behavior: DataTableForkBehavior, + /// The database the source resolved to when it was copied. + pub(crate) source_database: DataTableDatabase, + /// Whether the source's owners and grants were replayed into the copy: it was under roles. + pub(crate) replayed: bool, + /// For a resource-backed copy, the resource and variables its connection was resolved from. + pub(crate) connection: Option, +} + +/// What a resource-backed data table's connection is resolved from: its resource, and every +/// resource and variable that one references, as stored — and, for a secret kept in an external +/// backend, the value that backend holds. The connection is resolved from the snapshot itself +/// ([`ConnectionSnapshot::resolve`]), so it is exactly the one these rows describe. +#[derive(PartialEq)] +pub(crate) struct ConnectionSnapshot { + root: String, + resources: std::collections::BTreeMap>, + /// Stored value and whether it is secret. + variables: std::collections::BTreeMap>, + external_secrets: std::collections::BTreeMap, +} + +/// Read the [`ConnectionSnapshot`] of resource `resource_path` in workspace `w_id`. +/// +/// Authorization: none, and it holds secret values. Callers MUST have authorized using that +/// resource, and never disclose the snapshot or what it resolves to. +pub(crate) async fn connection_snapshot( + db: &DB, + conn: &mut sqlx::PgConnection, + w_id: &str, + resource_path: &str, +) -> Result { + let root = resource_path.trim_start_matches("$res:").to_string(); + let mut snapshot = ConnectionSnapshot { + root: root.clone(), + resources: Default::default(), + variables: Default::default(), + external_secrets: Default::default(), + }; + let mut pending = vec![root]; + while let Some(path) = pending.pop() { + if snapshot.resources.contains_key(&path) { + continue; + } + let value: Option = + sqlx::query_scalar("SELECT value FROM resource WHERE workspace_id = $1 AND path = $2") + .bind(w_id) + .bind(&path) + .fetch_optional(&mut *conn) + .await? + .flatten(); + let mut strings = vec![]; + collect_strings(value.as_ref(), &mut strings); + for reference in strings { + if let Some(var) = reference.strip_prefix("$var:") { + if snapshot.variables.contains_key(var) { + continue; + } + let row: Option<(String, bool)> = sqlx::query_as( + "SELECT value, is_secret FROM variable WHERE workspace_id = $1 AND path = $2", + ) + .bind(w_id) + .bind(var) + .fetch_optional(&mut *conn) + .await?; + if let Some((stored, true)) = &row { + if windmill_common::secret_backend::is_external_stored_value(stored) { + let secret = windmill_common::secret_backend::get_secret_value( + db, w_id, var, stored, + ) + .await?; + snapshot.external_secrets.insert(var.to_string(), secret); + } + } + snapshot.variables.insert(var.to_string(), row); + } else if let Some(res) = reference.strip_prefix("$res:") { + pending.push(res.to_string()); + } + } + snapshot.resources.insert(path, value); + } + Ok(snapshot) +} + +impl ConnectionSnapshot { + /// The connection these rows resolve to, as the data table's own resolution would: references + /// substituted, secrets decrypted. + pub(crate) async fn resolve(&self, db: &DB, w_id: &str) -> Result { + let root = self.resource(&self.root)?; + self.substitute(db, w_id, root, 0).await + } + + /// Whether the resource itself holds the connection's fields, which the fork repoints by setting + /// its `dbname`, rather than being a reference to another resource. + pub(crate) fn root_holds_connection(&self) -> bool { + self.resource(&self.root).is_ok_and(|v| v.is_object()) + } + + fn resource(&self, path: &str) -> Result<&serde_json::Value> { + self.resources + .get(path) + .and_then(|v| v.as_ref()) + .ok_or_else(|| Error::NotFound(format!("resource {path} does not exist"))) + } + + fn substitute<'a>( + &'a self, + db: &'a DB, + w_id: &'a str, + value: &'a serde_json::Value, + depth: usize, + ) -> std::pin::Pin> + Send + 'a>> + { + Box::pin(async move { + if depth > 32 { + return Err(Error::BadRequest( + "resource references nest too deeply".to_string(), + )); + } + Ok(match value { + serde_json::Value::Object(map) => { + let mut out = serde_json::Map::new(); + for (key, val) in map { + out.insert(key.clone(), self.substitute(db, w_id, val, depth).await?); + } + serde_json::Value::Object(out) + } + serde_json::Value::Array(items) => { + let mut out = vec![]; + for val in items { + out.push(self.substitute(db, w_id, val, depth).await?); + } + serde_json::Value::Array(out) + } + serde_json::Value::String(s) if s.starts_with("$res:") => { + let path = &s[5..]; + self.substitute(db, w_id, self.resource(path)?, depth + 1) + .await? + } + serde_json::Value::String(s) if s.starts_with("$var:") => { + let path = &s[5..]; + let (stored, secret) = self + .variables + .get(path) + .and_then(|v| v.as_ref()) + .ok_or_else(|| { + Error::NotFound(format!("variable {path} does not exist")) + })?; + serde_json::Value::String(match (secret, self.external_secrets.get(path)) { + (false, _) => stored.clone(), + (true, Some(external)) => external.clone(), + (true, None) => windmill_common::variables::decrypt( + &windmill_common::variables::build_crypt(db, w_id).await?, + stored.clone(), + ) + .map_err(|e| { + Error::internal_err(format!("Error decrypting variable {s}: {e}")) + })?, + }) + } + other => other.clone(), + }) + }) + } +} + +fn collect_strings<'a>(value: Option<&'a serde_json::Value>, out: &mut Vec<&'a str>) { + match value { + Some(serde_json::Value::String(s)) => out.push(s), + Some(serde_json::Value::Array(items)) => { + items.iter().for_each(|v| collect_strings(Some(v), out)) + } + Some(serde_json::Value::Object(map)) => { + map.values().for_each(|v| collect_strings(Some(v), out)) + } + _ => {} + } +} + +/// What a data table of the fork should be copied as. +pub(crate) struct CopyRequest<'a> { + pub(crate) name: &'a str, + pub(crate) dbname: &'a str, + pub(crate) behavior: DataTableForkBehavior, +} + +/// Check that `authed` may copy each data table of `parent_w_id` as asked, before anything is +/// created. +/// +/// Who may copy is what it was before data table roles — anyone for the schema, an admin of the +/// workspace for the rows — narrowed under roles to whoever may connect as one of them: even the +/// schema alone lists every table, which under roles only role holders can read in the parent. +pub(crate) async fn authorize_copies( + db: &DB, + authed: &ApiAuthed, + parent_w_id: &str, + requests: &[CopyRequest<'_>], +) -> Result<()> { + for request in requests { + match request.behavior { + DataTableForkBehavior::KeepOriginal => { + return Err(Error::BadRequest(format!( + "Data table '{}' is kept, which copies nothing", + request.name + ))) + } + DataTableForkBehavior::SchemaOnly => {} + DataTableForkBehavior::SchemaAndData => { + require_admin(authed.is_admin, &authed.username)?; + if *CLOUD_HOSTED { + return Err(Error::BadRequest( + "Cloning schema and data is not available on cloud".to_string(), + )); + } + } + } + windmill_common::validate_dbname(request.dbname)?; + if !request.dbname.starts_with("wm_fork_") { + return Err(Error::BadRequest(format!( + "Forked datatable database name '{}' must start with 'wm_fork_'", + request.dbname + ))); + } + let governing = ensure_datatable_is_clonable(db, parent_w_id, request.name).await?; + ensure_reaches_governing_datatable(db, parent_w_id, request.name, &governing, authed) + .await?; + if governing.datatable.permissions.is_some() { + crate::datatable_replay_oss::ensure_replay()?; + } + } + Ok(()) +} + +/// Make every copy in `requests`, in order, once [`authorize_copies`] allows them all. When one +/// fails, the copies already made are dropped before the error is returned. +pub(crate) async fn make_copies( + db: &DB, + authed: &ApiAuthed, + parent_w_id: &str, + requests: &[CopyRequest<'_>], +) -> Result> { + authorize_copies(db, authed, parent_w_id, requests).await?; + let mut copies = Vec::with_capacity(requests.len()); + for request in requests { + match make_copy(db, authed, parent_w_id, request).await { + Ok(copy) => copies.push(copy), + Err(e) => return Err(drop_copies_after(db, copies, e).await), + } + } + Ok(copies) +} + +/// Drop every copy, returning `error` — with what could not be dropped appended, since nothing +/// else will name those databases again. +pub(crate) async fn drop_copies_after(db: &DB, copies: Vec, error: Error) -> Error { + let mut stranded = Vec::new(); + for copy in copies { + if let Err(e) = drop_copy(db, ©.source_database, ©.dbname).await { + tracing::error!("Could not drop '{}' after a failed fork: {e}", copy.dbname); + stranded.push(format!("'{}' ({e})", copy.dbname)); + } + } + if stranded.is_empty() { + error + } else { + Error::ExecutionErr(format!( + "{error}. These databases created for the fork could not be dropped: {}", + stranded.join(", ") + )) + } +} + +async fn drop_copy(db: &DB, source_database: &DataTableDatabase, dbname: &str) -> Result<()> { + if source_database.resource_type + == windmill_common::workspaces::DataTableCatalogResourceType::Instance + { + match windmill_common::drop_unused_instance_database(db, dbname).await? { + windmill_common::Cleanup::Dropped => Ok(()), + windmill_common::Cleanup::InUse(users) => Err(Error::BadRequest(format!( + "kept, since workspaces {} now use it", + users.join(", ") + ))), + windmill_common::Cleanup::Waiter(pid) => Err(Error::BadRequest(format!( + "kept, since a request (pid {pid}) is still waiting to name it" + ))), + } + } else { + // On a server of the workspace's own, where a resource edited meanwhile can already name it + // and nothing locks such an edit: dropping it could take someone's data. + Err(Error::BadRequest( + "kept on its PostgreSQL server, where it may already be in use; drop it there once it \ + is not, before forking the same data table under this id again" + .to_string(), + )) + } +} + +async fn make_copy( + db: &DB, + authed: &ApiAuthed, + parent_w_id: &str, + request: &CopyRequest<'_>, +) -> Result { + let governing = ensure_datatable_is_clonable(db, parent_w_id, request.name).await?; + let source_database = governing.datatable.database.clone().ok_or_else(|| { + Error::internal_err(format!( + "Data table '{}' resolves to an entry that owns no database", + request.name + )) + })?; + let is_instance = governing.is_instance(); + // Resolved from the snapshot, not read again: the connection the copy is made on is then + // exactly what these rows describe, which the fork's own clone of them is checked against. + let (server, connection): (PgDatabase, Option) = if is_instance { + let server = serde_json::from_value( + get_datatable_resource_from_db_unchecked(db, parent_w_id, request.name).await?, + ) + .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {e}")))?; + (server, None) + } else { + let snapshot = connection_snapshot( + db, + &mut *db.acquire().await?, + &governing.workspace_id, + &source_database.resource_path, + ) + .await?; + if !snapshot.root_holds_connection() { + return Err(Error::BadRequest(format!( + "Data table '{}' uses resource '{}', which only refers to another resource: the \ + fork's copy of it could not be pointed at the copied database. Point the data \ + table at the resource holding the connection, then fork again.", + request.name, source_database.resource_path + ))); + } + let server = serde_json::from_value(snapshot.resolve(db, &governing.workspace_id).await?) + .map_err(|e| { + Error::internal_err(format!("Failed to parse database credentials: {e}")) + })?; + (server, Some(snapshot)) + }; + + // Dumped before the database exists, so a source that cannot be read leaves nothing behind. + // Ownership never carries over: the restore runs as the target's connection user. Grants do, + // except on the instance, where the replay below is what reproduces them. + let dump = pg_dump_database( + &server, + PgDumpOptions { + schema_only: request.behavior == DataTableForkBehavior::SchemaOnly, + no_owner: true, + no_acl: is_instance, + ..Default::default() + }, + ) + .await?; + + if is_instance { + windmill_common::create_custom_instance_database(db, request.dbname, "datatable").await?; + } else { + create_database_on_server(db, &server, request.dbname).await?; + } + + let target = PgDatabase { dbname: request.dbname.to_string(), ..server.clone() }; + let filled = fill( + db, + authed, + parent_w_id, + request.name, + &governing, + &server, + &target, + &dump, + ) + .await; + match filled { + Ok(replayed) => Ok(MadeCopy { + name: request.name.to_string(), + dbname: request.dbname.to_string(), + behavior: request.behavior, + source_database, + replayed, + connection, + }), + Err(e) => { + if let Err(drop_err) = drop_copy(db, &source_database, request.dbname).await { + tracing::error!( + "Could not drop '{}' after a failed copy of data table '{}': {drop_err}", + request.dbname, + request.name + ); + return Err(Error::ExecutionErr(format!( + "{e}. The database '{}' created for the copy could not be dropped: {drop_err}", + request.dbname + ))); + } + Err(e) + } + } +} + +/// Restore the dump into the new database and, for a data table under roles, replay the source's +/// owners and grants into it. Returns whether it replayed. +#[allow(clippy::too_many_arguments)] +async fn fill( + db: &DB, + authed: &ApiAuthed, + parent_w_id: &str, + name: &str, + governing: &GoverningDatatable, + source: &PgDatabase, + target: &PgDatabase, + dump: &DumpFile, +) -> Result { + pg_import_dump(target, dump).await?; + + // Held until the replay commits: a role renamed or dropped meanwhile would change what the + // replay names, and a settings save could move the source onto another database or put it under + // roles. Taken in the same order as the permissions save and the ACL apply. + // On a connection of its own: the checks below take theirs from the pool, and a lock holder + // drawn from the pool too could leave a small one with nothing to give them. + let database_url = windmill_common::get_database_url().await?; + let mut lock_holder = ::connect_with( + &database_url.connect_options().await?, + ) + .await + .map_err(|e| Error::internal_err(format!("Failed to connect to the database: {e}")))?; + let mut tx = sqlx::Connection::begin(&mut lock_holder).await?; + lock_role_catalog(&mut tx).await?; + lock_settings_rows(&mut tx, parent_w_id, name).await?; + + // Everything so far was decided before the locks. + let now = ensure_datatable_is_clonable(db, parent_w_id, name).await?; + ensure_reaches_governing_datatable(db, parent_w_id, name, &now, authed).await?; + if !same_database(&now.datatable.database, &governing.datatable.database) { + return Err(Error::BadRequest(format!( + "Data table '{name}' moved to another database while it was being copied; fork again" + ))); + } + + let replayed = now.datatable.permissions.is_some(); + if replayed { + crate::datatable_replay_oss::ensure_replay()?; + let catalog = read_role_catalog_tx(&mut tx).await?; + // The replay leaves `CONNECT` to the catalog, and creating the database only tried to set + // it: a copy `PUBLIC` could still connect to would admit logins the source turns away. + windmill_common::datatable_roles::converge_connect_grants_with( + db, + &target.dbname, + &catalog, + ) + .await?; + let catalog_roles: BTreeSet = catalog.values().map(|r| r.name.clone()).collect(); + let (source, _source_notices) = connect_with_notices(db, source).await?; + let (mut target, mut notices) = connect_with_notices(db, target).await?; + // One transaction on the copy: a replay that stops halfway leaves objects owned by one role + // and granted as another. + let pg_tx = target.transaction().await.map_err(|e| { + Error::internal_err(format!( + "Failed to open a transaction on the copy: {}", + pg_error_message(&e) + )) + })?; + crate::datatable_replay_oss::replay_owners_and_grants( + &source, + &pg_tx, + &mut notices, + &catalog_roles, + ) + .await?; + pg_tx.commit().await.map_err(|e| { + Error::internal_err(format!( + "Failed to commit the replayed grants: {}", + pg_error_message(&e) + )) + })?; + } + tx.commit().await?; + Ok(replayed) +} + +/// Lock every settings row the resolution of data table `name` of `start_w_id` passes through, in +/// the order it passes them — each pointer, the entry that owns the database, and each workspace its +/// roles come from — and return them. Deleting or repointing any of them would leave a copy made +/// for the resolution answering for another one. +pub(crate) async fn lock_settings_rows( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + start_w_id: &str, + name: &str, +) -> Result> { + let mut path: Vec<(String, String)> = vec![]; + let (mut w_id, mut datatable) = (start_w_id.to_string(), name.to_string()); + loop { + if path.contains(&(w_id.clone(), datatable.clone())) || path.len() > 32 { + return Err(Error::BadRequest(format!( + "Data table '{name}' of workspace '{start_w_id}' resolves through a cycle" + ))); + } + let entry: Option = sqlx::query_scalar( + "SELECT datatable->'datatables'->$2 FROM workspace_settings + WHERE workspace_id = $1 FOR UPDATE", + ) + .bind(&w_id) + .bind(&datatable) + .fetch_optional(&mut **tx) + .await? + .flatten(); + path.push((w_id.clone(), datatable.clone())); + let entry: Option = + entry.and_then(|e| serde_json::from_value(e).ok()); + match entry.and_then(|e| e.reference.or(e.governed_by)) { + Some(next) => (w_id, datatable) = (next.workspace_id, next.datatable), + None => break, + } + } + Ok(path.into_iter().map(|(w_id, _)| w_id).collect()) +} + +pub(crate) fn same_database(a: &Option, b: &Option) -> bool { + match (a, b) { + (Some(a), Some(b)) => { + a.resource_type == b.resource_type && a.resource_path == b.resource_path + } + _ => false, + } +} diff --git a/backend/windmill-api-workspaces/src/datatable_migrations.rs b/backend/windmill-api-workspaces/src/datatable_migrations.rs index ef5ea37e1d..df1fac203b 100644 --- a/backend/windmill-api-workspaces/src/datatable_migrations.rs +++ b/backend/windmill-api-workspaces/src/datatable_migrations.rs @@ -30,6 +30,7 @@ use windmill_api_auth::{require_super_admin, ApiAuthed}; use windmill_api_jobs::run_wait_result_internal; use windmill_audit::audit_oss::audit_log; use windmill_audit::ActionKind; +use windmill_common::datatable_roles::ADMIN_DATATABLE_ROLE; use windmill_common::db::UserDB; use windmill_common::error::{pg_error_message, Error, JsonResult, Result}; use windmill_common::jobs::{JobPayload, RawCode}; @@ -38,7 +39,11 @@ use windmill_common::runnable_settings::{ConcurrencySettingsWithCustom, Debounci use windmill_common::scripts::ScriptLang; use windmill_common::users::username_to_permissioned_as; use windmill_common::worker::to_raw_value; -use windmill_common::workspaces::get_datatable_resource_from_db_unchecked; +use windmill_common::worker::SqlAnnotations; +use windmill_common::workspaces::{ + ensure_can_use_datatable_role, ensure_datatable_admin_access, + get_datatable_resource_from_db_unchecked, resolve_governing_datatable, DatatableAccess, +}; use windmill_common::{PgDatabase, DB}; use windmill_git_sync::{ handle_deployment_metadata, handle_deployment_metadata_batch, DeployedObject, @@ -86,6 +91,79 @@ pub(crate) fn routes() -> Router { ) } +/// Refuse a migration whose role this caller may not use, before a job is pushed or a version +/// recorded. +/// +/// A migration that declares `-- role ` runs as that role, so the caller has to be one of its +/// tenants. One that declares none runs as `admin` and reaches every object in the database +/// whatever the roles grant, so it is for the admins of the workspace that governs the data table +/// — a fork can run a migration under a role it holds, never a migration under `admin`. +/// +/// The executor re-checks the role when it resolves the connection, so this is not the boundary. It +/// is what makes the refusal legible: which migration, and which role. +async fn ensure_migration_role_allowed( + db: &DB, + w_id: &str, + datatable_name: &str, + authed: &ApiAuthed, + sql: &str, + migration: &str, +) -> Result<()> { + let access = DatatableAccess::Authed(authed.to_authed_ref()); + match SqlAnnotations::datatable_role(sql)? { + Some(role) => { + ensure_can_use_datatable_role(db, w_id, datatable_name, Some(&role), &access, migration) + .await + } + None => ensure_datatable_admin_access(db, w_id, datatable_name, &access) + .await + .map_err(|e| { + Error::NotAuthorized(format!( + "{migration} declares no role, so it would run as admin. {e}" + )) + }), + } +} + +/// Refuse writing or deleting a migration definition unless this caller may run both its up and its +/// down. A definition is run later by whoever applies or rolls back pending migrations, as the role +/// it declares, so gating only the run would let anyone plant SQL for a data table's admin to run. +async fn ensure_may_edit_migration( + db: &DB, + w_id: &str, + datatable_name: &str, + authed: &ApiAuthed, + migration: &str, + code_up: &str, + code_down: Option<&str>, +) -> Result<()> { + // Admin access reaches everything a role can, so it edits any definition without reading its + // annotation, which one saved before annotations were parsed may no longer pass. A refusal + // there would leave such a definition impossible to delete or fix. + let access = DatatableAccess::Authed(authed.to_authed_ref()); + if ensure_datatable_admin_access(db, w_id, datatable_name, &access) + .await + .is_ok() + { + return Ok(()); + } + for sql in std::iter::once(code_up).chain(code_down) { + ensure_migration_role_allowed(db, w_id, datatable_name, authed, sql, migration).await?; + } + Ok(()) +} + +/// Operators run deployed code only, and a migration is SQL written or run by hand. Checked before +/// anything else, including the role checks: on a data table not under roles those pass everyone. +fn ensure_not_operator(authed: &ApiAuthed) -> Result<()> { + if authed.is_operator { + return Err(Error::NotAuthorized( + "Operators cannot manage or run data table migrations".to_string(), + )); + } + Ok(()) +} + #[derive(Serialize)] struct AppliedMigration { version: i64, @@ -128,7 +206,18 @@ async fn datatable_database_arg( .await? .ok_or_else(|| Error::internal_err(format!("datatable {datatable_name} not found")))?; - Ok(to_raw_value(&format!("datatable://{datatable_name}"))) + // `?role=admin` rather than a bare reference, so a migration that declares no `-- role` runs + // as the connection that owns the schema instead of falling through to the data table's + // default role — which is what `ensure_migration_role_allowed` gated it as, and which is the + // only role a DDL statement can be expected to succeed under. A migration that does declare a + // role overrides this: the annotation wins over the reference. + // + // A legacy name containing `?` cannot be migrated through this reference: the appended query + // makes it neither an exact name nor a parseable one. Accepted on purpose, since such names can + // no longer be created and none are expected to carry migrations. + Ok(to_raw_value(&format!( + "datatable://{datatable_name}?role={ADMIN_DATATABLE_ROLE}" + ))) } /// Run a migration's SQL as a normal Windmill `postgresql` job, permissioned as @@ -384,6 +473,12 @@ async fn run_datatable_migrations( Path((w_id, datatable_name)): Path<(String, String)>, Query(query): Query, ) -> JsonResult { + ensure_not_operator(&authed)?; + // Before the admin connection is opened at all: the bookkeeping below is created and read + // through it, so a caller no role covers must be refused here rather than after the fact. + crate::datatable_permissions::ensure_reaches_datatable(&db, &w_id, &datatable_name, &authed) + .await?; + audit_log( &db, &authed, @@ -440,6 +535,15 @@ async fn run_datatable_migrations( if applied_versions.contains(&m.timestamp) { continue; } + ensure_migration_role_allowed( + &db, + &w_id, + &datatable_name, + &authed, + &m.code_up, + &format!("Migration {} ({})", m.timestamp, m.name), + ) + .await?; run_datatable_migration_job(&db, &user_db, &authed, &w_id, &database_arg, &m.code_up) .await .map_err(|e| { @@ -506,6 +610,12 @@ async fn rollback_datatable_migrations( Path((w_id, datatable_name)): Path<(String, String)>, Query(query): Query, ) -> JsonResult { + ensure_not_operator(&authed)?; + // Before the admin connection is opened at all: the bookkeeping below is created and read + // through it, so a caller no role covers must be refused here rather than after the fact. + crate::datatable_permissions::ensure_reaches_datatable(&db, &w_id, &datatable_name, &authed) + .await?; + audit_log( &db, &authed, @@ -588,6 +698,16 @@ async fn rollback_datatable_migrations( )) })?; + ensure_migration_role_allowed( + &db, + &w_id, + &datatable_name, + &authed, + &code_down, + &format!("Migration {} ({})", version, definition.name), + ) + .await?; + let database_arg = datatable_database_arg(&db, &w_id, &datatable_name).await?; run_datatable_migration_job(&db, &user_db, &authed, &w_id, &database_arg, &code_down) .await @@ -748,10 +868,15 @@ async fn read_applied_datatable_versions( /// List a data table's migrations annotated with whether each has been applied. async fn datatable_migrations_status( - _authed: ApiAuthed, + authed: ApiAuthed, Extension(db): Extension, Path((w_id, datatable_name)): Path<(String, String)>, ) -> JsonResult { + // Reads `_wm_migrations` through the data table's admin connection, so it answers to the same + // question as running one: may you reach this data table at all. + crate::datatable_permissions::ensure_reaches_datatable(&db, &w_id, &datatable_name, &authed) + .await?; + let enabled = datatable_migrations_enabled(&db, &w_id, &datatable_name).await?; if !enabled { return Ok(Json(DatatableMigrationsStatusResult { @@ -1125,9 +1250,20 @@ async fn create_datatable_migration( Path((w_id, datatable_name)): Path<(String, String)>, Json(payload): Json, ) -> JsonResult { + ensure_not_operator(&authed)?; validate_datatable_path_segment(&datatable_name)?; validate_migration_name(&payload.name)?; ensure_datatable_migrations_enabled(&db, &w_id, &datatable_name).await?; + ensure_may_edit_migration( + &db, + &w_id, + &datatable_name, + &authed, + &format!("Migration '{}'", payload.name), + &payload.code_up, + payload.code_down.as_deref(), + ) + .await?; let mut tx = db.begin().await?; let timestamp = insert_datatable_migration_def( @@ -1179,6 +1315,34 @@ async fn delete_datatable_migration( Extension(db): Extension, Path((w_id, datatable_name, timestamp)): Path<(String, String, i64)>, ) -> Result { + ensure_not_operator(&authed)?; + let deleted_message = format!("Deleted migration {} from {}", timestamp, datatable_name); + + // A definition already gone is not an error: `wmill sync push` deletes whatever the repo + // dropped, which may no longer exist here. + let Some(definition) = sqlx::query!( + "SELECT name, code_up, code_down FROM datatable_migrations \ + WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3", + &w_id, + &datatable_name, + timestamp, + ) + .fetch_optional(&db) + .await? + else { + return Ok(deleted_message); + }; + ensure_may_edit_migration( + &db, + &w_id, + &datatable_name, + &authed, + &format!("Migration {} ({})", timestamp, definition.name), + &definition.code_up, + definition.code_down.as_deref(), + ) + .await?; + // Hold the run-serialization lock across the applied-check and the delete: a // run snapshots a migration's SQL before recording its version, so an // unserialized delete could race it and leave `_wm_migrations` pointing at a @@ -1206,19 +1370,46 @@ async fn delete_datatable_migration( ))); } + // Delete only the SQL the role check above cleared: it read the definition without the lock, + // and a rewrite that landed since was never checked. let deleted_name = sqlx::query_scalar!( "DELETE FROM datatable_migrations \ WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3 \ + AND code_up = $4 AND code_down IS NOT DISTINCT FROM $5 \ RETURNING name", &w_id, &datatable_name, timestamp, + &definition.code_up, + definition.code_down.as_deref(), ) .fetch_optional(&db) .await?; // The definition is removed; runs may resume (audit/deploy metadata below // don't need the lock). drop(lock_client); + let Some(name) = deleted_name else { + // Nothing matched either because another delete got there first, which is this one's + // outcome too, or because the definition was rewritten. + let still_defined = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM datatable_migrations \ + WHERE workspace_id = $1 AND datatable = $2 AND timestamp = $3)", + &w_id, + &datatable_name, + timestamp, + ) + .fetch_one(&db) + .await? + .unwrap_or(false); + if !still_defined { + return Ok(deleted_message); + } + return Err(Error::BadRequest(format!( + "Migration {} on data table '{}' changed while it was being deleted. Reload it and \ + retry.", + timestamp, datatable_name + ))); + }; audit_log( &db, @@ -1231,23 +1422,10 @@ async fn delete_datatable_migration( ) .await?; - // Only tally a change if a migration was actually deleted. - if let Some(name) = deleted_name { - record_datatable_migration_deployment( - &authed, - &db, - &w_id, - &datatable_name, - timestamp, - &name, - ) + record_datatable_migration_deployment(&authed, &db, &w_id, &datatable_name, timestamp, &name) .await?; - } - Ok(format!( - "Deleted migration {} from {}", - timestamp, datatable_name - )) + Ok(deleted_message) } #[derive(Deserialize)] @@ -1268,6 +1446,7 @@ async fn upsert_datatable_migration( Path((w_id, datatable_name)): Path<(String, String)>, Json(payload): Json, ) -> Result { + ensure_not_operator(&authed)?; validate_datatable_path_segment(&datatable_name)?; validate_migration_name(&payload.name)?; ensure_datatable_migrations_enabled(&db, &w_id, &datatable_name).await?; @@ -1293,9 +1472,9 @@ async fn upsert_datatable_migration( // run-serialization lock across the applied-check and the write below so an // in-flight run can't record a version for the SQL we're about to overwrite. // Held until the end of the handler (well past the write). The exempt - // upserts need no lock: a new or unchanged one overwrites nothing, and one - // that only adds a down leaves the `code_up` a concurrent run is recording - // a version for untouched. + // upserts need no lock: a new one overwrites nothing, and one that only adds + // a down leaves the `code_up` a concurrent run is recording a version for + // untouched. let only_adds_down = existing.as_ref().is_some_and(|existing| { existing.name == payload.name && existing.code_up == payload.code_up @@ -1307,8 +1486,40 @@ async fn upsert_datatable_migration( && existing.code_up == payload.code_up && existing.code_down == payload.code_down }); + let upserted_message = format!( + "Upserted migration {} in {}", + payload.timestamp, datatable_name + ); + // A re-push of the definition as stored changes nothing, so it is not checked, written, audited, + // synced or counted. Writing it anyway would re-create a definition deleted since the read above. + if unchanged { + return Ok(upserted_message); + } + ensure_may_edit_migration( + &db, + &w_id, + &datatable_name, + &authed, + &format!("Migration {} ({})", payload.timestamp, payload.name), + &payload.code_up, + payload.code_down.as_deref(), + ) + .await?; + // Replacing a definition removes the one stored, so it answers to the same check as a delete. + if let Some(existing) = existing.as_ref() { + ensure_may_edit_migration( + &db, + &w_id, + &datatable_name, + &authed, + &format!("Migration {} ({})", payload.timestamp, existing.name), + &existing.code_up, + existing.code_down.as_deref(), + ) + .await?; + } let _run_lock = match existing.as_ref() { - Some(_) if !only_adds_down && !unchanged => { + Some(_) if !only_adds_down => { // Fail closed: if we can't lock/read the applied set (e.g. the // data-table database is temporarily unreachable), refuse the change // rather than risk overwriting a migration that has already run. @@ -1338,31 +1549,27 @@ async fn upsert_datatable_migration( _ => None, }; - // An exempt upsert judged the row from an unlocked read and then writes - // without the lock, so that whole read is re-tested here, where `ON CONFLICT - // DO UPDATE` re-reads the row under a row lock. Otherwise a request working - // from a stale definition silently reverts whatever changed in between — a - // second addition's down, or a locked rewrite of the up whose new SQL a run - // may already have recorded a version for. Reading no row at all is part of - // the premise: the equality is NULL when `$8` is, so a version created in the - // meantime is refused rather than overwritten. - let recheck_observed = _run_lock.is_none(); + // The role checks, and the exemptions from the lock, all judged the row from the unlocked read + // above, so that whole read is re-tested here, where `ON CONFLICT DO UPDATE` re-reads the row + // under a row lock. Otherwise a request working from a stale definition silently replaces + // whatever changed in between: SQL the role checks never saw, or a locked rewrite of the up + // whose new SQL a run may already have recorded a version for. Reading no row at all is part of + // the premise: the equality is NULL when `$7` is, so a version created in the meantime is + // refused rather than overwritten. let written = sqlx::query!( "INSERT INTO datatable_migrations (workspace_id, datatable, timestamp, name, code_up, code_down) \ VALUES ($1, $2, $3, $4, $5, $6) \ ON CONFLICT (workspace_id, datatable, timestamp) DO UPDATE \ SET name = EXCLUDED.name, code_up = EXCLUDED.code_up, code_down = EXCLUDED.code_down \ - WHERE NOT $7 \ - OR (datatable_migrations.name = $8::text \ - AND datatable_migrations.code_up = $9::text \ - AND datatable_migrations.code_down IS NOT DISTINCT FROM $10::text)", + WHERE datatable_migrations.name = $7::text \ + AND datatable_migrations.code_up = $8::text \ + AND datatable_migrations.code_down IS NOT DISTINCT FROM $9::text", &w_id, &datatable_name, payload.timestamp, &payload.name, &payload.code_up, payload.code_down.as_deref(), - recheck_observed, existing.as_ref().map(|e| e.name.as_str()), existing.as_ref().map(|e| e.code_up.as_str()), existing.as_ref().and_then(|e| e.code_down.as_deref()), @@ -1401,24 +1608,17 @@ async fn upsert_datatable_migration( ) .await?; - // An unchanged re-push is not counted: `wmill sync push` sends every migration - // on every sync, so counting those would swamp the definitions people write. - if !unchanged { - windmill_common::feature_usage::log_feature_usage( - "datatable", - "migration_created", - if existing.is_none() { - "synced" - } else { - "edited" - }, - ); - } + windmill_common::feature_usage::log_feature_usage( + "datatable", + "migration_created", + if existing.is_none() { + "synced" + } else { + "edited" + }, + ); - Ok(format!( - "Upserted migration {} in {}", - payload.timestamp, datatable_name - )) + Ok(upserted_message) } /// Generate the first migration for a data table by snapshotting its current @@ -1431,6 +1631,16 @@ async fn generate_initial_datatable_migration( Extension(db): Extension, Path((w_id, datatable_name)): Path<(String, String)>, ) -> JsonResult { + ensure_not_operator(&authed)?; + // Returns a `pg_dump` of the whole schema and writes into the data table's own bookkeeping, so + // it answers to the workspace that governs it rather than to whoever is asking. + ensure_datatable_admin_access( + &db, + &w_id, + &datatable_name, + &DatatableAccess::Authed(authed.to_authed_ref()), + ) + .await?; validate_datatable_path_segment(&datatable_name)?; ensure_datatable_migrations_enabled(&db, &w_id, &datatable_name).await?; @@ -1601,9 +1811,21 @@ pub(crate) struct DatatableRename { pub(crate) to: String, } -async fn resolve_datatable_pg(db: &DB, w_id: &str, datatable: &str) -> Result { +/// The database whose `_wm_migrations` a rename or delete of `datatable` in `w_id` should touch — +/// `None` when that is somebody else's. +/// +/// A fork's entry points at the workspace that governs the data table, so renaming or removing it +/// changes what the fork calls the data table and nothing more. Following the pointer here would +/// let a fork admin relabel or wipe the *governing* workspace's migration bookkeeping through +/// their own settings form, and the parent would then re-run every migration from zero. +async fn resolve_datatable_pg(db: &DB, w_id: &str, datatable: &str) -> Result> { + let governing = resolve_governing_datatable(db, w_id, datatable).await?; + if governing.workspace_id != w_id { + return Ok(None); + } let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable).await?; serde_json::from_value(db_resource) + .map(Some) .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e))) } @@ -1621,7 +1843,9 @@ fn ignore_missing_wm_migrations(e: tokio_postgres::Error) -> Result<()> { /// Drop a data table's rows from its own database's `_wm_migrations`. async fn remote_forget_datatable_migrations(db: &DB, w_id: &str, datatable: &str) -> Result<()> { - let pg_db = resolve_datatable_pg(db, w_id, datatable).await?; + let Some(pg_db) = resolve_datatable_pg(db, w_id, datatable).await? else { + return Ok(()); + }; let (client, connection) = pg_db.connect(Some(db)).await?; tokio::spawn(async move { let _ = connection.await; @@ -1646,7 +1870,9 @@ async fn remote_rename_datatable_migrations( from: &str, to: &str, ) -> Result<()> { - let pg_db = resolve_datatable_pg(db, w_id, resolve_by).await?; + let Some(pg_db) = resolve_datatable_pg(db, w_id, resolve_by).await? else { + return Ok(()); + }; let (client, connection) = pg_db.connect(Some(db)).await?; tokio::spawn(async move { let _ = connection.await; diff --git a/backend/windmill-api-workspaces/src/datatable_permissions.rs b/backend/windmill-api-workspaces/src/datatable_permissions.rs new file mode 100644 index 0000000000..69e04e328d --- /dev/null +++ b/backend/windmill-api-workspaces/src/datatable_permissions.rs @@ -0,0 +1,79 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Who may connect to a data table as which role. +//! +//! The decision lives on the data table entry of the workspace that governs it, which is not +//! necessarily the workspace asking: a fork's entry points at its parent's, and everything here +//! resolves through that pointer first. Nothing in this module runs SQL against the data table — +//! a save is tenant lists and a default, and the Postgres roles themselves are the instance +//! catalog's business. + +use axum::{routing::get, Router}; + +use windmill_api_auth::ApiAuthed; +use windmill_common::error::Result; +use windmill_common::workspaces::GoverningDatatable; +use windmill_common::DB; + +use crate::datatable_permissions_oss as roles; + +pub(crate) fn routes() -> Router { + Router::new() + .route( + "/datatable_permissions/{datatable_name}", + get(roles::get_datatable_permissions).post(roles::set_datatable_permissions), + ) + .route( + "/datatable_usable_roles/{datatable_name}", + get(roles::list_usable_datatable_roles), + ) +} + +/// Administering a data table — its permissions, its migrations that declare no role, its exports +/// — is for the admins of the workspace that governs it. A fork can use the data table; it never +/// administers it. +// The gate for whatever administers a data table under roles, which the routes of this module alone +// do not always reach. +#[allow(dead_code)] +pub(crate) async fn ensure_governs_datatable( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + governing: &GoverningDatatable, +) -> Result<()> { + roles::ensure_governs_datatable(db, authed, w_id, governing).await +} + +/// Refuse a caller that no tenant of this data table covers. +/// +/// The bookkeeping endpoints below open the data table's `admin` connection to read or create +/// `_wm_migrations` before they know which migration will run — so without this, someone covered +/// by no role at all can still force admin-backed reads and writes on a database they may not +/// touch. It asks only "may you reach this data table as anything"; which role a given migration +/// runs as is still decided per migration, and by the executor after that. +pub(crate) async fn ensure_reaches_datatable( + db: &DB, + w_id: &str, + datatable_name: &str, + authed: &ApiAuthed, +) -> Result<()> { + roles::ensure_reaches_datatable(db, w_id, datatable_name, authed).await +} + +/// [`ensure_reaches_datatable`] against an entry already resolved, for a caller that goes on to +/// connect from that same entry. +pub(crate) async fn ensure_reaches_governing_datatable( + db: &DB, + w_id: &str, + datatable_name: &str, + governing: &GoverningDatatable, + authed: &ApiAuthed, +) -> Result<()> { + roles::ensure_reaches_governing_datatable(db, w_id, datatable_name, governing, authed).await +} diff --git a/backend/windmill-api-workspaces/src/datatable_permissions_oss.rs b/backend/windmill-api-workspaces/src/datatable_permissions_oss.rs new file mode 100644 index 0000000000..736493cc2d --- /dev/null +++ b/backend/windmill-api-workspaces/src/datatable_permissions_oss.rs @@ -0,0 +1,112 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Where the data table permissions endpoints and their gates come from: the enterprise +//! implementation, or a refusal. Roles are an Enterprise Edition feature; see +//! `windmill_common::datatable_roles_oss`. + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub(crate) use crate::datatable_permissions_ee::{ + ensure_governs_datatable, ensure_reaches_datatable, ensure_reaches_governing_datatable, + get_datatable_permissions, list_usable_datatable_roles, set_datatable_permissions, + usable_datatable_roles, +}; + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +pub(crate) use ce::*; + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +mod ce { + use windmill_api_auth::ApiAuthed; + use windmill_common::{ + datatable_roles_oss::datatable_roles_unavailable as unavailable, + error::Result, + workspaces::{resolve_governing_datatable, GoverningDatatable}, + DB, + }; + + /// Nobody administers a data table's roles without them. + #[allow(dead_code)] + pub(crate) async fn ensure_governs_datatable( + _db: &DB, + _authed: &ApiAuthed, + _w_id: &str, + _governing: &GoverningDatatable, + ) -> Result<()> { + Err(unavailable()) + } + + /// A data table not under roles is reached as it was before roles existed. One under roles is + /// refused: no role of it can be connected as. + pub(crate) async fn ensure_reaches_datatable( + db: &DB, + w_id: &str, + datatable_name: &str, + _authed: &ApiAuthed, + ) -> Result<()> { + let governing = resolve_governing_datatable(db, w_id, datatable_name).await?; + if governing.datatable.permissions.is_none() { + Ok(()) + } else { + Err(unavailable()) + } + } + + pub(crate) async fn ensure_reaches_governing_datatable( + _db: &DB, + _w_id: &str, + _datatable_name: &str, + governing: &GoverningDatatable, + _authed: &ApiAuthed, + ) -> Result<()> { + if governing.datatable.permissions.is_none() { + Ok(()) + } else { + Err(unavailable()) + } + } + + // The routes stay registered so the API has one shape; each answers after authentication, + // before anything is read. + + pub(crate) async fn get_datatable_permissions(_authed: ApiAuthed) -> Result { + Err(unavailable()) + } + + pub(crate) async fn set_datatable_permissions(_authed: ApiAuthed) -> Result { + Err(unavailable()) + } + + pub(crate) async fn list_usable_datatable_roles(_authed: ApiAuthed) -> Result { + Err(unavailable()) + } + + pub(crate) struct UsableDatatableRoles { + pub(crate) permissioned: bool, + pub(crate) roles: Vec, + pub(crate) default_role: String, + } + + /// A data table not under roles is used as `admin`, as before roles existed. One under roles + /// is refused: no role of it can be connected as. + pub(crate) async fn usable_datatable_roles( + _db: &DB, + _authed: &ApiAuthed, + _w_id: &str, + governing: &GoverningDatatable, + ) -> Result { + if governing.datatable.permissions.is_some() { + return Err(unavailable()); + } + Ok(UsableDatatableRoles { + permissioned: false, + roles: vec![], + default_role: windmill_common::datatable_roles::ADMIN_DATATABLE_ROLE.to_string(), + }) + } +} diff --git a/backend/windmill-api-workspaces/src/datatable_replay_oss.rs b/backend/windmill-api-workspaces/src/datatable_replay_oss.rs new file mode 100644 index 0000000000..edcf8e402b --- /dev/null +++ b/backend/windmill-api-workspaces/src/datatable_replay_oss.rs @@ -0,0 +1,48 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Where the replay of a data table's owners and grants into its copy comes from: the enterprise +//! one, or a refusal. Without it a data table under roles is not copied at all: its rows would +//! arrive owned by the admin connection with no grant for any role. + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub(crate) use crate::datatable_replay_ee::replay_owners_and_grants; + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub(crate) fn ensure_replay() -> windmill_common::error::Result<()> { + Ok(()) +} + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +use { + std::collections::BTreeSet, + tokio::sync::mpsc, + tokio_postgres::error::DbError, + windmill_common::error::{Error, Result}, +}; + +/// Checked before anything is created. +#[cfg(not(all(feature = "private", feature = "enterprise")))] +pub(crate) fn ensure_replay() -> Result<()> { + Err(Error::BadRequest( + "Cloning a data table under roles is a Windmill Enterprise Edition feature: the copy \ + needs the source's owners and grants replayed. Fork it keeping the original database \ + instead." + .to_string(), + )) +} + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +pub(crate) async fn replay_owners_and_grants( + _source: &tokio_postgres::Client, + _target: &tokio_postgres::Transaction<'_>, + _notices: &mut mpsc::UnboundedReceiver, + _catalog_roles: &BTreeSet, +) -> Result<()> { + ensure_replay() +} diff --git a/backend/windmill-api-workspaces/src/lib.rs b/backend/windmill-api-workspaces/src/lib.rs index 22f2a2c5bb..c437d7e2b4 100644 --- a/backend/windmill-api-workspaces/src/lib.rs +++ b/backend/windmill-api-workspaces/src/lib.rs @@ -1,11 +1,26 @@ #[cfg(feature = "parquet")] pub mod ai_session_backups; pub mod data_metrics; +pub mod datatable_acl; +pub mod datatable_acl_oss; +pub mod datatable_clone; pub mod datatable_migrations; +pub mod datatable_permissions; +pub mod datatable_permissions_oss; +pub mod datatable_replay_oss; pub mod deployment_requests; +pub mod remote_deploy; pub mod workspaces; pub mod workspaces_extra; pub mod workspaces_oss; #[cfg(feature = "private")] pub mod workspaces_ee; + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub mod datatable_acl_ee; +#[cfg(all(feature = "private", feature = "enterprise"))] +pub mod datatable_replay_ee; + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub mod datatable_permissions_ee; diff --git a/backend/windmill-api-workspaces/src/remote_deploy.rs b/backend/windmill-api-workspaces/src/remote_deploy.rs new file mode 100644 index 0000000000..3e701cbf14 --- /dev/null +++ b/backend/windmill-api-workspaces/src/remote_deploy.rs @@ -0,0 +1,794 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2026 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Deploying from the UI into a workspace of another Windmill instance. +//! +//! The browser runs the same deploy it runs between two workspaces of this instance, and sends +//! every call aimed at the target through [`proxy`], which forwards it to the remote workspace +//! with the caller's own token for that instance. The remote authorizes and audits the deploy as +//! that person, so nothing here needs to know what a deploy is made of. + +use std::time::Duration; + +use axum::{ + body::{Body, Bytes}, + extract::{DefaultBodyLimit, Extension, OriginalUri, Path}, + http::{header, HeaderMap, Method, StatusCode}, + response::Response, + routing::{any, get, post}, + Json, Router, +}; +use chrono::{DateTime, Utc}; +use lazy_static::lazy_static; +use magic_crypt::MagicCrypt256; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use windmill_api_auth::{is_effectively_unscoped, ApiAuthed, Tokened}; +use windmill_audit::{audit_oss::audit_log, ActionKind}; +use windmill_common::{ + auth::hash_token, + error::{error_source_chain, Error, Result}, + ssrf::validate_url_for_ssrf, + utils::{configure_client, rd_string, require_admin}, + variables::{build_crypt, decrypt, encrypt}, + worker::CLOUD_HOSTED, + DB, +}; + +lazy_static! { + static ref REMOTE_WORKSPACE_ID: Regex = Regex::new("^[a-zA-Z0-9_-]{1,50}$").unwrap(); + /// Shared so that a deploy's calls reuse connections. A cloud instance instead builds a client + /// per request, pinned to the addresses it just validated for that target. + static ref REMOTE_CLIENT: reqwest::Client = remote_client_builder().build().unwrap(); +} + +const PROXY_PREFIX: &str = "/remote_deploy/proxy/"; + +pub fn workspaced_service(proxy_body_limit: usize) -> Router { + Router::new() + .route("/target", get(get_target).post(set_target)) + .route("/connect", post(connect)) + .route("/disconnect", post(disconnect)) + // `{key}` is the caller's `proxy_key`. The session cookie is `SameSite=Lax`, so it rides a + // top-level navigation from any site: without a value such a link cannot know, it could + // spend the stored token, to run something on the remote or to render its content on this + // origin. `Sec-Fetch-Site` is no substitute, since plain-http instances never receive it. + .route( + "/proxy/{key}/{*rest}", + any(proxy).layer(DefaultBodyLimit::max(proxy_body_limit)), + ) +} + +/// A request URI as logs should record it: with the proxy key masked, since that key is what keeps +/// a link from spending a stored remote token. +pub struct RedactedUri<'a>(pub &'a axum::http::Uri); + +impl std::fmt::Display for RedactedUri<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let Some((head, keyed)) = self.0.path().split_once(PROXY_PREFIX) else { + return self.0.fmt(f); + }; + let rest = keyed.find('/').map_or("", |i| &keyed[i..]); + write!(f, "{head}{PROXY_PREFIX}***{rest}")?; + match self.0.query() { + Some(query) => write!(f, "?{query}"), + None => Ok(()), + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct RemoteDeployTarget { + /// Root URL of the remote instance, without `/api`. + pub base_url: String, + pub workspace_id: String, +} + +impl RemoteDeployTarget { + fn normalized(self) -> Result { + let base_url = self.base_url.trim().trim_end_matches('/').to_string(); + let parsed = url::Url::parse(&base_url) + .map_err(|e| Error::BadRequest(format!("Invalid remote instance URL: {e}")))?; + if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { + return Err(Error::BadRequest( + "The remote instance URL must be an http(s) URL with a host".to_string(), + )); + } + if !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + { + return Err(Error::BadRequest( + "The remote instance URL must not carry credentials, a query or a fragment" + .to_string(), + )); + } + let workspace_id = self.workspace_id.trim().to_string(); + if !REMOTE_WORKSPACE_ID.is_match(&workspace_id) { + return Err(Error::BadRequest(format!( + "Invalid remote workspace id: {workspace_id}" + ))); + } + Ok(Self { base_url, workspace_id }) + } + + fn api_url(&self, suffix: &str) -> String { + format!("{}/api/w/{}/{suffix}", self.base_url, self.workspace_id) + } +} + +#[derive(Serialize)] +struct RemoteDeployConnection { + remote_email: String, + /// Goes in every proxy URL, see [`workspaced_service`]. + proxy_key: String, + connected_at: DateTime, +} + +#[derive(Serialize)] +struct RemoteDeployStatus { + target: Option, + /// The caller's own connection to `target`. + connection: Option, +} + +/// A stored token acts on another instance as its owner, with none of the restrictions of the +/// credential that reaches it. Only the owner acting directly with full rights may use or replace +/// it: a job token runs as whoever a `wm_deployers` member pointed `on_behalf_of` at, and a +/// scoped or read-only token was never granted this. +fn require_own_credentials(authed: &ApiAuthed) -> Result<()> { + if authed.job_id.is_some() + || authed.read_only + || !is_effectively_unscoped(authed.scopes.as_deref()) + { + return Err(Error::PermissionDenied( + "Deploying to a remote instance requires your own session or an unscoped token, \ + not a job, scoped or read-only token" + .to_string(), + )); + } + Ok(()) +} + +fn parse_target(target: Option) -> Result> { + target + .map(serde_json::from_value) + .transpose() + .map_err(|e| Error::internal_err(format!("reading the remote deploy target: {e}"))) +} + +fn no_target(w_id: &str) -> Error { + Error::BadRequest(format!( + "Workspace {w_id} has no remote deploy target. An admin sets it in the workspace \ + settings, under Dev workspace" + )) +} + +async fn load_target(db: &DB, w_id: &str) -> Result> { + let target = sqlx::query_scalar!( + "SELECT remote_deploy_target FROM workspace_settings WHERE workspace_id = $1", + w_id + ) + .fetch_optional(db) + .await? + .flatten(); + parse_target(target) +} + +async fn require_target(db: &DB, w_id: &str) -> Result { + load_target(db, w_id).await?.ok_or_else(|| no_target(w_id)) +} + +fn remote_client_builder() -> reqwest::ClientBuilder { + configure_client(reqwest::ClientBuilder::new()) + .user_agent("windmill/remote-deploy") + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(120)) +} + +async fn remote_client(target: &RemoteDeployTarget) -> Result { + // A workspace admin chose the URL. A self-hosted instance may deploy into its own private + // network, as it may reach a private git remote; a cloud workspace must not. + if !*CLOUD_HOSTED { + return Ok(REMOTE_CLIENT.clone()); + } + validate_url_for_ssrf(&target.base_url) + .await? + .apply_dns_pinning(remote_client_builder()) + .build() + .map_err(|e| Error::internal_err(format!("building the remote deploy client: {e}"))) +} + +fn unreachable(target: &RemoteDeployTarget, e: reqwest::Error) -> Error { + Error::BadGateway(format!( + "Could not reach the remote instance {}: {}", + target.base_url, + error_source_chain(&e) + )) +} + +/// Responses carrying a `proxy_key` must not be kept by any cache between here and the browser. +const NO_STORE: [(header::HeaderName, &str); 1] = [(header::CACHE_CONTROL, "no-store")]; + +struct StoredConnection { + token: String, + remote_email: String, + proxy_key: String, + connected_at: DateTime, +} + +/// The caller's connection to `target`, if they may still use it. +/// +/// `connect` takes no lock against what clears these rows (a removal from the workspace, a target +/// change, a key rotation): writers take those rows in every order, so any lock it held could close +/// a deadlock. A row can therefore land after one of them ran, however long its remote call took, +/// and is voided here instead, by identity rather than by comparing times: it counts only while +/// the target setting and the owner's membership are the very ones it was connected under (see +/// `member_since`, `target_changed_at`) or the owner is a superadmin, and while it decrypts. An +/// emptied row is a disconnect's. +async fn load_connection( + db: &DB, + w_id: &str, + email: &str, + target: &RemoteDeployTarget, +) -> Result> { + let Some(row) = sqlx::query_as!( + StoredConnection, + "SELECT t.token, t.remote_email, t.proxy_key, t.connected_at FROM remote_deploy_token t + JOIN workspace_settings s ON s.workspace_id = t.workspace_id + WHERE t.workspace_id = $1 AND t.email = $2 AND t.base_url = $3 + AND t.remote_workspace_id = $4 AND t.token <> '' + AND s.remote_deploy_target_changed_at IS NOT DISTINCT FROM t.target_changed_at + AND (EXISTS (SELECT 1 FROM usr u WHERE u.workspace_id = t.workspace_id + AND u.email = t.email AND u.created_at = t.member_since) + OR EXISTS (SELECT 1 FROM password p WHERE p.email = t.email AND p.super_admin))", + w_id, + email, + &target.base_url, + &target.workspace_id + ) + .fetch_optional(db) + .await? + else { + return Ok(None); + }; + match decrypt(&build_crypt(db, w_id).await?, row.token) { + Ok(token) => Ok(Some(StoredConnection { token, ..row })), + Err(e) => { + tracing::warn!("remote deploy token of {email} in {w_id} does not decrypt: {e}"); + Ok(None) + } + } +} + +async fn get_target( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> Result<( + [(header::HeaderName, &'static str); 1], + Json, +)> { + let target = load_target(&db, &w_id).await?; + // The connection carries the proxy key. A credential that may not use the proxy (see + // `require_own_credentials`) must not read it either: it could hand its owner a working link. + let connection = match &target { + Some(target) if require_own_credentials(&authed).is_ok() => { + load_connection(&db, &w_id, &authed.email, target) + .await? + .map(|c| RemoteDeployConnection { + remote_email: c.remote_email, + proxy_key: c.proxy_key, + connected_at: c.connected_at, + }) + } + _ => None, + }; + Ok((NO_STORE, Json(RemoteDeployStatus { target, connection }))) +} + +#[derive(Deserialize)] +struct SetRemoteDeployTarget { + target: Option, +} + +async fn set_target( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(request): Json, +) -> Result { + if !cfg!(feature = "enterprise") { + return Err(Error::BadRequest( + "Deploying to another instance is only available on Windmill Enterprise Edition" + .to_string(), + )); + } + // Everyone who connects afterwards hands their token to this URL. + require_own_credentials(&authed)?; + require_admin(authed.is_admin, &authed.username)?; + let target = request + .target + .map(RemoteDeployTarget::normalized) + .transpose()?; + + let mut tx = db.begin().await?; + let (base_url, remote_workspace_id) = match &target { + Some(t) => (Some(t.base_url.as_str()), Some(t.workspace_id.as_str())), + None => (None, None), + }; + audit_log( + &mut *tx, + &authed, + "workspaces.edit_remote_deploy_target", + ActionKind::Update, + &w_id, + None, + Some( + [ + ("base_url", base_url.unwrap_or("")), + ("remote_workspace_id", remote_workspace_id.unwrap_or("")), + ] + .into(), + ), + ) + .await?; + let target_json = target + .as_ref() + .map(serde_json::to_value) + .transpose() + .map_err(|e| Error::internal_err(e.to_string()))?; + sqlx::query!( + "UPDATE workspace_settings SET remote_deploy_target = $1::jsonb, + remote_deploy_target_changed_at = CASE + WHEN remote_deploy_target IS DISTINCT FROM $1::jsonb THEN clock_timestamp() + ELSE remote_deploy_target_changed_at END + WHERE workspace_id = $2", + target_json, + &w_id + ) + .execute(&mut *tx) + .await?; + // A token is only ever sent to the target it was granted for, so the ones for any other + // target are dead credentials. + sqlx::query!( + "DELETE FROM remote_deploy_token WHERE workspace_id = $1 + AND ($2::text IS NULL OR base_url <> $2 OR remote_workspace_id <> $3)", + &w_id, + base_url, + remote_workspace_id + ) + .execute(&mut *tx) + .await?; + tx.commit().await?; + + Ok(match target { + Some(t) => format!( + "Workspace {w_id} deploys to {} on {}", + t.workspace_id, t.base_url + ), + None => format!("Removed the remote deploy target of workspace {w_id}"), + }) +} + +#[derive(Deserialize)] +struct ConnectRequest { + token: String, + /// The target the caller got the token for, as the drawer showed it. + target: RemoteDeployTarget, +} + +#[derive(Deserialize)] +struct RemoteWhoami { + email: String, +} + +async fn connect( + authed: ApiAuthed, + Tokened { token: credential }: Tokened, + Extension(db): Extension, + Path(w_id): Path, + Json(request): Json, +) -> Result<( + [(header::HeaderName, &'static str); 1], + Json, +)> { + require_own_credentials(&authed)?; + // Read before the remote call, which can take long enough for any of it to change: the row is + // bound to the target version and the membership seen here (see `load_connection`), and + // stamped with when this connect started, so that a disconnect or a newer connect made while + // it runs keeps it from landing. + let start = sqlx::query!( + r#"SELECT clock_timestamp() AS "started_at!", s.remote_deploy_target, + s.remote_deploy_target_changed_at, + (SELECT u.created_at FROM usr u + WHERE u.workspace_id = s.workspace_id AND u.email = $2) AS member_since + FROM workspace_settings s WHERE s.workspace_id = $1"#, + &w_id, + &authed.email + ) + .fetch_optional(&db) + .await? + .ok_or_else(|| no_target(&w_id))?; + let target = parse_target(start.remote_deploy_target)?.ok_or_else(|| no_target(&w_id))?; + // A token is only ever sent to the instance it was meant for. Without this, re-pointing the + // target between the user getting a token and posting it here would hand it to the new one; + // after this check the token still goes to `target` only, never to what the setting became. + let requested = request.target.normalized()?; + if requested.base_url != target.base_url || requested.workspace_id != target.workspace_id { + return Err(Error::BadRequest( + "The remote deploy target changed since you started connecting; reload and connect \ + again" + .to_string(), + )); + } + let token = request.token.trim(); + if token.is_empty() { + return Err(Error::BadRequest("The token is empty".to_string())); + } + + let response = remote_client(&target) + .await? + .get(target.api_url("users/whoami")) + .bearer_auth(token) + .send() + .await + .map_err(|e| unreachable(&target, e))?; + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + return Err(Error::BadRequest(format!( + "{} did not accept this token for workspace {} ({status}): {body}", + target.base_url, target.workspace_id + ))); + } + let whoami: RemoteWhoami = response.json().await.map_err(|e| { + Error::BadGateway(format!( + "{} did not answer like a Windmill instance: {}", + target.base_url, + error_source_chain(&e) + )) + })?; + + let proxy_key = rd_string(32); + let mc = build_crypt(&db, &w_id).await?; + // No lock against removals, target changes or key rotations: `load_connection` voids a row + // that lands after one of them. A disconnect or a newer connect stamps the row itself, which + // this one then leaves alone. Without a membership to bind to, the row is bound to the account + // through the credential making this request, which deleting the account removes (except + // through `leave_instance`, which leaves its tokens): the address could otherwise be deleted + // and taken by a new account while the remote call runs, and the foreign key would accept it. + let mut tx = db.begin().await?; + let connected_at = sqlx::query_scalar!( + "INSERT INTO remote_deploy_token + (workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key, + connected_at, member_since, target_changed_at) + SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + WHERE $9::timestamptz IS NOT NULL + OR EXISTS (SELECT 1 FROM token WHERE token_hash = $11) + ON CONFLICT (workspace_id, email) DO UPDATE SET + base_url = EXCLUDED.base_url, remote_workspace_id = EXCLUDED.remote_workspace_id, + token = EXCLUDED.token, remote_email = EXCLUDED.remote_email, + proxy_key = EXCLUDED.proxy_key, connected_at = EXCLUDED.connected_at, + member_since = EXCLUDED.member_since, target_changed_at = EXCLUDED.target_changed_at + WHERE remote_deploy_token.connected_at < EXCLUDED.connected_at + RETURNING connected_at", + &w_id, + &authed.email, + &target.base_url, + &target.workspace_id, + encrypt(&mc, token), + &whoami.email, + &proxy_key, + start.started_at, + start.member_since, + start.remote_deploy_target_changed_at, + hash_token(&credential) + ) + .fetch_optional(&mut *tx) + .await? + .ok_or_else(|| { + Error::BadRequest(match start.member_since { + Some(_) => "This connect was not saved: you disconnected or connected again while it \ + was running" + .to_string(), + None => format!( + "This connect was not saved: you disconnected, connected again or signed out \ + while it was running. Connecting workspace {w_id} without being one of its \ + members needs a session or an API token of this instance" + ), + }) + })?; + audit_log( + &mut *tx, + &authed, + "workspaces.remote_deploy_connect", + ActionKind::Create, + &w_id, + Some(&whoami.email), + Some([("base_url", target.base_url.as_str())].into()), + ) + .await?; + tx.commit().await?; + + Ok(( + NO_STORE, + Json(RemoteDeployConnection { remote_email: whoami.email, proxy_key, connected_at }), + )) +} + +async fn disconnect( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, +) -> Result { + require_own_credentials(&authed)?; + let mut tx = db.begin().await?; + // Emptied rather than deleted, and stamped, even with no connection yet: a connect that started + // before this must not bring one back when it lands (see `connect`). + sqlx::query!( + "INSERT INTO remote_deploy_token + (workspace_id, email, base_url, remote_workspace_id, token, remote_email, proxy_key, + connected_at) + VALUES ($1, $2, '', '', '', '', '', clock_timestamp()) + ON CONFLICT (workspace_id, email) DO UPDATE SET + token = '', proxy_key = '', connected_at = clock_timestamp()", + &w_id, + &authed.email + ) + .execute(&mut *tx) + .await?; + audit_log( + &mut *tx, + &authed, + "workspaces.remote_deploy_disconnect", + ActionKind::Delete, + &w_id, + None, + None, + ) + .await?; + tx.commit().await?; + Ok(format!( + "Disconnected from the remote deploy target of {w_id}" + )) +} + +/// The remote URL for the request path after the proxy prefix and key, as the client sent it. +/// +/// `url` rewrites a path while parsing it: it resolves dot segments (`%2e` spellings included) +/// and turns backslashes into slashes. Either would reach a route other than the one this +/// instance authorized — outside the remote workspace, or a route its read-only check does not +/// recognize — so a path the parser would change is refused rather than forwarded. +fn forwarded_url( + target: &RemoteDeployTarget, + original_path: &str, + query: Option<&str>, +) -> Result { + let suffix = original_path + .split_once(PROXY_PREFIX) + .and_then(|(_, keyed)| keyed.split_once('/')) + .map(|(_, suffix)| suffix) + .unwrap_or_default(); + let invalid = || Error::BadRequest(format!("Invalid remote deploy path: {suffix}")); + let base_path = url::Url::parse(&target.base_url) + .map_err(|_| invalid())? + .path() + .trim_end_matches('/') + .to_string(); + let mut url = url::Url::parse(&target.api_url(suffix)).map_err(|_| invalid())?; + let expected_path = format!("{base_path}/api/w/{}/{suffix}", target.workspace_id); + if suffix.is_empty() || url.path() != expected_path { + return Err(invalid()); + } + url.set_query(query); + Ok(url) +} + +async fn proxy( + authed: ApiAuthed, + Extension(db): Extension, + Path((w_id, key, _rest)): Path<(String, String, String)>, + OriginalUri(uri): OriginalUri, + method: Method, + headers: HeaderMap, + body: Bytes, +) -> Result { + require_own_credentials(&authed)?; + let target = require_target(&db, &w_id).await?; + let url = forwarded_url(&target, uri.path(), uri.query())?; + let stored = load_connection(&db, &w_id, &authed.email, &target) + .await? + .ok_or_else(|| { + Error::BadRequest(format!( + "Connect to {} with your own token before deploying there", + target.base_url + )) + })?; + if !constant_time_eq::constant_time_eq(key.as_bytes(), stored.proxy_key.as_bytes()) { + return Err(Error::BadRequest( + "This remote deploy link is not yours or is out of date; reload the page".to_string(), + )); + } + + // Only what describes the payload: the caller's cookie and token belong to this instance. + let mut request = remote_client(&target) + .await? + .request(method, url) + .bearer_auth(stored.token) + .body(body); + for name in [header::CONTENT_TYPE, header::ACCEPT] { + if let Some(value) = headers.get(&name) { + request = request.header(name, value.clone()); + } + } + let response = request.send().await.map_err(|e| unreachable(&target, e))?; + + let status = response.status(); + // Passed through, a 401 would read as this instance's session having expired and log the + // user out of it. + if status == StatusCode::UNAUTHORIZED { + let body = response.text().await.unwrap_or_default(); + return Err(Error::BadGateway(format!( + "{} rejected your stored token: {body}", + target.base_url + ))); + } + if status.is_redirection() { + let location = response + .headers() + .get(header::LOCATION) + .and_then(|l| l.to_str().ok()) + .unwrap_or_default() + .to_string(); + return Err(Error::BadGateway(format!( + "{} redirected to {location}. Set the remote deploy target to the URL it redirects to", + target.base_url + ))); + } + // The body is the remote's and may be anything its users stored (an uploaded file, a job + // result typed `text/html`); served from this origin it must never render as a page here. + let mut builder = Response::builder() + .status(status) + .header(header::X_CONTENT_TYPE_OPTIONS, "nosniff") + .header( + header::CONTENT_SECURITY_POLICY, + "sandbox; default-src 'none'", + ); + if let Some(content_type) = response.headers().get(header::CONTENT_TYPE) { + builder = builder.header(header::CONTENT_TYPE, content_type); + } + builder + .body(Body::from_stream(response.bytes_stream())) + .map_err(|e| Error::internal_err(format!("building the proxied response: {e}"))) +} + +/// Move the stored remote tokens to a new workspace key. A token that no longer decrypts is +/// dropped: its owner connects again. A disconnect's emptied row is kept, as the stamp that keeps +/// an older connect from landing. +pub(crate) async fn reencrypt_tokens( + conn: &mut sqlx::PgConnection, + w_id: &str, + old: &MagicCrypt256, + new: &MagicCrypt256, +) -> Result<()> { + let rows = sqlx::query!( + "SELECT email, token FROM remote_deploy_token + WHERE workspace_id = $1 AND token <> '' FOR UPDATE", + w_id + ) + .fetch_all(&mut *conn) + .await?; + for row in rows { + match decrypt(old, row.token) { + Ok(plain) => { + sqlx::query!( + "UPDATE remote_deploy_token SET token = $1 + WHERE workspace_id = $2 AND email = $3", + encrypt(new, &plain), + w_id, + row.email + ) + .execute(&mut *conn) + .await?; + } + Err(_) => { + sqlx::query!( + "DELETE FROM remote_deploy_token WHERE workspace_id = $1 AND email = $2", + w_id, + row.email + ) + .execute(&mut *conn) + .await?; + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn forwarded_url_refuses_paths_the_parser_rewrites() { + let target = RemoteDeployTarget { + base_url: "https://prod.example.com/windmill".to_string(), + workspace_id: "prod".to_string(), + }; + let prefix = "/api/w/dev/remote_deploy/proxy/someKey"; + let url = forwarded_url( + &target, + &format!("{prefix}/scripts/get/p/f/team/my%20script"), + Some("with_starred_info=true"), + ) + .unwrap(); + assert_eq!( + url.as_str(), + "https://prod.example.com/windmill/api/w/prod/scripts/get/p/f/team/my%20script?with_starred_info=true" + ); + for path in [ + format!("{prefix}/../../users/list"), + format!("{prefix}/%2e%2e/%2e%2e/users/list"), + format!("{prefix}/jobs\\run_wait_result\\p/f/team/action"), + prefix.to_string(), + ] { + assert!( + forwarded_url(&target, &path, None).is_err(), + "{path} should be refused" + ); + } + } + + #[test] + fn redacted_uri_masks_the_proxy_key() { + let proxied: axum::http::Uri = "/api/w/dev/remote_deploy/proxy/secretKey/flows/get/f/a?x=1" + .parse() + .unwrap(); + assert_eq!( + RedactedUri(&proxied).to_string(), + "/api/w/dev/remote_deploy/proxy/***/flows/get/f/a?x=1" + ); + let other: axum::http::Uri = "/api/w/dev/flows/list?x=1".parse().unwrap(); + assert_eq!(RedactedUri(&other).to_string(), "/api/w/dev/flows/list?x=1"); + } + + #[test] + fn target_normalization() { + let target = RemoteDeployTarget { + base_url: " https://prod.example.com/ ".to_string(), + workspace_id: " prod ".to_string(), + } + .normalized() + .unwrap(); + assert_eq!(target.base_url, "https://prod.example.com"); + assert_eq!( + target.api_url("flows/create"), + "https://prod.example.com/api/w/prod/flows/create" + ); + + for (base_url, workspace_id) in [ + ("ftp://prod.example.com", "prod"), + ("https://user:pass@prod.example.com", "prod"), + ("https://prod.example.com?token=x", "prod"), + ("https://prod.example.com", "prod/../admins"), + ] { + assert!( + RemoteDeployTarget { + base_url: base_url.to_string(), + workspace_id: workspace_id.to_string(), + } + .normalized() + .is_err(), + "{base_url} {workspace_id} should be refused" + ); + } + } +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 2bef0d28ab..8ebd4eeb26 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -12,6 +12,7 @@ use windmill_api_auth::{ }; use windmill_api_users::users::WorkspaceInvite; use windmill_common::email_oss::send_email_if_possible; +use windmill_common::ssrf::{validate_webhook_url, webhook_ssrf_error_message}; use windmill_common::usernames::{get_instance_username_or_create_pending, VALID_USERNAME}; use windmill_common::webhook::WebhookShared; use windmill_common::{BASE_URL, DB}; @@ -38,17 +39,19 @@ use windmill_common::global_settings::HTTP_ROUTE_WORKSPACED_ROUTE; use windmill_common::query_builders::{render_db_quoted_identifier, DbType}; use windmill_common::users::username_to_permissioned_as; use windmill_common::variables::{ - build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE, + crypt_from_key_with_suffix, decrypt, encrypt, WORKSPACE_CRYPT_CACHE, }; use windmill_common::worker::{to_raw_value, CLOUD_HOSTED}; use windmill_common::workspaces::GitRepositorySettings; #[cfg(feature = "enterprise")] use windmill_common::workspaces::WorkspaceDeploymentUISettings; use windmill_common::workspaces::{ - check_deploy_rules, check_user_against_rule, get_datatable_resource_from_db_unchecked, + check_deploy_rules, check_user_against_rule, get_datatable_resource_from_db, + get_datatable_resource_from_db_unchecked, parse_datatable_ref_for, resolve_governing_datatable, validate_dev_workspace_id, validate_fork_workspace_id, validate_workspace_name, DataTable, - DataTableCatalogResourceType, DataTableForkBehavior, ProtectionRuleKind, ProtectionRules, - ProtectionRuleset, RuleCheckResult, WorkspaceGitSyncSettings, DEV_WORKSPACE_LOCK_RULE_NAME, + DataTableCatalogResourceType, DataTableForkBehavior, DatatableAccess, GoverningDatatable, + ProtectionRuleKind, ProtectionRules, ProtectionRuleset, RuleCheckResult, + WorkspaceGitSyncSettings, DEV_WORKSPACE_LOCK_RULE_NAME, }; use windmill_common::workspaces::{Ducklake, DucklakeCatalogResourceType}; use windmill_common::PgDatabase; @@ -141,6 +144,8 @@ pub fn workspaced_service() -> Router { get(test_datatable_connection), ) .merge(crate::datatable_migrations::routes()) + .merge(crate::datatable_permissions::routes()) + .merge(crate::datatable_acl::routes()) .route("/git_sync_enabled", get(get_git_sync_enabled)) .route("/git_sync_deploy_mode", get(get_git_sync_deploy_mode)) .route("/edit_git_sync_config", post(edit_git_sync_config)) @@ -152,6 +157,10 @@ pub fn workspaced_service() -> Router { .route("/edit_deploy_ui_config", post(edit_deploy_ui_config)) .route("/edit_default_app", post(edit_default_app)) .route("/edit_guest_access", post(edit_guest_access)) + .route( + "/edit_add_admins_and_developers_to_forks", + post(edit_add_admins_and_developers_to_forks), + ) .route("/edit_guest_jwt_key", post(edit_guest_jwt_key)) .route("/guest_usage", get(get_guest_usage)) .route("/default_app", get(get_default_app)) @@ -335,6 +344,7 @@ pub struct WorkspaceSettings { pub guest_jwt_public_key: Option, #[serde(skip_serializing_if = "Option::is_none")] pub guest_jwt_jwks_url: Option, + pub add_admins_and_developers_to_forks: bool, } /// Subset of `WorkspaceSettings` that is safe to return to any workspace @@ -360,6 +370,8 @@ pub struct WorkspacePublicSettings { /// Not sensitive, and the app editor needs it to say whether the guest rung is /// live -- an app can be set to `guest` while the workspace has guests off. pub guest_access_enabled: bool, + /// Read by the fork dialog, which tells the forker who else the fork will include. + pub add_admins_and_developers_to_forks: bool, #[serde(skip_serializing_if = "Option::is_none")] pub deploy_ui: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -490,8 +502,8 @@ struct CreateWorkspaceFork { id: String, name: String, color: Option, - /// Datatable names that were forked. For each, the backend will update the - /// forked workspace's datatable config to point to the new database. + /// Data tables the fork gets a copy of rather than a pointer at the parent's. For each, the + /// fork's entry is pointed at the new database. #[serde(default)] forked_datatables: Vec, /// Lakes the user explicitly chose to SHARE with the parent (the fork then reads and @@ -523,6 +535,11 @@ struct CreateWorkspaceFork { struct ForkedDatatableInfo { name: String, new_dbname: String, + /// What this request copies into `new_dbname`, which it creates. Optional only so that the + /// entry older CLIs send — naming a database they created and filled themselves — is refused + /// with a message rather than a parse error. + #[serde(default)] + fork_behavior: Option, } #[derive(Deserialize)] @@ -552,6 +569,7 @@ struct UserWorkspace { /// screen off this. pub created_by: Option, pub disabled: bool, + pub is_service_account: bool, } #[derive(Deserialize)] @@ -1122,7 +1140,8 @@ async fn get_settings( error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, - guest_jwt_jwks_url + guest_jwt_jwks_url, + add_admins_and_developers_to_forks FROM workspace_settings WHERE @@ -1140,6 +1159,8 @@ async fn get_settings( if let Some(git_sync) = settings.git_sync.as_mut() { redact_git_sync_webhook_secrets(git_sync); } + settings.datatable = + windmill_common::workspaces::strip_datatable_permissions(settings.datatable.take()); Ok(Json(settings)) } @@ -1162,6 +1183,7 @@ async fn get_public_settings( teams_team_guid, mute_critical_alerts, guest_access_enabled, + add_admins_and_developers_to_forks, deploy_ui, large_file_storage, datatable @@ -1176,8 +1198,10 @@ async fn get_public_settings( .await .map_err(|e| Error::internal_err(format!("getting public settings: {e:#}")))?; - let settings = not_found_if_none(settings, "workspace settings", &w_id)?; + let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?; tx.commit().await?; + settings.datatable = + windmill_common::workspaces::strip_datatable_permissions(settings.datatable.take()); Ok(Json(settings)) } @@ -1900,6 +1924,16 @@ async fn edit_webhook( )); } + // An empty URL is stored as-is and means "no webhook" to the sender. + if let Some(webhook) = ew.webhook.as_deref().filter(|w| !w.is_empty()) { + validate_webhook_url(webhook).await.map_err(|e| { + Error::BadRequest(format!( + "Webhook URL is not allowed: {}", + webhook_ssrf_error_message(&e) + )) + })?; + } + let mut tx = db.begin().await?; if let Some(webhook) = &ew.webhook { @@ -2184,6 +2218,12 @@ struct DataTableListItem { name: String, resource_type: String, resource_path: String, + /// The workspace whose entry governs this one, when it is not this workspace — a fork pointing + /// at its parent. Its permissions apply here, and only its admins may edit them. + #[serde(skip_serializing_if = "Option::is_none")] + governing_workspace_id: Option, + /// Whether the governing entry is under roles. + permissioned: bool, } async fn list_datatables( @@ -2191,26 +2231,29 @@ async fn list_datatables( Extension(db): Extension, Path(w_id): Path, ) -> JsonResult> { - let config = sqlx::query_scalar!( - "SELECT datatable->'datatables' FROM workspace_settings WHERE workspace_id = $1", - &w_id - ) - .fetch_one(&db) - .await?; + // A pointer entry owns no database, so what it resolves to is the only truthful answer here. + // One that resolves to nothing — a pointer whose workspace was deleted — is dropped rather than + // listed with a database it does not have; what happened is named where it is actionable + // instead: by the delete that stranded it, and by any attempt to use it. + let resolved = + windmill_common::workspaces::resolve_workspace_governing_datatables(&db, &w_id).await?; - let items: Vec = match config { - Some(val) => { - let map: HashMap = serde_json::from_value(val).unwrap_or_default(); - map.into_iter() - .map(|(name, dt)| DataTableListItem { - name, - resource_type: dt.database.resource_type.as_ref().to_string(), - resource_path: dt.database.resource_path, - }) - .collect() - } - None => vec![], - }; + let mut items = Vec::with_capacity(resolved.len()); + for (name, governing) in resolved { + let database = governing + .datatable + .database + .as_ref() + .expect("a governing entry owns a database"); + items.push(DataTableListItem { + name, + resource_type: database.resource_type.as_ref().to_string(), + resource_path: database.resource_path.clone(), + governing_workspace_id: (governing.governing_workspace_id() != w_id) + .then(|| governing.governing_workspace_id().to_string()), + permissioned: governing.datatable.permissions.is_some(), + }); + } Ok(Json(items)) } @@ -2246,6 +2289,25 @@ struct DataTableTables { schemas: TableListMap, #[serde(skip_serializing_if = "Option::is_none")] error: Option, + /// On the instance database: the only kind that can be under roles or have its access edited. + instance: bool, + permissioned: bool, + /// The roles this caller may connect as, by name; empty when not under roles. + usable_roles: Vec, + default_role: String, + /// What the role the listing connected as may create. + can_create_schema: bool, + creatable_schemas: Vec, +} + +#[derive(Deserialize)] +struct ListDataTableTablesQuery { + /// List only this data table: each entry opens a connection to its database. + datatable_name: Option, + /// The data table `role` applies to. Every other one is listed as its default role, since a + /// role name means nothing outside the data table it belongs to. + role_for: Option, + role: Option, } #[derive(Deserialize)] @@ -2253,6 +2315,7 @@ struct GetDataTableSchemaQuery { datatable_name: String, schema_name: String, table_name: String, + role: Option, } #[derive(Serialize, Debug)] @@ -2298,6 +2361,15 @@ async fn test_datatable_connection( Path((w_id, datatable_name)): Path<(String, String)>, ) -> JsonResult { require_admin(authed.is_admin, &authed.username)?; + // Reports what the admin connection can do, so it answers to the workspace that governs the + // data table rather than to whichever one is asking. + windmill_common::workspaces::ensure_datatable_admin_access( + &db, + &w_id, + &datatable_name, + &DatatableAccess::Authed(authed.to_authed_ref()), + ) + .await?; let db_resource = get_datatable_resource_from_db_unchecked(&db, &w_id, &datatable_name).await?; let pg_db: PgDatabase = serde_json::from_value(db_resource) @@ -2385,7 +2457,7 @@ async fn test_datatable_connection( } async fn list_datatable_schemas( - _authed: ApiAuthed, + authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ) -> JsonResult> { @@ -2393,7 +2465,7 @@ async fn list_datatable_schemas( let mut results = Vec::new(); for datatable_name in datatable_names { - let schema = match get_datatable_schema(&db, &w_id, &datatable_name).await { + let schema = match get_datatable_schema(&db, &authed, &w_id, &datatable_name).await { Ok(schemas) => DataTableSchema { datatable_name, schemas, error: None }, Err(e) => DataTableSchema { datatable_name, @@ -2408,40 +2480,106 @@ async fn list_datatable_schemas( } async fn list_datatable_tables( - _authed: ApiAuthed, + authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, + Query(query): Query, ) -> JsonResult> { - let datatable_names = list_datatable_names(&db, &w_id).await?; + if query.role.is_some() && query.role_for.is_none() { + return Err(Error::BadRequest( + "`role` needs `role_for`, the data table it is a role of".to_string(), + )); + } + if let (Some(only), Some(role_for)) = + (query.datatable_name.as_deref(), query.role_for.as_deref()) + { + if only != role_for { + return Err(Error::BadRequest(format!( + "`role_for` names '{role_for}', which `datatable_name` leaves out of the listing" + ))); + } + } + let mut datatable_names = list_datatable_names(&db, &w_id).await?; + for named in [query.role_for.as_deref(), query.datatable_name.as_deref()] + .into_iter() + .flatten() + { + if !datatable_names.iter().any(|n| n == named) { + return Err(Error::NotFound(format!( + "No data table named '{named}' in this workspace" + ))); + } + } + if let Some(only) = query.datatable_name.as_deref() { + datatable_names.retain(|n| n == only); + } let mut results = Vec::new(); for datatable_name in datatable_names { - let tables = match get_datatable_tables(&db, &w_id, &datatable_name).await { - Ok(schemas) => DataTableTables { datatable_name, schemas, error: None }, - Err(e) => DataTableTables { - datatable_name, - schemas: HashMap::new(), - error: Some(e.to_string()), - }, - }; - results.push(tables); + let role = query + .role + .as_deref() + .filter(|_| query.role_for.as_deref() == Some(datatable_name.as_str())); + results.push(list_one_datatable_tables(&db, &authed, &w_id, datatable_name, role).await); } Ok(Json(results)) } +async fn list_one_datatable_tables( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + datatable_name: String, + role: Option<&str>, +) -> DataTableTables { + let mut entry = DataTableTables { + datatable_name, + schemas: HashMap::new(), + error: None, + instance: false, + permissioned: false, + usable_roles: vec![], + default_role: windmill_common::datatable_roles::ADMIN_DATATABLE_ROLE.to_string(), + can_create_schema: false, + creatable_schemas: vec![], + }; + let result: Result<()> = async { + let governing = resolve_governing_datatable(db, w_id, &entry.datatable_name).await?; + entry.instance = governing.is_instance(); + let usable = + crate::datatable_permissions_oss::usable_datatable_roles(db, authed, w_id, &governing) + .await?; + entry.permissioned = usable.permissioned; + entry.usable_roles = usable.roles; + entry.default_role = usable.default_role; + let listing = get_datatable_tables(db, authed, w_id, &entry.datatable_name, role).await?; + entry.schemas = listing.schemas; + entry.can_create_schema = listing.can_create_schema; + entry.creatable_schemas = listing.creatable_schemas; + Ok(()) + } + .await; + if let Err(e) = result { + entry.error = Some(e.to_string()); + } + entry +} + async fn get_datatable_table_schema( - _authed: ApiAuthed, + authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, Query(query): Query, ) -> JsonResult { let columns = get_datatable_table_columns( &db, + &authed, &w_id, &query.datatable_name, &query.schema_name, &query.table_name, + query.role.as_deref(), ) .await?; @@ -2469,13 +2607,38 @@ async fn list_datatable_names(db: &DB, w_id: &str) -> Result> { .collect()) } -async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Result { - // Get the datatable resource (connection credentials) - let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?; +/// Connect to a data table as the caller, not as `admin`: the role they named, or the data table's +/// default. A data table not under roles resolves as `admin`, exactly as it did before roles. +/// +/// Every schema-browsing query below then reports what this Postgres role can actually reach, +/// which is why they filter on `has_schema_privilege` — `pg_catalog` is world-readable, so an +/// unfiltered listing would name schemas the connection cannot even enter. +async fn resolve_datatable_pg_as_caller( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + datatable_name: &str, + role: Option<&str>, +) -> Result { + let db_resource = get_datatable_resource_from_db( + db, + w_id, + datatable_name, + role, + DatatableAccess::Authed(authed.to_authed_ref()), + ) + .await?; + serde_json::from_value(db_resource) + .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e))) +} - // Parse the resource as PgDatabase - let pg_db: PgDatabase = serde_json::from_value(db_resource) - .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; +async fn get_datatable_schema( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + datatable_name: &str, +) -> Result { + let pg_db = resolve_datatable_pg_as_caller(db, authed, w_id, datatable_name, None).await?; // Connect to the datatable database let (client, connection) = pg_db.connect(Some(db)).await?; @@ -2495,6 +2658,7 @@ async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Resu FROM pg_namespace WHERE nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog') AND nspname NOT LIKE 'pg_%' + AND has_schema_privilege(oid, 'USAGE') ORDER BY nspname "#, &[], @@ -2562,10 +2726,20 @@ async fn get_datatable_schema(db: &DB, w_id: &str, datatable_name: &str) -> Resu Ok(schema_map) } -async fn get_datatable_tables(db: &DB, w_id: &str, datatable_name: &str) -> Result { - let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?; - let pg_db: PgDatabase = serde_json::from_value(db_resource) - .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; +struct DatatableTableListing { + schemas: TableListMap, + can_create_schema: bool, + creatable_schemas: Vec, +} + +async fn get_datatable_tables( + db: &DB, + authed: &ApiAuthed, + w_id: &str, + datatable_name: &str, + role: Option<&str>, +) -> Result { + let pg_db = resolve_datatable_pg_as_caller(db, authed, w_id, datatable_name, role).await?; let (client, connection) = pg_db.connect(Some(db)).await?; tokio::spawn(async move { @@ -2577,10 +2751,11 @@ async fn get_datatable_tables(db: &DB, w_id: &str, datatable_name: &str) -> Resu let schema_rows = client .query( r#" - SELECT nspname::text AS schema_name + SELECT nspname::text AS schema_name, has_schema_privilege(oid, 'CREATE') AS can_create FROM pg_namespace WHERE nspname NOT IN ('information_schema', 'pg_toast', 'pg_catalog') AND nspname NOT LIKE 'pg_%' + AND has_schema_privilege(oid, 'USAGE') ORDER BY nspname "#, &[], @@ -2590,11 +2765,29 @@ async fn get_datatable_tables(db: &DB, w_id: &str, datatable_name: &str) -> Resu Error::internal_err(format!("Failed to query schemas: {}", pg_error_message(&e))) })?; + let can_create_schema: bool = client + .query_one( + "SELECT has_database_privilege(current_database(), 'CREATE')", + &[], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to read database privileges: {}", + pg_error_message(&e) + )) + })? + .get(0); + let mut table_map: TableListMap = HashMap::new(); + let mut creatable_schemas = Vec::new(); let schema_names: Vec = schema_rows .iter() .map(|row| { let name: String = row.get(0); + if row.get::<_, bool>(1) { + creatable_schemas.push(name.clone()); + } table_map.entry(name.clone()).or_default(); name }) @@ -2624,15 +2817,17 @@ async fn get_datatable_tables(db: &DB, w_id: &str, datatable_name: &str) -> Resu table_map.entry(table_schema).or_default().push(table_name); } - Ok(table_map) + Ok(DatatableTableListing { schemas: table_map, can_create_schema, creatable_schemas }) } async fn get_datatable_table_columns( db: &DB, + authed: &ApiAuthed, w_id: &str, datatable_name: &str, schema_name: &str, table_name: &str, + role: Option<&str>, ) -> Result { if is_system_pg_schema(schema_name) { return Err(Error::BadRequest(format!( @@ -2641,9 +2836,7 @@ async fn get_datatable_table_columns( ))); } - let db_resource = get_datatable_resource_from_db_unchecked(db, w_id, datatable_name).await?; - let pg_db: PgDatabase = serde_json::from_value(db_resource) - .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; + let pg_db = resolve_datatable_pg_as_caller(db, authed, w_id, datatable_name, role).await?; let (client, connection) = pg_db.connect(Some(db)).await?; tokio::spawn(async move { @@ -2739,6 +2932,18 @@ fn truncate_column_default(default: String) -> String { mod tests { use super::*; + #[test] + fn a_dev_workspace_copies_into_the_fork_database_namespace() { + assert_eq!( + forked_datatable_dbname("wm-fork-my-fork", "main"), + "wm_fork_my_fork__main" + ); + assert_eq!( + forked_datatable_dbname("my-dev", "main"), + "wm_fork_my_dev__main" + ); + } + /// The header of a pg_dump, followed by an object whose body also holds a `SET`. const DUMP: &str = "--\n\ -- PostgreSQL database dump\n\ @@ -2853,7 +3058,10 @@ mod tests { } /// Resolve a source string to PgDatabase credentials with user-scoped permission checks. -/// For `datatable://name`: accessible to everyone (variables are resolved internally). +/// +/// For `datatable://name`: the **admin** connection, so it is gated on admin reach. Every caller +/// copies, dumps or drops a whole database, and a dump taken under a restricted role would be a +/// silently truncated copy rather than an error — which is worse than refusing. /// For `$res:path`: uses UserDB (row-level security) to verify the user can see the resource, /// then interpolates `$var:` references in the resource value. pub(crate) async fn resolve_pg_source_checked( @@ -2864,6 +3072,13 @@ pub(crate) async fn resolve_pg_source_checked( source: &str, ) -> Result { let db_resource = if let Some(name) = source.strip_prefix("datatable://") { + windmill_common::workspaces::ensure_datatable_admin_access( + db, + w_id, + name, + &DatatableAccess::Authed(authed.to_authed_ref()), + ) + .await?; get_datatable_resource_from_db_unchecked(db, w_id, name).await? } else if let Some(path) = source.strip_prefix("$res:") { let db_with_authed = windmill_common::db::DbWithOptAuthed::from_authed( @@ -2904,22 +3119,13 @@ pub(crate) async fn resolve_pg_source_checked( /// Whether the data table `name` is backed by the Windmill instance's own PostgreSQL /// rather than a user resource. pub(crate) async fn is_instance_datatable(db: &DB, w_id: &str, name: &str) -> Result { - let config = sqlx::query_scalar!( - "SELECT datatable->'datatables'->$2 FROM workspace_settings WHERE workspace_id = $1", - w_id, - name - ) - .fetch_optional(db) - .await? - .flatten(); - Ok(config - .and_then(|v| { - v.get("database") - .and_then(|d| d.get("resource_type")) - .and_then(|r| r.as_str()) - .map(|s| s == "instance") - }) - .unwrap_or(false)) + // Resolved rather than read: a pointer entry owns no database of its own, so only the entry it + // lands on can answer. A name that resolves to nothing keeps the historical `false`. + Ok(resolve_governing_datatable(db, w_id, name) + .await + .ok() + .and_then(|g| g.datatable.database) + .is_some_and(|d| d.resource_type == DataTableCatalogResourceType::Instance)) } /// Same, for the `datatable://` / `$res:` form the import endpoints take. @@ -3206,7 +3412,10 @@ async fn server_setting_names(pg_db: &PgDatabase) -> Result> { /// so a dump that breaks partway through imports partially and reads as a success. /// ON_ERROR_STOP surfaces the failure and --single-transaction makes the restore /// all-or-nothing, leaving the target as it was and the import retryable. -async fn pg_import_dump(target_db: &PgDatabase, dump_file: &DumpFile) -> Result<()> { +/// +/// Authorization: none. Writes into `target_db` with the credentials it carries; callers MUST have +/// authorized writing to that database. +pub(crate) async fn pg_import_dump(target_db: &PgDatabase, dump_file: &DumpFile) -> Result<()> { let supported_settings = server_setting_names(target_db).await?; comment_out_unsupported_settings(dump_file, &supported_settings).await?; @@ -3250,6 +3459,14 @@ async fn create_pg_database( ) -> Result { windmill_common::validate_dbname(&req.target_dbname)?; + // The copy this database is for is refused a call later, and nothing collects an instance + // database that no data table entry names. Refuse here too, so the clone stops before one + // exists rather than leaving an empty registered `wm_fork_…` behind. + if let Some(reference) = req.source.strip_prefix("datatable://") { + let (name, _) = parse_datatable_ref_for(&db, &w_id, reference).await?; + ensure_copied_without_roles(&ensure_datatable_is_clonable(&db, &w_id, &name).await?)?; + } + // Non-superadmin: restrict dbname to wm_fork_ prefix if !windmill_api_auth::is_super_admin_authed(&db, &authed).await? { if !req.target_dbname.starts_with("wm_fork_") { @@ -3266,50 +3483,62 @@ async fn create_pg_database( } else { let source_pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?; - let (client, connection) = source_pg.connect(Some(&db)).await?; - let join_handle = tokio::spawn(async move { connection.await }); - - let row = client - .query_one( - "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)", - &[&req.target_dbname], - ) - .await - .map_err(|e| { - Error::internal_err(format!( - "Failed to check database existence: {}", - pg_error_message(&e) - )) - })?; - let db_exists: bool = row.get(0); - - if db_exists { - drop(client); - let _ = windmill_common::shutdown_pg_connection(join_handle).await; - return Err(Error::BadRequest(format!( - "Database '{}' already exists on the resource server", - req.target_dbname - ))); - } - - client - .execute(&format!("CREATE DATABASE \"{}\"", &req.target_dbname), &[]) - .await - .map_err(|e| { - Error::internal_err(format!( - "Failed to create database '{}': {}", - req.target_dbname, - pg_error_message(&e) - )) - })?; - - drop(client); - windmill_common::shutdown_pg_connection(join_handle).await?; + create_database_on_server(&db, &source_pg, &req.target_dbname).await?; } Ok(format!("Created database '{}'", req.target_dbname)) } +/// `CREATE DATABASE` on the server `server` connects to, refusing a name already taken there. +/// +/// Authorization: none. Callers MUST have authorized creating a database on that server — resolved +/// from a data table or resource the caller may administer. +pub(crate) async fn create_database_on_server( + db: &DB, + server: &PgDatabase, + dbname: &str, +) -> Result<()> { + windmill_common::validate_dbname(dbname)?; + let (client, connection) = server.connect(Some(db)).await?; + let join_handle = tokio::spawn(async move { connection.await }); + + let row = client + .query_one( + "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)", + &[&dbname], + ) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to check database existence: {}", + pg_error_message(&e) + )) + })?; + let db_exists: bool = row.get(0); + + if db_exists { + drop(client); + let _ = windmill_common::shutdown_pg_connection(join_handle).await; + return Err(Error::BadRequest(format!( + "Database '{dbname}' already exists on the resource server" + ))); + } + + client + .execute(&format!("CREATE DATABASE \"{dbname}\""), &[]) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to create database '{dbname}': {}", + pg_error_message(&e) + )) + })?; + + drop(client); + windmill_common::shutdown_pg_connection(join_handle).await?; + Ok(()) +} + #[derive(Deserialize)] struct ImportPgDatabaseRequest { source: String, @@ -3319,6 +3548,56 @@ struct ImportPgDatabaseRequest { fork_behavior: DataTableForkBehavior, } +/// Whether data table `name` of `w_id` has a shape a copy can be made of, returning what governs +/// it. Checked before anything is created — by the fork request for the copies it makes, and by +/// `create_pg_database` — and again when the fork's entry is written. +/// +/// It is not the check for a data table under roles: a copy of one is only correct with its +/// owners and grants replayed, which the fork request does and the older endpoints refuse +/// ([`ensure_copied_without_roles`]). +/// +/// Authorization: none. Callers MUST have authorized reaching data table `name` of `w_id` before +/// using what it returns. +pub(crate) async fn ensure_datatable_is_clonable( + db: &DB, + w_id: &str, + name: &str, +) -> Result { + let governing = resolve_governing_datatable(db, w_id, name).await?; + // The copy has to name a database of its own. A resource-backed entry reached through a + // pointer names one this workspace does not own, so there is nothing here to repoint. + let is_instance = governing + .datatable + .database + .as_ref() + .is_some_and(|d| d.resource_type == DataTableCatalogResourceType::Instance); + if governing.workspace_id != w_id && !is_instance { + return Err(Error::BadRequest(format!( + "Data table '{name}' points at a resource-backed data table in another workspace \ + and cannot be copied; fork it from the workspace that owns it." + ))); + } + Ok(governing) +} + +/// Refuse a copy of a data table under roles through the endpoints that copy rows and nothing +/// else. +/// +/// `pg_dump` carries no roles and the import runs with `--no-privileges`, so such a copy arrives +/// with its objects owned by the admin connection and no `GRANT` for any role: the tenants pass +/// Windmill's check, connect as the role they were given, and are denied by Postgres on everything. +/// A fork request that makes the copy itself replays the owners and grants instead. +fn ensure_copied_without_roles(governing: &GoverningDatatable) -> Result<()> { + if governing.datatable.permissions.is_some() { + return Err(Error::BadRequest(format!( + "Data table '{}' is under roles, so a copy has to carry its owners and grants: \ + clone it through the fork wizard or `wmill workspace fork`, which do.", + governing.name + ))); + } + Ok(()) +} + /// Import (pg_dump/pg_import) from source to target async fn import_pg_database( authed: ApiAuthed, @@ -3331,6 +3610,11 @@ async fn import_pg_database( return Ok("No action needed for KeepOriginal behavior".to_string()); } + if let Some(reference) = req.source.strip_prefix("datatable://") { + let (name, _) = parse_datatable_ref_for(&db, &w_id, reference).await?; + ensure_copied_without_roles(&ensure_datatable_is_clonable(&db, &w_id, &name).await?)?; + } + if req.fork_behavior == DataTableForkBehavior::SchemaAndData { require_admin(authed.is_admin, &authed.username)?; if *CLOUD_HOSTED { @@ -3449,6 +3733,15 @@ async fn edit_ducklake_config( } let mut tx = db.begin().await?; + windmill_common::lock_instance_databases( + &mut tx, + new_config.settings.ducklakes.values().filter_map(|lake| { + (lake.catalog.resource_type + == windmill_common::workspaces::DucklakeCatalogResourceType::Instance) + .then_some(lake.catalog.resource_path.as_str()) + }), + ) + .await?; let args_for_audit = format!("{:?}", new_config.settings); audit_log( @@ -3524,24 +3817,54 @@ async fn edit_ducklake_config( Ok(format!("Edit ducklake config for workspace {}", &w_id)) } +/// What a save left behind. `stranded_references` names the data tables in other workspaces that +/// were governed by one this save deleted — a field rather than a sentence in a success string, +/// so the UI decides whether to warn on the data rather than on the server's prose. +#[derive(Serialize)] +pub struct EditDataTableConfigResult { + #[serde(skip_serializing_if = "Vec::is_empty")] + stranded_references: Vec, +} + +#[derive(Serialize)] +pub struct StrandedReference { + workspace_id: String, + datatable: String, +} + async fn edit_datatable_config( authed: ApiAuthed, Extension(db): Extension, Path(w_id): Path, ApiAuthed { is_admin, username, .. }: ApiAuthed, Json(mut new_config): Json, -) -> Result { +) -> JsonResult { require_admin(is_admin, &username)?; let is_superadmin = require_super_admin(&db, &authed).await.is_ok(); let mut tx = db.begin().await?; + windmill_common::lock_instance_databases( + &mut tx, + new_config.settings.datatables.values().filter_map(|dt| { + dt.database + .as_ref() + .filter(|d| d.resource_type == DataTableCatalogResourceType::Instance) + .map(|d| d.resource_path.as_str()) + }), + ) + .await?; + // Read under the row lock this transaction will write with. `permissions`, `reference` and + // `forked_from` are carried across from what this read returns, so a permissions save + // committing between the read and the whole-document write below would be silently rolled back + // by it. let old_datatables: HashMap = serde_json::from_value( sqlx::query_scalar!( - "SELECT ws.datatable->'datatables' FROM workspace_settings ws WHERE ws.workspace_id = $1", + "SELECT ws.datatable->'datatables' FROM workspace_settings ws + WHERE ws.workspace_id = $1 FOR UPDATE", &w_id ) - .fetch_one(&db) + .fetch_one(&mut *tx) .await? .unwrap_or(serde_json::Value::Null), ) @@ -3563,6 +3886,58 @@ async fn edit_datatable_config( crate::datatable_migrations::validate_datatable_path_segment(&r.from)?; crate::datatable_migrations::validate_new_datatable_name(&r.to)?; } + // A rename is a claim about what this save is doing, and other workspaces' pointers are + // rewritten from it — so the claim has to match the configuration it describes, or a caller + // can move every fork of one data table onto another by asserting a rename that did not + // happen. The shape below is what "these old keys became those new keys" actually means. + { + let old_keys = &old_datatables; + let new_keys = &new_config.settings.datatables; + let froms: std::collections::HashSet<&str> = + new_config.renames.iter().map(|r| r.from.as_str()).collect(); + let tos: std::collections::HashSet<&str> = + new_config.renames.iter().map(|r| r.to.as_str()).collect(); + if froms.len() != new_config.renames.len() { + return Err(Error::BadRequest( + "A data table is renamed twice in one save".to_string(), + )); + } + if tos.len() != new_config.renames.len() { + return Err(Error::BadRequest( + "Two data tables are renamed to the same name in one save".to_string(), + )); + } + for r in &new_config.renames { + if !old_keys.contains_key(&r.from) { + return Err(Error::BadRequest(format!( + "Cannot rename data table '{}': this workspace has no such data table", + r.from + ))); + } + if !new_keys.contains_key(&r.to) { + return Err(Error::BadRequest(format!( + "Cannot rename data table '{}' to '{}': the save does not contain '{}'", + r.from, r.to, r.to + ))); + } + // The source has to be gone, or gone-and-reoccupied by another rename — which is what + // a swap is. Without this, `main -> decoy` passes against a save that keeps both, and + // every fork of `main` silently follows onto a different data table. + if new_keys.contains_key(&r.from) && !tos.contains(r.from.as_str()) { + return Err(Error::BadRequest(format!( + "Data table '{}' is renamed to '{}' but the save still contains '{}'", + r.from, r.to, r.from + ))); + } + // And the target has to be free, or freed by another rename. + if old_keys.contains_key(&r.to) && !froms.contains(r.to.as_str()) { + return Err(Error::BadRequest(format!( + "Cannot rename data table '{}' to '{}': '{}' already exists", + r.from, r.to, r.to + ))); + } + } + } // Map new name -> old name so a renamed data table inherits the previous // flag instead of being treated as brand new. @@ -3583,18 +3958,68 @@ async fn edit_datatable_config( .get(name.as_str()) .copied() .unwrap_or(name.as_str()); - dt.migrations_enabled = match old_datatables.get(lookup) { + let old = old_datatables.get(lookup); + dt.migrations_enabled = match old { Some(old) => old.migrations_enabled, None => { // Keyed by how the substrate is serialized into `workspace_settings`, // so these line up with the `datatable_configured` adoption counts. - created_substrates.push(match dt.database.resource_type { - DataTableCatalogResourceType::Instance => "instance", - DataTableCatalogResourceType::Postgresql => "postgresql", + created_substrates.push(match dt.database.as_ref().map(|d| d.resource_type) { + Some(DataTableCatalogResourceType::Instance) => "instance", + Some(DataTableCatalogResourceType::Postgresql) => "postgresql", + None => "reference", }); Some(true) } }; + // Carried across from the stored entry rather than taken from the request. `permissions` + // is an access decision, edited through its own endpoint; `reference` is what makes a fork + // answer to the workspace that governs its data table, and letting a save clear it would + // hand the fork the database outright. `forked_from` is the clone stamp the fork flow + // writes: whether an entry has one is carried the same way, since it is what marks the + // database droppable, but the schema baseline inside it is the diff view's to advance. + // `governed_by` is a clone's `reference` for its roles, and clearing it would hand the + // fork the copied rows the same way. + dt.permissions = old.and_then(|old| old.permissions.clone()); + dt.reference = old.and_then(|old| old.reference.clone()); + dt.governed_by = old.and_then(|old| old.governed_by.clone()); + dt.forked_from = match old.and_then(|old| old.forked_from.as_ref()) { + Some(stored) => Some(dt.forked_from.take().unwrap_or_else(|| stored.clone())), + None => None, + }; + // A clone's roles and grants were replayed into the database it was copied into, and hold + // for that database alone: whoever saves, it stays where it is. + if dt.governed_by.is_some() + && !crate::datatable_clone::same_database( + &dt.database, + &old.and_then(|old| old.database.clone()), + ) + { + return Err(Error::BadRequest(format!( + "Data table '{name}' is a clone taking its roles from the data table it was copied \ + from, and its grants hold for its own database only: it cannot be moved." + ))); + } + // Carrying the block onto a resource-backed entry would produce a data table the chokepoint + // refuses on every job — a save that succeeds and breaks everything afterwards. Refuse it + // instead: turning roles off first is one step, and it keeps discarding an access decision + // something somebody chose rather than a side effect of moving a database. + if dt.permissions.is_some() + && dt + .database + .as_ref() + .is_some_and(|d| d.resource_type != DataTableCatalogResourceType::Instance) + { + return Err(Error::BadRequest(format!( + "Data table '{name}' is under roles, which only a data table on the instance \ + database can be. Turn its roles off before moving it to a PostgreSQL resource." + ))); + } + // A pointer names no database of its own, so the form's empty `database` is correct there. + if dt.reference.is_some() { + dt.database = None; + } + windmill_common::workspaces::validate_datatable_shape(name, dt)?; } let args_for_audit = format!("{:?}", new_config.settings); @@ -3609,16 +4034,32 @@ async fn edit_datatable_config( ) .await?; - // Check that non-superadmins are not abusing Instance databases + // Check that non-superadmins are not abusing Instance databases, which reach a database this + // workspace does not own. Pointing an entry at another workspace's data table is not checked + // here because it cannot be requested at all: `reference` is overwritten from the stored entry + // above, for every caller. + // + // Compared against the entry the carried fields came from, not the one stored under the same + // name: otherwise swapping two names keeps each database in place while moving a clone's + // `governed_by` off the copy it governs. if !is_superadmin { for (name, dt) in new_config.settings.datatables.iter() { - if dt.database.resource_type == DataTableCatalogResourceType::Instance { - let old_dt = old_datatables.get(name); - if old_dt.is_none() - || old_dt.unwrap().database.resource_type - != DataTableCatalogResourceType::Instance - || old_dt.unwrap().database.resource_path != dt.database.resource_path - { + let old_dt = old_datatables.get( + rename_src + .get(name.as_str()) + .copied() + .unwrap_or(name.as_str()), + ); + if dt + .database + .as_ref() + .is_some_and(|d| d.resource_type == DataTableCatalogResourceType::Instance) + { + let unchanged = old_dt.and_then(|o| o.database.as_ref()).is_some_and(|o| { + o.resource_type == DataTableCatalogResourceType::Instance + && Some(&o.resource_path) == dt.database.as_ref().map(|d| &d.resource_path) + }); + if !unchanged { return Err(Error::BadRequest( "Only superadmins can create or modify data tables with Instance databases" .to_string(), @@ -3628,6 +4069,85 @@ async fn edit_datatable_config( } } + // Worked out from the locked entries rather than taken from `deleted_datatables`: a settings + // sync sends the whole map without that list, and dropping a governing entry strands every + // fork pointing at it all the same. + let removed: Vec = old_datatables + .keys() + .filter(|name| { + !new_config.settings.datatables.contains_key(*name) + && !new_config.renames.iter().any(|r| &r.from == *name) + }) + .cloned() + .collect(); + + // A database under roles is reached only through an entry that carries them. Roles follow an + // entry through a declared rename alone, and a settings sync never declares one, so an entry + // without roles that newly points at such a database — a name added, or an existing one + // repointed — would answer everyone there as `admin`. That holds whichever workspace governs it. + let newly_pointed: Vec<(&String, &str)> = new_config + .settings + .datatables + .iter() + // A clone carries its roles through `governed_by` rather than `permissions`. + .filter(|(_, dt)| dt.permissions.is_none() && dt.governed_by.is_none()) + .filter_map(|(name, dt)| { + let db = dt + .database + .as_ref() + .filter(|d| d.resource_type == DataTableCatalogResourceType::Instance)?; + let lookup = rename_src + .get(name.as_str()) + .copied() + .unwrap_or(name.as_str()); + let repointed = old_datatables + .get(lookup) + .and_then(|old| old.database.as_ref()) + .is_none_or(|old_db| { + old_db.resource_type != db.resource_type + || old_db.resource_path != db.resource_path + }); + repointed.then_some((name, db.resource_path.as_str())) + }) + .collect(); + // Another workspace turning roles on for the same database holds only its own settings row, so + // without this the scan below could read past its uncommitted write. + windmill_common::datatable_roles::lock_instance_databases_governance( + &mut *tx, + newly_pointed.iter().map(|(_, dbname)| *dbname), + ) + .await?; + let governed_elsewhere: Vec = if newly_pointed.is_empty() { + vec![] + } else { + sqlx::query_scalar( + "SELECT DISTINCT dt.value->'database'->>'resource_path' FROM workspace_settings ws + CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt + WHERE ws.workspace_id <> $1 AND (dt.value ? 'permissions' OR dt.value ? 'governed_by') + AND dt.value->'database'->>'resource_type' = 'instance'", + ) + .bind(&w_id) + .fetch_all(&mut *tx) + .await? + }; + for (name, dbname) in newly_pointed { + let governed_here = old_datatables.values().any(|old| { + (old.permissions.is_some() || old.governed_by.is_some()) + && old.database.as_ref().is_some_and(|d| { + d.resource_type == DataTableCatalogResourceType::Instance + && d.resource_path == dbname + }) + }); + if governed_here || governed_elsewhere.iter().any(|g| g == dbname) { + return Err(Error::BadRequest(format!( + "Data table '{name}' would point at database '{dbname}', which a data table under \ + roles uses, without carrying those roles: everyone reaching '{name}' would connect \ + there as `admin`. Rename the data table under roles from the data table settings, \ + which carries its roles, or turn its roles off first." + ))); + } + } + let config: serde_json::Value = serde_json::to_value(new_config.settings) .map_err(|err| Error::internal_err(err.to_string()))?; @@ -3649,6 +4169,47 @@ async fn edit_datatable_config( ) .await?; + // A fork points at a data table by name, so a rename here has to follow or every fork's entry + // resolves to nothing. In two passes through a temporary name, like the migration cascade one + // layer down: applied in order, `sa -> sb` then `sb -> sa` would move what pointed at `sa` all + // the way back to `sa`, and `A -> B`, `B -> C` would carry `A`'s pointers to `C`. Each pointer + // moves once, from what it named before this save. Inside the transaction: the rename and the + // pointers that name it are one change, and half of it is a fork whose jobs stop. + for (i, r) in new_config.renames.iter().enumerate() { + repoint_datatable_references(&mut tx, &w_id, &r.from, &format!("__wm_rename_tmp/{i}")) + .await?; + } + for (i, r) in new_config.renames.iter().enumerate() { + repoint_datatable_references(&mut tx, &w_id, &format!("__wm_rename_tmp/{i}"), &r.to) + .await?; + } + + // A deletion cannot be followed the same way — there is nothing to point at any more. Read who + // is left stranded so the caller is told, the way deleting a workspace does. + let mut stranded: Vec = Vec::new(); + for name in &removed { + let rows = sqlx::query!( + r#"SELECT ws.workspace_id AS "workspace_id!", dt.key AS "datatable!" + FROM workspace_settings ws + CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt + WHERE EXISTS ( + SELECT 1 FROM (VALUES ('reference'), ('governed_by')) k(link) + WHERE dt.value->k.link->>'workspace_id' = $1 + AND dt.value->k.link->>'datatable' = $2 + )"#, + &w_id, + name, + ) + .fetch_all(&mut *tx) + .await?; + stranded.extend( + rows.into_iter().map(|r| StrandedReference { + workspace_id: r.workspace_id, + datatable: r.datatable, + }), + ); + } + tx.commit().await?; for substrate in created_substrates { @@ -3663,7 +4224,9 @@ async fn edit_datatable_config( ) .await?; - Ok(format!("Edit datatable config for workspace {}", &w_id)) + Ok(Json(EditDataTableConfigResult { + stranded_references: stranded, + })) } #[derive(Deserialize)] @@ -4692,6 +5255,47 @@ async fn edit_guest_access( )) } +#[derive(Deserialize)] +struct EditAddAdminsAndDevelopersToForks { + add_admins_and_developers_to_forks: bool, +} + +async fn edit_add_admins_and_developers_to_forks( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(EditAddAdminsAndDevelopersToForks { add_admins_and_developers_to_forks }): Json< + EditAddAdminsAndDevelopersToForks, + >, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + let mut tx = db.begin().await?; + sqlx::query!( + "UPDATE workspace_settings SET add_admins_and_developers_to_forks = $1 WHERE workspace_id = $2", + add_admins_and_developers_to_forks, + &w_id + ) + .execute(&mut *tx) + .await?; + + audit_log( + &mut *tx, + &authed, + "workspaces.edit_add_admins_and_developers_to_forks", + ActionKind::Update, + &w_id, + Some(&add_admins_and_developers_to_forks.to_string()), + None, + ) + .await?; + tx.commit().await?; + + Ok(format!( + "Adding admins and developers to new forks set to {add_admins_and_developers_to_forks} for workspace {w_id}" + )) +} + #[derive(Deserialize)] struct EditGuestJwtKey { /// A PEM public key (RS or ES family), or a JWKS URL, at most one. Both empty clears the @@ -5280,9 +5884,6 @@ async fn set_encryption_key( )); } - // Build the previous cipher before the transaction (reads from cache/pool) - let previous_encryption_key = build_crypt(&db, w_id.as_str()).await?; - let mut tx = db.begin().await?; // Under the row's lock, so two rotations racing serialize and each sees the key the @@ -5316,17 +5917,14 @@ async fn set_encryption_key( None }; + // From the keys read and written under the lock, never from `build_crypt`: its + // cache can still hold a key an earlier rotation replaced, and the git-sync + // secrets below are skipped rather than failed when they do not decrypt. + let previous_encryption_key = crypt_from_key_with_suffix(&previous_key, ""); + let new_encryption_key = crypt_from_key_with_suffix(&request.new_key, ""); + let mut reencrypted_secret_paths: Vec = Vec::new(); if !request.skip_reencrypt.unwrap_or(false) { - // Build the new cipher directly from the key string, since the transaction - // hasn't committed yet and build_crypt() would read the old key from the pool. - let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() { - format!("{}{}", request.new_key, salt) - } else { - request.new_key.clone() - }; - let new_encryption_key = magic_crypt::new_magic_crypt!(crypt_key, 256); - let mut truncated_new_key = request.new_key.clone(); truncated_new_key.truncate(8); tracing::warn!( @@ -5366,6 +5964,21 @@ async fn set_encryption_key( } } + reencrypt_git_sync_secrets( + &mut tx, + &w_id, + &previous_encryption_key, + &new_encryption_key, + ) + .await?; + crate::remote_deploy::reencrypt_tokens( + &mut tx, + &w_id, + &previous_encryption_key, + &new_encryption_key, + ) + .await?; + tx.commit().await?; // Invalidate the cache only after the transaction has committed @@ -5403,6 +6016,64 @@ async fn set_encryption_key( return Ok(()); } +/// Move the git-sync secrets the server keeps under the workspace key (stored +/// repository tokens, webhook secrets) to the new key. They are never synced, so +/// unlike variables they are still under the old key when the caller skips +/// re-encryption. +async fn reencrypt_git_sync_secrets( + conn: &mut sqlx::PgConnection, + w_id: &str, + old: &magic_crypt::MagicCrypt256, + new: &magic_crypt::MagicCrypt256, +) -> Result<()> { + let Some((mut credentials, mut git_sync)) = + sqlx::query_as::<_, (serde_json::Value, Option)>( + "SELECT git_credentials, git_sync FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE", + ) + .bind(w_id) + .fetch_optional(&mut *conn) + .await? + else { + return Ok(()); + }; + let reencrypt = |value: &mut serde_json::Value| { + let Some(ciphertext) = value.as_str() else { + return; + }; + match decrypt(old, ciphertext.to_string()) { + Ok(plain) => *value = serde_json::Value::String(encrypt(new, &plain)), + // Left by an earlier rotation and unrecoverable either way; failing here + // would block every later rotation of the workspace. + Err(e) => tracing::warn!( + "a git-sync secret of workspace {w_id} does not decrypt under its current key, leaving it as is: {e}" + ), + } + }; + for entry in credentials.as_array_mut().into_iter().flatten() { + if let Some(token) = entry.get_mut("token") { + reencrypt(token); + } + } + let repositories = git_sync + .as_mut() + .and_then(|g| g.get_mut("repositories")) + .and_then(|r| r.as_array_mut()); + for repo in repositories.into_iter().flatten() { + if let Some(secret) = repo.pointer_mut("/auto_pull/webhook_secret") { + reencrypt(secret); + } + } + sqlx::query( + "UPDATE workspace_settings SET git_credentials = $2, git_sync = $3 WHERE workspace_id = $1", + ) + .bind(w_id) + .bind(credentials) + .bind(git_sync) + .execute(&mut *conn) + .await?; + Ok(()) +} + #[derive(Serialize)] struct UsedTriggers { pub websocket_used: bool, @@ -5531,7 +6202,7 @@ async fn user_workspaces( workspace.is_dev_workspace, workspace.dev_workspace_label, workspace.owner AS \"created_by?\", CASE WHEN usr.operator THEN workspace_settings.operator_settings ELSE NULL END as operator_settings, - usr.disabled + usr.disabled, usr.is_service_account FROM workspace JOIN usr ON usr.workspace_id = workspace.id JOIN workspace_settings ON workspace_settings.workspace_id = workspace.id @@ -6352,7 +7023,8 @@ async fn update_workspace_settings( ducklake = source_ws.ducklake, dbt_warehouses = source_ws.dbt_warehouses, datatable = source_ws.datatable, - git_app_installations = source_ws.git_app_installations + git_app_installations = source_ws.git_app_installations, + add_admins_and_developers_to_forks = source_ws.add_admins_and_developers_to_forks FROM workspace_settings source_ws WHERE source_ws.workspace_id = $1 AND workspace_settings.workspace_id = $2 @@ -6493,14 +7165,21 @@ async fn copy_workspace_members( tx: &mut Transaction<'_, Postgres>, source_workspace_id: &str, target_workspace_id: &str, + admins_and_developers_only: bool, ) -> Result<()> { + // Admins and developers join as manual members: the fork does not inherit the source's + // instance-group config, so a copied `instance_group` provenance would let the fork's + // reconciliation delete them and their data. sqlx::query!( "INSERT INTO usr (workspace_id, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via) - SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, added_via + SELECT $1, username, email, is_admin, created_at, operator, disabled, role, is_service_account, + CASE WHEN $3 THEN NULL ELSE added_via END FROM usr WHERE workspace_id = $2 + AND (NOT $3 OR (NOT operator AND NOT disabled AND NOT is_service_account)) ON CONFLICT DO NOTHING", target_workspace_id, source_workspace_id, + admins_and_developers_only, ) .execute(&mut **tx) .await?; @@ -6917,6 +7596,127 @@ async fn clear_orphaned_compat_address( Ok(()) } +/// SQL boolean: the principal the `principal` expression yields resolves in the workspace bound as +/// `$1`. The same predicate `clone_scripts` and `clone_flows` inline, whose `query!` macros cannot +/// take a composed string, so keep the three in step. +fn principal_resolves_sql(principal: &str) -> String { + format!( + "CASE WHEN {principal} LIKE 'u/%' THEN EXISTS ( + SELECT 1 FROM usr u WHERE u.workspace_id = $1 + AND u.username = substring({principal} from 3) + UNION ALL + SELECT 1 FROM password pw WHERE pw.super_admin + AND (pw.username = substring({principal} from 3) + OR pw.email = substring({principal} from 3))) + WHEN {principal} LIKE 'g/%' THEN EXISTS ( + SELECT 1 FROM group_ g WHERE g.workspace_id = $1 + AND g.name = substring({principal} from 3)) + ELSE EXISTS ( + SELECT 1 FROM usr u WHERE u.workspace_id = $1 AND u.username = {principal} + UNION ALL + SELECT 1 FROM password pw WHERE pw.email = {principal} AND pw.super_admin) + END" + ) +} + +/// Re-point the identities a fork clones verbatim at its creator when they name nobody in the fork, +/// once its membership is final so copied members keep theirs. Unlike scripts and flows these +/// cannot drop the identity: an app deploy rejects a preserved one that does not resolve, and +/// publisher apps, schedules and triggers need one to run. +async fn repoint_unresolvable_cloned_identities( + tx: &mut Transaction<'_, Postgres>, + target_workspace_id: &str, + authed: &ApiAuthed, +) -> Result<()> { + let principal = username_to_permissioned_as(&authed.username); + + sqlx::query(&format!( + "UPDATE app SET policy = policy + || jsonb_build_object('on_behalf_of', $2::text, 'on_behalf_of_email', $3::text) + WHERE workspace_id = $1 AND policy->>'on_behalf_of' IS NOT NULL + AND NOT ({})", + principal_resolves_sql("(policy->>'on_behalf_of')") + )) + .bind(target_workspace_id) + .bind(&principal) + .bind(&authed.email) + .execute(&mut **tx) + .await?; + + // A draft holding a genuine NUL escape (the rule of `json_text_has_nul_escape`) is left as it + // is, since parsing it would abort the fork. The check must stay in a CASE: json `->>` raises on + // a NUL anywhere in the value, and Postgres reorders plain AND conditions. + let nul_escape = r"(^|[^\\])(\\\\)*\\u0000"; + sqlx::query(&format!( + "UPDATE draft SET value = to_json(jsonb_set(jsonb_set(to_jsonb(value), + ARRAY['policy', 'on_behalf_of'], to_jsonb($2::text)), + ARRAY['policy', 'on_behalf_of_email'], to_jsonb($3::text))) + WHERE workspace_id = $1 AND typ IN ('app', 'raw_app') + AND CASE WHEN value::text ~ $4 THEN false + ELSE value->'policy'->>'on_behalf_of' IS NOT NULL AND NOT ({}) END", + principal_resolves_sql("(value->'policy'->>'on_behalf_of')") + )) + .bind(target_workspace_id) + .bind(&principal) + .bind(&authed.email) + .bind(nul_escape) + .execute(&mut **tx) + .await?; + + sqlx::query(&format!( + "UPDATE draft SET value = to_json(jsonb_set(to_jsonb(value), + ARRAY['permissioned_as'], to_jsonb($2::text))) + WHERE workspace_id = $1 AND starts_with(typ::text, 'trigger_') + AND CASE WHEN value::text ~ $3 THEN false + ELSE value->>'permissioned_as' IS NOT NULL AND NOT ({}) END", + principal_resolves_sql("(value->>'permissioned_as')") + )) + .bind(target_workspace_id) + .bind(&principal) + .bind(nul_escape) + .execute(&mut **tx) + .await?; + + let column_resolves = principal_resolves_sql("permissioned_as"); + + // SAFETY: every table name is a literal from this list, never user input. + for table in [ + "http_trigger", + "websocket_trigger", + "kafka_trigger", + "nats_trigger", + "postgres_trigger", + "mqtt_trigger", + "amqp_trigger", + "sqs_trigger", + "gcp_trigger", + "azure_trigger", + "email_trigger", + ] { + sqlx::query(&format!( + "UPDATE {table} SET permissioned_as = $2 + WHERE workspace_id = $1 AND NOT ({column_resolves})" + )) + .bind(target_workspace_id) + .bind(&principal) + .execute(&mut **tx) + .await?; + } + + // `email` is still written for workers that predate `permissioned_as`. + sqlx::query(&format!( + "UPDATE schedule SET permissioned_as = $2, email = $3 + WHERE workspace_id = $1 AND NOT ({column_resolves})" + )) + .bind(target_workspace_id) + .bind(&principal) + .bind(&authed.email) + .execute(&mut **tx) + .await?; + + Ok(()) +} + /// Carries over the recorded principal under the rule spelled out on [`clone_scripts`]. async fn clone_flows( tx: &mut Transaction<'_, Postgres>, @@ -7362,14 +8162,32 @@ async fn clone_drafts( // filtered like `clone_scripts`: the address the draft still carries re-derives the // clone's own principal at deploy time, which is the more accurate answer of the two. sqlx::query!( - "INSERT INTO draft (workspace_id, path, typ, value, created_at, email) + // A script hash is content-addressed and copied as-is, so a script draft's base + // still names a version the clone has. `clone_flows` / `clone_apps` mint new ids, + // so those drafts arrive with no base (staleness falls back to the timestamps), + // lineage field included, or the next autosave would re-derive the source id. + // + // `clean` is `strip_json_nul`'s parity rule in SQL, so a pre-sanitizer U+0000 + // escape cannot abort the clone on `to_jsonb` or arrive with its principal + // unstripped: escaped backslashes park on chr(1) (lossless, a `json` value's text + // cannot hold a raw control byte) so only a real NUL is removed, and chr(92) + // spells the backslash so no escape sequence reaches this source file. + r#"INSERT INTO draft (workspace_id, path, typ, value, created_at, email, base) SELECT $2, path, typ, - CASE WHEN typ IN ('script', 'flow') - THEN to_json(to_jsonb(value) - 'on_behalf_of') - ELSE value END, - created_at, email - FROM draft - WHERE workspace_id = $1 AND (email = $3 OR email IS NULL)", + to_json( + CASE WHEN typ IN ('script', 'flow') THEN clean - 'on_behalf_of' ELSE clean END + - CASE WHEN typ = 'flow' THEN 'version_id' + WHEN typ IN ('app', 'raw_app') THEN 'parent_version' + ELSE '' END + ), + created_at, email, + CASE WHEN typ = 'script' THEN base END + FROM ( + SELECT d.path, d.typ, d.created_at, d.email, d.base, + replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean + FROM draft d + WHERE d.workspace_id = $1 AND (d.email = $3 OR d.email IS NULL) + ) s"#, source_workspace_id, target_workspace_id, authed_email, @@ -7513,6 +8331,7 @@ async fn create_workspace_fork_branch( // dangling branch on the synced repos. check_fork_w_id_conflict(&db, &nw.id).await?; purge_stale_fork_diff_state(&db, &nw.id).await?; + validate_forked_datatables(&db, &authed, &w_id, &nw).await?; Ok(Json( handle_fork_branch_creation(&authed.email, &authed.username, &db, &w_id, &nw.id).await?, @@ -7545,12 +8364,142 @@ async fn snapshot_datatable_schema( .map_err(|e| Error::internal_err(format!("Failed to serialize schema: {}", e))) } -async fn apply_forked_datatable( - db: &DB, +/// Turn every data table the fork chose to keep into a pointer at the parent's entry. +/// +/// `clone_workspace_data` copies `workspace_settings` wholesale, so a kept data table arrives as a +/// byte-identical copy naming the parent's database — including the parent's `permissions`, which +/// a fork admin could then edit to widen their own access to it. A pointer has nothing local to +/// edit: the parent's entry stays the only place the decision lives. +/// +/// The cloned data tables are skipped: they own a fresh database of their own, and +/// `apply_forked_datatable` has already dropped their copied `permissions` — for a clone of a data +/// table under roles, in favor of `governed_by`. +async fn point_kept_datatables_at_parent( tx: &mut Transaction<'_, Postgres>, parent_w_id: &str, forked_w_id: &str, + cloned: &[ForkedDatatableInfo], +) -> Result<()> { + let settings: Option = sqlx::query_scalar!( + "SELECT datatable FROM workspace_settings WHERE workspace_id = $1", + forked_w_id + ) + .fetch_optional(&mut **tx) + .await? + .flatten(); + + let Some(mut settings) = settings else { + return Ok(()); + }; + let Some(datatables) = settings + .get_mut("datatables") + .and_then(|d| d.as_object_mut()) + else { + return Ok(()); + }; + + let mut changed = false; + for (name, entry) in datatables.iter_mut() { + if cloned.iter().any(|c| &c.name == name) { + continue; + } + let dt: DataTable = match serde_json::from_value(entry.clone()) { + Ok(dt) => dt, + Err(_) => continue, + }; + // Already a pointer: the parent was itself a fork, and its entry names the workspace that + // governs. Following it from here is the same answer, so leave it alone. + if dt.reference.is_some() { + continue; + } + // Only instance databases. A resource-backed data table names a resource, and the settings + // clone gave the fork its own copy of that resource in its own workspace — pointing at the + // parent's entry would silently move the fork onto the parent's resource instead. + if dt + .database + .as_ref() + .is_none_or(|d| d.resource_type != DataTableCatalogResourceType::Instance) + { + continue; + } + *entry = serde_json::to_value(DataTable { + database: None, + reference: Some(windmill_common::workspaces::DataTableReference { + workspace_id: parent_w_id.to_string(), + datatable: name.clone(), + }), + governed_by: None, + forked_from: None, + migrations_enabled: dt.migrations_enabled, + permissions: None, + }) + .map_err(|e| Error::internal_err(format!("serializing data table '{name}': {e}")))?; + changed = true; + } + + if changed { + sqlx::query!( + "UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = $2", + settings, + forked_w_id + ) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + +/// Move every pointer, and every clone's `governed_by`, in any workspace that names `(w_id, from)` +/// to `(w_id, to)`. +/// +/// `EXISTS` rather than a `LIKE` over the whole document: the update rewrites the row, so matching +/// every workspace that holds any pointer would rewrite rows to a byte-identical value and hold an +/// exclusive lock on them until commit. +async fn repoint_datatable_references( + tx: &mut Transaction<'_, Postgres>, + w_id: &str, + from: &str, + to: &str, +) -> Result<()> { + sqlx::query!( + r#"UPDATE workspace_settings ws + SET datatable = ( + SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg( + dt.key, + (SELECT CASE WHEN r.v->'governed_by'->>'workspace_id' = $1 + AND r.v->'governed_by'->>'datatable' = $2 + THEN jsonb_set(r.v, '{governed_by,datatable}', to_jsonb($3::text)) + ELSE r.v END + FROM (SELECT CASE WHEN dt.value->'reference'->>'workspace_id' = $1 + AND dt.value->'reference'->>'datatable' = $2 + THEN jsonb_set(dt.value, '{reference,datatable}', to_jsonb($3::text)) + ELSE dt.value END AS v) r) + )) + FROM jsonb_each(ws.datatable->'datatables') dt + ) + WHERE EXISTS ( + SELECT 1 FROM jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) d, + LATERAL (VALUES ('reference'), ('governed_by')) k(link) + WHERE d.value->k.link->>'workspace_id' = $1 + AND d.value->k.link->>'datatable' = $2 + )"#, + w_id, + from, + to, + ) + .execute(&mut **tx) + .await?; + Ok(()) +} + +async fn apply_forked_datatable( + db: &DB, + tx: &mut Transaction<'_, Postgres>, + authed: &ApiAuthed, + parent_w_id: &str, + forked_w_id: &str, fdt: &ForkedDatatableInfo, + copy: &crate::datatable_clone::MadeCopy, ) -> Result<()> { windmill_common::validate_dbname(&fdt.new_dbname)?; if !fdt.new_dbname.starts_with("wm_fork_") { @@ -7559,6 +8508,52 @@ async fn apply_forked_datatable( fdt.new_dbname ))); } + // Held until the fork commits, as the permissions save holds them: the source moved onto + // another database, or put under roles or taken off them, since its copy was made would link + // the copy to roles its grants were not replayed for. + let locked = crate::datatable_clone::lock_settings_rows(tx, parent_w_id, &fdt.name).await?; + let governing = ensure_datatable_is_clonable(db, parent_w_id, &fdt.name).await?; + let under_roles = governing.datatable.permissions.is_some(); + let unchanged = crate::datatable_clone::same_database( + &governing.datatable.database, + &Some(copy.source_database.clone()), + ) && copy.replayed == under_roles + && crate::datatable_clone::lock_settings_rows(tx, parent_w_id, &fdt.name).await? == locked; + if !unchanged { + return Err(Error::BadRequest(format!( + "Data table '{}' changed while it was being copied for this fork — its database, or \ + whether it is under roles. Create the fork again.", + fdt.name + ))); + } + // The fork keeps a snapshot of the source's schema, which under roles is only for those who may + // connect as one of them — as the copy itself was. + crate::datatable_permissions::ensure_reaches_governing_datatable( + db, + parent_w_id, + &fdt.name, + &governing, + authed, + ) + .await?; + // Under roles, the copy holds rows the source's roles decide who reaches, so that entry keeps + // deciding. Settled from the source as it resolves now: the settings clone may have handed the + // fork a pointer, or a clone of its own. A copy of a data table without roles is the fork's, + // as it was before roles existed: everyone reached all of it already. + let governed_by = governing + .datatable + .permissions + .is_some() + .then(|| { + serde_json::to_value(governing.governor.clone().unwrap_or_else(|| { + windmill_common::workspaces::DataTableReference { + workspace_id: governing.workspace_id.clone(), + datatable: governing.name.clone(), + } + })) + }) + .transpose() + .map_err(|e| Error::internal_err(format!("serializing a clone's governor: {e}")))?; // Snapshot the schema from the source (parent) datatable let schema = snapshot_datatable_schema(db, parent_w_id, &fdt.name).await?; @@ -7583,33 +8578,63 @@ async fn apply_forked_datatable( let dt: DataTable = serde_json::from_value(config_val) .map_err(|e| Error::internal_err(format!("Failed to parse datatable config: {}", e)))?; - if dt.database.resource_type == DataTableCatalogResourceType::Instance { - // Instance: update resource_path to the new dbname - sqlx::query!( + // A clone owns its copy, so the fork's entry has to be terminal. When the parent was itself a + // fork the settings clone hands down a pointer instead, and what it points at is what the copy + // was taken from. `ensure_datatable_is_clonable` already settled that this shape can be + // cloned, so there is nothing left to refuse here — by now the database exists and is filled. + let database = match dt.database.clone() { + Some(database) => database, + None => governing.datatable.database.clone().ok_or_else(|| { + Error::internal_err(format!( + "Data table '{}' resolves to an entry that owns no database", + fdt.name + )) + })?, + }; + + if database.resource_type == DataTableCatalogResourceType::Instance { + // The whole `database` object, not just its `resource_path`: a pointer entry has none to + // patch. `reference` goes with it — exactly one of the two may be set. + let new_database = serde_json::json!({ + "resource_type": "instance", + "resource_path": &fdt.new_dbname, + }); + sqlx::query( r#"UPDATE workspace_settings SET datatable = jsonb_set( - jsonb_set(datatable, ARRAY['datatables', $2, 'database', 'resource_path'], to_jsonb($3::text)), - ARRAY['datatables', $2, 'forked_from'], $4::jsonb - ) + datatable #- ARRAY['datatables', $2, 'reference'], + ARRAY['datatables', $2, 'database'], $3::jsonb) WHERE workspace_id = $1"#, - forked_w_id, - &fdt.name, - &fdt.new_dbname, - forked_from, ) + .bind(forked_w_id) + .bind(&fdt.name) + .bind(new_database) .execute(&mut **tx) .await?; } else { - // Resource: update the resource's dbname and mark as ws_specific - let resource_path = &dt.database.resource_path; - sqlx::query!( + // Resource: point it at the copy and mark it ws_specific. What the settings clone carried is + // the source's resource and variables as they are now, which an edit made while the copy + // was being made can have changed, and changed back: the clone has to be what the copy's + // connection was resolved from. + let resource_path = &database.resource_path; + let cloned = + crate::datatable_clone::connection_snapshot(db, &mut **tx, forked_w_id, resource_path) + .await?; + if copy.connection.as_ref() != Some(&cloned) { + return Err(Error::BadRequest(format!( + "The resource of data table '{}' changed while it was being copied for this \ + fork. Create the fork again.", + fdt.name + ))); + } + sqlx::query( r#"UPDATE resource SET value = jsonb_set(value, '{dbname}', to_jsonb($3::text)) WHERE workspace_id = $1 AND path = $2"#, - forked_w_id, - resource_path, - &fdt.new_dbname, ) + .bind(forked_w_id) + .bind(resource_path) + .bind(&fdt.new_dbname) .execute(&mut **tx) .await?; @@ -7620,20 +8645,31 @@ async fn apply_forked_datatable( ) .execute(&mut **tx) .await?; - - // Set forked_from on the datatable config - sqlx::query!( - r#"UPDATE workspace_settings - SET datatable = jsonb_set(datatable, ARRAY['datatables', $2, 'forked_from'], $3::jsonb) - WHERE workspace_id = $1"#, - forked_w_id, - &fdt.name, - forked_from, - ) - .execute(&mut **tx) - .await?; } + // The settings clone copied the source's `permissions` along, and a clone of a clone its + // `governed_by`: a clone keeps no `permissions` of its own, and a link only under roles. + sqlx::query( + r#"UPDATE workspace_settings + SET datatable = jsonb_set( + CASE WHEN $3::jsonb IS NULL + THEN datatable #- ARRAY['datatables', $2, 'permissions'] + #- ARRAY['datatables', $2, 'governed_by'] + ELSE jsonb_set( + datatable #- ARRAY['datatables', $2, 'permissions'], + ARRAY['datatables', $2, 'governed_by'], $3::jsonb) + END, + ARRAY['datatables', $2, 'forked_from'], $4::jsonb + ) + WHERE workspace_id = $1"#, + ) + .bind(forked_w_id) + .bind(&fdt.name) + .bind(governed_by) + .bind(forked_from) + .execute(&mut **tx) + .await?; + Ok(()) } @@ -7968,7 +9004,169 @@ async fn create_workspace_fork( ensure_no_existing_dev_workspace(&db, &parent_workspace_id).await?; } + // Refused here, before any database exists; `make_copies` checks again under the locks. + validate_forked_datatables(&db, &authed, &parent_workspace_id, &nw).await?; + + // Detached from the request: a client that goes away while the copies are made must still end + // with the fork created or no copy left behind, and dropping the handler's future would skip + // that cleanup. + tokio::spawn(async move { + let fork_id = nw.id.clone(); + let copies = crate::datatable_clone::make_copies( + &db, + &authed, + &parent_workspace_id, + ©_requests(&nw.forked_datatables), + ) + .await?; + let replayed: Vec = copies + .iter() + .filter(|c| c.replayed) + .map(|c| c.behavior) + .collect(); + match write_workspace_fork( + db.clone(), + authed, + parent_workspace_id, + nw, + dev_workspace_label, + &copies, + ) + .await + { + Ok(message) => { + for behavior in replayed { + windmill_common::feature_usage::log_feature_usage( + "datatable", + "clone_replayed", + match behavior { + DataTableForkBehavior::SchemaOnly => "schema_only", + _ => "schema_and_data", + }, + ); + } + Ok(message) + } + // A commit whose acknowledgement was lost can still have committed, and a concurrent + // request for the same id can have: once the write has settled, the cleanup keeps + // whichever copies a committed fork names, and drops the rest. + Err(e) => match wait_for_fork_write(&db, &fork_id).await { + Ok(()) => Err(crate::datatable_clone::drop_copies_after(&db, copies, e).await), + Err(settle) => { + tracing::error!( + "Could not tell whether fork '{fork_id}' was created, so its copies were \ + kept: {settle}" + ); + Err(e) + } + }, + } + }) + .await + .map_err(|e| Error::internal_err(format!("Creating the fork stopped unexpectedly: {e}")))? +} + +/// The database a data table of the fork or dev workspace `fork_id` is copied into, as the wizard +/// and the CLI derive it. A dev workspace's id has no `wm-fork-` prefix, and its copies are dropped +/// by the same `wm_fork_` rule as a fork's. Dev workspace `x` and fork `wm-fork-x` share a name, as +/// their branches do: the second to copy a data table of that name is refused at `CREATE`. +fn forked_datatable_dbname(fork_id: &str, datatable: &str) -> String { + let suffix = fork_id + .strip_prefix(windmill_common::workspaces::WM_FORK_PREFIX) + .unwrap_or(fork_id); + format!("wm_fork_{}__{datatable}", suffix.replace('-', "_")) +} + +/// Wait until no transaction writing fork `fork_id` is still running: the lock +/// `write_workspace_fork` holds until its transaction ends is taken and released. +async fn wait_for_fork_write(db: &DB, fork_id: &str) -> Result<()> { + let mut tx = db.begin().await?; + sqlx::query("SELECT pg_advisory_xact_lock(hashtext('fork:' || $1))") + .bind(fork_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + Ok(()) +} + +/// Everything about a fork's data tables that can be refused before a git branch or a database is +/// created, and that the fork request re-checks. +/// +/// Every data table is copied by the fork request, into a database it creates: an entry naming a +/// database that already exists could reach a copy made for another fork — one left behind by a +/// deleted fork included — without that copy's governance. +async fn validate_forked_datatables( + db: &DB, + authed: &ApiAuthed, + parent_w_id: &str, + nw: &CreateWorkspaceFork, +) -> Result<()> { + let mut names = HashSet::new(); + for fdt in &nw.forked_datatables { + if fdt.fork_behavior.is_none() { + return Err(Error::BadRequest(format!( + "Data table '{}' names no `fork_behavior`: the fork request makes the copy of each \ + data table itself. Update the Windmill CLI.", + fdt.name + ))); + } + let expected = forked_datatable_dbname(&nw.id, &fdt.name); + if fdt.new_dbname != expected { + return Err(Error::BadRequest(format!( + "Data table '{}' of fork '{}' is copied into database '{expected}', not '{}'", + fdt.name, nw.id, fdt.new_dbname + ))); + } + if !names.insert(fdt.name.as_str()) { + return Err(Error::BadRequest(format!( + "Data table '{}' is named more than once in this fork", + fdt.name + ))); + } + } + crate::datatable_clone::authorize_copies( + db, + authed, + parent_w_id, + ©_requests(&nw.forked_datatables), + ) + .await +} + +/// The copies a fork request asks this request to make. [`validate_forked_datatables`] refuses an +/// entry without a `fork_behavior`. +fn copy_requests( + forked_datatables: &[ForkedDatatableInfo], +) -> Vec> { + forked_datatables + .iter() + .filter_map(|fdt| { + fdt.fork_behavior + .map(|behavior| crate::datatable_clone::CopyRequest { + name: &fdt.name, + dbname: &fdt.new_dbname, + behavior, + }) + }) + .collect() +} + +/// Write the fork, with its data tables pointed at `copies`. +async fn write_workspace_fork( + db: DB, + authed: ApiAuthed, + parent_workspace_id: String, + nw: CreateWorkspaceFork, + dev_workspace_label: Option, + copies: &[crate::datatable_clone::MadeCopy], +) -> Result { let mut tx: Transaction<'_, Postgres> = db.begin().await?; + // Held until this transaction ends: after an error, the copies' cleanup waits on it, so it reads + // whether the fork exists only once a commit still being resolved has settled. + sqlx::query("SELECT pg_advisory_xact_lock(hashtext('fork:' || $1))") + .bind(&nw.id) + .execute(&mut *tx) + .await?; if nw.is_dev_workspace { // The checks above ran outside a transaction, so the parent's eligibility and the chain's @@ -8018,8 +9216,19 @@ async fn create_workspace_fork( // intended. Dev creation is already admin-gated, so this is transitively admin-only too. Done before // the explicit creator insert below so the creator (a parent member) is copied with full metadata // (operator/role/is_service_account/added_via), not the bare row the insert alone would leave. + // Independently, the parent's admins can have every fork of it start with its admins and + // developers; the forker cannot opt out, since the point is that those admins can review it. if nw.copy_members && nw.is_dev_workspace { - copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id).await?; + copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id, false).await?; + } else if sqlx::query_scalar!( + "SELECT add_admins_and_developers_to_forks FROM workspace_settings WHERE workspace_id = $1", + parent_workspace_id + ) + .fetch_optional(&mut *tx) + .await? + .unwrap_or(false) + { + copy_workspace_members(&mut tx, &parent_workspace_id, &forked_id, true).await?; } // Ensure the creator is a member of the fork even without copy_members (or if they aren't a parent @@ -8038,6 +9247,14 @@ async fn create_workspace_fork( .execute(&mut *tx) .await?; + // The pointers this fork writes to the parent's data tables stay invisible until it commits, so + // a rename of one of them cannot carry them. Holding the parent's settings row makes such a + // rename wait for this commit, and makes the copy below read one that committed first. + sqlx::query("SELECT 1 FROM workspace_settings WHERE workspace_id = $1 FOR SHARE") + .bind(&parent_workspace_id) + .execute(&mut *tx) + .await?; + // Clone all data from the parent workspace using Rust implementation if let Err(e) = clone_workspace_data(&mut tx, &db, &parent_workspace_id, &forked_id, &authed).await @@ -8073,11 +9290,53 @@ async fn create_workspace_fork( // re-enables in the fork, with parent-conflict warnings on enable. clone_triggers_and_schedules(&mut tx, &parent_workspace_id, &forked_id).await?; + repoint_unresolvable_cloned_identities(&mut tx, &forked_id, &authed).await?; + + // Held until the fork commits, as settings saves naming these databases hold it: an entry + // saved elsewhere while a copy was being made would reach it without the governance written + // below, and the alias check on that save saw no governed entry yet. + let instance_copies: Vec<&str> = copies + .iter() + .filter(|c| c.source_database.resource_type == DataTableCatalogResourceType::Instance) + .map(|c| c.dbname.as_str()) + .collect(); + windmill_common::lock_instance_databases(&mut tx, instance_copies.iter().copied()).await?; + for dbname in &instance_copies { + let users = windmill_common::instance_database_users(&mut *tx, dbname).await?; + if !users.is_empty() { + return Err(Error::BadRequest(format!( + "Database '{dbname}' copied for this fork is already used by workspaces {}; \ + fork again", + users.join(", ") + ))); + } + } + // Update forked datatable settings to point to new databases for fdt in &nw.forked_datatables { - apply_forked_datatable(&db, &mut tx, &parent_workspace_id, &forked_id, fdt).await?; + let copy = copies.iter().find(|c| c.name == fdt.name).ok_or_else(|| { + Error::internal_err(format!("No copy was made of data table '{}'", fdt.name)) + })?; + apply_forked_datatable( + &db, + &mut tx, + &authed, + &parent_workspace_id, + &forked_id, + fdt, + copy, + ) + .await?; } + point_kept_datatables_at_parent( + &mut tx, + &parent_workspace_id, + &forked_id, + &nw.forked_datatables, + ) + .await?; + // The settings clone copies the source's ducklake config verbatim — including a parent // fork's own `fork_behavior` stamps. Sharing is a per-fork-creation choice, never // inherited: reset any cloned stamps first, then apply this fork's requested list. @@ -8126,6 +9385,20 @@ async fn create_workspace_fork( .await?; } + let copied = copies + .iter() + .map(|c| { + format!( + "{} ({})", + c.name, + match c.behavior { + DataTableForkBehavior::SchemaOnly => "schema_only", + _ => "schema_and_data", + } + ) + }) + .collect::>() + .join(", "); audit_log( &mut *tx, &authed, @@ -8133,7 +9406,7 @@ async fn create_workspace_fork( ActionKind::Create, &forked_id, Some(nw.name.as_str()), - None, + (!copied.is_empty()).then(|| [("copied_datatables", copied.as_str())].into()), ) .await?; tx.commit().await?; @@ -8880,13 +10153,33 @@ async fn leave_workspace( ) -> Result { windmill_api_auth::forbid_job_token_account_destruction(&authed)?; let mut tx = db.begin().await?; - sqlx::query!( + // The membership is what made `u/` mean this person. Leaving it behind in a tenant + // list would hand their data table access back on rejoin, or to whoever takes the name next. + windmill_common::workspaces::remove_datatable_tenant_in_workspace( + &mut tx, + &w_id, + &format!("u/{}", authed.username), + ) + .await?; + let left = sqlx::query!( "DELETE FROM usr WHERE workspace_id = $1 AND email = $2", &w_id, &authed.email ) .execute(&mut *tx) - .await?; + .await? + .rows_affected(); + // A superadmin deploys from workspaces it is no member of; leaving none is no reason to drop + // its connection. + if left > 0 { + sqlx::query!( + "DELETE FROM remote_deploy_token WHERE email = $1 AND workspace_id = $2", + &authed.email, + &w_id + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index e75f0d1ef2..9039bda2ef 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -113,7 +113,7 @@ pub(crate) async fn change_workspace_id( // Duplicate workspace settings (keep copy in old workspace for reference) info!("Duplicating workspace_settings table"); sqlx::query!( - "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2", + "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2", &rw.new_id, &old_id ) @@ -382,6 +382,13 @@ pub(crate) async fn change_workspace_id( ) .execute(&mut *tx) .await?; + sqlx::query!( + "UPDATE draft_move SET workspace_id = $1 WHERE workspace_id = $2", + &rw.new_id, + &old_id + ) + .execute(&mut *tx) + .await?; info!("Updating favorite table"); sqlx::query!( @@ -492,6 +499,34 @@ pub(crate) async fn change_workspace_id( .fetch_all(&mut *tx) .await?; + // A fork's data table entry names the workspace that governs it by id, so the rename has to + // follow there too — anywhere, not just in the reparented children: a detached workspace can + // point at this one without being its fork. Left behind, the pointer resolves to the archived + // shell and every job through it stops. A clone's `governed_by` names it the same way. + info!("Re-pointing data table references to the new workspace id"); + sqlx::query!( + r#"UPDATE workspace_settings ws + SET datatable = ( + SELECT jsonb_set(ws.datatable, '{datatables}', jsonb_object_agg( + dt.key, + (SELECT CASE WHEN r.v->'governed_by'->>'workspace_id' = $2 + THEN jsonb_set(r.v, '{governed_by,workspace_id}', to_jsonb($1::text)) + ELSE r.v END + FROM (SELECT CASE WHEN dt.value->'reference'->>'workspace_id' = $2 + THEN jsonb_set(dt.value, '{reference,workspace_id}', to_jsonb($1::text)) + ELSE dt.value END AS v) r) + )) + FROM jsonb_each(ws.datatable->'datatables') dt + ) + WHERE jsonb_typeof(ws.datatable->'datatables') = 'object' + AND (ws.datatable::text LIKE '%"reference"%' + OR ws.datatable::text LIKE '%"governed_by"%')"#, + &rw.new_id, + &old_id, + ) + .execute(&mut *tx) + .await?; + info!("Updating workspace_protection_rule table"); sqlx::query!( "UPDATE workspace_protection_rule SET workspace_id = $1 WHERE workspace_id = $2", @@ -806,6 +841,14 @@ pub(crate) async fn change_workspace_id( .execute(&mut *tx) .await?; + sqlx::query!( + "UPDATE remote_deploy_token SET workspace_id = $1 WHERE workspace_id = $2", + &rw.new_id, + &old_id + ) + .execute(&mut *tx) + .await?; + info!("Updating variable table"); sqlx::query!( "UPDATE variable SET workspace_id = $1 WHERE workspace_id = $2", @@ -971,6 +1014,24 @@ pub(crate) async fn delete_workspace( // but the destructive cleanup itself runs only after the commit below: a delete that // fails mid-way must never leave a live workspace with its fork data destroyed and no // registry row to retry from. Read-only: nothing is dropped here. + // Read before the delete: another workspace's data table entry can point at one of this + // workspace's, or be a clone taking its roles from one, and deleting the workspace it names + // leaves it resolving to nothing. Nothing sweeps them — turning them back into copies would + // hand each fork the database outright — so the deleter is told which data tables they just + // stranded. + let stranded_pointers = sqlx::query_as::<_, (String, String)>( + r#"SELECT ws.workspace_id, dt.key + FROM workspace_settings ws + CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt + WHERE dt.value->'reference'->>'workspace_id' = $1 + OR dt.value->'governed_by'->>'workspace_id' = $1 + ORDER BY ws.workspace_id, dt.key"#, + ) + .bind(&w_id) + .fetch_all(&db) + .await + .unwrap_or_default(); + let fork_ducklake_cleanups = prepare_fork_ducklake_cleanups(&db, &w_id, None) .await .unwrap_or_else(|e| { @@ -1289,7 +1350,23 @@ pub(crate) async fn delete_workspace( tracing::warn!("failed to broadcast fork lineage change: {e:#}"); } - Ok(format!("Deleted workspace {}", &w_id)) + if stranded_pointers.is_empty() { + Ok(format!("Deleted workspace {}", &w_id)) + } else { + let stranded = stranded_pointers + .iter() + .map(|(workspace_id, datatable)| format!("{workspace_id}/{datatable}")) + .collect::>() + .join(", "); + Ok(format!( + concat!( + "Deleted workspace {}. These data tables were governed by it and no longer ", + "resolve: {}. Their databases still exist; a superadmin can point them at ", + "another workspace's data table." + ), + &w_id, stranded + )) + } } #[derive(Deserialize)] @@ -1343,15 +1420,20 @@ pub async fn drop_forked_datatable_databases( let mut errors: Vec = Vec::new(); for dt_name in &req.datatable_names { - let dt = match datatables.get(dt_name) { - Some(dt) if dt.forked_from.is_some() => dt, + // Only a clone is droppable, and a clone is terminal by construction: a kept data table is + // a pointer at the parent's database, which this fork does not own. + let database = match datatables.get(dt_name) { + Some(dt) if dt.forked_from.is_some() => match dt.database.as_ref() { + Some(database) => database, + None => continue, + }, _ => continue, }; - if dt.database.resource_type + if database.resource_type == windmill_common::workspaces::DataTableCatalogResourceType::Instance { - let db_to_drop = &dt.database.resource_path; + let db_to_drop = &database.resource_path; if !db_to_drop.starts_with("wm_fork_") { errors.push(format!( "Refusing to drop instance database '{}' for datatable://{}: name does not start with 'wm_fork_'", diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index 364e8ef674..21ca421af3 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.814.0 + version: 1.817.0 title: Windmill API contact: @@ -1588,6 +1588,89 @@ paths: additionalProperties: $ref: "#/components/schemas/CustomInstanceDb" + /settings/datatable_roles: + get: + summary: list the instance's data table roles + operationId: listInstanceDatatableRoles + tags: + - setting + responses: + "200": + description: the instance role catalog + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/InstanceDatatableRole" + post: + summary: create a data table role on the instance's Postgres cluster + operationId: createInstanceDatatableRole + tags: + - setting + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + responses: + "200": + description: the created role + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceDatatableRole" + + /settings/datatable_roles/{id}: + post: + summary: rename a data table role or turn its login on and off + operationId: updateInstanceDatatableRole + tags: + - setting + parameters: + - in: path + name: id + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: + type: string + enabled: + type: boolean + responses: + "200": + description: the updated role + content: + application/json: + schema: + $ref: "#/components/schemas/InstanceDatatableRole" + delete: + summary: drop a data table role from the cluster and from every workspace that named it + operationId: deleteInstanceDatatableRole + tags: + - setting + parameters: + - in: path + name: id + required: true + schema: + type: string + responses: + "200": + description: deleted + /settings/setup_custom_instance_pg_database/{name}: post: summary: Runs CREATE DATABASE on the Windmill Postgres and grants access to the custom_instance_user @@ -1679,6 +1762,26 @@ paths: schema: type: string + /settings/instance_ui: + get: + summary: get the instance-wide announcement banner and accent color + description: | + Readable by any authenticated user; every page load reads it. Each value + is the raw stored setting, or null when unset. + operationId: getInstanceUi + tags: + - setting + responses: + "200": + description: the instance_banner and accent_color settings + content: + application/json: + schema: + type: object + properties: + instance_banner: {} + accent_color: {} + /settings_u/ruff_config: get: summary: get instance ruff config (unauthenticated) @@ -3983,9 +4086,13 @@ paths: guest_access_enabled: type: boolean description: Whether this workspace admits guest sessions. An app's own `guest` execution mode is inert while this is false. + add_admins_and_developers_to_forks: + type: boolean + description: Whether every new fork of this workspace starts with its admins and developers as members, keeping their role. required: - workspace_id - guest_access_enabled + - add_admins_and_developers_to_forks /w/{workspace}/workspaces/get_settings: get: @@ -4076,6 +4183,9 @@ paths: guest_jwt_jwks_url: type: string description: JWKS URL a guest JWT (`jwt_guest_`) is verified against for this workspace. Mutually exclusive with `guest_jwt_public_key`. + add_admins_and_developers_to_forks: + type: boolean + description: Whether every new fork of this workspace starts with its admins and developers as members, keeping their role. /w/{workspace}/workspaces/get_deploy_to: get: @@ -4097,6 +4207,100 @@ paths: deploy_to: type: string + /w/{workspace}/remote_deploy/target: + get: + summary: get the workspace's deploy target on another instance, and the caller's connection to it + description: | + Once connected, deploy calls aimed at the target go through + `/w/{workspace}/remote_deploy/proxy/{proxy_key}/{route}`, which forwards any method to + `{base_url}/api/w/{target workspace}/{route}` with the caller's stored token. + operationId: getRemoteDeployTarget + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: remote deploy target and the caller's connection to it + content: + application/json: + schema: + $ref: "#/components/schemas/RemoteDeployStatus" + post: + summary: set or clear the workspace's deploy target on another instance + description: Changing the target drops every token stored for the previous one. + operationId: setRemoteDeployTarget + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + target: + $ref: "#/components/schemas/RemoteDeployTarget" + responses: + "200": + description: status + content: + text/plain: + schema: + type: string + + /w/{workspace}/remote_deploy/connect: + post: + summary: store the caller's own token for the remote deploy target + description: | + The token is checked against the target's whoami before it is stored. `target` names the + target the token was obtained for, and the request is refused if the workspace now points + elsewhere, so a token is never sent to an instance it was not meant for. + operationId: connectRemoteDeploy + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + token: + type: string + target: + $ref: "#/components/schemas/RemoteDeployTarget" + required: + - token + - target + responses: + "200": + description: the stored connection + content: + application/json: + schema: + $ref: "#/components/schemas/RemoteDeployConnection" + + /w/{workspace}/remote_deploy/disconnect: + post: + summary: forget the caller's token for the remote deploy target + operationId: disconnectRemoteDeploy + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + responses: + "200": + description: status + content: + text/plain: + schema: + type: string + /w/{workspace}/workspaces/is_premium: get: summary: get if workspace is premium @@ -5141,7 +5345,7 @@ paths: type: array items: type: object - required: [name, resource_type, resource_path] + required: [name, resource_type, resource_path, permissioned] properties: name: type: string @@ -5150,6 +5354,188 @@ paths: enum: [postgres, instance] resource_path: type: string + governing_workspace_id: + type: string + permissioned: + type: boolean + + /w/{workspace}/workspaces/datatable_permissions/{datatable_name}: + get: + summary: get who may connect to a data table as which role + operationId: getDatatablePermissions + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: datatable_name + in: path + required: true + schema: + type: string + responses: + "200": + description: the data table's roles and their tenants + content: + application/json: + schema: + $ref: "#/components/schemas/DatatablePermissions" + post: + summary: set who may connect to a data table as which role + operationId: setDatatablePermissions + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: datatable_name + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [permissioned] + properties: + permissioned: + type: boolean + default_role: + type: string + roles: + type: array + items: + $ref: "#/components/schemas/DatatableRoleTenants" + responses: + "200": + description: status + content: + text/plain: + schema: + type: string + + /w/{workspace}/workspaces/datatable_acl/{datatable_name}: + get: + summary: read the owner and grants of an instance data table's database, schema or table + operationId: getDatatableAcl + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: datatable_name + in: path + required: true + schema: + type: string + - name: kind + in: query + required: true + schema: + type: string + enum: [database, schema, table] + - name: schema + in: query + required: false + schema: + type: string + - name: table + in: query + required: false + schema: + type: string + responses: + "200": + description: owner and grants + content: + application/json: + schema: + $ref: "#/components/schemas/DatatableAclInfo" + + /w/{workspace}/workspaces/datatable_acl/{datatable_name}/plan: + post: + summary: preview the SQL an ownership or grant change would run (data table administrators only) + operationId: planDatatableAcl + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: datatable_name + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AclChangeRequest" + responses: + "200": + description: statements that would run, in a single transaction + content: + application/json: + schema: + $ref: "#/components/schemas/AclPlan" + + /w/{workspace}/workspaces/datatable_acl/{datatable_name}/apply: + post: + summary: run an ownership or grant change exactly as planned (data table administrators only) + operationId: applyDatatableAcl + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: datatable_name + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/AclChangeRequest" + responses: + "200": + description: change applied + content: + text/plain: + schema: + type: string + + /w/{workspace}/workspaces/datatable_usable_roles/{datatable_name}: + get: + summary: list the data table roles the caller may connect as + operationId: listUsableDatatableRoles + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: datatable_name + in: path + required: true + schema: + type: string + responses: + "200": + description: usable roles + content: + application/json: + schema: + type: object + required: [permissioned, roles, default_role] + properties: + permissioned: + type: boolean + roles: + type: array + items: + type: string + default_role: + type: string /w/{workspace}/workspaces/list_datatable_schemas: get: @@ -5223,6 +5609,21 @@ paths: - workspace parameters: - $ref: "#/components/parameters/WorkspaceId" + - name: datatable_name + in: query + description: list only this data table; each listed data table opens a connection to its database + schema: + type: string + - name: role_for + in: query + description: the data table `role` applies to; every other one is listed as its default role + schema: + type: string + - name: role + in: query + description: the role to list `role_for` as; refused, in that entry's `error`, if the caller may not use it + schema: + type: string responses: "200": description: table metadata of all datatables @@ -5256,6 +5657,11 @@ paths: required: true schema: type: string + - name: role + in: query + description: the data table role to read the table as; defaults to the data table's default role + schema: + type: string responses: "200": description: schema of one datatable table @@ -5331,7 +5737,22 @@ paths: description: status content: application/json: - schema: {} + schema: + type: object + properties: + stranded_references: + description: >- + Data tables in other workspaces that were governed by one this save deleted + and no longer resolve. + type: array + items: + type: object + required: [workspace_id, datatable] + properties: + workspace_id: + type: string + datatable: + type: string /w/{workspace}/workspaces/run_datatable_migrations/{datatable_name}: post: @@ -6100,6 +6521,39 @@ paths: schema: type: string + /w/{workspace}/workspaces/edit_add_admins_and_developers_to_forks: + post: + summary: choose whether new forks of this workspace start with its admins and developers + description: >- + When on, every fork created from this workspace gets the workspace's admins and + developers as members, with the role they hold here; operators, disabled users and + service accounts are left out. The setting is copied into each fork, so forks of a + fork follow it too. Off by default. Workspace-admin gated. + operationId: editAddAdminsAndDevelopersToForks + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + description: Whether new forks start with this workspace's admins and developers + required: true + content: + application/json: + schema: + type: object + properties: + add_admins_and_developers_to_forks: + type: boolean + required: + - add_admins_and_developers_to_forks + responses: + "200": + description: status + content: + text/plain: + schema: + type: string + /w/{workspace}/workspaces/edit_guest_jwt_key: post: summary: set the key guest JWTs are verified against for this workspace @@ -10133,8 +10587,49 @@ paths: current_timestamp: type: string format: date-time + path: + type: string + description: "`saved` only, upsert or delete: where the write landed. Differs from the URL path when the item had moved away from it; the editor follows it there. Absent when a delete found nothing to remove and the caller cannot read the path it moved to." required: [status, current_timestamp] + /w/{workspace}/drafts/move/{kind}/{path}: + post: + summary: move the current user's draft-only item to another path + description: Relocates the authed user's own draft row, along with both path keys inside its value (the typed path and the mirror the editors keep beside it). Only for draft-only items — a deployed item must be moved through its own deploy endpoint, which carries every draft with it. Restricted to script, flow, app and raw_app; any other kind is rejected with 400, because only these keep their deploy target where this endpoint rewrites it. + operationId: moveDraft + tags: + - draft + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: kind + in: path + required: true + description: script, flow, app or raw_app only. + schema: + type: string + enum: [script, flow, app, raw_app] + - $ref: "#/components/parameters/ScriptPath" + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + new_path: + type: string + summary: + type: string + description: Also restate the draft's summary. + required: [new_path] + responses: + "200": + description: move result + content: + text/plain: + schema: + type: string + /w/{workspace}/drafts/migrate_legacy/{kind}/{path}: post: summary: resolve a legacy (workspace-level) draft (admin only) @@ -10190,6 +10685,9 @@ paths: - kind - tag - deployment_message + - schema + x-mcp-tool-opaque-fields: + - schema tags: - script parameters: @@ -10229,6 +10727,9 @@ paths: - kind - tag - deployment_message + - schema + x-mcp-tool-opaque-fields: + - schema tags: - script parameters: @@ -10645,6 +11146,8 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/ScriptPath" + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PerPage" responses: "200": description: script history @@ -12050,6 +12553,8 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/ScriptPath" + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PerPage" tags: - flow responses: @@ -13928,6 +14433,8 @@ paths: parameters: - $ref: "#/components/parameters/WorkspaceId" - $ref: "#/components/parameters/ScriptPath" + - $ref: "#/components/parameters/Page" + - $ref: "#/components/parameters/PerPage" responses: "200": description: app history @@ -14195,9 +14702,9 @@ paths: "200": description: app updated content: - text/plain: + application/json: schema: - type: string + $ref: "#/components/schemas/AppDeployed" /w/{workspace}/apps/create_raw_source: post: @@ -14337,9 +14844,9 @@ paths: "200": description: app updated content: - text/plain: + application/json: schema: - type: string + $ref: "#/components/schemas/AppDeployed" /w/{workspace}/apps/update_raw/{path}: post: @@ -14393,9 +14900,9 @@ paths: "200": description: app updated content: - text/plain: + application/json: schema: - type: string + $ref: "#/components/schemas/AppDeployed" /w/{workspace}/apps/custom_path_exists/{custom_path}: get: @@ -15803,6 +16310,29 @@ paths: items: type: string + /w/{workspace}/jobs/queue/run_now/{id}: + post: + summary: start a queued job scheduled for later now + description: | + Moves the start of a queued job that is waiting for a future time (scheduled, debounced, + re-scheduled by a concurrency limit, or a flow step waiting on a sleep or a retry delay) + to now, keeping the same job id. A job with a + concurrency limit is still checked against its limit when it is pulled. A schedule's + upcoming tick is refused, since the schedule would queue that tick again. + operationId: runQueuedJobNow + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - $ref: "#/components/parameters/JobId" + responses: + "200": + description: job id + content: + text/plain: + schema: + type: string + /w/{workspace}/jobs/queue/cancel_selection: post: summary: cancel jobs based on the given uuids @@ -27443,6 +27973,13 @@ components: draft_saved_at: type: string format: date-time + draft_base: + type: string + description: | + The deployed version the draft forked from, as text whatever the + kind (script hash, flow version id, app version id). Compare to the + deployed head to tell a draft that is behind. Absent when there is + no draft or it was never forked from a deploy. no_deployed: type: boolean draft: @@ -28572,6 +29109,15 @@ components: type: object additionalProperties: $ref: "#/components/schemas/ModelPriceOverride" + context_window_per_model: + type: object + description: >- + Context window in tokens per `provider:model`, overriding the built-in + table the AI chat uses to decide when to compact its history. + additionalProperties: + type: integer + minimum: 1024 + maximum: 10000000 copilot_disabled: type: boolean description: >- @@ -29225,6 +29771,15 @@ components: type: string schema: type: object + description: >- + JSON Schema of the arguments of `main`, which is what a run form and an + MCP tool offer. Omitted (or `{}`), it is inferred from `content` for + TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible + scripts. For other languages, or code that does not parse, a new script + gets none. A new version of an existing script also keeps what the + previous version's schema says about each argument, or that whole schema + when nothing can be inferred. A dbt script always takes its schema from its + descriptor, whatever is sent. is_template: type: boolean lock: @@ -29338,6 +29893,11 @@ components: type: string deployment_msg: type: string + created_at: + type: string + format: date-time + created_by: + type: string required: - script_hash @@ -30640,6 +31200,7 @@ components: - "jobs" - "jobs.cancel" - "jobs.force_cancel" + - "jobs.run_now" - "jobs.disapproval" - "jobs.delete" - "account.delete" @@ -33393,6 +33954,8 @@ components: $ref: "#/components/schemas/LoggedWizardStatus" replication_user_error: type: string + user_connect: + $ref: "#/components/schemas/LoggedWizardStatus" CustomInstanceDbTag: type: string @@ -33400,6 +33963,327 @@ components: - ducklake - datatable + InstanceDatatableRole: + type: object + required: [id, name, enabled] + properties: + id: + type: string + name: + type: string + enabled: + type: boolean + + DatatableRoleTenants: + type: object + required: [id, tenants] + properties: + id: + type: string + name: + type: string + tenants: + type: array + items: + type: string + + DatatablePermissions: + type: object + required: [supported, permissioned, default_role, roles, editable, available_roles] + properties: + supported: + type: boolean + description: >- + Whether this data table can be put under roles at all. Only one backed by the + instance database can: a role is a login on that cluster. + permissioned: + type: boolean + default_role: + type: string + roles: + type: array + items: + $ref: "#/components/schemas/DatatableRoleTenants" + governing_workspace_id: + type: string + clone_of: + type: object + description: for a clone, the data table whose roles it takes + required: [workspace_id, datatable] + properties: + workspace_id: + type: string + datatable: + type: string + editable: + type: boolean + available_roles: + type: array + items: + $ref: "#/components/schemas/InstanceDatatableRole" + ungoverned_reachers: + type: array + items: + type: object + required: [workspace_id, datatable] + properties: + workspace_id: + type: string + datatable: + type: string + + AclTarget: + description: what access is read or changed on + oneOf: + - $ref: "#/components/schemas/AclTargetDatabase" + - $ref: "#/components/schemas/AclTargetSchema" + - $ref: "#/components/schemas/AclTargetTable" + discriminator: + propertyName: kind + mapping: + database: "#/components/schemas/AclTargetDatabase" + schema: "#/components/schemas/AclTargetSchema" + table: "#/components/schemas/AclTargetTable" + + AclTargetDatabase: + type: object + required: [kind] + properties: + kind: + type: string + enum: [database] + + AclTargetSchema: + type: object + required: [kind, schema] + properties: + kind: + type: string + enum: [schema] + schema: + type: string + + AclTargetTable: + type: object + required: [kind, schema, table] + properties: + kind: + type: string + enum: [table] + schema: + type: string + table: + type: string + + AclChange: + description: one change to plan or apply + oneOf: + - $ref: "#/components/schemas/AclChangeSetOwner" + - $ref: "#/components/schemas/AclChangeGrant" + - $ref: "#/components/schemas/AclChangeRevoke" + discriminator: + propertyName: type + mapping: + set_owner: "#/components/schemas/AclChangeSetOwner" + grant: "#/components/schemas/AclChangeGrant" + revoke: "#/components/schemas/AclChangeRevoke" + + AclChangeSetOwner: + type: object + description: >- + hands the target to role — for a schema, with everything already in it but an extension's + members, which stay with the extension + required: [type, role] + properties: + type: + type: string + enum: [set_owner] + role: + type: string + description: a data table role of the instance, or admin + + AclChangeGrant: + type: object + required: [type, role, privileges, scope] + properties: + type: + type: string + enum: [grant] + role: + type: string + description: a data table role of the instance, or admin + privileges: + type: array + items: + type: string + scope: + $ref: "#/components/schemas/AclGrantScope" + + AclChangeRevoke: + type: object + required: [type, role, privileges, scope] + properties: + type: + type: string + enum: [revoke] + role: + type: string + description: a data table role of the instance, other than admin + privileges: + type: array + items: + type: string + scope: + $ref: "#/components/schemas/AclGrantScope" + objects: + type: array + description: >- + objects inside the target the revoke covers, empty for the target itself. Only with the + target scope; a revoke on all objects of a kind is refused, since it cannot say which + grants it takes back. + items: + $ref: "#/components/schemas/AclObject" + + AclGrantScope: + type: string + enum: + [ + target, + all_tables, + all_sequences, + all_functions, + future_tables, + future_sequences, + future_functions, + ] + + AclChangeRequest: + type: object + required: [target, change] + properties: + target: + $ref: "#/components/schemas/AclTarget" + change: + $ref: "#/components/schemas/AclChange" + statements: + type: array + description: >- + The statements the plan showed. Required to apply, which plans again and refuses if + the result differs. + items: + type: string + + AclPlan: + type: object + required: [statements, warnings] + properties: + statements: + type: array + items: + type: string + warnings: + type: array + items: + type: string + + AclObject: + type: object + required: [name, kind] + properties: + name: + type: string + kind: + type: string + description: >- + TABLE, SEQUENCE, FUNCTION, PROCEDURE or TYPE — what the object is. A revoke turns it + into the keyword it takes, ROUTINE for both routine kinds; a type's grants are read + only. + args: + type: string + description: identity arguments of a routine, which is what tells two of the same name apart + + AclGrant: + type: object + required: [grantee, privileges, sources] + properties: + grantee: + type: string + privileges: + type: array + items: + type: string + object: + $ref: "#/components/schemas/AclObject" + future: + type: string + description: >- + set for a default privilege, naming the kind of object it covers (TABLES, SEQUENCES, + FUNCTIONS, TYPES, or SCHEMAS). On a schema, the defaults set in that schema; on the + database, the ones set database-wide, which apply in every schema and which no schema's + own defaults take back. + sources: + type: array + description: >- + the roles the grant comes from, each once — who granted it, or for a default privilege + the role whose future objects it covers. A revoke of some of the grant's privileges + takes them back from every source that gave them. + items: + $ref: "#/components/schemas/AclSource" + + AclSource: + type: object + required: [role, privileges, reachable] + properties: + role: + type: string + privileges: + type: array + description: >- + what role gave of the grant's privileges. A revoke is held back only by a source out of + reach that gave some of what it takes back. + items: + type: string + reachable: + type: boolean + description: >- + whether the data table's connection can take back what role gave. On an object that is + the owner, when the connection acts for the owner, and otherwise the connection itself; + for a default privilege, a creating role the connection acts for. What a source out of + reach gave is not revocable from here; privileges only other sources gave still are. + + DatatableAclInfo: + type: object + required: [owner, roles, editable, clone, supports_maintain, dbname, grants, children] + properties: + owner: + type: string + roles: + type: array + description: the roles a change may name; empty unless the caller may change anything + items: + type: string + editable: + type: boolean + description: whether the caller may plan and apply changes + clone: + type: boolean + description: whether this is a clone, whose grants stay as they were copied + supports_maintain: + type: boolean + description: whether the server is Postgres 17+, which added the MAINTAIN table privilege + dbname: + type: string + description: the database the target lives in + grants: + type: array + items: + $ref: "#/components/schemas/AclGrant" + children: + type: array + description: a database's schemas, or a schema's tables + items: + type: string + CustomInstanceDb: type: object required: @@ -34482,6 +35366,9 @@ components: nullable: true disabled: type: boolean + is_service_account: + type: boolean + description: Whether this membership is a service account. required: - id - name @@ -34533,6 +35420,16 @@ components: new_dbname: type: string description: "New database name for the fork" + fork_behavior: + type: string + enum: + - schema_only + - schema_and_data + description: >- + What the fork request copies into `new_dbname`, which it creates — with the + owners and grants of a data table under roles. This server refuses an entry + without it; servers predating it expect `new_dbname` created and filled + beforehand. shared_ducklakes: type: array items: @@ -35171,6 +36068,20 @@ components: - raw_app + AppDeployed: + type: object + description: "What a deploy of an existing app answers with. `version` is the one this call wrote, which is what an editor pins as the fork base of the draft it starts next: reading the head back afterwards cannot tell it from a deploy that landed beside it. A metadata-only update writes none and reports the head it kept." + properties: + path: + type: string + description: Where the app now lives, which differs from the request path on a rename. + version: + type: integer + format: int64 + required: + - path + - version + AppHistory: type: object properties: @@ -35178,6 +36089,11 @@ components: type: integer deployment_msg: type: string + created_at: + type: string + format: date-time + created_by: + type: string required: - version @@ -35247,6 +36163,8 @@ components: format: date-time deployment_msg: type: string + created_by: + type: string required: - id - created_at @@ -35448,9 +36366,11 @@ components: type: object additionalProperties: type: object - required: [database] properties: database: + description: >- + Set on an entry that owns its database. Absent on a fork's entry, which points at + another workspace's data table instead. type: object properties: resource_type: @@ -35462,6 +36382,28 @@ components: type: string required: - resource_type + reference: + description: >- + The workspace and data table that govern this one. Server-owned: written by fork + creation, and carried across a settings save whatever the request says. + type: object + required: [workspace_id, datatable] + properties: + workspace_id: + type: string + datatable: + type: string + governed_by: + description: >- + On a clone, the data table it was copied from, whose roles it takes. Server-owned + like `reference`. + type: object + required: [workspace_id, datatable] + properties: + workspace_id: + type: string + datatable: + type: string migrations_enabled: type: boolean description: Whether the SQL migrations feature is opted in for this data table @@ -35530,7 +36472,17 @@ components: DataTableTables: type: object - required: [datatable_name, schemas] + required: + [ + datatable_name, + schemas, + instance, + permissioned, + usable_roles, + default_role, + can_create_schema, + creatable_schemas, + ] properties: datatable_name: type: string @@ -35543,6 +36495,26 @@ components: type: string error: type: string + instance: + type: boolean + description: on the instance database, the only kind that can be under roles or have its access edited + permissioned: + type: boolean + usable_roles: + type: array + description: the roles the caller may connect as, by name; empty when not under roles + items: + type: string + default_role: + type: string + can_create_schema: + type: boolean + description: whether the role the listing connected as may create schemas + creatable_schemas: + type: array + description: the schemas the role the listing connected as may create in + items: + type: string DataTableTableSchema: type: object @@ -35713,6 +36685,44 @@ components: items: $ref: "#/components/schemas/GitSyncObjectType" + RemoteDeployTarget: + type: object + properties: + base_url: + type: string + description: root URL of the remote Windmill instance, without /api + workspace_id: + type: string + description: workspace on the remote instance that deploys land in + required: + - base_url + - workspace_id + + RemoteDeployConnection: + type: object + properties: + remote_email: + type: string + description: identity the stored token has on the remote instance + proxy_key: + type: string + description: goes in every proxy URL, `/w/{workspace}/remote_deploy/proxy/{proxy_key}/{route}` + connected_at: + type: string + format: date-time + required: + - remote_email + - proxy_key + - connected_at + + RemoteDeployStatus: + type: object + properties: + target: + $ref: "#/components/schemas/RemoteDeployTarget" + connection: + $ref: "#/components/schemas/RemoteDeployConnection" + WorkspaceDefaultScripts: type: object properties: diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 1710db9cec..16b4b17141 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -23,7 +23,7 @@ use windmill_ai::ai_cache::current_instance_ai_config_revision; use windmill_ai::ai_providers::{ empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel, }; -use windmill_ai::ai_types::MAX_MODEL_RATE; +use windmill_ai::ai_types::{validate_token_limit, CONTEXT_WINDOWS, MAX_MODEL_RATE, OUTPUT_LIMITS}; use windmill_ai::credentials::ProviderCredentials; #[cfg(feature = "bedrock")] use windmill_ai::providers::bedrock::{ @@ -445,6 +445,11 @@ pub struct AIConfig { /// Only models whose rates differ from the built-in table are stored. #[serde(skip_serializing_if = "Option::is_none")] pub model_pricing: Option>, + /// Per-model context windows the chat budgets its history against, keyed + /// `provider:model` like `max_tokens_per_model`. Overrides the client's built-in + /// table, which a model served through a custom endpoint is usually missing from. + #[serde(skip_serializing_if = "Option::is_none")] + pub context_window_per_model: Option>, /// Hides the Windmill AI assistant (chat, sessions, generation, completion, fixes) from /// the workspace UI. Only the workspace's own row is consulted: the flag holds even when /// the providers served come from the instance config or the free tier. AI agent steps @@ -508,6 +513,18 @@ impl AIConfig { Ok(()) } + pub fn validate_token_limits(&self) -> Result<()> { + for (entries, bounds) in [ + (&self.context_window_per_model, &CONTEXT_WINDOWS), + (&self.max_tokens_per_model, &OUTPUT_LIMITS), + ] { + for (key, tokens) in entries.iter().flatten() { + validate_token_limit(bounds, key, i64::from(*tokens)).map_err(Error::BadRequest)?; + } + } + Ok(()) + } + pub fn validate_sessions_retention(&self) -> Result<()> { match self.sessions_retention_days { Some(days) if !(1..=MAX_SESSIONS_RETENTION_DAYS).contains(&days) => { diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index 918fce53bf..54886e2568 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -69,8 +69,8 @@ use windmill_common::{ user_drafts::{overlay_or_draft_only, DraftUserRef, UserDraftItemKind, WithDraftOverlay}, users::username_to_permissioned_as, utils::{ - http_get_from_hub, not_found_if_none, paginate, query_elems_from_hub, require_admin, - strip_json_nul, Pagination, RunnableKind, StripPath, + check_proper_path, http_get_from_hub, not_found_if_none, paginate, paginate_optional, + query_elems_from_hub, require_admin, strip_json_nul, Pagination, RunnableKind, StripPath, }, variables::{build_crypt, build_crypt_with_key_suffix, encrypt}, worker::{to_raw_value, CLOUD_HOSTED}, @@ -274,6 +274,12 @@ pub struct AppHistory { pub version: i64, #[serde(skip_serializing_if = "Option::is_none")] pub deployment_msg: Option, + /// Who deployed this version, and when — the diff's version picker names them so + /// a reader can tell their own deploys from a teammate's. + #[serde(skip_serializing_if = "Option::is_none")] + pub created_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option>, } #[derive(Deserialize)] @@ -567,6 +573,17 @@ pub struct CreateApp { pub skip_draft_deletion: Option, } +/// What a deploy of an existing app answers with. `version` is the one this call wrote, +/// which is what an editor pins as the fork base of the draft it starts next: reading the +/// head back afterwards cannot tell it from a deploy that landed beside it. A +/// metadata-only update writes none and reports the head it kept. +#[derive(Serialize)] +pub struct AppDeployed { + /// Where the app now lives, which differs from the request path on a rename. + pub path: String, + pub version: i64, +} + #[derive(Serialize, Deserialize)] pub struct EditApp { pub path: Option, @@ -1220,18 +1237,42 @@ async fn get_app_history( authed: ApiAuthed, Extension(user_db): Extension, Path((w_id, path)): Path<(String, StripPath)>, + Query(pagination): Query, ) -> JsonResult> { let path = path.to_path(); check_scopes(&authed, || format!("apps:read:{}", &path))?; + // Unasked-for, this listing stays whole: the deployment-history panel reads it + // without paging. The diff picker asks for a page. + let (per_page, offset) = paginate_optional(pagination); let mut tx = user_db.begin(&authed).await?; + // Newest first in deployed order, which is a version's position in `app.versions` and + // not its `created_at`: the latter is the deploying transaction's start time, so two + // that overlap can carry it in the opposite order from the one they landed in. A row + // outside the array never sat in that sequence, so it sorts after the ones that did. + // Paging happens before the metadata joins, so a page costs its own rows. let query_result = sqlx::query!( - "SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg - FROM app a LEFT JOIN app_version av ON a.id = av.app_id LEFT JOIN deployment_metadata dm ON av.id = dm.app_version + "SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg, + av.created_by as created_by, av.created_at as created_at + FROM app a + JOIN LATERAL ( + SELECT av2.id, COALESCE(v.ord, 0) AS ord + FROM app_version av2 + LEFT JOIN unnest(a.versions) WITH ORDINALITY AS v(id, ord) ON v.id = av2.id + WHERE av2.app_id = a.id + ORDER BY ord DESC, av2.id DESC + LIMIT $3 OFFSET $4 + ) page ON TRUE + JOIN app_version av ON av.id = page.id + LEFT JOIN deployment_metadata dm ON av.id = dm.app_version WHERE a.workspace_id = $1 AND a.path = $2 - ORDER BY created_at DESC", + ORDER BY page.ord DESC", w_id, path, - ).fetch_all(&mut *tx).await?; + per_page, + offset, + ) + .fetch_all(&mut *tx) + .await?; tx.commit().await?; let result: Vec = query_result @@ -1240,6 +1281,8 @@ async fn get_app_history( app_id: row.app_id, version: row.version_id, deployment_msg: row.deployment_msg, + created_by: Some(row.created_by), + created_at: Some(row.created_at), }) .collect(); return Ok(Json(result)); @@ -1253,14 +1296,22 @@ async fn get_latest_version( let path = path.to_path(); check_scopes(&authed, || format!("apps:read:{}", path))?; let mut tx = user_db.begin(&authed).await?; + // The head is the tail of `app.versions` — the version the runtime serves. Deploys + // append to it under the app row's lock, whereas `app_version.created_at` is the + // deploying transaction's start time, so two that overlap can carry it in either + // order and the newest timestamp is then not the one that landed last. let row = sqlx::query!( - "SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg - FROM app a LEFT JOIN app_version av ON a.id = av.app_id LEFT JOIN deployment_metadata dm ON av.id = dm.app_version - WHERE a.workspace_id = $1 AND a.path = $2 - ORDER BY created_at DESC", + "SELECT a.id as app_id, av.id as version_id, dm.deployment_msg as deployment_msg, + av.created_by as created_by, av.created_at as created_at + FROM app a JOIN app_version av + ON av.id = a.versions[array_upper(a.versions, 1)] AND av.app_id = a.id + LEFT JOIN deployment_metadata dm ON av.id = dm.app_version + WHERE a.workspace_id = $1 AND a.path = $2", w_id, path, - ).fetch_optional(&mut *tx).await?; + ) + .fetch_optional(&mut *tx) + .await?; tx.commit().await?; if let Some(row) = row { @@ -1268,6 +1319,8 @@ async fn get_latest_version( app_id: row.app_id, version: row.version_id, deployment_msg: row.deployment_msg, + created_by: Some(row.created_by), + created_at: Some(row.created_at), }; return Ok(Json(Some(result))); @@ -2453,6 +2506,7 @@ async fn create_app_internal<'a>( // inside process_app_multipart!, so checking after this call would leave a // denied app committed in the DB. check_scopes(&authed, || format!("apps:write:{}", &app.path))?; + check_proper_path(&app.path)?; validate_frontend_sdk_scopes(&app.policy)?; if raw_app { validate_raw_app_path_keys(&app.value.0)?; @@ -2591,6 +2645,14 @@ async fn create_app_internal<'a>( .execute(&mut *tx) .await?; } + windmill_common::user_drafts::clear_draft_moves_from( + &mut tx, + &w_id, + &[UserDraftItemKind::App, UserDraftItemKind::RawApp], + &app.path, + None, + ) + .await?; let id = sqlx::query_scalar!( "INSERT INTO app (workspace_id, path, summary, policy, versions, custom_path, labels) @@ -2947,7 +3009,7 @@ async fn update_app( Extension(webhook): Extension, Path((w_id, path)): Path<(String, StripPath)>, Json(ns): Json, -) -> Result { +) -> JsonResult { if authed.is_operator { return Err(Error::NotAuthorized( "Operators cannot update apps for security reasons".to_string(), @@ -2986,7 +3048,7 @@ async fn update_app( }, ); - Ok(format!("app {} updated (npath: {:?})", opath, npath)) + Ok(Json(AppDeployed { path: npath, version: v_id })) } /// Deploy a raw app from its sources, compiling them on a worker. `update_raw` @@ -3000,7 +3062,7 @@ async fn update_app_raw_source( Extension(webhook): Extension, Path((w_id, path)): Path<(String, StripPath)>, Json(ns): Json, -) -> Result { +) -> JsonResult { if authed.is_operator { return Err(Error::NotAuthorized( "Operators cannot update apps for security reasons".to_string(), @@ -3098,7 +3160,7 @@ async fn update_app_raw_source( }, ); - Ok(format!("app {} updated (npath: {:?})", opath, npath)) + Ok(Json(AppDeployed { path: npath, version: v_id })) } /// Whether the caller may create an app at `path` — asked of the database rather @@ -3191,6 +3253,7 @@ async fn create_app_raw_source( // Before the compile, which costs a job on a worker: it must not run for a // path already taken, nor for one the caller can't write. `create_app_internal` // rejects both, but only after the sources have been built. + check_proper_path(&path)?; if app_exists(&db, &w_id, &path).await? { return Err(Error::BadRequest(format!("App {path} already exists"))); } @@ -3324,7 +3387,7 @@ async fn update_app_raw<'a>( Extension(webhook): Extension, Path((w_id, path)): Path<(String, StripPath)>, multipart: Multipart, -) -> Result { +) -> JsonResult { if authed.is_operator { return Err(Error::NotAuthorized( "Operators cannot update apps for security reasons".to_string(), @@ -3372,7 +3435,7 @@ async fn update_app_raw<'a>( }, ); - Ok(format!("app {} updated (npath: {:?})", opath, npath)) + Ok(Json(AppDeployed { path: npath, version: v_id })) } // async fn create_app_internal<'a>( // authed: ApiAuthed, @@ -3401,6 +3464,9 @@ async fn update_app_internal<'a>( // the token's write scope, not just the source path. if let Some(npath) = ns.path.as_deref() { check_scopes(&authed, || format!("apps:write:{}", npath))?; + if npath != path { + check_proper_path(npath)?; + } } if raw_app { @@ -3749,6 +3815,19 @@ async fn update_app_internal<'a>( .execute(&mut *tx) .await?; } + if npath != path { + // Everything left at the old path is a draft this deploy didn't consume + // — teammates' rows, and the deployer's own when the caller asked us to + // keep it. Carry them rather than strand them. + windmill_common::user_drafts::move_drafts_for_path( + &mut tx, + &w_id, + &[UserDraftItemKind::App, UserDraftItemKind::RawApp], + path, + &npath, + ) + .await?; + } audit_log( &mut *tx, &authed, @@ -4376,9 +4455,11 @@ async fn execute_component( // — like `/jobs/run/preview` — confine it to worker tags the caller may use // (a `if_jobs:filter_tags`-restricted token must not escape its filter). // `is_preview` implies an authed caller (the guard above returns otherwise). + let push_args = PushArgs { args: &args.args, extra: args.extra }; if is_preview { if let Some(authed) = opt_authed.as_ref() { - crate::jobs::check_tag_available_for_workspace(&db, &w_id, &tag, authed).await?; + crate::jobs::check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, authed) + .await?; } } // Identity is already resolved to the requesting user in preview mode (the @@ -4409,7 +4490,7 @@ async fn execute_component( tx, &w_id, job_payload, - PushArgs { args: &args.args, extra: args.extra }, + push_args, &username, email, permissioned_as, diff --git a/backend/windmill-api/src/capture.rs b/backend/windmill-api/src/capture.rs index 2e2058ea80..6255b5eb13 100644 --- a/backend/windmill-api/src/capture.rs +++ b/backend/windmill-api/src/capture.rs @@ -567,6 +567,9 @@ async fn set_config( }; let mut tx = user_db.begin(&authed).await?; + if matches!(nc.trigger_kind, TriggerKind::Postgres) { + windmill_common::datatable_roles::lock_datatable_streams(&mut *tx, false).await?; + } sqlx::query!( r#" @@ -614,6 +617,9 @@ async fn ping_config( )>, ) -> Result<()> { let mut tx = user_db.begin(&authed).await?; + if matches!(trigger_kind, TriggerKind::Postgres) { + windmill_common::datatable_roles::lock_datatable_streams(&mut *tx, false).await?; + } sqlx::query!( r#" diff --git a/backend/windmill-api/src/db.rs b/backend/windmill-api/src/db.rs index e8a6858db7..9619e96ebc 100644 --- a/backend/windmill-api/src/db.rs +++ b/backend/windmill-api/src/db.rs @@ -372,7 +372,10 @@ pub async fn migrate( } crate::live_migrations::custom_migrations(&mut custom_migrator).await?; - Ok(None) + Ok(Some(crate::live_migrations::spawn_background_migrations( + db.clone(), + killpill_rx, + ))) } pub async fn wait_for_migrations( diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index e5928f761e..8bcbb56384 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -19,7 +19,7 @@ use windmill_common::{ error::{Error, Result}, user_drafts::{DraftUserRef, UserDraftItemKind, ENCRYPTED_DRAFT_PREFIX}, users::resolve_username_to_email, - utils::strip_json_nul, + utils::{check_proper_path, strip_json_nul}, variables::{build_crypt, encrypt}, }; @@ -29,6 +29,7 @@ pub fn workspaced_service() -> Router { .route("/get/{kind}/{*path}", get(get_draft_for_user)) .route("/get_own/{kind}/{*path}", get(get_own_draft)) .route("/update/{kind}/{*path}", post(update_draft)) + .route("/move/{kind}/{*path}", post(move_draft)) .route("/migrate_legacy/{kind}/{*path}", post(migrate_legacy_draft)) } @@ -320,12 +321,71 @@ pub struct SaveDraftResponse { /// On `saved`: when the change was applied (client remembers it as the /// next `last_sync`). On `conflict`: the existing row's `created_at`. pub current_timestamp: chrono::DateTime, + /// `saved` only: where the write landed. Differs from the URL path when the item + /// had moved away from it; the editor follows it there. Absent when a delete found + /// nothing to remove and the caller cannot read the path it moved to. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, } -/// Apply the current user's draft at (workspace, kind, path): non-null `value` -/// upserts, `null` (or omitted) deletes. Either way, when the existing row is -/// newer than `last_sync` (and `force` is false) the op is skipped and the -/// response is `status = conflict` + the server's current timestamp. +/// The version a draft forked from, as the editors write it into `draft.value`. +/// Each kind names it differently and only one is ever set. +#[derive(Deserialize)] +struct DraftBaseVersion { + /// Scripts: hex-encoded script hash. + #[serde(default)] + parent_hash: Option, + /// Flows: `flow_version.id`. + #[serde(default)] + version_id: Option, + /// Apps / raw apps: `app_version.id`. + #[serde(default)] + parent_version: Option, +} + +impl DraftBaseVersion { + /// The base as the `draft.base` column stores it: one opaque text id whatever + /// the kind, so a reader compares it to the head without knowing the kind's + /// own field name or type. + fn as_text(&self, kind: UserDraftItemKind) -> Option { + use UserDraftItemKind::*; + match kind { + Script => self.parent_hash.clone(), + Flow => self.version_id.map(|v| v.to_string()), + _ => self.parent_version.map(|v| v.to_string()), + } + } +} + +/// The version this draft forked from, or `None` when it has none — a kind +/// that keeps no lineage, a malformed payload, or a draft that was never forked +/// from a deploy. Pure: no queries. +fn draft_lineage(kind: UserDraftItemKind, value: &str) -> Option { + use UserDraftItemKind::*; + if !matches!(kind, Script | Flow | App | RawApp) { + return None; + } + let base = serde_json::from_str::(value).ok()?; + let has_base = match kind { + Script => base + .parent_hash + .as_deref() + .and_then(|h| windmill_common::scripts::to_i64(h).ok()) + .is_some(), + Flow => base.version_id.is_some(), + _ => base.parent_version.is_some(), + }; + has_base.then_some(base) +} + +/// Apply the current user's draft: non-null `value` upserts, `null` (or +/// omitted) deletes. Either way, when the existing row is newer than +/// `last_sync` (and `force` is false) the op is skipped and the response is +/// `status = conflict` + the server's current timestamp. +/// +/// A save addressed to a path its item moved away from lands where the move took +/// the drafts (`draft_move`), unless the caller still has a draft of their own at +/// that path. The response names where it landed. async fn update_draft( authed: ApiAuthed, Extension(db): Extension, @@ -334,7 +394,7 @@ async fn update_draft( Json(req): Json, ) -> Result> { let email = &authed.email; - let path = path.to_path(); + let url_path = path.to_path(); // Saving a draft requires write permission on the underlying path. Deleting // (discarding) one's OWN draft does not: the email-scoped row belongs to the // authed user, so they can always discard it even after losing write access @@ -343,11 +403,68 @@ async fn update_draft( // touch the caller's own row. Legacy (NULL-email) rows aren't owned by anyone // — they keep the write gate. let is_own_discard = req.value.is_none() && !req.legacy; + // `legacy` targets the workspace-level row and is delete-only: an upsert writes the + // caller's own row whatever it says. Every read of that rule goes through this. + let legacy_delete = req.value.is_none() && req.legacy; + + // Whose row this write is for: the caller's, or the workspace-level one on a legacy + // DELETE (`legacy` is delete-only, so an upsert is the caller's own row either way). + // It picks both the record that applies — an item's move (`email IS NULL`) covers the + // legacy row too, since the same rename carried it — and the draft whose presence + // means this path is still the write's own. + let owner: Option<&str> = (!legacy_delete).then_some(email.as_str()); + // The caller's own draft-only move outranks the move of the deployed item. Only the + // kinds whose value carries a deploy target are ever recorded as moved, so for the + // rest this would be a guaranteed-empty query on the autosave hot path. + let moved_to = match kind.typed_path_field() { + None => None, + Some(_) => { + sqlx::query_scalar!( + r#"SELECT m.new_path FROM draft_move m + WHERE m.workspace_id = $1 AND m.typ = $2 AND m.old_path = $3 + AND (m.email IS NULL OR m.email = $4) + AND NOT EXISTS ( + SELECT 1 FROM draft d + WHERE d.workspace_id = $1 AND d.typ = $2 AND d.path = $3 + AND d.email IS NOT DISTINCT FROM $4 + ) + ORDER BY m.email IS NULL + LIMIT 1"#, + &w_id, + kind as UserDraftItemKind, + url_path, + owner, + ) + .fetch_optional(&db) + .await? + } + }; + let path: &str = moved_to.as_deref().unwrap_or(url_path); + + // Everything past here writes, so the gate applies from here on. Answered + // without the path when the item moved: it may have gone somewhere the caller + // cannot see. if !is_own_discard { - require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?; + match require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await { + // Naming the move is for whoever was editing the item: it tells them why a + // save they were already making stopped landing. Someone who cannot read the + // path they addressed gets the plain denial, or the wording itself would + // answer whether an item was moved away from a path they only guessed at. + Err(Error::NotAuthorized(e)) if moved_to.is_some() => { + return Err(Error::NotAuthorized( + match require_can_read_path(&authed, &user_db, &w_id, kind, url_path).await { + Ok(()) => { + "this draft's item was moved to a path you cannot write".to_string() + } + Err(_) => e, + }, + )); + } + other => other?, + } } - let applied_at = if let Some(value) = &req.value { + let applied = if let Some(value) = &req.value { // Secret variable values must never sit in `draft.value` in plaintext // (see `encrypt_secret_variable_value`). let serialized = if kind == UserDraftItemKind::Variable { @@ -359,19 +476,49 @@ async fn update_draft( // escape and later make any `->>`/`to_jsonb` extraction raise `22P05`. // Strip it here so a NUL never reaches the column. let serialized = strip_json_nul(&serialized); + // `base` is derived here from the value's per-kind field rather than sent + // by the client, so every writer (editors, chat, CLI) fills it the same way. + // Read from the sanitized text, which is what the value column gets: a NUL in the + // lineage field otherwise costs the draft its base (the hash no longer parses), + // leaving a draft that reads as up to date whatever the head is. + let base = draft_lineage(kind, serialized.as_ref()).and_then(|l| l.as_text(kind)); // Upsert. The conflict check rides on the DO UPDATE WHERE clause — // when the row is newer than `last_sync`, RETURNING yields nothing. // `created_at` defaults to `now()` but the migration overrides it ($8) // so a migrated draft keeps its original age instead of jumping to top. - sqlx::query_scalar!( - r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at) - VALUES ($1, $2, $3, $4, $5::text::json, COALESCE($8::timestamptz, now())) + // + // A moved save ($10) carries the path keys its editor had before the move. + // One naming the path it addressed ($11) follows to where it landed; a draft + // already there keeps the keys the move gave it. A pre-sanitizer NUL escape + // in that draft makes `to_jsonb` raise, so it takes the incoming keys. + sqlx::query!( + r#"INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base) + VALUES ($1, $2, $3::text, $4, + CASE WHEN $10::bool + THEN to_json($5::text::jsonb || jsonb_strip_nulls(jsonb_build_object( + 'path', CASE WHEN $5::text::jsonb -> 'path' = to_jsonb($11::text) + THEN to_jsonb($3::text) END, + 'draft_path', CASE WHEN $5::text::jsonb -> 'draft_path' = to_jsonb($11::text) + THEN to_jsonb($3::text) END))) + ELSE $5::text::json + END, + COALESCE($8::timestamptz, now()), $9) ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL - DO UPDATE SET value = EXCLUDED.value, created_at = EXCLUDED.created_at + DO UPDATE SET value = CASE + WHEN NOT $10::bool + OR position(chr(92) || 'u0000' in replace(draft.value::text, chr(92) || chr(92), '')) > 0 + THEN EXCLUDED.value + ELSE to_json((to_jsonb(EXCLUDED.value) - 'path' - 'draft_path') + || jsonb_strip_nulls(jsonb_build_object( + 'path', to_jsonb(draft.value) -> 'path', + 'draft_path', to_jsonb(draft.value) -> 'draft_path'))) + END, + created_at = EXCLUDED.created_at, + base = EXCLUDED.base WHERE $7::bool = true OR $6::timestamptz IS NULL OR draft.created_at <= $6::timestamptz - RETURNING created_at"#, + RETURNING path, created_at"#, &w_id, email, path, @@ -380,9 +527,13 @@ async fn update_draft( req.last_sync, req.force, req.created_at, + base.as_deref(), + moved_to.is_some(), + url_path, ) .fetch_optional(&db) .await? + .map(|r| (r.created_at, Some(r.path))) } else { // Delete, same conflict rule in the WHERE clause. Returns NULL when // the row was too new (conflict) OR already absent (idempotent) — @@ -403,16 +554,20 @@ async fn update_draft( kind as UserDraftItemKind, req.last_sync, req.force, - req.legacy, + legacy_delete, ) .fetch_optional(&db) .await? + // Named for the same reason an upsert is: the editor that discarded is still on + // the path the item left, and reloading there would land on nothing. + .map(|ts| (ts, moved_to.clone())) }; - if let Some(ts) = applied_at { + if let Some((ts, path)) = applied { return Ok(Json(SaveDraftResponse { status: SaveDraftStatus::Saved, current_timestamp: ts, + path, })); } @@ -428,7 +583,7 @@ async fn update_draft( email, path, kind as UserDraftItemKind, - req.legacy, + legacy_delete, ) .fetch_optional(&db) .await?; @@ -437,20 +592,307 @@ async fn update_draft( Some(ts) => Ok(Json(SaveDraftResponse { status: SaveDraftStatus::Conflict, current_timestamp: ts, + path: None, })), // Delete + nothing-was-there ⇒ report success with server's NOW(). None => { let now = sqlx::query_scalar!(r#"SELECT now() as "now!""#) .fetch_one(&db) .await?; + // A retry of a routed discard whose answer was lost lands here: the row is + // gone but the editor is still on the path the item left, so it needs the + // destination as much as the first attempt did. Unlike the arm above there is + // no deleted row proving the caller ever held that draft, and an own discard + // is not gated, so this names a path to someone who may have none of it: the + // read gate, which is what keeps a path from being disclosed elsewhere in this + // module. Without it, discarding at a guessed path reads `draft_move`. + let disclosed = match moved_to { + Some(dest) => { + match require_can_read_path(&authed, &user_db, &w_id, kind, &dest).await { + Ok(()) => Some(dest), + Err(Error::NotFound(_)) + | Err(Error::NotAuthorized(_)) + | Err(Error::BadRequest(_)) => None, + Err(e) => return Err(e), + } + } + None => None, + }; Ok(Json(SaveDraftResponse { status: SaveDraftStatus::Saved, current_timestamp: now, + path: disclosed, })) } } } +#[derive(Deserialize)] +pub struct MoveDraftRequest { + pub new_path: String, + /// Also restate the draft's summary, so the same drawer that renames a + /// deployed item can retitle a draft-only one. + #[serde(default)] + pub summary: Option, +} + +/// Relocate the authed user's own DRAFT-ONLY item. Such an item is nothing but +/// its draft row, so moving it is a rewrite of that row's path plus both path +/// keys inside its value — there is no deployed row, schedule or trigger to +/// cascade to. +/// +/// The owner's own open editor follows: its next save, still addressed to the old +/// path, lands at the new one through the move record, and it is told where. +/// +/// Scoped to the caller's own row on purpose: two users can each have a draft +/// at the same never-deployed path, and those are two separate items. +/// +/// A DEPLOYED item must move through its own deploy endpoint instead, which +/// cascades everything that references the path and carries every draft along. +async fn move_draft( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, kind, path)): Path<(String, UserDraftItemKind, windmill_common::utils::StripPath)>, + Json(req): Json, +) -> Result { + let path = path.to_path(); + let new_path = req.new_path.as_str(); + // Only the full-page editor kinds, which is exactly the set that has a typed + // path to rewrite. Reading the movable set off the same mapping the rewrite + // uses keeps them from drifting apart: a resource, a variable or a trigger + // keeps its deploy target in `value.path` with no editor to stage a rename, + // so moving one would leave the real target naming the old location and the + // next deploy would recreate it where it came from. + let (Some(typed_field), Some(mirror_field)) = + (kind.typed_path_field(), kind.mirror_path_field()) + else { + return Err(Error::BadRequest(format!( + "moving a {kind:?} draft is not supported — only scripts, flows and apps" + ))); + }; + // Validate before authorizing: `require_can_write_path` is not a format check + // (an admin returns immediately, and a user returns early inside their own + // namespace), so without this a malformed path is stored as-is, and an over-long + // or NUL-bearing one reaches Postgres as a raw server error. The source is a URL + // segment and arrives decoded, so it needs the same check as the destination. + check_proper_path(path)?; + check_proper_path(new_path)?; + // A summary-only edit is a legitimate use of this endpoint: the drawer edits + // both fields, and for a draft-only script the path it posts back is the row + // path unchanged (`list_scripts` only reports `draft_path` when it differs). + // Returning early on the path alone would drop the new summary silently. + if new_path == path && req.summary.is_none() { + return Ok("unchanged".to_string()); + } + require_can_write_path(&authed, &db, &user_db, &w_id, kind, path).await?; + if new_path != path { + require_can_write_path(&authed, &db, &user_db, &w_id, kind, new_path).await?; + } + + if let Some(table) = kind.deployed_table() { + // `table` is from the closed `deployed_table()` enum, never user input. + // Archived and soft-deleted rows keep sitting at their path — a script + // move archives its parent in place — so an existence check that counted + // them would refuse a move away from, or into, a path nothing occupies. + // `create_script_internal` resolves its own path clashes the same way. + let archived_filter = if table == "script" { + " AND NOT archived AND NOT deleted" + } else { + "" + }; + let query = format!( + "SELECT 1 FROM {table} WHERE path = $1 AND workspace_id = $2{archived_filter} LIMIT 1" + ); + let mut tx = user_db.clone().begin(&authed).await?; + let deployed_at_old = sqlx::query_scalar::<_, i32>(&query) + .bind(path) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await?; + let deployed_at_new = sqlx::query_scalar::<_, i32>(&query) + .bind(new_path) + .bind(&w_id) + .fetch_optional(&mut *tx) + .await?; + tx.commit().await?; + if deployed_at_old.is_some() { + return Err(Error::BadRequest(format!( + "'{path}' is deployed — move it from its editor so schedules and triggers follow" + ))); + } + if deployed_at_new.is_some() { + return Err(Error::BadRequest(format!( + "'{new_path}' already has a deployed item — moving there would turn this into a draft on top of it" + ))); + } + } + + // A NUL in the summary reaches Postgres as a raw byte and fails the statement with + // an encoding error, so it is dropped here the way `strip_json_nul` drops one from a + // draft value. `json` cannot store it either. + let summary = req.summary.as_ref().map(|s| s.replace('\0', "")); + + // A classic app and a raw app share the `app` table, so a draft of either kind + // occupies the destination for both: deploying there deletes the caller's drafts + // of both kinds, taking the item that lost the collision with it. + let collision_typs: Vec<&str> = match kind { + UserDraftItemKind::App | UserDraftItemKind::RawApp => vec![ + UserDraftItemKind::App.as_str(), + UserDraftItemKind::RawApp.as_str(), + ], + _ => vec![kind.as_str()], + }; + + // One transaction with the move record, so a save addressed to the old path + // never sees the row gone without knowing where it went. + let mut tx = db.begin().await?; + let moved = sqlx::query_scalar!( + r#"UPDATE draft + SET path = $3, + -- Both path keys, not just the typed one: the editors mirror the + -- typed path into the other while it differs from the row's path, + -- and the loaders prefer the mirror — left naming the old location + -- it un-does this move on the next save. `create_missing = false` + -- on both, so a draft carrying only one keeps only one. + value = to_json( + jsonb_set( + jsonb_set( + CASE WHEN $7::text IS NULL THEN to_jsonb(value) + ELSE jsonb_set(to_jsonb(value), ARRAY['summary'], to_jsonb($7::text)) + END, + ARRAY[$5::text], to_jsonb($3::text), false + ), + ARRAY[$8::text], to_jsonb($3::text), false + ) + ) + WHERE workspace_id = $1 + AND path = $2 + AND typ = $4 + AND email = $6 + -- A pre-sanitizer NUL escape makes `to_jsonb` raise 22P05. Excluded + -- here so the statement can't 500; reported below instead. Unlike the + -- passive carry, rewriting the value IS this operation, so skipping it + -- silently would move the row and leave its typed path stale. + AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) = 0 + -- Skipped on a summary-only edit, where the "target" row is this + -- row and the guard would refuse the update against itself. + AND ($2 = $3 OR NOT EXISTS ( + SELECT 1 FROM draft o + WHERE o.workspace_id = $1 AND o.path = $3 AND o.typ::text = ANY($9::text[]) + -- Of this kind only the caller's own row and the legacy one collide: + -- teammates' drafts of one item share its path by design, but a deploy + -- there wipes those two together, so a second would discard edits the + -- caller never saw. The other app kind is a different item on the same + -- deployed path, so it collides whoever owns it. + AND (o.typ <> $4 OR o.email = $6 OR o.email IS NULL) + )) + RETURNING id"#, + &w_id, + path, + new_path, + kind as UserDraftItemKind, + typed_field, + &authed.email, + summary, + mirror_field, + &collision_typs as &[&str], + ) + .fetch_optional(&mut *tx) + .await?; + if moved.is_some() && new_path != path { + windmill_common::user_drafts::record_draft_move( + &mut tx, + &w_id, + &[kind], + path, + new_path, + Some(&authed.email), + ) + .await?; + } + tx.commit().await?; + + if moved.is_none() { + let row = sqlx::query!( + r#"SELECT + -- The guard's own predicate, ordered own row, then legacy, then another + -- user's other-kind row: each needs a different remedy, and a bare + -- LIMIT 1 would name an arbitrary one. + (SELECT typ::text FROM draft WHERE workspace_id = $1 AND path = $3 + AND typ::text = ANY($6::text[]) + AND (typ <> $2 OR email = $4 OR email IS NULL) + ORDER BY CASE WHEN email = $4 THEN 0 WHEN email IS NULL THEN 1 ELSE 2 END + LIMIT 1) as "at_target", + EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3 + AND typ::text = ANY($6::text[]) AND email = $4) as "at_target_own!", + EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $3 + AND typ::text = ANY($6::text[]) AND email IS NULL) as "at_target_legacy!", + EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5 + AND typ = $2 AND email = $4 + AND position(chr(92) || 'u0000' in replace(value::text, chr(92) || chr(92), '')) > 0 + ) as "poisoned!", + EXISTS(SELECT 1 FROM draft WHERE workspace_id = $1 AND path = $5 + AND typ = $2 AND email IS NULL) as "legacy!" "#, + &w_id, + kind as UserDraftItemKind, + new_path, + &authed.email, + path, + &collision_typs as &[&str], + ) + .fetch_one(&db) + .await?; + return Err(Error::BadRequest(if row.poisoned { + // This endpoint also serves a summary-only edit, so name the operation + // the caller actually asked for rather than always saying "moved". + let attempted = if new_path == path { "updated" } else { "moved" }; + format!( + "'{path}' contains a NUL character and predates the sanitizer, so it cannot be \ + {attempted}. Reopen it, re-save to rewrite it cleanly, then retry." + ) + } else if row.legacy { + // The home list synthesizes a draft-only row for the legacy draft with the + // caller's own name on it, so this is reachable from the row menu. Only an + // admin can claim or discard that row, and only from the drafts page. + format!( + "'{path}' is a legacy workspace draft with no owner, so it cannot be moved. \ + A workspace admin can claim or discard it on the Review & deploy drafts page." + ) + } else if let Some(occupant) = row.at_target.filter(|_| new_path != path) { + // Naming the kind matters for the app pair: a classic-app draft refusing a + // raw-app move is invisible in the raw-app list the caller is looking at. + let occupant = occupant.replace('_', " "); + if row.at_target_own { + format!("You already have a draft at '{new_path}' ({occupant})") + } else if row.at_target_legacy { + // An ownerless row the caller cannot clear themselves, so send them to + // the one place it can be resolved rather than to "discard your draft". + format!( + "A legacy workspace draft with no owner is already at '{new_path}' \ + ({occupant}). A workspace admin can claim or discard it on the Review & \ + deploy drafts page." + ) + } else { + // The other app kind, owned by someone else: one deployed path cannot hold + // both, so this is the other item's path, not a teammate's copy of this one. + format!( + "'{new_path}' holds another user's {occupant} draft, and an app and a raw \ + app cannot share a path. Pick another path, or ask them to move or \ + discard theirs." + ) + } + } else { + format!("You have no draft at '{path}'") + })); + } + + if new_path == path { + return Ok(format!("updated draft {path}")); + } + Ok(format!("moved draft {path} to {new_path}")) +} + #[derive(Deserialize, Debug)] #[serde(rename_all = "snake_case")] pub enum MigrateLegacyDraftAction { @@ -504,12 +946,12 @@ async fn migrate_legacy_draft( r#"WITH legacy AS ( DELETE FROM draft WHERE workspace_id = $1 AND path = $2 AND typ = $3 AND email IS NULL - RETURNING value + RETURNING value, base ) - INSERT INTO draft (workspace_id, email, path, typ, value, created_at) - SELECT $1, $4, $2, $3, value, now() FROM legacy + INSERT INTO draft (workspace_id, email, path, typ, value, created_at, base) + SELECT $1, $4, $2, $3, value, now(), base FROM legacy ON CONFLICT (workspace_id, path, typ, email) WHERE email IS NOT NULL - DO UPDATE SET value = EXCLUDED.value, created_at = now() + DO UPDATE SET value = EXCLUDED.value, created_at = now(), base = EXCLUDED.base RETURNING 1 as "one!""#, &w_id, path, @@ -767,8 +1209,8 @@ async fn require_can_write_path( /// Resolves to `Ok(())` if `authed` can read at `path`. Three layers: /// 1. admin → always. -/// 2. Path-prefix match against own `u/{username}` or any folder in -/// `authed.folders` (the precomputed read set, with groups + direct +/// 2. Path-prefix match against own `u/{username}`, a group in `authed.groups`, or any +/// folder in `authed.folders` (the precomputed read set, with groups + direct /// grants already factored in). /// 3. RLS-aware `SELECT 1` against the backing table — covers item-level /// extra_perms grants that bypass folder/owner checks. @@ -793,6 +1235,10 @@ async fn require_can_read_path( if parts.len() >= 2 { match parts[0] { "u" if parts[1] == authed.username => return Ok(()), + // As `require_can_write_path` and the `see_member` RLS policy read it: a + // draft-only `g/` path has no row for the probe below to fall back on, so + // without this a member cannot see a draft their group owns. + "g" if authed.groups.iter().any(|g| g == parts[1]) => return Ok(()), "f" => { let folder = parts[1]; if authed.folders.iter().any(|(name, _, _)| name == folder) { diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index eb77cb6e36..b9e71271d4 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -116,7 +116,9 @@ use windmill_common::{ query_builders, scripts::{ScriptHash, ScriptLang}, users::username_to_permissioned_as, - utils::{not_found_if_none, now_from_db, paginate, require_admin, Pagination, StripPath}, + utils::{ + not_found_if_none, now_from_db, paginate, require_admin, Pagination, ScheduleType, StripPath, + }, }; use windmill_common::{ @@ -304,6 +306,7 @@ pub fn workspaced_service() -> Router { .route("/queue/position/{timestamp}", get(get_queue_position)) .route("/queue/scheduled_for/{id}", get(get_scheduled_for)) .route("/queue/cancel_selection", post(cancel_selection)) + .route("/queue/run_now/{id}", post(run_queued_job_now)) .route("/completed/count", get(count_completed_jobs)) .route("/completed/count_jobs", get(count_completed_jobs_detail)) .route( @@ -835,6 +838,113 @@ async fn force_cancel( } } +/// Moves a queued job's `scheduled_for` to now so the next pull picks it up, keeping its id. +/// A job with a concurrency limit still goes through the usual check at pull time and is +/// re-scheduled again if its key is still saturated. +async fn run_queued_job_now( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, id)): Path<(String, Uuid)>, +) -> error::Result { + require_job_update_read_access(&db, &user_db, &authed, &w_id, &id, None).await?; + refuse_upcoming_schedule_tick(&db, &w_id, id).await?; + + let mut tx = db.begin().await?; + let previous = sqlx::query_scalar!( + "UPDATE v2_job_queue q SET scheduled_for = now() + FROM (SELECT id, scheduled_for FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 FOR UPDATE) prev + WHERE q.id = prev.id AND NOT q.running AND q.suspend = 0 AND q.canceled_by IS NULL + AND q.scheduled_for > now() + RETURNING prev.scheduled_for", + id, + w_id, + ) + .fetch_optional(&mut *tx) + .await?; + + let Some(previous) = previous else { + tx.commit().await?; + return Err( + if sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1 AND workspace_id = $2)", + id, + w_id + ) + .fetch_one(&db) + .await? + .unwrap_or(false) + { + Error::BadRequest(format!( + "job {id} is not waiting for a future start: it is running, suspended, \ + canceled or already due" + )) + } else { + Error::NotFound(format!("queued job id {id} does not exist")) + }, + ); + }; + + audit_log( + &mut *tx, + &authed, + "jobs.run_now", + ActionKind::Update, + &w_id, + Some(&id.to_string()), + Some([("previous_scheduled_for", previous.to_rfc3339().as_str())].into()), + ) + .await?; + tx.commit().await?; + + windmill_queue::append_logs( + &id, + &w_id, + format!( + "\nStart moved from {previous} to now by {}\n", + authed.display_username() + ), + &db.clone().into(), + ) + .await; + + Ok(id.to_string()) +} + +/// A schedule queues its next tick when the current one completes, computed from the +/// completion time. Starting a tick that is not yet due would therefore queue that same +/// tick again and run the schedule twice. A tick pushed past its time by a concurrency +/// limit no longer sits on a cron occurrence, so it may still be started early. +async fn refuse_upcoming_schedule_tick(db: &DB, w_id: &str, id: Uuid) -> error::Result<()> { + let Some(tick) = sqlx::query!( + "SELECT q.scheduled_for, s.path, s.schedule, s.cron_version, s.timezone + FROM v2_job j + JOIN v2_job_queue q USING (id) + JOIN schedule s ON s.workspace_id = j.workspace_id AND s.path = j.trigger + WHERE j.id = $1 AND j.workspace_id = $2 AND j.trigger_kind = 'schedule' + AND j.parent_job IS NULL AND q.scheduled_for > now()", + id, + w_id, + ) + .fetch_optional(db) + .await? + else { + return Ok(()); + }; + let sched = ScheduleType::from_str(&tick.schedule, tick.cron_version.as_deref(), false)?; + let tz = + chrono_tz::Tz::from_str(&tick.timezone).map_err(|e| Error::BadRequest(e.to_string()))?; + let just_before = (tick.scheduled_for - chrono::Duration::milliseconds(1)).with_timezone(&tz); + if sched.find_next(&just_before)? == tick.scheduled_for { + return Err(Error::BadRequest(format!( + "job {id} is the upcoming tick of schedule {}; run the schedule's script or flow \ + directly instead", + tick.path + ))); + } + Ok(()) +} + #[derive(Serialize)] struct QueuePosition { position: Option, @@ -7226,7 +7336,10 @@ pub async fn run_workflow_as_code( ) -> error::Result<(StatusCode, String)> { #[cfg(feature = "enterprise")] check_license_key_valid().await?; - check_tag_available_for_workspace(&db, &w_id, &run_query.tag, &authed).await?; + let mut extra = HashMap::new(); + extra.insert(ENTRYPOINT_OVERRIDE.to_string(), to_raw_value(&entrypoint)); + let args = PushArgs { args: &task.args.unwrap_or_else(HashMap::new), extra: Some(extra) }; + check_tag_available_for_workspace(&db, &w_id, &run_query.tag, &args, &authed).await?; check_scopes(&authed, || format!("jobs:run"))?; if !is_valid_entrypoint_name(&entrypoint) { @@ -7320,10 +7433,6 @@ pub async fn run_workflow_as_code( i += 1; } - let mut extra = HashMap::new(); - extra.insert(ENTRYPOINT_OVERRIDE.to_string(), to_raw_value(&entrypoint)); - - let args = PushArgs { args: &task.args.unwrap_or_else(HashMap::new), extra: Some(extra) }; let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(tag).or(Some(job.tag)); @@ -7360,7 +7469,7 @@ pub async fn run_workflow_as_code( tx, &w_id, job_payload, - PushArgs { args: &args.args, extra: args.extra }, + args, authed.display_username(), email, permissioned_as, @@ -7646,7 +7755,8 @@ pub async fn run_wait_result_job_by_path_get( .await?; let tag = run_query.tag.clone().or(tag); - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; + let push_args = PushArgs { args: &args.args, extra: args.extra }; + check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; let (email, permissioned_as, push_authed, tx) = if let Some(on_behalf_of) = on_behalf_authed.as_ref() { @@ -7670,7 +7780,7 @@ pub async fn run_wait_result_job_by_path_get( tx, &w_id, job_payload, - PushArgs { args: &args.args, extra: args.extra }, + push_args, authed.display_username(), email, permissioned_as, @@ -7791,7 +7901,8 @@ pub async fn run_wait_result_script_by_path_internal( .await?; let tag = run_query.tag.clone().or(tag); - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; + let push_args = PushArgs { args: &args.args, extra: args.extra }; + check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; let (email, permissioned_as, push_authed, tx) = if let Some(on_behalf_of) = on_behalf_of.as_ref() { @@ -7815,7 +7926,7 @@ pub async fn run_wait_result_script_by_path_internal( tx, &w_id, job_payload, - PushArgs { args: &args.args, extra: args.extra }, + push_args, authed.display_username(), email, permissioned_as, @@ -7904,7 +8015,8 @@ pub async fn run_wait_result_script_by_hash( check_scopes(&authed, || format!("jobs:run:scripts:{path}"))?; let tag = run_query.tag.clone().or(tag); - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; + let push_args = PushArgs { args: &args.args, extra: args.extra }; + check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; let (email, permissioned_as, push_authed, tx) = if let Some(obo) = on_behalf_of.as_ref() { ( @@ -7940,7 +8052,7 @@ pub async fn run_wait_result_script_by_hash( && has_preprocessor.unwrap_or(false), labels, }, - PushArgs { args: &args.args, extra: args.extra }, + push_args, authed.display_username(), email, permissioned_as, @@ -8365,8 +8477,9 @@ pub async fn run_wait_result_flow_by_version( /// job lives, in particular DuckDB, which runs in-process in the worker. /// /// What it does permit is any statement against the workspace's data tables, writes and DDL -/// included: the helper's body is an unrestricted SQL template and data tables carry no -/// per-user ACL. Narrowing that is a separate decision from this exemption. +/// included: the helper's body is an unrestricted SQL template. What that reaches is the +/// operator's own data table role — the preview job is permissioned as them, so the executor +/// resolves it under their tenancy like any other job. /// /// The database argument is only half the target: the executor honors a `-- database` /// directive in the SQL over it, and `-- s3` redirects the result set, so both are refused. @@ -8464,7 +8577,6 @@ async fn run_preview_script( require_path_read_access_for_preview(&authed, &preview.path)?; let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(preview.tag.clone()); - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); let preview_args = preview.args.unwrap_or_default(); @@ -8483,6 +8595,7 @@ async fn run_preview_script( } let extra = if extra.is_empty() { None } else { Some(extra) }; let push_args = PushArgs { extra, args: &preview_args }; + check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; let (uuid, tx) = push( &db, @@ -8598,7 +8711,7 @@ async fn run_inline_preview_script( #[cfg(not(feature = "run_inline"))] async fn run_inline_preview_script() -> error::Result { Err(error::Error::InternalErr( - "inline preview requires the worker feature".to_string(), + "inline preview requires the run_inline feature on the worker".to_string(), )) } @@ -8722,7 +8835,12 @@ fn register_potential_assets_on_inline_execution( .as_ref() .and_then(|args| args.get("database")) .map(|v| v.get().trim_matches('"')) - .and_then(|dt| dt.strip_prefix("datatable://")); + .and_then(|dt| dt.strip_prefix("datatable://")) + // `?role=` picks the connection, not the data table. Anything else after a `?` may be + // part of a name stored before names were restricted, so it stays. + .map(|dt| { + windmill_common::workspaces::parse_datatable_ref(dt).map_or(dt, |(name, _)| name) + }); if let Some(datatable) = datatable { let re = regex::Regex::new(r#"SET search_path TO "([^"]+)";"#).unwrap(); let (schema, content) = if let Some(captures) = re.captures(&preview.content) { @@ -8839,7 +8957,6 @@ async fn run_bundle_preview_script( let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(preview.tag.clone()); - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; let ltx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); let args = preview.args.unwrap_or_default(); @@ -8854,6 +8971,7 @@ async fn run_bundle_preview_script( m }); let push_args = PushArgs { extra, args: &args }; + check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; is_tar = match preview.kind { Some(PreviewKind::Tarbundle) => true, @@ -9505,7 +9623,6 @@ async fn run_preview_flow_job( require_path_read_access_for_preview(&authed, &raw_flow.path)?; let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(raw_flow.tag.clone()); - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); let chat_input_enabled = raw_flow.value.chat_input_enabled.unwrap_or(false); @@ -9523,6 +9640,8 @@ async fn run_preview_flow_job( to_raw_value(temp_script_refs), ); } + check_tag_available_for_workspace(&db, &w_id, &tag, &PushArgs::from(&flow_args), &authed) + .await?; let (uuid, mut tx) = push( &db, @@ -9768,11 +9887,6 @@ async fn run_dynamic_select( } } - // Same tag-permission gate a normal run gets (run_flow / push_script_job_by_path_into_queue): - // a caller allowed to read the flow must still be allowed to use its worker tag. No-op for - // inline (tag is None); the script branch checked this inside its helper and returned above. - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; - // Invoke the dyn-select entrypoint instead of `main`. let mut args = request.args.unwrap_or_default(); args.insert( @@ -9780,6 +9894,11 @@ async fn run_dynamic_select( serde_json::value::to_raw_value(&request.entrypoint_function)?, ); + // Same tag-permission gate a normal run gets (run_flow / push_script_job_by_path_into_queue): + // a caller allowed to read the flow must still be allowed to use its worker tag. No-op for + // inline (tag is None); the script branch checked this inside its helper and returned above. + check_tag_available_for_workspace(&db, &w_id, &tag, &PushArgs::from(&args), &authed).await?; + let scheduled_for = run_query.get_scheduled_for(&db).await?; let tx = PushIsolationLevel::Isolated(user_db.clone(), authed.clone().into()); @@ -9914,8 +10033,9 @@ pub async fn run_job_by_hash_inner( } let scheduled_for = run_query.get_scheduled_for(&db).await?; let tag = run_query.tag.clone().or(tag); + let push_args = PushArgs { args: &args.args, extra: args.extra }; - check_tag_available_for_workspace(&db, &w_id, &tag, &authed).await?; + check_tag_available_for_workspace(&db, &w_id, &tag, &push_args, &authed).await?; let (email, permissioned_as, push_authed, tx) = if let Some(obo) = on_behalf_of.as_ref() { ( @@ -9951,7 +10071,7 @@ pub async fn run_job_by_hash_inner( && has_preprocessor.unwrap_or(false), labels, }, - PushArgs { args: &args.args, extra: args.extra }, + push_args, authed.display_username(), email, permissioned_as, diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index b70d80140a..d6b29a8501 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -560,7 +560,7 @@ pub async fn run_server( if server_mode || mcp_mode { use mcp::{ add_www_authenticate_header, add_www_authenticate_header_gateway, - extract_workspace_from_token, + extract_workspace_from_token, reject_token_query_param, }; let (mcp_router, mcp_cancellation_token) = setup_mcp_server( db.clone(), @@ -573,15 +573,17 @@ pub async fn run_server( let workspaced_mcp_router = mcp_router .clone() .route_layer(from_extractor::()) + .layer(axum::middleware::from_fn(reject_token_query_param)) .layer(axum::middleware::from_fn(add_www_authenticate_header)) .layer(axum::middleware::from_fn(extract_and_store_workspace_id)); // Gateway MCP router — resolves workspace from token let gateway_mcp_router = mcp_router .route_layer(from_extractor::()) + .layer(axum::middleware::from_fn(extract_workspace_from_token)) + .layer(axum::middleware::from_fn(reject_token_query_param)) .layer(axum::middleware::from_fn( add_www_authenticate_header_gateway, - )) - .layer(axum::middleware::from_fn(extract_workspace_from_token)); + )); ( workspaced_mcp_router, gateway_mcp_router, @@ -729,6 +731,12 @@ pub async fn run_server( path_autocomplete::workspaced_service(), ) .nest("/raw_apps", raw_apps::workspaced_service()) + .nest( + "/remote_deploy", + windmill_api_workspaces::remote_deploy::workspaced_service( + request_size_limit * 5, + ), + ) // CORS so the opaque-origin app iframe can read // resources/list, resources/type/* with a scoped token. .nest( diff --git a/backend/windmill-api/src/live_migrations.rs b/backend/windmill-api/src/live_migrations.rs index 4c6f5e245c..334ee340a8 100644 --- a/backend/windmill-api/src/live_migrations.rs +++ b/backend/windmill-api/src/live_migrations.rs @@ -6,8 +6,9 @@ * LICENSE-AGPL for a copy of the license. */ -use sqlx::Postgres; -use windmill_common::error::Error; +use sqlx::{PgConnection, Postgres}; +use tokio::task::JoinHandle; +use windmill_common::{db::DB, error::Error}; use crate::db::CustomMigrator; use sqlx::migrate::Migrate; @@ -121,3 +122,294 @@ async fn fix_flow_versioning_migration(migrator: &mut CustomMigrator) -> Result< } Ok(()) } + +// Held for the whole background run so only one server does it at a time. +const BACKGROUND_MIGRATIONS_LOCK_ID: i64 = 4_931_072_518_336_401; + +/// Schema changes too slow to hold server startup for, run by one server at a time after the +/// sqlx migrations. Each step is recorded in `windmill_migrations` once done; a step interrupted +/// by a restart or an error starts over on the next start and must resume safely. +pub fn spawn_background_migrations( + db: DB, + mut killpill_rx: tokio::sync::broadcast::Receiver<()>, +) -> JoinHandle<()> { + tokio::spawn(async move { + tokio::select! { + r = run_background_migrations(&db) => { + if let Err(err) = r { + tracing::error!("Background migrations stopped, retrying on the next start: {err:#}"); + } + } + _ = killpill_rx.recv() => { + tracing::info!("Killpill received, stopping background migrations"); + } + } + }) +} + +async fn run_background_migrations(db: &DB) -> Result<(), Error> { + // Detached so the pool's 5min statement_timeout, lifted here for the index builds, never + // comes back with this connection; closing it also releases the advisory lock. + let mut conn = db.acquire().await?.detach(); + conn.execute("SET statement_timeout = 0").await?; + let locked = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)") + .bind(BACKGROUND_MIGRATIONS_LOCK_ID) + .fetch_one(&mut conn) + .await?; + if !locked { + return Ok(()); + } + + for step in [AUDIT_OPERATION_INDEX, RETIRE_LEGACY_AUDIT] { + if background_migration_done(&mut conn, step).await? { + continue; + } + // Steps bound the lock waits that would queue audit inserts behind them with + // lock_timeout, and resume where they stopped, so a lock timeout is retried here. + let mut attempt = 1; + loop { + let run = match step { + AUDIT_OPERATION_INDEX => create_audit_operation_index(&mut conn).await, + RETIRE_LEGACY_AUDIT => retire_legacy_audit_table(&mut conn).await, + _ => unreachable!("background migration {step} has no step function"), + }; + match run { + Ok(()) => break, + Err(err) if attempt < 10 && is_lock_timeout(&err) => { + tracing::warn!( + "Background migration {step} timed out on a lock, retrying in 30s: {err:#}" + ); + attempt += 1; + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + } + Err(err) => return Err(err), + } + } + mark_background_migration_done(&mut conn, step).await?; + } + Ok(()) +} + +const AUDIT_OPERATION_INDEX: &str = "audit_partitioned_workspace_operation_index"; +const RETIRE_LEGACY_AUDIT: &str = "retire_legacy_audit_table"; + +// Short on purpose: a statement waiting for its lock is also a wait for every audit insert +// queued behind it. +const STEP_LOCK_TIMEOUT: &str = "SET lock_timeout = '1s'"; + +fn is_lock_timeout(err: &Error) -> bool { + matches!(err, Error::SqlErr { error: sqlx::Error::Database(db_err), .. } + if db_err.code().as_deref() == Some("55P03")) +} + +async fn background_migration_done(conn: &mut PgConnection, name: &str) -> Result { + Ok(sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM windmill_migrations WHERE name = $1)", + ) + .bind(name) + .fetch_one(conn) + .await?) +} + +async fn mark_background_migration_done(conn: &mut PgConnection, name: &str) -> Result<(), Error> { + sqlx::query("INSERT INTO windmill_migrations (name) VALUES ($1) ON CONFLICT DO NOTHING") + .bind(name) + .execute(conn) + .await?; + tracing::info!("Background migration {name} done"); + Ok(()) +} + +const AUDIT_OPERATION_INDEX_KEY: &str = r#"(workspace_id, operation, id DESC, "timestamp")"#; + +/// A plain `CREATE INDEX` on the partitioned table holds a SHARE lock on every partition until the +/// whole build ends, blocking the audit insert each job push makes in its own transaction. Each +/// partition is built CONCURRENTLY instead and attached to a parent created `ON ONLY`, which turns +/// valid once all partitions are attached. Partitions created later get the index from the parent. +async fn create_audit_operation_index(conn: &mut PgConnection) -> Result<(), Error> { + // Metadata only, but it waits for every open transaction that wrote an audit row, and every + // audit insert (so every job push) queues behind it meanwhile. Give up early instead. + conn.execute(STEP_LOCK_TIMEOUT).await?; + let created = conn + .execute( + format!( + "CREATE INDEX IF NOT EXISTS ix_audit_partitioned_workspace_operation \ + ON ONLY audit_partitioned {AUDIT_OPERATION_INDEX_KEY}" + ) + .as_str(), + ) + .await; + conn.execute("RESET lock_timeout").await?; + created?; + let partitions: Vec = sqlx::query_scalar( + "SELECT c.relname::text FROM pg_inherits p JOIN pg_class c ON c.oid = p.inhrelid + WHERE p.inhparent = 'audit_partitioned'::regclass + AND NOT EXISTS ( + SELECT 1 FROM pg_inherits ip JOIN pg_index i ON i.indexrelid = ip.inhrelid + WHERE ip.inhparent = 'ix_audit_partitioned_workspace_operation'::regclass + AND i.indrelid = c.oid) + ORDER BY c.relname DESC", + ) + .fetch_all(&mut *conn) + .await?; + let quote = |name: &str| format!("\"{}\"", name.replace('"', "\"\"")); + for partition in partitions { + let index = quote(&format!( + "{partition}_workspace_id_operation_id_timestamp_idx" + )); + tracing::info!("Building ix_audit_partitioned_workspace_operation on {partition}"); + // An interrupted CONCURRENTLY build leaves an invalid index under this name. + conn.execute(format!("DROP INDEX CONCURRENTLY IF EXISTS {index}").as_str()) + .await?; + conn.execute( + format!( + "CREATE INDEX CONCURRENTLY {index} ON {} {AUDIT_OPERATION_INDEX_KEY}", + quote(&partition) + ) + .as_str(), + ) + .await?; + conn.execute( + format!( + "ALTER INDEX ix_audit_partitioned_workspace_operation ATTACH PARTITION {index}" + ) + .as_str(), + ) + .await?; + } + Ok(()) +} + +const AUDIT_COLUMNS: &str = + "workspace_id, id, timestamp, username, operation, action_kind, resource, parameters, email, span"; + +/// Moves the last 30 days of the pre-partitioning `audit` table into daily partitions and drops +/// it with anything older. An empty `audit` view takes its place for servers still running an +/// older version, which read it in a `UNION ALL` with `audit_partitioned`. +async fn retire_legacy_audit_table(conn: &mut PgConnection) -> Result<(), Error> { + let is_table = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM pg_class WHERE oid = to_regclass('audit') AND relkind = 'r')", + ) + .fetch_one(&mut *conn) + .await?; + if !is_table { + return Ok(()); + } + // Creating a partition and re-owning the sequence queue every audit insert (so every job + // push) behind them while they wait for their own lock, e.g. on a long-running reader, and + // the DROP queues older servers' reads the same way. Give up early and retry instead. + conn.execute(STEP_LOCK_TIMEOUT).await?; + + let days: Vec = sqlx::query_scalar( + "SELECT DISTINCT timestamp::date FROM audit + WHERE timestamp > now() - interval '30 days' ORDER BY 1", + ) + .fetch_all(&mut *conn) + .await?; + for day in days { + let next = day + chrono::Duration::days(1); + // Created outside any transaction: a partition created inside one keeps a lock on + // audit_partitioned that blocks every job push's audit insert until it commits. + conn.execute( + format!( + "CREATE TABLE IF NOT EXISTS \"audit_{}\" PARTITION OF audit_partitioned \ + FOR VALUES FROM ('{day}') TO ('{next}')", + day.format("%Y%m%d") + ) + .as_str(), + ) + .await?; + // One statement per day, so a row is always in exactly one of the two tables. + let moved = sqlx::query(&format!( + "WITH moved AS ( + DELETE FROM audit WHERE timestamp >= $1::date AND timestamp < $1::date + 1 + RETURNING {AUDIT_COLUMNS} + ) + INSERT INTO audit_partitioned ({AUDIT_COLUMNS}) SELECT {AUDIT_COLUMNS} FROM moved" + )) + .bind(day) + .execute(&mut *conn) + .await?; + tracing::info!( + "Moved {} legacy audit rows of {day} into audit_partitioned", + moved.rows_affected() + ); + } + + // audit_partitioned draws its ids from this sequence, which dropping its owner would drop. + // Not in the transaction below: its lock stops every insert's nextval until commit, and the + // DROP can wait there on readers of the old table. + conn.execute("ALTER SEQUENCE audit_id_seq OWNED BY audit_partitioned.id") + .await?; + let mut tx = conn.begin().await?; + // Servers older than audit partitioning still write to the table during a rolling upgrade: + // rows they added after their day was moved above are swept up under the lock. + tx.execute("LOCK TABLE audit IN ACCESS EXCLUSIVE MODE") + .await?; + tx.execute( + format!( + "WITH moved AS ( + DELETE FROM audit WHERE timestamp > now() - interval '30 days' + RETURNING {AUDIT_COLUMNS} + ) + INSERT INTO audit_partitioned ({AUDIT_COLUMNS}) SELECT {AUDIT_COLUMNS} FROM moved" + ) + .as_str(), + ) + .await?; + tx.execute("DROP TABLE audit").await?; + tx.execute( + format!("CREATE VIEW audit AS SELECT {AUDIT_COLUMNS} FROM audit_partitioned WHERE false") + .as_str(), + ) + .await?; + tx.execute("GRANT ALL ON audit TO windmill_user, windmill_admin") + .await?; + tx.commit().await?; + conn.execute("RESET lock_timeout").await?; + tracing::info!("Retired the legacy audit table"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[sqlx::test(migrations = "../migrations")] + async fn retire_legacy_audit_table_keeps_the_last_30_days(db: DB) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO audit (workspace_id, id, timestamp, username, operation, action_kind) + VALUES ('w', -2, now() - interval '3 days', 'u', 'recent', 'execute'), + ('w', -1, now() - interval '60 days', 'u', 'old', 'execute')", + ) + .execute(&db) + .await?; + + retire_legacy_audit_table(&mut *db.acquire().await?).await?; + + let moved: Vec<(i64, String)> = sqlx::query_as( + "SELECT id, operation::text FROM audit_partitioned WHERE workspace_id = 'w'", + ) + .fetch_all(&db) + .await?; + assert_eq!(moved, vec![(-2, "recent".to_string())]); + + // The id sequence outlives the table that owned it. + sqlx::query( + "INSERT INTO audit_partitioned (workspace_id, username, operation, action_kind) + VALUES ('w', 'u', 'after', 'execute')", + ) + .execute(&db) + .await?; + + // Servers on an older version still read `audit` in a UNION with audit_partitioned. + let seen: i64 = sqlx::query_scalar( + "SELECT count(*) FROM (SELECT * FROM audit_partitioned UNION ALL SELECT * FROM audit) a + WHERE workspace_id = 'w'", + ) + .fetch_one(&db) + .await?; + assert_eq!(seen, 2); + Ok(()) + } +} diff --git a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs index ffefaba2dd..1962710578 100644 --- a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs +++ b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs @@ -699,6 +699,10 @@ pub fn all_tools() -> Vec { "content": { "type": "string" }, + "schema": { + "type": "object", + "description": "JSON Schema of the arguments of `main`, which is what a run form and an MCP tool offer. Omitted (or `{}`), it is inferred from `content` for TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible scripts. For other languages, or code that does not parse, a new script gets none. A new version of an existing script also keeps what the previous version's schema says about each argument, or that whole schema when nothing can be inferred. A dbt script always takes its schema from its descriptor, whatever is sent." + }, "language": { "type": "string", "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt" @@ -757,6 +761,10 @@ is, a different one moves it there and archives the old path"), "content": { "type": "string" }, + "schema": { + "type": "object", + "description": "JSON Schema of the arguments of `main`, which is what a run form and an MCP tool offer. Omitted (or `{}`), it is inferred from `content` for TypeScript, Python, Go, Bash, PowerShell, SQL, GraphQL and Ansible scripts. For other languages, or code that does not parse, a new script gets none. A new version of an existing script also keeps what the previous version's schema says about each argument, or that whole schema when nothing can be inferred. A dbt script always takes its schema from its descriptor, whatever is sent." + }, "language": { "type": "string", "description": "Possible values: python3, deno, go, bash, powershell, postgresql, mysql, bigquery, snowflake, mssql, oracledb, graphql, nativets, bun, php, rust, ansible, csharp, nu, java, ruby, rlang, duckdb, bunnative, dbt" @@ -1024,10 +1032,12 @@ is, a different one moves it there and archives the old path"), "description": "Detailed documentation for this flow" }, "value": { - "type": "object" + "type": "object", + "description": "The flow structure containing modules and optional preprocessor/failure handlers" }, "schema": { - "type": "object" + "type": "object", + "description": "JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')" }, "path": { "type": "string" @@ -1080,10 +1090,12 @@ is, a different one moves it there and archives the old path"), "description": "Detailed documentation for this flow" }, "value": { - "type": "object" + "type": "object", + "description": "The flow structure containing modules and optional preprocessor/failure handlers" }, "schema": { - "type": "object" + "type": "object", + "description": "JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')" }, "tag": { "type": "string" diff --git a/backend/windmill-api/src/mcp/core.rs b/backend/windmill-api/src/mcp/core.rs index 480e3c0841..472c7bbec9 100644 --- a/backend/windmill-api/src/mcp/core.rs +++ b/backend/windmill-api/src/mcp/core.rs @@ -43,11 +43,14 @@ use axum::{ extract::{Extension, Path}, http::Request, middleware::Next, - response::Response, + response::{IntoResponse, Response}, routing::get, Json, Router, }; -use windmill_common::{auth::hash_token, db::GatewayWorkspaceId, error::JsonResult}; +use windmill_common::{ + auth::hash_token, db::GatewayWorkspaceId, error::JsonResult, + global_settings::MCP_DISABLE_TOKEN_QUERY_PARAM, +}; // McpAuth impl for ApiAuthed is in windmill-api-auth (same crate as the type) @@ -383,6 +386,12 @@ impl McpBackend for WindmillBackend { }) } + async fn runnable_list_fingerprint(&self, workspace_id: &str) -> BackendResult { + windmill_common::runnables::runnable_list_fingerprint(&self.db, workspace_id) + .await + .map_err(|e| ErrorData::internal_error(e.to_string(), None)) + } + fn all_endpoint_tools(&self) -> Vec { all_tools() } @@ -446,6 +455,29 @@ pub async fn add_www_authenticate_header( } } +/// Middleware refusing a credential carried in the MCP URL once the instance sets +/// `mcp_disable_token_query_param`. Sits outside everything that reads the token, so neither +/// the gateway lookup nor `ApiAuthed` ever sees it, and inside the `WWW-Authenticate` layer, +/// whose header is what sends the client into the OAuth flow instead. Refused rather than +/// ignored: the URL leaked the token whether or not the request used it. +pub async fn reject_token_query_param(request: Request, next: Next) -> Response { + let carries_token = MCP_DISABLE_TOKEN_QUERY_PARAM.load(std::sync::atomic::Ordering::Relaxed) + && request + .uri() + .query() + .is_some_and(|q| url::form_urlencoded::parse(q.as_bytes()).any(|(k, _)| k == "token")); + if carries_token { + return ( + axum::http::StatusCode::UNAUTHORIZED, + "This instance does not accept a token in the MCP URL. Remove the token query \ + parameter and let your client sign in through OAuth, or send the token in an \ + Authorization header.", + ) + .into_response(); + } + next.run(request).await +} + /// Extract the bearer token from either the `Authorization` header or the /// `?token=` query parameter (MCP clients commonly pass it in the URL). fn extract_gateway_token(request: &Request) -> Option { diff --git a/backend/windmill-api/src/mcp/mod.rs b/backend/windmill-api/src/mcp/mod.rs index 5f6bd5edb5..5545d59a9f 100644 --- a/backend/windmill-api/src/mcp/mod.rs +++ b/backend/windmill-api/src/mcp/mod.rs @@ -12,5 +12,5 @@ pub mod oauth_server; pub use core::{ add_www_authenticate_header, add_www_authenticate_header_gateway, extract_and_store_workspace_id, extract_workspace_from_token, list_tools_service, - setup_mcp_server, + reject_token_query_param, setup_mcp_server, }; diff --git a/backend/windmill-api/src/raw_apps.rs b/backend/windmill-api/src/raw_apps.rs index d7bc3bc6c0..35e4f693a4 100644 --- a/backend/windmill-api/src/raw_apps.rs +++ b/backend/windmill-api/src/raw_apps.rs @@ -5,7 +5,10 @@ * Please see the included NOTICE for copyright information and * LICENSE-AGPL for a copy of the license. */ -use crate::{db::ApiAuthed, utils::check_scopes}; +use crate::{ + db::ApiAuthed, + utils::{build_scope_path_filter, check_scopes, ScopePathFilter}, +}; use axum::{ body::Body, extract::{Extension, Json, Path, Query}, @@ -103,6 +106,31 @@ async fn list_apps( } } + // In the WHERE, not a retain after the fetch: the result is paginated, and a + // post-fetch filter would return short pages and let a page's size report how + // many raw apps the token may not read. One `?` per term, since a chained `.bind` + // substitutes into a `?` inside the value an earlier one inserted; `starts_with`, + // since LIKE reads a `_` in the path as a wildcard. + if let ScopePathFilter::Restricted { exact, prefix } = + build_scope_path_filter(&authed, "raw_apps", "read") + { + let terms: Vec = exact + .iter() + .chain(&prefix) + .map(|p| "app.path = ?".bind(p)) + .chain( + prefix + .iter() + .map(|p| "starts_with(app.path, ?)".bind(&format!("{p}/"))), + ) + .collect(); + sqlb.and_where(if terms.is_empty() { + "false".to_string() + } else { + format!("({})", terms.join(" OR ")) + }); + } + let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; let rows = sqlx::query_as::<_, ListableApp>(&sql) diff --git a/backend/windmill-api/src/tracing_init.rs b/backend/windmill-api/src/tracing_init.rs index 56adeee184..d99a7e5907 100644 --- a/backend/windmill-api/src/tracing_init.rs +++ b/backend/windmill-api/src/tracing_init.rs @@ -14,6 +14,7 @@ use axum::response::Response as AxumResponse; use hyper::Response; use tower_http::trace::{MakeSpan, OnFailure, OnResponse}; use uuid::Uuid; +use windmill_api_workspaces::remote_deploy::RedactedUri; use windmill_common::log_context::{with_log_context, LogContext}; lazy_static::lazy_static! { @@ -86,7 +87,7 @@ impl MakeSpan for MyMakeSpan { tracing::error_span!( "request", method = %request.method(), - uri = %request.uri(), + uri = %RedactedUri(request.uri()), username = field::Empty, workspace_id = field::Empty, traceId = tracing_id, @@ -113,7 +114,7 @@ pub async fn log_context_middleware(request: Request, next: Next) -> AxumRespons let ctx = LogContext { method: Some(request.method().to_string()), - uri: Some(request.uri().to_string()), + uri: Some(RedactedUri(request.uri()).to_string()), trace_id, ..Default::default() }; diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index 4dd073b3af..a7f9aeda89 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -352,6 +352,17 @@ async fn update_username_in_workpsace<'c>( new_username: &str, w_id: &str, ) -> error::Result<()> { + // ---- data table tenants ---- + // Tenants name the user, so the rename has to follow here too; a list left naming the old + // username silently drops the access instead of moving it. + windmill_common::workspaces::rename_datatable_tenant_in_workspace( + tx, + w_id, + &format!("u/{old_username}"), + &format!("u/{new_username}"), + ) + .await?; + // ---- instance and workspace users ---- sqlx::query!( "UPDATE usr SET username = $1 WHERE email = $2", diff --git a/backend/windmill-api/src/workspaces.rs b/backend/windmill-api/src/workspaces.rs index a0a27eab4c..186cfbc291 100644 --- a/backend/windmill-api/src/workspaces.rs +++ b/backend/windmill-api/src/workspaces.rs @@ -108,6 +108,7 @@ async fn edit_copilot_config( } ai_config.validate_model_pricing()?; + ai_config.validate_token_limits()?; ai_config.validate_sessions_retention()?; let mut tx = db.begin().await?; diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index 69565ffcbd..ea79aaa572 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -281,8 +281,29 @@ enum ArchiveImpl { Tar(tokio_tar::Builder), } +/// Entry paths come from item paths stored in the workspace; an absolute or +/// drive-prefixed path, or a parent segment, would make extraction write outside +/// the target directory. Win32 strips trailing dots and spaces from a segment, +/// so `.. ` resolves to `..` there: any segment made only of those is refused. +/// A colon matters only in the first segment (a drive prefix); later segments, +/// such as a data table name, may carry one. +fn check_archive_entry_path(path: &str) -> Result<()> { + let is_dot_segment = |seg: &str| !seg.is_empty() && seg.chars().all(|c| c == '.' || c == ' '); + let first = path.split(['/', '\\']).next().unwrap_or_default(); + if path.starts_with(['/', '\\']) + || first.contains(':') + || path.split(['/', '\\']).any(is_dot_segment) + { + return Err(Error::internal_err(format!( + "refusing to write archive entry with path traversal: {path}" + ))); + } + Ok(()) +} + impl ArchiveImpl { async fn write_to_archive(&mut self, content: &str, path: &str) -> Result<()> { + check_archive_entry_path(path)?; match self { ArchiveImpl::Tar(t) => { let bytes = content.as_bytes(); @@ -1425,9 +1446,17 @@ pub(crate) async fn tarball_workspace( use strum::IntoEnumIterator; for service_name in ServiceName::iter() { - let native_triggers = - list_native_triggers(&mut *tx, &w_id, service_name, None, None, None, None) - .await?; + let native_triggers = list_native_triggers( + &mut *tx, + &w_id, + service_name, + None, + None, + None, + None, + windmill_api_auth::ScopePathFilter::AllowAll, + ) + .await?; // Native triggers (Nextcloud, Google Drive, GitHub) are never // cloned into a fork — a fork only has one if its owner created @@ -1639,7 +1668,9 @@ pub(crate) async fn tarball_workspace( mute_critical_alerts: row.mute_critical_alerts, color: row.color.clone(), operator_settings: row.operator_settings.clone(), - datatable: row.datatable.clone(), + datatable: windmill_common::workspaces::strip_datatable_permissions( + row.datatable.clone(), + ), slack_team_id: row.slack_team_id.clone(), slack_name: row.slack_name.clone(), slack_command_script: row.slack_command_script.clone(), @@ -1703,7 +1734,7 @@ pub(crate) async fn tarball_workspace( mute_critical_alerts: row.mute_critical_alerts, color: row.color, operator_settings: row.operator_settings, - datatable: row.datatable, + datatable: windmill_common::workspaces::strip_datatable_permissions(row.datatable), slack_team_id: row.slack_team_id, slack_name: row.slack_name, slack_command_script: row.slack_command_script, @@ -1784,6 +1815,41 @@ pub(crate) async fn tarball_workspace( Ok((headers, body)) } +#[cfg(test)] +mod archive_entry_path_tests { + use super::check_archive_entry_path; + + #[test] + fn rejects_traversal_and_absolute_paths() { + for ok in [ + "u/admin/app.app.json", + "f/x/a..b.script.json", + "settings.yaml", + "migrations/datatable/foo:bar/20260617120000_name.up.sql", + ] { + assert!( + check_archive_entry_path(ok).is_ok(), + "{ok} should be accepted" + ); + } + for bad in [ + "f/x/../../evil.app.json", + "../evil", + "/etc/evil", + "f\\..\\evil", + "f/x/.. /.. /evil.app.json", + "f/x/.../evil", + "C:/evil", + "\\evil", + ] { + assert!( + check_archive_entry_path(bad).is_err(), + "{bad} should be rejected" + ); + } + } +} + #[cfg(test)] mod fork_export_tests { use super::*; diff --git a/backend/windmill-common/Cargo.toml b/backend/windmill-common/Cargo.toml index 06de7cc4aa..e95fadfe66 100644 --- a/backend/windmill-common/Cargo.toml +++ b/backend/windmill-common/Cargo.toml @@ -18,7 +18,7 @@ parquet = [] aws_auth = ["dep:aws-sdk-sts", "dep:aws-config"] otel = ["dep:opentelemetry-semantic-conventions", "dep:opentelemetry-otlp", "dep:opentelemetry_sdk", "dep:tracing-opentelemetry", "dep:opentelemetry-appender-tracing", "dep:tonic", "dep:opentelemetry"] -smtp = ["dep:mail-send"] +smtp = ["dep:mail-send", "dep:rustls", "dep:tokio-rustls", "dep:rustls-native-certs", "dep:webpki-roots"] scoped_cache = [] cloud = [] dev_override = [] @@ -85,6 +85,10 @@ aws-sdk-rds = { workspace = true, optional = true } indexmap.workspace = true bytes.workspace = true mail-send = { workspace = true, optional = true } +rustls = { workspace = true, optional = true } +tokio-rustls = { workspace = true, optional = true } +rustls-native-certs = { workspace = true, optional = true } +webpki-roots = { workspace = true, optional = true } futures-core.workspace = true async-stream.workspace = true const_format.workspace = true @@ -136,6 +140,7 @@ equivalent = "1.0.2" [dev-dependencies] # `test-util` is not part of tokio's `full`; it is what lets tests pause the clock. tokio = { workspace = true, features = ["test-util"] } +rcgen.workspace = true [target.'cfg(not(target_env = "msvc"))'.dependencies] tikv-jemalloc-ctl = { optional = true, workspace = true } diff --git a/backend/windmill-common/src/auth.rs b/backend/windmill-common/src/auth.rs index 3e8eb58a14..86b7f173ac 100644 --- a/backend/windmill-common/src/auth.rs +++ b/backend/windmill-common/src/auth.rs @@ -20,7 +20,8 @@ use crate::{ /// Whether `label` denotes a user-created token rather than a system token /// (`session`, `guest_session`, `ephemeral*`, `debugger-token`, `mcp-oauth-*`, -/// `embed_app:*`, `sdk_app:*`, `impersonation:*`). System-token labels are load-bearing — +/// `embed_app:*`, `sdk_app:*`, `impersonation:*`, `cli-login:*`, `remote-deploy:*`). +/// System-token labels are load-bearing — /// session cleanup, super_admin propagation, expiry notifications and username overrides /// all key off them — so they must not be user-editable. `None` (no label) is treated as /// a user token. @@ -47,10 +48,18 @@ pub fn is_user_token(label: Option<&str>) -> bool { && !l.starts_with(APP_EMBED_TOKEN_LABEL_PREFIX) && !l.starts_with(RAW_APP_SDK_TOKEN_LABEL_PREFIX) && !l.starts_with("impersonation:") + && !l.starts_with(CLI_LOGIN_TOKEN_LABEL_PREFIX) + && !l.starts_with(REMOTE_DEPLOY_TOKEN_LABEL_PREFIX) } } } +/// How far ahead of a user token's expiration its owner is warned (`check_expiring_tokens` in +/// the monitor). A token whose whole lifetime fits in this window gets no warning at all: it +/// would arrive minutes after creation, about a lifetime its creator just picked. Its +/// "expired and deleted" notice still goes out. +pub const TOKEN_EXPIRY_WARNING_DAYS: i32 = 7; + /// Label prefix, followed by the app path, of the token an app viewer's sandboxed iframe /// runs with. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out. pub const APP_EMBED_TOKEN_LABEL_PREFIX: &str = "embed_app:"; @@ -59,6 +68,21 @@ pub const APP_EMBED_TOKEN_LABEL_PREFIX: &str = "embed_app:"; /// frontend SDK. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out. pub const RAW_APP_SDK_TOKEN_LABEL_PREFIX: &str = "sdk_app:"; +/// Label prefix, followed by the username, of the token the CLI authorization page mints for +/// `wmill` logins. Reserved in [`is_user_token`], whose SQL and frontend mirrors spell it out: +/// the CLI signs in again on its own once that token expires, so an expiry email for it asks +/// the user to do nothing. Not in [`is_server_minted_label`], since the page mints it through +/// `/users/tokens/create`. +pub const CLI_LOGIN_TOKEN_LABEL_PREFIX: &str = "cli-login:"; + +/// Label prefix, followed by the host of the instance deploying with it, of the token the +/// `/user/remote_deploy_authorize` page mints for another Windmill instance. Reserved in +/// [`is_user_token`], whose SQL and frontend mirrors spell it out: it is renewed by connecting +/// again from the deploying instance, which reports this one rejecting it, so an expiry email or +/// alert here points at the wrong place. Not in [`is_server_minted_label`], since the page mints it +/// through `/users/tokens/create`. +pub const REMOTE_DEPLOY_TOKEN_LABEL_PREFIX: &str = "remote-deploy:"; + /// Whether `label` belongs to a namespace only the server mints, and which therefore must be /// rejected by `create_token`. Narrower than [`is_user_token`], which also drives label /// editability and expiry notifications and can afford to reserve more: `Ephemeral lsp token`, @@ -475,14 +499,16 @@ async fn fetch_authed_from_permissioned_as_inner( // principal — a username freed and reassigned while its previous holder keeps a privileged // account — would mix one account's role with another's instance privileges. let member = match permissioned_as.split_once('/') { - Some(("u", name)) => sqlx::query!( - "SELECT is_admin, operator, email FROM usr where username = $1 AND \ + Some(("u", name)) => { + sqlx::query!( + "SELECT is_admin, operator, email FROM usr where username = $1 AND \ workspace_id = $2 AND disabled = false", - name, - &w_id - ) - .fetch_optional(&mut *conn) - .await?, + name, + &w_id + ) + .fetch_optional(&mut *conn) + .await? + } _ => None, }; let resolved_email; @@ -980,6 +1006,8 @@ mod tests { assert!(!is_user_token(Some("embed_app:f/team/dashboard"))); assert!(!is_user_token(Some("sdk_app:u/admin/raw app"))); assert!(!is_user_token(Some("impersonation:admin@windmill.dev"))); + assert!(!is_user_token(Some("cli-login:admin"))); + assert!(!is_user_token(Some("remote-deploy:windmill.example.com"))); } #[test] diff --git a/backend/windmill-common/src/datatable_roles.rs b/backend/windmill-common/src/datatable_roles.rs new file mode 100644 index 0000000000..a9195055ac --- /dev/null +++ b/backend/windmill-common/src/datatable_roles.rs @@ -0,0 +1,362 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! The instance's data table role catalog. +//! +//! A data table role is a real Postgres login role on the Windmill cluster, named exactly as the +//! user named it, shared by every instance database. Windmill decides who may ask for a role (the +//! per-data-table tenant lists in [`crate::workspaces`]); Postgres decides what the role may then +//! touch. The catalog here is only the first half's vocabulary plus the cluster provisioning. +//! +//! Entries are keyed by a generated id so a rename moves nothing else: tenants name the id. + +use std::collections::BTreeMap; + +use crate::{ + error::{Error, Result}, + DB, +}; + +/// The connection every data table resolved to before roles existed (`custom_instance_user`). It +/// owns every pre-existing object, so it is a reserved name rather than a catalog entry: never +/// created, renamed or dropped. +pub const ADMIN_DATATABLE_ROLE: &str = "admin"; + +/// The login the admin connection uses, and the role every created role is granted to — that +/// membership is what later lets it `ALTER ... OWNER TO` a role and drop it. +pub const CUSTOM_INSTANCE_USER: &str = "custom_instance_user"; + +/// One catalog entry, as stored in `datatable_role`. The password is per role and instance-wide; +/// it belongs to the instance, not to any workspace's settings. +/// No `Serialize`/`Deserialize`: the catalog is rows now, and a derived `Serialize` would emit +/// `pwd` — the same way out for a credential that the hand-written `Debug` below closes on the log +/// side. +#[derive(Clone)] +pub struct InstanceDatatableRole { + /// The Postgres role name, verbatim. + pub name: String, + pub enabled: bool, + /// Absent only for a role whose provisioning did not finish; resolving as it then errors + /// rather than falling back to admin. + /// + /// A plain string rather than a `StringOrSecretRef` like the instance user's password: that + /// one is a secret ref because an operator supplies it and may want it to come from their own + /// backend, while this one is minted here and never entered by anyone, so there is nothing for + /// a ref to point at. Encrypting generated secrets at rest is a separate change that would + /// take the replication password with it. + pub pwd: Option, +} + +/// Hand-written so `{:?}` on a catalog cannot put a live Postgres password in a log line or an +/// audit record. Everything else about the entry is safe to print. +impl std::fmt::Debug for InstanceDatatableRole { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("InstanceDatatableRole") + .field("name", &self.name) + .field("enabled", &self.enabled) + .field("pwd", &self.pwd.as_ref().map(|_| "")) + .finish() + } +} + +pub type DatatableRoleCatalog = BTreeMap; + +/// Names Postgres or Windmill already owns. `admin` is excluded because it never reaches the +/// cluster as a role name at all — it resolves to `custom_instance_user`. +fn is_reserved_role_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + lower == ADMIN_DATATABLE_ROLE + || lower == "postgres" + || lower == "public" + || lower.starts_with("pg_") + || lower.starts_with("windmill_") + || lower.starts_with("custom_instance_") +} + +/// The charset is what makes every downstream interpolation safe: the name reaches Postgres as a +/// quoted identifier, a `-- role ` annotation, and a `?role=` query parameter. +pub fn validate_role_name(name: &str) -> Result<()> { + if name.is_empty() || name.len() > 63 { + return Err(Error::BadRequest(format!( + "Invalid data table role name '{name}': it must be between 1 and 63 characters" + ))); + } + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') + { + return Err(Error::BadRequest(format!( + "Invalid data table role name '{name}': only letters, digits, '_' and '-' are allowed" + ))); + } + if is_reserved_role_name(name) { + return Err(Error::BadRequest(format!( + "'{name}' is reserved and cannot be used as a data table role name" + ))); + } + Ok(()) +} + +/// A double-quoted Postgres identifier. Doubling `"` is Postgres's own escaping inside one, so this +/// quotes any name — schema, table or role. Role names are validated as well +/// ([`validate_role_name`]) because they also travel unquoted, in `-- role ` and `?role=`. +pub fn quote_ident(name: &str) -> String { + format!("\"{}\"", name.replace('"', "\"\"")) +} + +/// Serialize the mutations that are not already serialized by the row itself. +/// +/// A create is an insert and a delete is a delete, which Postgres orders for us — the unique index +/// on `name` is what makes two concurrent creates of the same name one winner and one error. What +/// still needs it is the window between the cluster DDL and the row: `CREATE ROLE` is not visible +/// to another transaction's `pg_roles` check until commit, so without this two creates of the same +/// name both pass their existence check and one fails on the index having already made the login. +/// Held for the transaction, so the DDL has to run on that same transaction to be covered. +pub async fn lock_role_catalog(tx: &mut sqlx::Transaction<'_, sqlx::Postgres>) -> Result<()> { + sqlx::query!("SELECT pg_advisory_xact_lock(hashtext('datatable_role_catalog'))") + .execute(&mut **tx) + .await?; + Ok(()) +} + +/// A replication stream reads every row whatever a data table's roles grant. Turning roles on looks +/// for streams holding this exclusive; whatever can start a Postgres trigger or capture streaming +/// holds it shared on the transaction that commits it. So either the look sees the stream, or the +/// stream's listener connects after roles are committed and refuses. Held for the transaction. +pub async fn lock_datatable_streams(conn: &mut sqlx::PgConnection, exclusive: bool) -> Result<()> { + let lock = if exclusive { + "pg_advisory_xact_lock" + } else { + "pg_advisory_xact_lock_shared" + }; + sqlx::query(&format!("SELECT {lock}(hashtext('datatable_streams'))")) + .execute(conn) + .await?; + Ok(()) +} + +/// Whether an instance database is reached only through entries under roles is decided by two +/// writes that lock different workspaces' settings rows: turning roles on for one entry, and a +/// settings save pointing an entry without roles at the database. Each holds this for every +/// database it decides on, so neither reads past the other's uncommitted write. Held for the +/// transaction; the names are locked in sorted order so two holders cannot deadlock. +pub async fn lock_instance_databases_governance<'a>( + conn: &mut sqlx::PgConnection, + dbnames: impl IntoIterator, +) -> Result<()> { + let dbnames: std::collections::BTreeSet<&str> = dbnames.into_iter().collect(); + for dbname in dbnames { + sqlx::query("SELECT pg_advisory_xact_lock(hashtext('datatable_instance_database:' || $1))") + .bind(dbname) + .execute(&mut *conn) + .await?; + } + Ok(()) +} + +/// Disclosure: returns every role's stored Postgres password in plaintext. Any server path that +/// has to resolve or name a role may call it — including handlers open to a workspace member, who +/// need the names — but callers MUST NOT let `pwd` reach a response, a log line, an audit record +/// or an export. Nothing about who may call it: the credential is the whole risk, and `Debug` is +/// hand-written to redact it for the same reason. +pub async fn read_role_catalog(db: &DB) -> Result { + crate::datatable_roles_oss::read_role_catalog(db).await +} + +/// As [`read_role_catalog`], reading inside the caller's transaction so the value is the one +/// [`lock_role_catalog`] is protecting. Same disclosure contract. +pub async fn read_role_catalog_tx( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result { + crate::datatable_roles_oss::read_role_catalog_tx(tx).await +} + +/// Record a role, in the caller's transaction so it commits with the `CREATE ROLE` it describes. +/// +/// Authorization: writes a generated Postgres credential. Callers MUST restrict this to superadmin +/// paths and MUST hold [`lock_role_catalog`] on `tx`. +pub async fn insert_role_catalog_entry( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + id: &str, + role: &InstanceDatatableRole, +) -> Result<()> { + crate::datatable_roles_oss::insert_role_catalog_entry(tx, id, role).await +} + +/// Update a role's recorded name, login flag and password. Same contract as +/// [`insert_role_catalog_entry`]. +pub async fn update_role_catalog_entry( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + id: &str, + role: &InstanceDatatableRole, +) -> Result<()> { + crate::datatable_roles_oss::update_role_catalog_entry(tx, id, role).await +} + +/// Forget a role. Same contract as [`insert_role_catalog_entry`]; run it in the transaction that +/// drops the cluster login, so the two cannot disagree. +pub async fn delete_role_catalog_entry( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + id: &str, +) -> Result<()> { + crate::datatable_roles_oss::delete_role_catalog_entry(tx, id).await +} + +/// Resolve the role a caller named to its catalog id. A disabled role is an error rather than a +/// silent fallback: the caller asked for something the instance deliberately turned off. +pub fn role_id_by_name<'a>(catalog: &'a DatatableRoleCatalog, name: &str) -> Result<&'a str> { + let entry = catalog + .iter() + .find(|(_, role)| role.name == name) + .ok_or_else(|| { + Error::NotFound(format!( + "'{name}' is not a data table role of this instance. Defined roles: {}.", + catalog + .values() + .map(|r| r.name.as_str()) + .collect::>() + .join(", ") + )) + })?; + if !entry.1.enabled { + return Err(Error::BadRequest(format!( + "Data table role '{name}' is disabled on this instance" + ))); + } + Ok(entry.0.as_str()) +} + +/// Every instance database the registry knows about. Role provisioning has to reach all of them: +/// a role that cannot `CONNECT` to a database is refused by Postgres before any grant matters. +/// +/// Authorization: checks nothing, and names every instance database across all workspaces. Callers +/// MUST be superadmin-gated or keep the names server-side; never return them to a workspace caller. +pub async fn registered_instance_databases(db: &DB) -> Result> { + crate::datatable_roles_oss::registered_instance_databases(db).await +} + +/// `CONNECT` on `dbname` for every enabled role, and none for `PUBLIC`. Run at role creation, at +/// database creation, and lazily whenever an instance data table is administered, so a database +/// provisioned before a role existed is repaired rather than left silently unreachable. +/// +/// Authorization: rewrites a database's ACL with the server's own credentials and checks nothing. +/// Callers MUST have authorized administration of `dbname` — superadmin, or an admin of the +/// workspace governing a data table on it. +pub async fn converge_connect_grants(db: &DB, dbname: &str) -> Result<()> { + crate::datatable_roles_oss::converge_connect_grants(db, dbname).await +} + +/// As [`converge_connect_grants`], with a catalog the caller already read. Same contract. +pub async fn converge_connect_grants_with( + db: &DB, + dbname: &str, + catalog: &DatatableRoleCatalog, +) -> Result<()> { + crate::datatable_roles_oss::converge_connect_grants_with(db, dbname, catalog).await +} + +/// `CREATE ROLE LOGIN PASSWORD ...; GRANT TO custom_instance_user`, and `CONNECT` on +/// every registered database. No privileges beyond that — an admin grants them through SQL or the +/// ACL editor. +/// +/// Authorization: creates a cluster-wide Postgres login. Callers MUST restrict this to superadmin +/// paths, and MUST hold [`lock_role_catalog`] on the same transaction. +pub async fn create_instance_role( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + name: &str, + password: &str, +) -> Result<()> { + crate::datatable_roles_oss::create_instance_role(tx, name, password).await +} + +/// Authorization: alters a cluster-wide Postgres login. Callers MUST restrict this to superadmin +/// paths, and MUST hold [`lock_role_catalog`] on the same transaction. +pub async fn set_instance_role_login( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + name: &str, + enabled: bool, +) -> Result<()> { + crate::datatable_roles_oss::set_instance_role_login(tx, name, enabled).await +} + +/// A rename discards an md5-hashed password, so the caller has to hand over a fresh one. +/// +/// Authorization: renames a cluster-wide Postgres login. Callers MUST restrict this to superadmin +/// paths, and MUST hold [`lock_role_catalog`] on the same transaction. +pub async fn rename_instance_role( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + from: &str, + to: &str, + password: &str, +) -> Result<()> { + crate::datatable_roles_oss::rename_instance_role(tx, from, to, password).await +} + +/// A role owning anything in any database blocks its own `DROP ROLE`, and both its objects and the +/// privileges granted to it are only visible from inside each database — hence the pass over the +/// registry. An unreachable database aborts the whole delete: dropping the role while one database +/// still holds objects owned by it leaves those objects owned by a numeric OID nobody can name. +/// +/// Each pass runs as the instance's own Postgres user rather than `custom_instance_user`, which +/// owns the databases and can therefore revoke a grant whoever made it. `custom_instance_user` +/// could only undo what it granted itself, so a privilege planted by an operator in psql — the +/// ordinary way privileges reach a role — would survive and block the drop. +/// +/// Authorization: drops a cluster-wide Postgres login and reassigns everything it owns. Callers +/// MUST restrict this to superadmin paths, and MUST hold [`lock_role_catalog`] on `tx`. +/// +/// The per-database passes open their own connections and cannot join `tx`; the lock is what keeps +/// a concurrent mutation out while they run. Only the final `DROP ROLE` is on `tx`, so it commits +/// or rolls back with the catalog write that forgets the role. Those passes commit as they go, so +/// callers MUST have disabled the role in an earlier committed transaction: a failure part-way +/// then leaves a disabled role to retry, not an enabled one already stripped in some databases. +pub async fn drop_instance_role( + db: &DB, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + name: &str, +) -> Result<()> { + crate::datatable_roles_oss::drop_instance_role(db, tx, name).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn role_names_are_validated() { + assert!(validate_role_name("analytics").is_ok()); + assert!(validate_role_name("read-only_2").is_ok()); + assert!(validate_role_name("").is_err()); + assert!(validate_role_name(&"a".repeat(64)).is_err()); + assert!(validate_role_name("has space").is_err()); + assert!(validate_role_name("quote\"injection").is_err()); + // Reserved, case-insensitively. + assert!(validate_role_name("admin").is_err()); + assert!(validate_role_name("Postgres").is_err()); + assert!(validate_role_name("pg_read_all_data").is_err()); + assert!(validate_role_name("windmill_user").is_err()); + assert!(validate_role_name("custom_instance_user").is_err()); + } + + #[test] + fn a_disabled_role_is_an_error_not_a_fallback() { + let mut catalog = DatatableRoleCatalog::new(); + catalog.insert( + "id1".to_string(), + InstanceDatatableRole { + name: "analytics".to_string(), + enabled: false, + pwd: Some("x".to_string()), + }, + ); + assert!(role_id_by_name(&catalog, "analytics").is_err()); + assert!(role_id_by_name(&catalog, "nope").is_err()); + catalog.get_mut("id1").unwrap().enabled = true; + assert_eq!(role_id_by_name(&catalog, "analytics").unwrap(), "id1"); + } +} diff --git a/backend/windmill-common/src/datatable_roles_oss.rs b/backend/windmill-common/src/datatable_roles_oss.rs new file mode 100644 index 0000000000..2a3f9dda48 --- /dev/null +++ b/backend/windmill-common/src/datatable_roles_oss.rs @@ -0,0 +1,209 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Where data table roles come from: the enterprise implementation, or a refusal. +//! +//! Roles are an Enterprise Edition feature. An edition without them creates, grants and connects +//! as none, and a data table saved under roles — by an enterprise build, before a downgrade — is +//! refused rather than resolved as `admin`. A data table not under roles, asked for no role, +//! resolves as it always has. `private` alone is not that edition: community builds carry it. + +use crate::error::Error; + +/// What every roles path answers without the Enterprise Edition. The frontend matches this exact +/// sentence (`datatableUsableRoles.ts`) to read the refusal as "not under roles": reword both. +pub fn datatable_roles_unavailable() -> Error { + Error::BadRequest("Data table roles are a Windmill Enterprise Edition feature".to_string()) +} + +#[cfg(all(feature = "private", feature = "enterprise"))] +pub(crate) use crate::datatable_roles_ee::{ + can_use_datatable_role, can_use_datatable_role_in_governing_workspace, converge_connect_grants, + converge_connect_grants_with, create_instance_role, delete_role_catalog_entry, + drop_instance_role, ensure_can_use_datatable_role, ensure_datatable_admin_access, + ensure_instance_db_grant_options_unchecked, forget_datatable_role_everywhere, + insert_role_catalog_entry, read_role_catalog, read_role_catalog_tx, + registered_instance_databases, rename_instance_role, resolve_datatable_role_connection, + set_instance_role_login, update_role_catalog_entry, +}; + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +pub(crate) use ce::*; + +#[cfg(not(all(feature = "private", feature = "enterprise")))] +mod ce { + use super::datatable_roles_unavailable as unavailable; + use crate::{ + datatable_roles::{DatatableRoleCatalog, InstanceDatatableRole}, + db::AuthedRef, + error::Result, + workspaces::{ + resolve_governing_datatable, DataTableRoleTenants, DatatableAccess, GoverningDatatable, + }, + DB, + }; + + type Tx<'a> = sqlx::Transaction<'a, sqlx::Postgres>; + + pub(crate) async fn read_role_catalog(_db: &DB) -> Result { + Err(unavailable()) + } + + pub(crate) async fn read_role_catalog_tx(_tx: &mut Tx<'_>) -> Result { + Err(unavailable()) + } + + pub(crate) async fn insert_role_catalog_entry( + _tx: &mut Tx<'_>, + _id: &str, + _role: &InstanceDatatableRole, + ) -> Result<()> { + Err(unavailable()) + } + + pub(crate) async fn update_role_catalog_entry( + _tx: &mut Tx<'_>, + _id: &str, + _role: &InstanceDatatableRole, + ) -> Result<()> { + Err(unavailable()) + } + + pub(crate) async fn delete_role_catalog_entry(_tx: &mut Tx<'_>, _id: &str) -> Result<()> { + Err(unavailable()) + } + + pub(crate) async fn registered_instance_databases(_db: &DB) -> Result> { + Err(unavailable()) + } + + /// Nothing to converge: with no roles to admit, an instance database keeps the `CONNECT` + /// grants it was created with, `PUBLIC`'s included, as it did before roles existed. + pub(crate) async fn converge_connect_grants(_db: &DB, _dbname: &str) -> Result<()> { + Ok(()) + } + + /// As [`converge_connect_grants`]. + pub(crate) async fn converge_connect_grants_with( + _db: &DB, + _dbname: &str, + _catalog: &DatatableRoleCatalog, + ) -> Result<()> { + Ok(()) + } + + pub(crate) async fn create_instance_role( + _tx: &mut Tx<'_>, + _name: &str, + _password: &str, + ) -> Result<()> { + Err(unavailable()) + } + + pub(crate) async fn set_instance_role_login( + _tx: &mut Tx<'_>, + _name: &str, + _enabled: bool, + ) -> Result<()> { + Err(unavailable()) + } + + pub(crate) async fn rename_instance_role( + _tx: &mut Tx<'_>, + _from: &str, + _to: &str, + _password: &str, + ) -> Result<()> { + Err(unavailable()) + } + + pub(crate) async fn drop_instance_role(_db: &DB, _tx: &mut Tx<'_>, _name: &str) -> Result<()> { + Err(unavailable()) + } + + pub(crate) async fn ensure_instance_db_grant_options_unchecked( + _db: &DB, + _dbname: &str, + ) -> Result<()> { + Err(unavailable()) + } + + /// No tenant list covers anyone: there is no role to connect as. + pub(crate) fn can_use_datatable_role( + _tenants: &DataTableRoleTenants, + _authed: &AuthedRef<'_>, + ) -> bool { + false + } + + pub(crate) async fn can_use_datatable_role_in_governing_workspace( + _db: &DB, + _governing_w_id: &str, + _w_id: &str, + _tenants: &DataTableRoleTenants, + _access: &DatatableAccess<'_>, + ) -> Result { + Err(unavailable()) + } + + /// Reached only for a data table under roles or a caller naming a role: both are refused. + pub(crate) async fn resolve_datatable_role_connection( + _db: &DB, + _w_id: &str, + _name: &str, + _governing: &GoverningDatatable, + _db_resource: serde_json::Value, + _role: Option<&str>, + _access: DatatableAccess<'_>, + ) -> Result { + Err(unavailable()) + } + + /// A data table not under roles, asked for no role or for `admin`, is not a role decision and + /// passes, as it did before roles existed. Anything else is refused. + pub(crate) async fn ensure_can_use_datatable_role( + db: &DB, + w_id: &str, + name: &str, + role: Option<&str>, + _access: &DatatableAccess<'_>, + _context: &str, + ) -> Result<()> { + let governing = resolve_governing_datatable(db, w_id, name).await?; + if governing.datatable.permissions.is_none() + && role.is_none_or(|r| r == crate::datatable_roles::ADMIN_DATATABLE_ROLE) + { + Ok(()) + } else { + Err(unavailable()) + } + } + + /// A data table not under roles is the `admin` connection for anyone who reaches it, as before + /// roles existed. One under roles is refused. + pub(crate) async fn ensure_datatable_admin_access( + db: &DB, + w_id: &str, + name: &str, + _access: &DatatableAccess<'_>, + ) -> Result<()> { + let governing = resolve_governing_datatable(db, w_id, name).await?; + if governing.datatable.permissions.is_none() { + Ok(()) + } else { + Err(unavailable()) + } + } + + pub(crate) async fn forget_datatable_role_everywhere( + _tx: &mut Tx<'_>, + _role_id: &str, + ) -> Result<()> { + Err(unavailable()) + } +} diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index ef63fc2347..e72a2bb4e2 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -102,6 +102,49 @@ pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret"; pub const AUTOMATE_USERNAME_CREATION_SETTING: &str = "automate_username_creation"; pub const DISABLE_WORKSPACE_INVITE_EMAILS_SETTING: &str = "disable_workspace_invite_emails"; pub const DISABLE_PASSWORD_LOGIN_SETTING: &str = "disable_password_login"; +/// Refuse `?token=` on the MCP endpoints, leaving the `Authorization` header as the only way +/// in. A URL-borne credential ends up in browser history, proxy logs and referrers, so an +/// instance that cares sends MCP clients through the OAuth flow instead. +pub const MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING: &str = "mcp_disable_token_query_param"; +/// Ceiling, in days, on how far ahead a token minted through `POST /users/tokens/create` or +/// `POST /users/tokens/impersonate` may expire; a request asking for more, or for no +/// expiration at all, is shortened to it rather than refused. On those routes only: server-side +/// mints (webhook tokens, app embed tokens, sessions) choose a lifetime the caller never picks +/// and go straight to `create_token_internal`. Read and validated by +/// [`parse_max_token_expiration_days`]. +pub const MAX_TOKEN_EXPIRATION_DAYS_SETTING: &str = "max_token_expiration_days"; +/// Largest `max_token_expiration_days` read as a ceiling, about 2,700 years. The token form +/// applies the same bound (`frontend/src/lib/tokenExpiration.ts`) so that it and the server +/// agree on whether a ceiling exists. +pub const MAX_TOKEN_EXPIRATION_DAYS_BOUND: i64 = 1_000_000; + +/// Reads a stored `max_token_expiration_days`: `Ok(None)` when unset or cleared (null or an +/// empty string), the ceiling for a whole number of days within +/// `1..=MAX_TOKEN_EXPIRATION_DAYS_BOUND` stored as an integer, an integral float or a string of +/// digits, and an error for anything else. +/// +/// The settings API and config sync both reject the error at write time: the token routes can +/// only read an unparseable value as no ceiling, so accepting a typo would silently turn the +/// policy off. `parseMaxTokenExpirationDays` in the frontend must accept exactly the same values. +pub fn parse_max_token_expiration_days( + value: Option<&serde_json::Value>, +) -> Result, String> { + let days = match value { + None | Some(serde_json::Value::Null) => return Ok(None), + Some(serde_json::Value::String(s)) if s.trim().is_empty() => return Ok(None), + Some(serde_json::Value::Number(n)) => n + .as_i64() + .or_else(|| n.as_f64().filter(|f| f.fract() == 0.0).map(|f| f as i64)), + Some(serde_json::Value::String(s)) => s.trim().parse::().ok(), + Some(_) => None, + }; + match days { + Some(days) if (1..=MAX_TOKEN_EXPIRATION_DAYS_BOUND).contains(&days) => Ok(Some(days)), + _ => Err(format!( + "must be a whole number of days from 1 to {MAX_TOKEN_EXPIRATION_DAYS_BOUND}, or empty for no limit" + )), + } +} pub const AUTO_LOGIN_PROVIDER_SETTING: &str = "auto_login_provider"; /// Name of the SAML attribute or OIDC userinfo claim carrying the user's IdP groups. Unset or /// empty leaves instance-group membership entirely to SCIM. @@ -238,6 +281,51 @@ pub fn validate_instance_banner(value: &serde_json::Value) -> Result<(), String> Ok(()) } +/// Instance-wide accent color (`#rrggbb`) that recolors the UI's accent tokens and +/// tints the sidebar, so each environment of a deployment is recognizable at a glance. +/// Readable by any authenticated user, like [`INSTANCE_BANNER_SETTING`]. +pub const ACCENT_COLOR_SETTING: &str = "accent_color"; + +/// Validate an [`ACCENT_COLOR_SETTING`] value. +/// +/// Only `#rrggbb` is accepted: the value is interpolated into a stylesheet every user +/// loads, so anything looser is CSS injection into every session of the instance. +pub fn validate_accent_color(value: &serde_json::Value) -> Result<(), String> { + let s = value + .as_str() + .ok_or_else(|| "must be a string".to_string())?; + let hex = s.strip_prefix('#').unwrap_or(""); + if hex.len() != 6 || !hex.chars().all(|c| c.is_ascii_hexdigit()) { + return Err("must be a hex color of the form #rrggbb".to_string()); + } + Ok(()) +} + +/// The settings every signed-in browser reads on each full page load. +#[derive(serde::Serialize, Clone, Debug, Default, PartialEq)] +pub struct InstanceUi { + pub instance_banner: Option, + pub accent_color: Option, +} + +pub async fn get_instance_ui(db: &Pool) -> error::Result { + let rows = sqlx::query!( + "SELECT name, value FROM global_settings WHERE name = ANY($1)", + &[INSTANCE_BANNER_SETTING, ACCENT_COLOR_SETTING] as &[&str] + ) + .fetch_all(db) + .await?; + let mut ui = InstanceUi::default(); + for row in rows { + match row.name.as_str() { + INSTANCE_BANNER_SETTING => ui.instance_banner = Some(row.value), + ACCENT_COLOR_SETTING => ui.accent_color = Some(row.value), + _ => {} + } + } + Ok(ui) +} + /// Validate a [`GITHUB_APP_WEBHOOK_BASE_URL_SETTING`] value. /// /// The receiver path is appended to it verbatim, so anything that doesn't @@ -368,6 +456,7 @@ use std::sync::atomic::AtomicBool; lazy_static::lazy_static! { pub static ref HTTP_ROUTE_WORKSPACED_ROUTE: AtomicBool = AtomicBool::new(false); pub static ref DISABLE_PASSWORD_LOGIN: AtomicBool = AtomicBool::new(false); + pub static ref MCP_DISABLE_TOKEN_QUERY_PARAM: AtomicBool = AtomicBool::new(false); /// Origins HTTP routes allow cross-origin when they configure none of their /// own. Empty means unset, which keeps the historical `*`. pub static ref HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS: arc_swap::ArcSwap> = @@ -811,6 +900,55 @@ pub fn workspace_integration_auth_endpoint(client_name: &str, base_url: &str) -> mod tests { use super::*; + // `frontend/src/lib/tokenExpiration.test.ts` holds the same table for the token form's + // parser; the two must stay in step. + #[test] + fn max_token_expiration_days_accepts_only_whole_days_within_the_bound() { + use serde_json::json; + for (stored, days) in [ + (json!(7), 7), + (json!(7.0), 7), + (json!("7"), 7), + (json!(" 30 "), 30), + (json!("+7"), 7), + ( + json!(MAX_TOKEN_EXPIRATION_DAYS_BOUND), + MAX_TOKEN_EXPIRATION_DAYS_BOUND, + ), + ] { + assert_eq!( + parse_max_token_expiration_days(Some(&stored)), + Ok(Some(days)), + "{stored}" + ); + } + for cleared in [json!(null), json!(""), json!(" ")] { + assert_eq!( + parse_max_token_expiration_days(Some(&cleared)), + Ok(None), + "{cleared}" + ); + } + assert_eq!(parse_max_token_expiration_days(None), Ok(None)); + for bad in [ + json!(7.5), + json!(0), + json!(-3), + json!("7.0"), + json!("1e1"), + json!("0x7"), + json!(MAX_TOKEN_EXPIRATION_DAYS_BOUND + 1), + json!("99999999999999999999"), + json!(true), + json!([7]), + ] { + assert!( + parse_max_token_expiration_days(Some(&bad)).is_err(), + "{bad} must be rejected" + ); + } + } + #[test] fn webhook_base_url_errors_never_echo_credentials() { // These strings reach sync-config output and operator logs, so no branch may @@ -846,6 +984,26 @@ mod tests { } } + #[test] + fn accent_color_accepts_only_hex_rgb() { + for ok in ["#1f9d55", "#ABCDEF"] { + assert!( + validate_accent_color(&serde_json::json!(ok)).is_ok(), + "{ok}" + ); + } + for bad in [ + serde_json::json!("1f9d55"), + serde_json::json!("#fff"), + serde_json::json!("red"), + serde_json::json!("#000000;} body{display:none"), + serde_json::json!("#12345g"), + serde_json::json!(123), + ] { + assert!(validate_accent_color(&bad).is_err(), "{bad}"); + } + } + #[test] fn instance_banner_rejects_unsafe_and_malformed_values() { // The link becomes the href of an anchor shown to every user of the instance, diff --git a/backend/windmill-common/src/guest_jwt.rs b/backend/windmill-common/src/guest_jwt.rs index 9f3e47d708..dc0407163d 100644 --- a/backend/windmill-common/src/guest_jwt.rs +++ b/backend/windmill-common/src/guest_jwt.rs @@ -167,7 +167,13 @@ pub fn jwk_algorithms(jwk: &Jwk) -> Option> { { return None; } - match (&jwk.algorithm, jwk.common.algorithm) { + let alg = crate::jwt::jwk_algorithm(jwk); + // A key that names an `alg` we cannot verify with (RSA-OAEP, ...) is not an alg-less + // key to be tried against the whole family: it is published for something else. + if alg.is_none() && jwk.common.key_algorithm.is_some() { + return None; + } + match (&jwk.algorithm, alg) { (AlgorithmParameters::RSA(_), Some(alg)) if RSA_ALGORITHMS.contains(&alg) => { Some(vec![alg]) } @@ -197,6 +203,9 @@ pub fn verify( let mut validation = Validation::new(algorithms[0]); validation.algorithms = algorithms.to_vec(); validation.validate_nbf = true; + // Audience is not part of the guest contract: issuers commonly stamp their own `aud`, + // and no accepted audience is configured, so an `aud` claim must not fail the token. + validation.validate_aud = false; let claims = jsonwebtoken::decode::(token, key, &validation) .map_err(|e| Error::NotAuthorized(format!("guest JWT refused: {e}")))? .claims; @@ -648,6 +657,12 @@ mod tests { assert_eq!(jwk_algorithms(&k), None); } + #[test] + fn a_key_naming_an_encryption_alg_is_refused() { + let k = jwk(serde_json::json!({"kty":"RSA","alg":"RSA-OAEP","n":"aa","e":"AQAB"})); + assert_eq!(jwk_algorithms(&k), None); + } + #[test] fn a_key_marked_for_encryption_is_refused() { let k = jwk(serde_json::json!({"kty":"RSA","use":"enc","n":"aa","e":"AQAB"})); @@ -789,6 +804,50 @@ mod tests { .is_err()); } + #[test] + fn a_token_carrying_an_audience_still_verifies() { + // jsonwebtoken validates `aud` by default when the token has one, which would refuse + // every issuer that stamps its own audience. + let key = jsonwebtoken::DecodingKey::from_jwk(&jwk(serde_json::json!({ + "kty": "EC", "crv": "P-256", "kid": "k1", "x": PUB1_X, "y": PUB1_Y + }))) + .unwrap(); + let token = jsonwebtoken::encode( + &jsonwebtoken::Header::new(Algorithm::ES256), + &serde_json::json!({ + "email": "g@example.com", + "workspace_id": "ws", + "app_path": "u/a/app", + "aud": "https://issuer.example/api", + "exp": jsonwebtoken::get_current_timestamp() + 600, + }), + &jsonwebtoken::EncodingKey::from_ec_pem(PRIV1.as_bytes()).unwrap(), + ) + .unwrap(); + verify(&token, &key, &[Algorithm::ES256], "ws").expect("aud is not validated"); + } + + #[test] + fn an_8192_bit_rsa_key_verifies() { + // The largest modulus real issuers use; a crypto backend capped at 4096 bits rejects + // every token signed with it while the key itself still loads. + const PRIVATE: &str = include_str!("../tests/fixtures/guest_jwt_rsa8192_private.pem"); + const PUBLIC: &str = include_str!("../tests/fixtures/guest_jwt_rsa8192_public.pem"); + let (key, algs) = decoding_key_from_pem(PUBLIC).expect("8192-bit SPKI loads"); + let token = jsonwebtoken::encode( + &jsonwebtoken::Header::new(Algorithm::RS256), + &serde_json::json!({ + "email": "g@example.com", + "workspace_id": "ws", + "app_path": "u/a/app", + "exp": jsonwebtoken::get_current_timestamp() + 600, + }), + &jsonwebtoken::EncodingKey::from_rsa_pem(PRIVATE.as_bytes()).unwrap(), + ) + .unwrap(); + verify(&token, &key, algs, "ws").expect("8192-bit RSA signature verifies"); + } + // A real RSA public key (SPKI). Its private counterpart is RSA_PKCS1_PRIVATE below. const RSA_PUBLIC: &str = "-----BEGIN PUBLIC KEY-----\n\ MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx3J0fQcHp2ZlMI4rCVsY\n\ diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index de3fd7684a..508379c3a4 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -831,6 +831,8 @@ pub struct CustomInstanceDbLogs { pub replication_user: String, #[serde(skip_serializing_if = "Option::is_none")] pub replication_user_error: Option, + #[serde(skip_serializing_if = "String::is_empty")] + pub user_connect: String, } // --------------------------------------------------------------------------- @@ -1027,9 +1029,10 @@ const NESTED_SENSITIVE_FIELDS: &[(&str, &[&str])] = &[ ), ( "object_store_cache_config", - &["secret_key", "serviceAccountKey"], + &["secret_key", "serviceAccountKey", "accessKey"], ), ("custom_instance_pg_databases", &["user_pwd"]), + ("github_enterprise_app", &["private_key"]), ]; fn redact_json_value(value: &serde_json::Value) -> serde_json::Value { @@ -1351,6 +1354,14 @@ pub async fn sync_global_settings_declarative( .map_err(|e| anyhow::anyhow!("{banner_key}: {e}"))?, } + let accent_key = crate::global_settings::ACCENT_COLOR_SETTING; + match desired.get(accent_key) { + None | Some(serde_json::Value::Null) => {} + Some(serde_json::Value::String(s)) if s.trim().is_empty() => {} + Some(color) => crate::global_settings::validate_accent_color(color) + .map_err(|e| anyhow::anyhow!("{accent_key}: {e}"))?, + } + // An origin list that cannot be parsed is dropped at boot, leaving the // empty default — which is no restriction at all. Rejecting it here is what // keeps a typo in a ConfigMap from silently widening CORS instance-wide. @@ -1358,6 +1369,10 @@ pub async fn sync_global_settings_declarative( crate::global_settings::parse_allowed_origins_setting(desired.get(origins_key)) .map_err(|e| anyhow::anyhow!("{origins_key}: {e}"))?; + let max_expiration_key = crate::global_settings::MAX_TOKEN_EXPIRATION_DAYS_SETTING; + crate::global_settings::parse_max_token_expiration_days(desired.get(max_expiration_key)) + .map_err(|e| anyhow::anyhow!("{max_expiration_key}: {e}"))?; + let diff = diff_global_settings(current, desired, ApplyMode::Replace); apply_settings_diff(db, &diff).await?; @@ -2641,6 +2656,26 @@ mod tests { assert!(formatted.contains("****")); } + #[test] + fn format_setting_value_redacts_nested_credentials() { + let val = serde_json::json!({ + "type": "Azure", + "accountName": "acct", + "containerName": "c", + "accessKey": "azure-storage-account-key-12345" + }); + let formatted = format_setting_value("object_store_cache_config", &val); + assert!(!formatted.contains("azure-storage-account-key-12345")); + assert!(formatted.contains("acct")); + + let val = serde_json::json!({ + "app_id": 1, + "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEsecretbody\n-----END RSA PRIVATE KEY-----" + }); + let formatted = format_setting_value("github_enterprise_app", &val); + assert!(!formatted.contains("MIIEsecretbody")); + } + #[test] #[should_panic(expected = "literal_value() called on unresolved secret ref")] fn string_or_secret_ref_literal_value_panics_on_ref() { diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index 6f3248f3fb..796d1c999a 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -17,7 +17,7 @@ use crate::{ get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path, scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang}, utils::{StripPath, HTTP_CLIENT}, - worker::{to_raw_value, CUSTOM_TAGS_PER_WORKSPACE, WINDMILL_DIR}, + worker::{custom_tag_matches, to_raw_value, CUSTOM_TAGS_PER_WORKSPACE, WINDMILL_DIR}, workspaces::workspace_with_fork_ancestors, FlowVersionInfo, ScriptHashInfo, Tag, }; @@ -337,33 +337,70 @@ lazy_static::lazy_static! { // `is_super_admin` is passed in (not derived from an email here) so callers can // make it job-token-aware: a job's WM_TOKEN must never count as superadmin // (GHSA-hfh4-cx4h-3fcr). See `is_super_admin_authed` at the request wrapper. +// +// Custom tags judge `resolved_tag`, the queue the job actually lands on, and `tag` only as the +// job's author wrote it: a dynamic tag names any queue its values do. Callers go through +// `windmill_queue::check_tag_available_for_push`, which resolves it the way `push` does. +// `resolved_tag` is `None` when the values are not known yet: only an entry spelled like `tag` +// admits it then. `tag_workspace` resolves to what the job's `$workspace` stands for. pub async fn check_tag_available_for_workspace_internal( db: &DB, w_id: &str, tag: &str, + resolved_tag: Option<&str>, + tag_workspace: impl Future, is_super_admin: bool, scope_tags: Option>, ) -> error::Result<()> { - let mut is_tag_in_scope_tags = None; - let mut is_tag_in_workspace_custom_tags = false; - - if let Some(scope_tags) = scope_tags.as_ref() { - is_tag_in_scope_tags = Some(scope_tags.contains(&tag)); - } + let is_tag_in_scope_tags = scope_tags.as_ref().map(|scope_tags| { + scope_tags.contains(&tag) || resolved_tag.is_some_and(|r| scope_tags.contains(&r)) + }); let custom_tags_per_w = CUSTOM_TAGS_PER_WORKSPACE.load(); - if custom_tags_per_w.global.contains(&tag.to_string()) { - is_tag_in_workspace_custom_tags = true; - } else if let Some(specific_tag) = custom_tags_per_w.specific.get(tag) { - // Only a fork-scoped tag can match through the lineage, so every other tag keeps the - // ancestor lookup off the push path entirely. - let chain = if specific_tag.is_fork_scoped() { - workspace_with_fork_ancestors(db, w_id).await? - } else { - vec![w_id.to_string()] - }; - is_tag_in_workspace_custom_tags = specific_tag.applies_to_workspace(&chain); - } + // Only an entry reading `$workspace` needs it, so every other tag keeps its lookup off the + // push path. + let tag_workspace = if resolved_tag.is_some() + && custom_tags_per_w + .global + .iter() + .chain(custom_tags_per_w.specific.keys()) + .any(|t| t.contains("$workspace")) + { + tag_workspace.await + } else { + w_id.to_string() + }; + // A job whose tag is written exactly as an entry resolves inside what that entry admits, which + // is the only way in for an entry `custom_tag_matches` cannot turn into a pattern. + let admits = |entry: &str| { + entry == tag || resolved_tag.is_some_and(|r| custom_tag_matches(entry, r, &tag_workspace)) + }; + let scoped = custom_tags_per_w + .specific + .iter() + .filter(|(name, _)| admits(name)) + .collect::>(); + // Only a fork-scoped tag can match through the lineage, so every other tag keeps the + // ancestor lookup off the push path entirely. + let chain = if scoped.iter().any(|(_, t)| t.is_fork_scoped()) { + workspace_with_fork_ancestors(db, w_id).await? + } else { + vec![w_id.to_string()] + }; + // A tag listed by its own name with a scope stays inside that scope: a pattern it fits does + // not open it to other workspaces. Only listing it by name without a scope does. + let lands_on = resolved_tag.unwrap_or(tag); + let confined_by = custom_tags_per_w + .specific + .get_key_value(lands_on) + .filter(|(name, t)| { + !name.contains('$') + && !t.applies_to_workspace(&chain) + && !custom_tags_per_w.global.iter().any(|e| e == lands_on) + }); + let is_tag_in_workspace_custom_tags = confined_by.is_none() + && (custom_tags_per_w.global.iter().any(|entry| admits(entry)) + || scoped.iter().any(|(_, t)| t.applies_to_workspace(&chain))); match is_tag_in_scope_tags { Some(true) | None => { @@ -375,6 +412,12 @@ pub async fn check_tag_available_for_workspace_internal( } if !is_super_admin { + let tag = match resolved_tag { + Some(resolved_tag) if resolved_tag != tag => { + format!("{tag} (resolved to {resolved_tag})") + } + _ => tag.to_string(), + }; if scope_tags.is_some() && is_tag_in_scope_tags.is_some() { return Err(Error::BadRequest(format!( "Tag {tag} is not available in your scope" @@ -383,9 +426,35 @@ pub async fn check_tag_available_for_workspace_internal( if *TAGS_ARE_SENSITIVE { return Err(Error::BadRequest(format!("{tag} is not available to you"))); + } else if let Some((name, confining)) = confined_by { + let overridden = custom_tags_per_w + .global + .iter() + .filter(|entry| admits(entry)) + .cloned() + .chain( + scoped + .iter() + .filter(|(_, t)| t.applies_to_workspace(&chain)) + .map(|(n, t)| t.authored(n)), + ) + .collect::>(); + let overridden = if overridden.is_empty() { + String::new() + } else { + format!( + ", which takes precedence over the custom tags it also fits: {}", + overridden.join(", ") + ) + }; + return Err(Error::BadRequest(format!( + "Tag {tag} is not available in workspace {w_id}: the custom tag {} restricts it \ + to other workspaces{overridden}", + confining.authored(name) + ))); } else { return Err(error::Error::BadRequest(format!( - "Only super admins are allowed to use tags that are not included in the allowed CUSTOM_TAGS: {:?}", + "Tag {tag} is not included in the allowed CUSTOM_TAGS, which only super admins can go beyond: {:?}", custom_tags_per_w ))); } diff --git a/backend/windmill-common/src/jwt.rs b/backend/windmill-common/src/jwt.rs index cc2f1102bf..76df13b518 100644 --- a/backend/windmill-common/src/jwt.rs +++ b/backend/windmill-common/src/jwt.rs @@ -3,7 +3,6 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use hmac::{Hmac, Mac}; use serde::{de::DeserializeOwned, Serialize}; use sha2::Sha256; -use std::collections::HashSet; lazy_static::lazy_static! { pub static ref JWT_SECRET: arc_swap::ArcSwap = arc_swap::ArcSwap::from_pointee("".to_string()); @@ -44,18 +43,8 @@ pub async fn decode_with_internal_secret(token: &str) -> er } pub fn decode_without_verify(token: &str) -> anyhow::Result { - // Create a validation that skips all checks - let mut validation = jsonwebtoken::Validation::default(); - validation.insecure_disable_signature_validation(); - validation.validate_exp = false; - validation.validate_nbf = false; - validation.required_spec_claims = HashSet::new(); - - // Use an empty key since we're not verifying - let key = jsonwebtoken::DecodingKey::from_secret(&[]); - - // Decode the token - let token_data = jsonwebtoken::decode::(token, &key, &validation)?; + // No signature, expiry, nbf or required-claim checks: the caller wants the raw claims. + let token_data = jsonwebtoken::dangerous::insecure_decode::(token)?; Ok(token_data.claims) } @@ -74,3 +63,36 @@ pub async fn generate_signature(header_and_payload: &str) -> anyhow::Result Option { + // `alg` is a `KeyAlgorithm` (signing and encryption algorithms alike); only the + // signing subset maps onto `Algorithm`. + jwk.common + .key_algorithm + .and_then(|alg| alg.to_string().parse::().ok()) +} + +#[cfg(test)] +mod tests { + use super::jwk_algorithm; + use jsonwebtoken::{jwk::Jwk, Algorithm}; + + #[test] + fn jwk_algorithm_reads_alg_when_present() { + let with: Jwk = + serde_json::from_value(serde_json::json!({"kty":"RSA","alg":"RS256","n":"aa","e":"AQAB"})) + .unwrap(); + assert_eq!(jwk_algorithm(&with), Some(Algorithm::RS256)); + let without: Jwk = + serde_json::from_value(serde_json::json!({"kty":"RSA","n":"aa","e":"AQAB"})).unwrap(); + assert_eq!(jwk_algorithm(&without), None); + let encryption: Jwk = serde_json::from_value( + serde_json::json!({"kty":"RSA","alg":"RSA-OAEP","n":"aa","e":"AQAB"}), + ) + .unwrap(); + assert_eq!(jwk_algorithm(&encryption), None); + } +} diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 8e17ec676e..1015948882 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -37,6 +37,10 @@ pub mod bench; pub mod cache; pub mod client; pub mod data_metrics; +pub mod datatable_roles; +#[cfg(all(feature = "private", feature = "enterprise"))] +mod datatable_roles_ee; +pub mod datatable_roles_oss; pub mod db; #[cfg(all(feature = "enterprise", feature = "private"))] mod db_entra_ee; @@ -114,6 +118,7 @@ pub mod queue; pub mod queue_metrics; pub mod result_stream; pub mod runnable_settings; +pub mod runnables; pub mod schedule; pub mod schema; pub mod scripts; @@ -1020,6 +1025,20 @@ impl Future for TokioPgConnection { } } +impl TokioPgConnection { + /// Drive the connection and hand back what the server sends outside of a query's response — + /// notices above all, which driving it as a future silently discards. + pub fn poll_message( + &mut self, + cx: &mut core::task::Context<'_>, + ) -> core::task::Poll>> { + match self { + TokioPgConnection::Tls(conn) => conn.poll_message(cx), + TokioPgConnection::NoTls(conn) => conn.poll_message(cx), + } + } +} + impl PgDatabase { /// The role the connection logs in as, whichever way it authenticates. pub fn login_name(&self) -> &str { @@ -1456,8 +1475,163 @@ pub fn validate_dbname(dbname: &str) -> error::Result<()> { Ok(()) } +/// Lock the instance databases among `names` until `tx` ends, in a stable order. Taken by every +/// settings save naming an instance database, and by [`drop_unused_instance_database`]: a save +/// cannot start using a database between that drop's check that nothing does and the drop. +pub async fn lock_instance_databases<'a>( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + names: impl IntoIterator, +) -> error::Result<()> { + let names: std::collections::BTreeSet<&str> = names.into_iter().collect(); + for name in names { + sqlx::query("SELECT pg_advisory_xact_lock(hashtext('instance_database:' || $1))") + .bind(name) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + +/// Drop instance database `dbname`, which a request just created, unless a workspace names it as a +/// data table or a ducklake catalog — then it is kept, and who keeps it is what comes back. A +/// database is registered on the instance as soon as it is created, so a superadmin can point a +/// workspace at it before the request that created it gives up on it. +/// +/// Authorization: none. Callers MUST pass only a database the same request created and has not +/// handed to anything yet. +/// +/// The lock, the check and the drop share one connection: a second one taken from the pool while +/// the first is held could wait forever on a small pool. That connection is closed rather than +/// returned, since a session lock outlives the future holding it: a cancellation between taking +/// the lock and releasing it would otherwise hand a locked session back to the pool, where every +/// later settings save waits on it. Closing on drop covers the cancellation and still counts the +/// connection against the pool, which detaching it would not. +pub async fn drop_unused_instance_database(db: &DB, dbname: &str) -> error::Result { + let mut conn = db.acquire().await?; + conn.close_on_drop(); + let key = format!("instance_database:{dbname}"); + // A save blocked on this lock is about to name the database, but it may also roll back — a + // later validation of its own, a superadmin check, a cancelled request. So it is let through + // and what it committed is read, rather than taken as a user: trusting it would strand the + // database, whose name then blocks every retry. A fresh waiter can always arrive, so after a + // few rounds the database is kept instead; the name is then a superadmin's to drop from + // instance settings, since a retry fails on the name before reaching this cleanup. + let mut rounds = 0; + let dropped = loop { + if let Err(e) = sqlx::query("SELECT pg_advisory_lock(hashtext($1))") + .bind(&key) + .execute(&mut *conn) + .await + { + break Err(e.into()); + } + match drop_if_unused_on(&mut conn, dbname).await { + Ok(Cleanup::Waiter(_)) if rounds < 2 => { + rounds += 1; + // Releasing hands the lock to the waiter; re-taking it above then waits for that + // transaction to end, so the next round reads what it actually committed. + if let Err(e) = unlock_instance_database(&mut conn, &key).await { + break Err(e); + } + } + Ok(outcome) => break Ok(outcome), + Err(e) => break Err(e), + } + }; + // Dropping closes the connection, and the server releases the lock with the session, so + // nothing here depends on an unlock landing. + dropped +} + +async fn unlock_instance_database(conn: &mut sqlx::PgConnection, key: &str) -> error::Result<()> { + sqlx::query("SELECT pg_advisory_unlock(hashtext($1))") + .bind(key) + .execute(&mut *conn) + .await?; + Ok(()) +} + +/// What [`drop_unused_instance_database`] did, and for whom when it kept the database. +pub enum Cleanup { + Dropped, + /// Workspaces that name the database, as [`instance_database_users`] reports them. + InUse(Vec), + /// A transaction still blocked on the lock after the rounds above, so what it will commit + /// stays unknown, named as its `pid`. + Waiter(i32), +} + +async fn drop_if_unused_on(conn: &mut sqlx::PgConnection, dbname: &str) -> error::Result { + let users = instance_database_users(conn, dbname).await?; + if !users.is_empty() { + return Ok(Cleanup::InUse(users)); + } + // A settings save naming this database takes the same lock, so one waiting on it would read + // its own users after the drop and commit a reference to nothing. + if let Some(waiter) = waiting_for_instance_database(conn, dbname).await? { + return Ok(Cleanup::Waiter(waiter)); + } + drop_custom_instance_database_on(conn, dbname).await?; + Ok(Cleanup::Dropped) +} + +/// The `pid` of a transaction blocked on `dbname`'s instance-database lock. The lock is +/// taken by key, so `pg_locks` reports it split across `classid` and `objid`, and it lists every +/// database on the cluster — another Windmill on the same one holds its own locks under the same +/// key. +async fn waiting_for_instance_database( + conn: &mut sqlx::PgConnection, + dbname: &str, +) -> error::Result> { + let pid: Option = sqlx::query_scalar( + "SELECT l.pid FROM pg_locks l + WHERE l.locktype = 'advisory' AND NOT l.granted + AND l.database = (SELECT oid FROM pg_database WHERE datname = current_database()) + AND l.classid = ((hashtext('instance_database:' || $1)::bigint >> 32) & 4294967295)::oid + AND l.objid = (hashtext('instance_database:' || $1)::bigint & 4294967295)::oid + LIMIT 1", + ) + .bind(dbname) + .fetch_optional(&mut *conn) + .await?; + Ok(pid) +} + +/// The workspaces naming instance database `dbname` as a data table or a ducklake catalog. Taken +/// under [`lock_instance_databases`] for `dbname`, the answer holds until that lock is released. +/// +/// Authorization: none, and it reads every workspace's settings. Callers MUST pass only a database +/// their own request created, and may name the workspaces returned only to a caller allowed to +/// create or drop instance databases. +pub async fn instance_database_users( + conn: &mut sqlx::PgConnection, + dbname: &str, +) -> error::Result> { + Ok(sqlx::query_scalar( + "SELECT DISTINCT ws.workspace_id FROM workspace_settings ws + WHERE EXISTS (SELECT 1 FROM jsonb_each(CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object' + THEN ws.datatable->'datatables' ELSE '{}'::jsonb END) dt + WHERE dt.value->'database'->>'resource_type' = 'instance' + AND dt.value->'database'->>'resource_path' = $1) + OR EXISTS (SELECT 1 FROM jsonb_each(CASE WHEN jsonb_typeof(ws.ducklake->'ducklakes') = 'object' + THEN ws.ducklake->'ducklakes' ELSE '{}'::jsonb END) dl + WHERE dl.value->'catalog'->>'resource_type' = 'instance' + AND dl.value->'catalog'->>'resource_path' = $1)", + ) + .bind(dbname) + .fetch_all(conn) + .await?) +} + /// Drop a custom instance database: validate, terminate connections, DROP DATABASE, remove from global_settings. pub async fn drop_custom_instance_database(db: &DB, dbname: &str) -> error::Result<()> { + drop_custom_instance_database_on(&mut *db.acquire().await?, dbname).await +} + +async fn drop_custom_instance_database_on( + conn: &mut sqlx::PgConnection, + dbname: &str, +) -> error::Result<()> { let dbname = dbname.trim(); validate_dbname(dbname)?; @@ -1472,7 +1646,7 @@ pub async fn drop_custom_instance_database(db: &DB, dbname: &str) -> error::Resu "SELECT EXISTS (SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1)", dbname ) - .fetch_one(db) + .fetch_one(&mut *conn) .await? .unwrap_or(false); @@ -1483,7 +1657,7 @@ pub async fn drop_custom_instance_database(db: &DB, dbname: &str) -> error::Resu "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '{}' AND pid <> pg_backend_pid()", dbname.replace('\'', "''") )) - .execute(db) + .execute(&mut *conn) .await { tracing::warn!("Failed to terminate connections to '{}': {}", dbname, e); @@ -1492,7 +1666,7 @@ pub async fn drop_custom_instance_database(db: &DB, dbname: &str) -> error::Resu // Drop the database // SAFETY: `dbname` has been validated via validate_dbname() before reaching this point. sqlx::query(&format!("DROP DATABASE IF EXISTS \"{}\"", dbname)) - .execute(db) + .execute(&mut *conn) .await .map_err(|e| { error::Error::internal_err(format!("Failed to drop database '{}': {}", dbname, e)) @@ -1508,12 +1682,47 @@ pub async fn drop_custom_instance_database(db: &DB, dbname: &str) -> error::Resu r#"UPDATE global_settings SET value = value #- ARRAY['databases', $1] WHERE name = 'custom_instance_pg_databases'"#, dbname ) - .execute(db) + .execute(&mut *conn) .await?; Ok(()) } +/// What `custom_instance_user` holds on an instance database. +/// +/// `WITH GRANT OPTION` throughout: this is the connection every data table resolves to as `admin`, +/// and it is the one that hands privileges to data table roles. Postgres refuses to let a role pass +/// on a privilege it does not itself hold with grant option, so without these an admin could own +/// the database and still be unable to grant `SELECT` on it to `analytics`. +pub(crate) fn instance_db_grants(dbname: &str) -> String { + format!( + "GRANT CONNECT ON DATABASE \"{dbname}\" TO custom_instance_user WITH GRANT OPTION; + GRANT CREATE ON DATABASE \"{dbname}\" TO custom_instance_user WITH GRANT OPTION; + DO $$ BEGIN + IF EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'public') THEN + GRANT USAGE ON SCHEMA public TO custom_instance_user WITH GRANT OPTION; + GRANT CREATE ON SCHEMA public TO custom_instance_user WITH GRANT OPTION; + ALTER DEFAULT PRIVILEGES IN SCHEMA public + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO custom_instance_user; + END IF; + END $$;" + ) +} + +/// Re-apply [`instance_db_grants`] to an instance database provisioned before data table roles +/// existed, whose grants carry no grant option. Connects as the instance's own Postgres user — +/// the database and `public` schema owner — since only it can hand out an option it holds. +/// +/// Authorization: reaches an instance database with the server's own credentials and checks +/// nothing. Callers MUST have authorized administration of `dbname` — superadmin, or an admin of +/// the workspace governing a data table on it. +pub async fn ensure_instance_db_grant_options_unchecked( + db: &DB, + dbname: &str, +) -> error::Result<()> { + crate::datatable_roles_oss::ensure_instance_db_grant_options_unchecked(db, dbname).await +} + /// Create a custom instance database: CREATE DATABASE, grant permissions, register in global_settings. /// The `tag` is stored in global_settings metadata (e.g. "datatable" or "ducklake"). pub async fn create_custom_instance_database( @@ -1547,23 +1756,46 @@ pub async fn create_custom_instance_database( error::Error::internal_err(format!("Failed to create database '{}': {}", dbname, e)) })?; + // Nothing names a database that failed past this point, and its name blocks the retry: drop it + // rather than leave it behind. + if let Err(e) = finish_custom_instance_database(db, dbname, tag).await { + match drop_unused_instance_database(db, dbname).await { + Ok(Cleanup::InUse(users)) => tracing::warn!( + "Kept '{dbname}' after failing to set it up: workspaces {} use it", + users.join(", ") + ), + Ok(Cleanup::Waiter(pid)) => tracing::warn!( + "Kept '{dbname}' after failing to set it up: a request (pid {pid}) is still \ + waiting to name it. Drop it from instance settings once it is unused." + ), + Ok(Cleanup::Dropped) => {} + Err(drop_err) => { + tracing::error!("Could not drop '{dbname}' after failing to set it up: {drop_err}") + } + } + return Err(e); + } + + // A data table role can only reach a database it may CONNECT to, and PUBLIC's default CONNECT + // would otherwise let every role in regardless of what this instance defines. Best-effort: a + // failure here leaves the database usable as `admin`, and the next role change repairs it. + if let Err(e) = crate::datatable_roles::converge_connect_grants(db, dbname).await { + tracing::warn!("Could not set CONNECT grants on instance database '{dbname}': {e}"); + } + + tracing::info!("Created custom instance database '{}'", dbname); + Ok(()) +} + +/// Grant `custom_instance_user` its privileges on a database just created, and register it. +async fn finish_custom_instance_database(db: &DB, dbname: &str, tag: &str) -> error::Result<()> { // Grant permissions to custom_instance_user let wmill_pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?; let new_pg_creds = PgDatabase { dbname: dbname.to_string(), ..wmill_pg_creds }; let (client, connection) = new_pg_creds.connect(Some(db)).await?; let join_handle = tokio::spawn(async move { connection.await }); - if let Err(e) = client - .batch_execute(&format!( - "GRANT CONNECT ON DATABASE \"{dbname}\" TO custom_instance_user; - GRANT USAGE ON SCHEMA public TO custom_instance_user; - GRANT CREATE ON SCHEMA public TO custom_instance_user; - GRANT CREATE ON DATABASE \"{dbname}\" TO custom_instance_user; - ALTER DEFAULT PRIVILEGES IN SCHEMA public - GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO custom_instance_user;" - )) - .await - { + if let Err(e) = client.batch_execute(&instance_db_grants(dbname)).await { tracing::warn!( "Failed to grant permissions on '{}': {}. Continuing.", dbname, @@ -1591,8 +1823,6 @@ pub async fn create_custom_instance_database( ) .execute(db) .await?; - - tracing::info!("Created custom instance database '{}'", dbname); Ok(()) } diff --git a/backend/windmill-common/src/query_builders.rs b/backend/windmill-common/src/query_builders.rs index 60d74f189d..546d2d454b 100644 --- a/backend/windmill-common/src/query_builders.rs +++ b/backend/windmill-common/src/query_builders.rs @@ -329,9 +329,11 @@ pub fn try_expand_internal_db_query( "ALTER_TABLE" => expand_alter_table(json_str, db_type).map(ExpandedQuery::sql), "CREATE_SCHEMA" => expand_create_schema(json_str, db_type).map(ExpandedQuery::sql), "DROP_SCHEMA" => expand_drop_schema(json_str, db_type).map(ExpandedQuery::sql), + "RENAME_SCHEMA" => expand_rename_schema(json_str, db_type).map(ExpandedQuery::sql), // Metadata queries "LOAD_TABLE_METADATA" => expand_load_table_metadata(json_str, db_type), "FOREIGN_KEYS" => expand_foreign_keys(json_str, db_type).map(ExpandedQuery::sql), + "ALL_FOREIGN_KEYS" => expand_all_foreign_keys(db_type).map(ExpandedQuery::sql), "PRIMARY_KEY_CONSTRAINT" => { expand_primary_key_constraint(json_str, db_type).map(ExpandedQuery::sql) } @@ -1716,6 +1718,13 @@ struct DropSchemaPayload { ducklake: Option, } +#[derive(Deserialize)] +struct RenameSchemaPayload { + schema: String, + new_schema: String, + ducklake: Option, +} + #[derive(Debug, Clone, Deserialize)] struct TableEditorColumn { name: String, @@ -2004,6 +2013,23 @@ fn expand_drop_schema(json_str: &str, db_type: DbType) -> Result Ok(maybe_wrap_ducklake(query, p.ducklake.as_deref())) } +fn expand_rename_schema(json_str: &str, db_type: DbType) -> Result { + let p: RenameSchemaPayload = serde_json::from_str(json_str) + .map_err(|e| format!("Invalid RENAME_SCHEMA payload: {}", e))?; + if !matches!(db_type, DbType::Postgresql | DbType::Snowflake) || p.ducklake.is_some() { + return Err(format!( + "Renaming a schema is not supported on {:?}", + db_type + )); + } + let query = format!( + "ALTER SCHEMA {} RENAME TO {};", + qi(&p.schema, db_type), + qi(&p.new_schema, db_type) + ); + Ok(query) +} + fn expand_create_table(json_str: &str, db_type: DbType) -> Result { let p: CreateTablePayload = serde_json::from_str(json_str) .map_err(|e| format!("Invalid CREATE_TABLE payload: {}", e))?; @@ -2598,7 +2624,9 @@ WHERE table_catalog = current_database()", ) } else { ( - "\nWHERE c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped\n AND ns.nspname != 'pg_catalog' AND ns.nspname != 'information_schema'".to_string(), + // pg_catalog is readable by everyone: without the privilege check this lists + // tables of schemas the connection's role cannot even enter. + "\nWHERE c.relkind = 'r' AND a.attnum > 0 AND NOT a.attisdropped\n AND ns.nspname != 'pg_catalog' AND ns.nspname != 'information_schema'\n AND has_schema_privilege(ns.oid, 'USAGE')".to_string(), ",\n ns.nspname AS schema_name,\n c.relname AS table_name".to_string(), "\nJOIN pg_catalog.pg_class c ON a.attrelid = c.oid\nJOIN pg_catalog.pg_namespace ns ON c.relnamespace = ns.oid".to_string(), "ns.nspname, c.relname, a.attnum".to_string(), @@ -2710,6 +2738,47 @@ fn expand_foreign_keys(json_str: &str, db_type: DbType) -> Result Result { + make_all_foreign_keys_query(db_type) +} + +/// Every foreign key of the database, one row per referencing column. +/// +/// Read from `pg_constraint` rather than `information_schema`: the latter's +/// `constraint_column_usage` loses the pairing of a composite key's columns, +/// which the diagram needs to line a source column up with the column it points +/// at. `unnest(conkey, confkey)` keeps them zipped in declaration order. +fn make_all_foreign_keys_query(db_type: DbType) -> Result { + if db_type != DbType::Postgresql { + return Err(format!( + "The schema diagram is only supported for PostgreSQL, not {:?}", + db_type + )); + } + Ok(String::from( + "SELECT + con.conname as fk_constraint_name, + src_ns.nspname as source_schema, + src.relname as source_table, + src_att.attname as source_column, + tgt_ns.nspname as target_schema, + tgt.relname as target_table, + tgt_att.attname as target_column, + k.ord as ordinal +FROM pg_catalog.pg_constraint con + JOIN pg_catalog.pg_class src ON src.oid = con.conrelid + JOIN pg_catalog.pg_namespace src_ns ON src_ns.oid = src.relnamespace + JOIN pg_catalog.pg_class tgt ON tgt.oid = con.confrelid + JOIN pg_catalog.pg_namespace tgt_ns ON tgt_ns.oid = tgt.relnamespace + JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS k(src_attnum, tgt_attnum, ord) ON true + JOIN pg_catalog.pg_attribute src_att ON src_att.attrelid = src.oid AND src_att.attnum = k.src_attnum + JOIN pg_catalog.pg_attribute tgt_att ON tgt_att.attrelid = tgt.oid AND tgt_att.attnum = k.tgt_attnum +WHERE con.contype = 'f' + AND src_ns.nspname NOT IN ('pg_catalog', 'information_schema') +ORDER BY src_ns.nspname, src.relname, con.conname, k.ord;", + )) +} + fn expand_primary_key_constraint(json_str: &str, db_type: DbType) -> Result { let p: PrimaryKeyConstraintPayload = serde_json::from_str(json_str) .map_err(|e| format!("Invalid PRIMARY_KEY_CONSTRAINT payload: {}", e))?; @@ -4101,6 +4170,13 @@ mod tests { assert_eq!(sql, "DROP SCHEMA \"old_schema\" CASCADE;"); } + #[test] + fn test_expand_rename_schema() { + let marker = r#"-- WM_INTERNAL_DB_RENAME_SCHEMA {"schema":"old","new_schema":"new"}"#; + let sql = expand_code(marker, &ScriptLang::Postgresql); + assert_eq!(sql, "ALTER SCHEMA \"old\" RENAME TO \"new\";"); + } + #[test] fn test_expand_create_schema_with_ducklake() { let marker = r#"-- WM_INTERNAL_DB_CREATE_SCHEMA {"schema":"s","ducklake":"lake"}"#; @@ -4468,6 +4544,7 @@ mod tests { assert!(sql.contains("schema_name")); assert!(sql.contains("table_name")); assert!(sql.contains("c.relkind = 'r'")); + assert!(sql.contains("has_schema_privilege(ns.oid, 'USAGE')")); } #[test] @@ -4686,6 +4763,26 @@ mod tests { assert!(sql.starts_with("ATTACH 'ducklake://lake' AS dl;USE dl;\n")); } + // ----------------------------------------------------------------------- + // ALL_FOREIGN_KEYS + // ----------------------------------------------------------------------- + + #[test] + fn test_expand_all_foreign_keys_postgresql() { + let marker = r#"-- WM_INTERNAL_DB_ALL_FOREIGN_KEYS {}"#; + let sql = expand_code(marker, &ScriptLang::Postgresql); + assert!(sql.contains("con.contype = 'f'")); + // Columns of a composite key must stay zipped with the ones they point at. + assert!(sql.contains("unnest(con.conkey, con.confkey) WITH ORDINALITY")); + } + + #[test] + fn test_expand_all_foreign_keys_rejects_non_postgres() { + let marker = r#"-- WM_INTERNAL_DB_ALL_FOREIGN_KEYS {}"#; + let result = try_expand_internal_db_query(marker, &ScriptLang::Mysql); + assert!(result.unwrap().is_err()); + } + // ----------------------------------------------------------------------- // PRIMARY_KEY_CONSTRAINT // ----------------------------------------------------------------------- diff --git a/backend/windmill-common/src/runnables.rs b/backend/windmill-common/src/runnables.rs new file mode 100644 index 0000000000..579e5e248e --- /dev/null +++ b/backend/windmill-common/src/runnables.rs @@ -0,0 +1,22 @@ +//! Queries spanning both scripts and flows. + +use crate::{error::Error, DB}; + +/// An opaque value that changes whenever a workspace's live scripts or flows are added, +/// removed, moved or redeployed: a redeploy is a new script hash or a new last flow +/// version, so schema and description changes register too. Not filtered by caller, +/// so it reveals nothing beyond whether it changed. +pub async fn runnable_list_fingerprint(db: &DB, workspace_id: &str) -> Result { + Ok(sqlx::query_scalar::<_, String>( + "SELECT COALESCE(md5(string_agg(k, ',' ORDER BY k)), '') FROM ( + SELECT 's' || path || ':' || hash FROM script + WHERE workspace_id = $1 AND archived = false + UNION ALL + SELECT 'f' || path || ':' || COALESCE(versions[array_upper(versions, 1)], 0) FROM flow + WHERE workspace_id = $1 AND archived = false + ) t(k)", + ) + .bind(workspace_id) + .fetch_one(db) + .await?) +} diff --git a/backend/windmill-common/src/sensitive_log_masks.rs b/backend/windmill-common/src/sensitive_log_masks.rs index b6f6262b77..c999297ec0 100644 --- a/backend/windmill-common/src/sensitive_log_masks.rs +++ b/backend/windmill-common/src/sensitive_log_masks.rs @@ -10,7 +10,7 @@ use std::borrow::Cow; use std::collections::{HashMap, HashSet}; -use std::sync::RwLock; +use std::sync::{Arc, RwLock}; use uuid::Uuid; /// Minimum length for a secret to be registered for masking. @@ -20,9 +20,27 @@ const MIN_SECRET_LENGTH: usize = 8; const MASKED_NOTICE: &str = "[windmill] secret value was masked for security reasons, use string transformations to display full value"; +/// The secrets registered for one job, plus the automaton compiled from them. +#[derive(Default)] +struct JobMasks { + secrets: HashSet, + /// Built on the first `snapshot` after a change and shared by every later + /// snapshot. Every job registers at least its own token, so without this + /// cache each log batch of each job would rebuild the automaton. + compiled: Option>, +} + +/// Aho-Corasick automaton for O(m) multi-pattern matching in a single pass, +/// regardless of the number of secrets registered, with the replacement +/// strings indexed to match the automaton's pattern order. +struct CompiledMasks { + ac: aho_corasick::AhoCorasick, + replacements: Vec, +} + lazy_static::lazy_static! { - /// Map of job_id -> set of secret values that should be masked in that job's logs. - static ref SENSITIVE_MASKS: RwLock>> = + /// Map of job_id -> secret values that should be masked in that job's logs. + static ref SENSITIVE_MASKS: RwLock> = RwLock::new(HashMap::new()); /// Set of currently running job IDs on this worker process. @@ -32,13 +50,8 @@ lazy_static::lazy_static! { } /// A lock-free snapshot of secrets for a job, taken once per log batch. -/// Uses Aho-Corasick for O(m) multi-pattern matching in a single pass, -/// regardless of the number of secrets registered. pub struct MaskSnapshot { - /// Aho-Corasick automaton for fast matching. - ac: aho_corasick::AhoCorasick, - /// Replacement strings, indexed to match the automaton's pattern order. - replacements: Vec, + compiled: Arc, /// Whether the security notice has already been appended for this snapshot. /// Tracked locally to avoid a global write lock on every masked line. notice_shown: std::cell::Cell, @@ -53,34 +66,104 @@ impl MaskSnapshot { } // Single-pass check + replace using the pre-built automaton - if !self.ac.is_match(text) { + if !self.compiled.ac.is_match(text) { return Cow::Borrowed(text); } - let mut result = self.ac.replace_all(text, &self.replacements); + let mut result = self + .compiled + .ac + .replace_all(text, &self.compiled.replacements); - // Append the notice only once per snapshot (i.e. per batch) + // Append the notice only once per snapshot (i.e. per batch), as its own line. + // Callers pass either a bare line (`handle_child`) or a chunk that already ends + // in a newline (nativets), and the sinks concatenate what they get verbatim: + // assuming either shape welds the notice onto a neighbouring line. if !self.notice_shown.get() { self.notice_shown.set(true); - result.push('\n'); - result.push_str(MASKED_NOTICE); + if result.ends_with('\n') { + result.push_str(MASKED_NOTICE); + result.push('\n'); + } else { + result.push('\n'); + result.push_str(MASKED_NOTICE); + } } Cow::Owned(result) } } +/// A masker for sinks that mask line by line rather than in batches, like nativets +/// masking each `console.log` chunk as V8 produces it. `snapshot` per line would +/// re-arm the security notice on every one; this keeps it to once per distinct set +/// of secrets while still picking up secrets registered mid-run. +/// +/// Masks by job id alone — the caller is the one that knows the text it passes +/// belongs to that job. +pub struct JobMasker { + job_id: Uuid, + snapshot: Option, +} + +impl JobMasker { + pub fn new(job_id: Uuid) -> Self { + JobMasker { job_id, snapshot: snapshot(&job_id) } + } + + /// Mask every secret registered for the job. Returns `Cow::Borrowed` when no match. + /// Falls back to the masks it last saw once the job is unregistered, so a sink + /// still draining past the end of a run does not start emitting secrets. + pub fn mask<'a>(&mut self, text: &'a str) -> Cow<'a, str> { + if let Some(fresh) = snapshot(&self.job_id) { + // Replacing an equivalent snapshot would re-arm the notice, so only take + // one built from a secret set we have not seen. + let unchanged = self + .snapshot + .as_ref() + .is_some_and(|cur| Arc::ptr_eq(&cur.compiled, &fresh.compiled)); + if !unchanged { + self.snapshot = Some(fresh); + } + } + match self.snapshot.as_ref() { + Some(snapshot) => snapshot.mask(text), + None => Cow::Borrowed(text), + } + } +} + /// Take a snapshot of the current secrets for a job. Returns `None` if no secrets /// are registered (the caller can then skip masking entirely for the whole batch). /// /// Call this once per log batch in `write_lines`, not per line. pub fn snapshot(job_id: &Uuid) -> Option { - let masks = SENSITIVE_MASKS.read().unwrap_or_else(|e| e.into_inner()); - let secrets = masks.get(job_id)?; - if secrets.is_empty() { - return None; + { + let masks = SENSITIVE_MASKS.read().unwrap_or_else(|e| e.into_inner()); + let job = masks.get(job_id)?; + if job.secrets.is_empty() { + return None; + } + if let Some(compiled) = job.compiled.as_ref() { + return Some(MaskSnapshot { + compiled: compiled.clone(), + notice_shown: std::cell::Cell::new(false), + }); + } } + let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner()); + let job = masks.get_mut(job_id)?; + if job.secrets.is_empty() { + return None; + } + let compiled = job + .compiled + .get_or_insert_with(|| Arc::new(compile(&job.secrets))); + Some(MaskSnapshot { compiled: compiled.clone(), notice_shown: std::cell::Cell::new(false) }) +} + +fn compile(secrets: &HashSet) -> CompiledMasks { // Sort longest-first so longer secrets are matched before shorter substrings let mut sorted: Vec<&String> = secrets.iter().collect(); sorted.sort_by(|a, b| b.len().cmp(&a.len())); @@ -106,7 +189,7 @@ pub fn snapshot(job_id: &Uuid) -> Option { .build(sorted.iter().map(|s| s.as_str())) .expect("failed to build aho-corasick automaton"); - Some(MaskSnapshot { ac, replacements, notice_shown: std::cell::Cell::new(false) }) + CompiledMasks { ac, replacements } } /// Register a job as currently running. Call this before `handle_queued_job`. @@ -148,20 +231,110 @@ pub fn register_secret_for_all_running_jobs(secret: &str) { let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner()); for job_id in job_ids { - if let Some(set) = masks.get_mut(&job_id) { - set.insert(secret.to_string()); + if let Some(job) = masks.get_mut(&job_id) { + if job.secrets.insert(secret.to_string()) { + job.compiled = None; + } } } } /// Register a secret value for a specific job. -/// Used for `$encrypted:` args where we know the job ID. +/// Used for the job's own token and for `$encrypted:` args, where we know the job ID. pub fn register_secret_for_job(job_id: Uuid, secret: &str) { if secret.len() < MIN_SECRET_LENGTH { return; } let mut masks = SENSITIVE_MASKS.write().unwrap_or_else(|e| e.into_inner()); - if let Some(set) = masks.get_mut(&job_id) { - set.insert(secret.to_string()); + if let Some(job) = masks.get_mut(&job_id) { + if job.secrets.insert(secret.to_string()) { + job.compiled = None; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The compiled automaton is cached per job, so a secret registered after the + /// first snapshot only gets masked if the cache is invalidated. + #[test] + fn snapshot_rebuilds_after_a_new_secret_is_registered() { + let job_id = Uuid::new_v4(); + register_running_job(job_id); + register_secret_for_job(job_id, "firstsecretvalue"); + let _ = snapshot(&job_id) + .expect("secret registered") + .mask("firstsecretvalue"); + + register_secret_for_job(job_id, "secondsecretvalue"); + + let snap = snapshot(&job_id).expect("secrets registered"); + let masked = snap.mask("firstsecretvalue then secondsecretvalue"); + assert!(!masked.contains("firstsecretvalue"), "{masked}"); + assert!(!masked.contains("secondsecretvalue"), "{masked}"); + unregister_running_job(job_id); + } + + /// A line-by-line sink must not repeat the notice on every line, and must still + /// pick up a secret registered after the masker was built. + #[test] + fn job_masker_notices_once_per_secret_set() { + let job_id = Uuid::new_v4(); + register_running_job(job_id); + register_secret_for_job(job_id, "firstsecretvalue"); + let mut masker = JobMasker::new(job_id); + + let first = masker.mask("saw firstsecretvalue").into_owned(); + assert!(!first.contains("firstsecretvalue"), "{first}"); + assert!(first.contains(MASKED_NOTICE), "{first}"); + + let second = masker.mask("saw firstsecretvalue again").into_owned(); + assert!(!second.contains("firstsecretvalue"), "{second}"); + assert!(!second.contains(MASKED_NOTICE), "{second}"); + + register_secret_for_job(job_id, "secondsecretvalue"); + let third = masker.mask("saw secondsecretvalue").into_owned(); + assert!(!third.contains("secondsecretvalue"), "{third}"); + unregister_running_job(job_id); + } + + /// Unregistration must not turn masking off under a sink that is still emitting: + /// the masker keeps working off the masks it last saw rather than going quiet. + #[test] + fn job_masker_masks_after_the_job_is_unregistered() { + let job_id = Uuid::new_v4(); + register_running_job(job_id); + register_secret_for_job(job_id, "supersecretvalue"); + let mut masker = JobMasker::new(job_id); + + unregister_running_job(job_id); + + let masked = masker.mask("logged supersecretvalue here"); + assert!(!masked.contains("supersecretvalue"), "{masked}"); + } + + /// The notice has to end up on a line of its own for both shapes callers pass: + /// a bare line (`handle_child`) and a newline-terminated chunk (nativets). The + /// sinks concatenate what they are given verbatim, so getting this wrong welds + /// the notice onto whichever line follows it. + #[test] + fn notice_lands_on_its_own_line_for_both_caller_shapes() { + let job_id = Uuid::new_v4(); + register_running_job(job_id); + register_secret_for_job(job_id, "supersecretvalue"); + + let line = snapshot(&job_id) + .expect("secret registered") + .mask("tok supersecretvalue"); + assert_eq!(line, format!("tok s*****e\n{MASKED_NOTICE}")); + + let chunk = snapshot(&job_id) + .expect("secret registered") + .mask("tok supersecretvalue\n"); + assert_eq!(chunk, format!("tok s*****e\n{MASKED_NOTICE}\n")); + + unregister_running_job(job_id); } } diff --git a/backend/windmill-common/src/ssrf.rs b/backend/windmill-common/src/ssrf.rs index 31dee749e6..0cdf7ffa4f 100644 --- a/backend/windmill-common/src/ssrf.rs +++ b/backend/windmill-common/src/ssrf.rs @@ -8,6 +8,8 @@ pub const ALLOW_PRIVATE_SAML_METADATA_URLS_ENV: &str = "ALLOW_PRIVATE_SAML_METAD pub const ALLOW_PRIVATE_GUEST_JWKS_URLS_ENV: &str = "ALLOW_PRIVATE_GUEST_JWKS_URLS"; +pub const ALLOW_PRIVATE_WEBHOOK_URLS_ENV: &str = "ALLOW_PRIVATE_WEBHOOK_URLS"; + /// Lets every git call reach hosts on a private network, whoever it is made for. /// Without it, [`private_git_host_allowed`] decides. pub const ALLOW_LOCAL_GIT_REMOTES_ENV: &str = "ALLOW_LOCAL_GIT_REMOTES"; @@ -207,6 +209,12 @@ pub fn allow_private_saml_metadata_urls() -> bool { .is_some_and(|v| v == "true" || v == "1") } +fn allow_private_webhook_urls() -> bool { + std::env::var(ALLOW_PRIVATE_WEBHOOK_URLS_ENV) + .ok() + .is_some_and(|v| v == "true" || v == "1") +} + fn allow_local_git_remotes() -> bool { std::env::var(ALLOW_LOCAL_GIT_REMOTES_ENV) .ok() @@ -321,6 +329,26 @@ pub async fn validate_mcp_server_url(url: &str) -> Result Result { + let parsed = + url::Url::parse(url).map_err(|e| SsrfValidationError::InvalidUrl(e.to_string()))?; + + match parsed.scheme() { + "http" | "https" => {} + scheme => return Err(SsrfValidationError::DisallowedScheme(scheme.to_string())), + } + + let host = parsed.host_str().ok_or(SsrfValidationError::MissingHost)?; + + if allow_private_webhook_urls() { + return Ok(ValidatedTarget::unpinned(host)); + } + + validate_url_for_ssrf(url).await +} + /// Validate an MCP-related URL and return the [`ValidatedTarget`] so the caller /// can pin the connect: the OAuth registration/discovery/token requests carry /// secrets, so they must target the validated address (see @@ -358,6 +386,16 @@ pub fn saml_ssrf_error_message(e: &SsrfValidationError) -> String { } } +pub fn webhook_ssrf_error_message(e: &SsrfValidationError) -> String { + match e { + SsrfValidationError::Private { .. } => format!( + "{e}. If you need to use private/internal webhook URLs, \ + set the {ALLOW_PRIVATE_WEBHOOK_URLS_ENV}=true environment variable" + ), + _ => e.to_string(), + } +} + fn is_private_ip(ip: &IpAddr) -> bool { match ip { IpAddr::V4(ipv4) => is_private_ipv4(ipv4), @@ -684,6 +722,71 @@ mod tests { )); } + struct PrivateWebhookUrlsEnvGuard { + previous: Option, + } + + impl PrivateWebhookUrlsEnvGuard { + fn set(value: Option<&str>) -> Self { + let previous = std::env::var(ALLOW_PRIVATE_WEBHOOK_URLS_ENV).ok(); + match value { + Some(value) => std::env::set_var(ALLOW_PRIVATE_WEBHOOK_URLS_ENV, value), + None => std::env::remove_var(ALLOW_PRIVATE_WEBHOOK_URLS_ENV), + } + Self { previous } + } + } + + impl Drop for PrivateWebhookUrlsEnvGuard { + fn drop(&mut self) { + match &self.previous { + Some(value) => std::env::set_var(ALLOW_PRIVATE_WEBHOOK_URLS_ENV, value), + None => std::env::remove_var(ALLOW_PRIVATE_WEBHOOK_URLS_ENV), + } + } + } + + #[tokio::test] + async fn validate_webhook_url_blocks_private_by_default_with_env_hint() { + let _lock = TEST_ENV_LOCK.lock().await; + let _guard = PrivateWebhookUrlsEnvGuard::set(None); + + let private_error = validate_webhook_url("http://127.0.0.1/hook") + .await + .unwrap_err(); + assert!(matches!( + private_error, + SsrfValidationError::Private { resolved: false } + )); + assert!( + webhook_ssrf_error_message(&private_error).contains("ALLOW_PRIVATE_WEBHOOK_URLS=true") + ); + + let invalid_error = validate_webhook_url("ftp://example.com/hook") + .await + .unwrap_err(); + assert!( + !webhook_ssrf_error_message(&invalid_error).contains(ALLOW_PRIVATE_WEBHOOK_URLS_ENV) + ); + } + + #[tokio::test] + async fn validate_webhook_url_allows_private_when_env_is_set_but_keeps_syntax_guards() { + let _lock = TEST_ENV_LOCK.lock().await; + let _guard = PrivateWebhookUrlsEnvGuard::set(Some("true")); + + assert!(validate_webhook_url("http://127.0.0.1/hook").await.is_ok()); + assert!(validate_webhook_url("http://10.0.0.1/hook").await.is_ok()); + assert!(matches!( + validate_webhook_url("file:///etc/passwd").await, + Err(SsrfValidationError::DisallowedScheme(_)) + )); + assert!(matches!( + validate_webhook_url("not-a-url").await, + Err(SsrfValidationError::InvalidUrl(_)) + )); + } + #[test] fn private_git_hosts_are_refused_to_non_admins_and_on_cloud() { use GitRemoteCaller::{AdminOrSystem, NonAdmin}; diff --git a/backend/windmill-common/src/user_drafts.rs b/backend/windmill-common/src/user_drafts.rs index aed9b5c5de..ebd0ce363f 100644 --- a/backend/windmill-common/src/user_drafts.rs +++ b/backend/windmill-common/src/user_drafts.rs @@ -160,6 +160,34 @@ impl UserDraftItemKind { } } + /// The `draft.value` key holding the user-typed target path — where a + /// deploy of this draft would land when the user staged a rename. A script + /// draft round-trips its own `path`; flows and apps write a separate + /// `draft_path`, and only when it differs from the row's path. + /// + /// `None` for every kind with no editor to stage a rename in — a resource, + /// a variable, a trigger. Their drafts carry no such key, so `Some` is also + /// the test for whether a kind can be moved at all. + pub fn typed_path_field(&self) -> Option<&'static str> { + use UserDraftItemKind::*; + match self { + Script => Some("path"), + Flow | App | RawApp => Some("draft_path"), + _ => None, + } + } + + /// The other of the two path keys a draft value can carry. The editors mirror + /// the typed path into it while it differs from the row's path, so a move that + /// rewrote only `typed_path_field` would leave the mirror naming the old + /// location — and the loaders prefer the mirror, which un-does the move. + pub fn mirror_path_field(&self) -> Option<&'static str> { + match self.typed_path_field()? { + "path" => Some("draft_path"), + _ => Some("path"), + } + } + /// Whether OTHER users' drafts at a path are visible to a viewer (the /// "others are editing" list, owner circles, and the `get_draft_for_user` /// View JSON / Fork endpoint). Enabled only for the full-page editor items @@ -212,6 +240,12 @@ pub struct WithDraftOverlay { pub is_draft: bool, #[serde(skip_serializing_if = "Option::is_none")] pub draft_saved_at: Option>, + /// The deployed version the draft forked from (`draft.base`), as text + /// whatever the kind. The editor compares it to the head it loaded to tell + /// a draft that is behind. Absent when there is no draft or it was never + /// forked from a deploy. + #[serde(skip_serializing_if = "Option::is_none")] + pub draft_base: Option, /// True when no deployed row exists at this path: `inner` is only a /// best-effort stand-in synthesized from the draft and only `draft` is /// canonical. Frontend uses this to disable "diff/reset vs deployed" and @@ -297,6 +331,7 @@ where inner: Box::new(deployed), is_draft: false, draft_saved_at: None, + draft_base: None, no_deployed: false, draft: None, other_drafts_users: Vec::new(), @@ -317,7 +352,7 @@ where // row when an owned one exists. let row = sqlx::query!( r#"SELECT value as "value!: sqlx::types::Json>", - created_at + created_at, base FROM draft WHERE workspace_id = $1 AND (email = $2 OR email IS NULL) @@ -338,6 +373,7 @@ where inner: Box::new(deployed), is_draft: false, draft_saved_at: None, + draft_base: None, no_deployed: false, draft: None, other_drafts_users, @@ -350,6 +386,7 @@ where inner: Box::new(deployed), is_draft: true, draft_saved_at: Some(row.created_at), + draft_base: row.base, no_deployed: false, draft: Some(draft_json), other_drafts_users, @@ -468,7 +505,8 @@ pub async fn overlay_or_draft_only( } } -/// Delete the drafts an address owns, across every workspace. +/// Delete the drafts an address owns, and the move records routing saves to them, +/// across every workspace. /// /// `draft.email` carries no foreign key to `password`: a draft's owner is any principal the /// instance authenticates, and an external JWT's subject never has a `password` row. Deleting an @@ -479,12 +517,14 @@ pub async fn overlay_or_draft_only( /// No authorization of its own: it acts instance-wide on whatever address it is handed, so the /// caller must already have authorized removing that account (superadmin, the account's own /// holder, or SCIM). -pub async fn delete_drafts_of_email<'c>( - executor: impl sqlx::PgExecutor<'c>, - email: &str, -) -> Result<()> { +pub async fn delete_drafts_of_email(conn: &mut sqlx::PgConnection, email: &str) -> Result<()> { sqlx::query!("DELETE FROM draft WHERE email = $1", email) - .execute(executor) + .execute(&mut *conn) + .await?; + // The records routing saves to those drafts go with them: one left behind would send + // a later holder of the address to a draft that no longer exists. + sqlx::query!("DELETE FROM draft_move WHERE email = $1", email) + .execute(&mut *conn) .await?; Ok(()) } @@ -526,6 +566,29 @@ pub async fn rename_drafts_of_email( ) .execute(&mut *conn) .await?; + // The records that route saves to those drafts follow the same address, or a save + // still addressed to a path the account moved away from would start a second draft + // there. The moving account's record for a path wins, as its draft just did. + sqlx::query!( + "DELETE FROM draft_move dest + WHERE dest.email = $1 + AND EXISTS (SELECT 1 FROM draft_move src + WHERE src.email = $2 + AND src.workspace_id = dest.workspace_id + AND src.old_path = dest.old_path + AND src.typ = dest.typ)", + new_email, + old_email + ) + .execute(&mut *conn) + .await?; + sqlx::query!( + "UPDATE draft_move SET email = $1 WHERE email = $2", + new_email, + old_email + ) + .execute(&mut *conn) + .await?; Ok(()) } @@ -582,6 +645,247 @@ pub async fn delete_own_draft_for_path( Ok(()) } +/// Carry every draft at `old_path` over to `new_path` when an item MOVES +/// (rename or relocation). A draft is bound to its item by nothing but the path +/// string, so without this a move detaches every draft on the item. No owner +/// filter: teammates' rows and the legacy NULL-email row follow too. +/// +/// **The caller must have authorized the underlying item move first.** This rewrites +/// rows the caller has no permission on and enforces nothing itself, taking both paths +/// on trust: it is safe only inside a deploy that has already cleared them, and is a +/// cross-user write with no gate anywhere else. +/// +/// The value keeps its base version, so every carried draft reads as behind the head the +/// move minted. Of its two path keys, one still naming `old_path` follows the row (the +/// editors write the item's own path there on every save, so it is the item's path and +/// not a staged rename); any other value is a rename the user staged, and is kept. +/// +/// A draft already at `new_path` occupies it the way a deployed item does, so the move is +/// refused with `BadRequest` inside the deploy's transaction, refusing the rename itself: +/// moving onto it would merge two items or strand the row that lost. A row a `draft_move` +/// record already brought there from `old_path` is this rename's own draft and does not +/// count: deploying it would otherwise be refused against itself, with no way out. +pub async fn move_drafts_for_path( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + w_id: &str, + kinds: &[UserDraftItemKind], + old_path: &str, + new_path: &str, +) -> Result<()> { + let typs = kinds.iter().map(|k| k.as_str()).collect::>(); + // Named by workspace username, as the editors name other users' drafts: the + // caller is often not the owner, and cannot clear a draft they cannot find. + let owners = sqlx::query!( + r#"SELECT d.email IS NULL as "legacy!", COALESCE(u.username, p.username) as username + FROM draft d + LEFT JOIN usr u ON u.workspace_id = d.workspace_id AND u.email = d.email + LEFT JOIN password p ON p.email = d.email AND p.super_admin = true + WHERE d.workspace_id = $1 AND d.path = $2 AND d.typ::text = ANY($3::text[]) + -- A row a move already brought here from the very path being renamed is this + -- rename's own draft, not an item in its way: the record says which owners + -- it moved (all of them for an item move, one for a draft-only move). Any + -- other row is a second item and still collides. + AND NOT EXISTS ( + SELECT 1 FROM draft_move m + WHERE m.workspace_id = $1 AND m.typ::text = ANY($3::text[]) + AND m.old_path = $4 AND m.new_path = $2 + AND (m.email IS NULL OR m.email IS NOT DISTINCT FROM d.email) + ) + ORDER BY 2"#, + w_id, + new_path, + &typs as &[&str], + old_path, + ) + .fetch_all(&mut **tx) + .await?; + if !owners.is_empty() { + let names = owners + .into_iter() + .map(|o| match (o.username, o.legacy) { + (Some(name), _) => name, + (None, true) => "a legacy workspace draft".to_string(), + (None, false) => "another user".to_string(), + }) + .collect::>() + .join(", "); + return Err(crate::error::Error::BadRequest(format!( + "'{new_path}' already has a draft on it ({names}) — it must be moved or discarded first" + ))); + } + // `draft.value` is `json`, so a NUL escape left by a pre-sanitizer write makes + // `to_jsonb` raise 22P05. `clean` is `strip_json_nul`'s parity rule in SQL (escaped + // backslashes parked on chr(1), which a `json` value's text cannot hold, so nothing + // collides with it; chr(92) spells the backslash so no escape sequence reaches this + // file). A row whose keys need re-pointing is therefore rewritten clean rather than + // left naming the old path, and one that needs nothing keeps its value byte for byte. + sqlx::query!( + r#"UPDATE draft AS d + SET path = $3::text, + value = ( + SELECT CASE + WHEN s.clean -> 'path' = to_jsonb($2::text) + OR s.clean -> 'draft_path' = to_jsonb($2::text) + THEN to_json( + s.clean + || CASE WHEN s.clean -> 'path' = to_jsonb($2::text) + THEN jsonb_build_object('path', $3::text) + ELSE '{}'::jsonb END + || CASE WHEN s.clean -> 'draft_path' = to_jsonb($2::text) + THEN jsonb_build_object('draft_path', $3::text) + ELSE '{}'::jsonb END + ) + ELSE d.value + END + FROM (SELECT replace(replace(replace(d.value::text, chr(92) || chr(92), chr(1)), + chr(92) || 'u0000', ''), chr(1), chr(92) || chr(92))::jsonb AS clean) s + ) + WHERE d.workspace_id = $1 + AND d.path = $2::text + AND d.typ::text = ANY($4::text[])"#, + w_id, + old_path, + new_path, + &typs as &[&str], + ) + .execute(&mut **tx) + .await?; + record_draft_move(tx, w_id, kinds, old_path, new_path, None).await +} + +/// Record that the drafts at `old_path` now live at `new_path`, so a draft save still +/// addressed to `old_path` lands on them (see `update_draft`). `email` scopes the +/// record to one user's draft-only move; `None` is a deployed item's move, for everyone. +/// +/// Kept to one hop: records pointing at `old_path` are re-pointed (an owner's move +/// leaves the item's own record alone and copies it into its own scope instead), and +/// records leaving either path are replaced, since `new_path` now holds the item. +/// +/// **The caller must have authorized the move first.** A record routes every later +/// draft write at `old_path` (any owner's, for an item move), and enforces nothing +/// itself. +/// +/// A record outlives the editors that need it: it ends when a later move touches either +/// path or an item is deployed at `old_path`, so a save meaning to start a NEW draft at a +/// vacated path would be routed instead. Nothing does that today: every surface parks a +/// new item at a minted `u//draft_` key (`mintDraftPath.ts`). +pub async fn record_draft_move( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + w_id: &str, + kinds: &[UserDraftItemKind], + old_path: &str, + new_path: &str, + email: Option<&str>, +) -> Result<()> { + let typs = kinds.iter().map(|k| k.as_str()).collect::>(); + sqlx::query!( + "DELETE FROM draft_move + WHERE workspace_id = $1 AND typ::text = ANY($2::text[]) + AND old_path IN ($3, $4) AND ($5::text IS NULL OR email = $5)", + w_id, + &typs as &[&str], + old_path, + new_path, + email, + ) + .execute(&mut **tx) + .await?; + // Routes that ended at the destination before this move describe drafts that were + // carried there for an item this one is replacing: left alive, a save addressed to + // the start of that chain would land on this move's draft instead. + sqlx::query!( + "DELETE FROM draft_move + WHERE workspace_id = $1 AND typ::text = ANY($2::text[]) + AND new_path = $4 AND old_path <> $3 AND ($5::text IS NULL OR email = $5)", + w_id, + &typs as &[&str], + old_path, + new_path, + email, + ) + .execute(&mut **tx) + .await?; + sqlx::query!( + "UPDATE draft_move SET new_path = $4 + WHERE workspace_id = $1 AND typ::text = ANY($2::text[]) + AND new_path = $3 AND ($5::text IS NULL OR email = $5)", + w_id, + &typs as &[&str], + old_path, + new_path, + email, + ) + .execute(&mut **tx) + .await?; + // An owner's move must not re-point what everyone else follows, so the item's own + // records ending at `old_path` are copied into this scope: a save addressed to the + // start of that chain still reaches this destination in one hop. Only those: another + // user's owner-scoped record routes their own draft-only item, which this move has + // nothing to do with. + if email.is_some() { + sqlx::query!( + "INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email) + SELECT m.workspace_id, m.typ, m.old_path, $4, $5::text + FROM draft_move m + WHERE m.workspace_id = $1 AND m.typ::text = ANY($2::text[]) + AND m.new_path = $3 AND m.email IS NULL + AND NOT EXISTS ( + SELECT 1 FROM draft_move o + WHERE o.workspace_id = m.workspace_id AND o.typ = m.typ + AND o.old_path = m.old_path AND o.email = $5::text + )", + w_id, + &typs as &[&str], + old_path, + new_path, + email, + ) + .execute(&mut **tx) + .await?; + } + sqlx::query!( + "INSERT INTO draft_move (workspace_id, typ, old_path, new_path, email) + SELECT $1, t::draft_kind, $3, $4, $5 FROM unnest($2::text[]) t", + w_id, + &typs as &[&str], + old_path, + new_path, + email, + ) + .execute(&mut **tx) + .await?; + Ok(()) +} + +/// Drop the move records at `path`: an item was just created there, so saves addressed to +/// it are its own (records leaving `path`). A deploy that is not a rename also drops the +/// records arriving, which point at an item that no longer owns the path; `keep_from`, +/// the path a rename came from, suppresses that, since the chain ending here is the one +/// this very deploy just wrote. +/// +/// **The caller must have authorized the deploy that created the item first.** +/// Dropping a record sends later draft writes at `path` back to `path`. +pub async fn clear_draft_moves_from( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + w_id: &str, + kinds: &[UserDraftItemKind], + path: &str, + keep_from: Option<&str>, +) -> Result<()> { + let typs = kinds.iter().map(|k| k.as_str()).collect::>(); + sqlx::query!( + "DELETE FROM draft_move WHERE workspace_id = $1 AND typ::text = ANY($2::text[]) + AND (old_path = $3 OR (new_path = $3 AND $4::text IS NULL))", + w_id, + &typs as &[&str], + path, + keep_from, + ) + .execute(&mut **tx) + .await?; + Ok(()) +} + /// Fetch the authed user's draft as a standalone payload, for "get by path" /// routes when no deployed row exists but a draft might. Returns it as a /// `WithDraftOverlay` with `inner` and `draft` both set to the same JSON and @@ -600,7 +904,7 @@ pub async fn fetch_draft_only( // Own draft first, legacy NULL-email row as fallback (see `maybe_overlay_draft`). let row = sqlx::query!( r#"SELECT value as "value!: sqlx::types::Json>", - created_at + created_at, base FROM draft WHERE workspace_id = $1 AND (email = $2 OR email IS NULL) @@ -631,6 +935,7 @@ pub async fn fetch_draft_only( inner: Box::new(draft_json.clone()), is_draft: true, draft_saved_at: Some(row.created_at), + draft_base: row.base, no_deployed: true, draft: Some(draft_json), other_drafts_users, diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index fd0bd634ea..b247c66532 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -245,6 +245,9 @@ lazy_static::lazy_static! { /// Reject a path the `proper_id` constraint would reject anyway, so the caller /// gets a plain 400 instead of the raw Postgres constraint-violation string, /// which names the table and constraint and echoes the input back. +/// +/// `app` and the trigger tables have no such constraint: for them this check is +/// the only authority, so loosening it lets malformed paths be stored. pub fn check_proper_path(path: &str) -> Result<()> { // The column is varchar(255); without this an over-long but well-formed path // still reaches Postgres and leaks the same kind of message back. @@ -479,6 +482,28 @@ pub fn paginate(pagination: Pagination) -> (usize, usize) { (per_page, offset) } +/// [`paginate`] for a listing that answers whole unless a size is asked for: the deploy +/// histories, which the history panels and the CLI read unpaged while the diff picker takes +/// a page at a time. An asked-for size is still clamped, and the offset saturates rather +/// than wrapping, so no caller can turn this into an unbounded scan or a negative bind. +pub fn paginate_optional(pagination: Pagination) -> (i64, i64) { + // Naming neither parameter asks for the whole listing, the contract these endpoints + // have always answered on. Naming either makes it a page like any other listing's. + if pagination.page.is_none() && pagination.per_page.is_none() { + return (i64::MAX, 0); + } + let per_page = pagination + .per_page + .unwrap_or(DEFAULT_PER_PAGE) + .clamp(1, MAX_PER_PAGE) as i64; + // Bound before Postgres sees it: an unchecked cast of a caller-controlled page becomes + // a negative OFFSET, which is an error rather than an empty page. + let offset = i64::try_from(pagination.page.unwrap_or(1).max(1) - 1) + .unwrap_or(i64::MAX) + .saturating_mul(per_page); + (per_page, offset) +} + pub fn paginate_without_limits(pagination: Pagination) -> (usize, usize) { let per_page = pagination.per_page.unwrap_or(MAX_PER_PAGE); let offset = (pagination.page.unwrap_or(1).max(1) - 1) * per_page; @@ -1672,6 +1697,31 @@ pub fn truncate_with_ellipsis(s: &str, max_chars: usize) -> String { mod tests { use super::*; + #[test] + fn test_paginate_optional_answers_whole_but_bounds_what_is_asked_for() { + // Nothing asked for: every row there can be, which is what the history panels and + // the CLI read. + assert_eq!( + paginate_optional(Pagination { page: None, per_page: None }), + (i64::MAX, 0) + ); + assert_eq!( + paginate_optional(Pagination { page: Some(3), per_page: Some(20) }), + (20, 40) + ); + // An asked-for size is still capped, so no caller turns this into an unbounded scan. + assert_eq!( + paginate_optional(Pagination { page: None, per_page: Some(usize::MAX) }), + (MAX_PER_PAGE as i64, 0) + ); + // A page nobody could mean lands past the end rather than going negative, which + // Postgres would reject outright. + let (per_page, offset) = + paginate_optional(Pagination { page: Some(usize::MAX), per_page: Some(20) }); + assert_eq!(per_page, 20); + assert_eq!(offset, i64::MAX); + } + /// A 5-field crontab line is the most common way to get a schedule rejected, and both /// parsers report it in terms a crontab user cannot act on, so the seconds field and the /// equivalent expression must reach the caller for v1 and v2 alike. diff --git a/backend/windmill-common/src/webhook.rs b/backend/windmill-common/src/webhook.rs index 02d6f7ddc5..2f0d83f7e2 100644 --- a/backend/windmill-common/src/webhook.rs +++ b/backend/windmill-common/src/webhook.rs @@ -164,7 +164,10 @@ impl WebhookShared { reqwest::Client::builder() .connect_timeout(Duration::from_secs(5)) // TODO: investigate pool timeouts and such if TCP load is high - .timeout(Duration::from_secs(5)), + .timeout(Duration::from_secs(5)) + // The workspace webhook URL is SSRF-checked when saved; a redirect + // would send the POST past that check. + .redirect(reqwest::redirect::Policy::none()), ) .build() .unwrap(); diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index ee8a389bb7..ad0a5068e8 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -41,6 +41,8 @@ use crate::{ #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] pub struct CustomTags { pub global: Vec, + /// Keyed by the entry's name, which may hold placeholders like a global entry: only a name + /// without any can be looked up by the tag it admits, the others through [`custom_tag_matches`]. pub specific: HashMap, } @@ -83,20 +85,7 @@ impl CustomTags { } else { self.specific .iter() - .map(|(tag, tag_data)| { - let separator = tag_data.tag_type.corresponding_separator(); - let mut workspaces = tag_data - .workspaces - .iter() - .map(|w| w.to_string()) - .collect::>() - .join(&*separator.to_string()); - if tag_data.tag_type == SpecificTagType::AllExcluding { - // the AllExcluding tag syntax has a leading separator - workspaces.insert(0, separator); - } - format!("{}({})", tag, workspaces) - }) + .map(|(tag, tag_data)| tag_data.authored(tag)) .collect::>() }; let all_tags = self.global.clone(); @@ -104,6 +93,84 @@ impl CustomTags { } } +/// Whether a job whose tag resolved to `tag` falls under the custom tag `entry`. An entry holding +/// placeholders is a pattern over resolved tags: `$args[...]` and `$flow_expr[...]` match any +/// text, since whoever pushes the job picks their values, and `$workspace` matches +/// `tag_workspace`, what the job's own `$workspace` resolves to. The text around them must match +/// as written: it is what confines `gpu-$args[size]` to the `gpu-` tags. +/// +/// Placeholders whose values are tied, a repeat or one reading inside another, make an entry no +/// wildcard pattern describes (`t-$args[id]-$args[id]` never resolves to `t-a-b`). Such an entry +/// matches nothing here: it admits only a job whose tag is written exactly as the entry is. +pub fn custom_tag_matches(entry: &str, tag: &str, tag_workspace: &str) -> bool { + if !entry.contains('$') { + return entry == tag; + } + let dynamic: Vec<&str> = CUSTOM_TAG_PLACEHOLDER + .find_iter(entry) + .map(|m| m.as_str()) + .filter(|p| *p != "$workspace") + .collect(); + let tied = dynamic + .iter() + .enumerate() + .any(|(i, a)| dynamic[i + 1..].iter().any(|b| placeholders_tied(a, b))); + if tied { + return false; + } + // The literal runs between wildcards, with `$workspace` substituted. + let mut pieces = vec![]; + let mut current = String::new(); + let mut last_end = 0; + for m in CUSTOM_TAG_PLACEHOLDER.find_iter(entry) { + current.push_str(&entry[last_end..m.start()]); + if m.as_str() == "$workspace" { + current.push_str(tag_workspace); + } else { + pieces.push(std::mem::take(&mut current)); + } + last_end = m.end(); + } + current.push_str(&entry[last_end..]); + pieces.push(current); + + let [first, rest @ ..] = pieces.as_slice() else { + return false; + }; + let Some((last, middle)) = rest.split_last() else { + return tag == first; + }; + let Some(mut inner) = tag + .strip_prefix(first.as_str()) + .and_then(|t| t.strip_suffix(last.as_str())) + else { + return false; + }; + for piece in middle { + match inner.find(piece.as_str()) { + Some(i) => inner = &inner[i + piece.len()..], + None => return false, + } + } + true +} + +/// Whether two `$args[...]` / `$flow_expr[...]` placeholders read the same value, or one reads a +/// value inside the other's. +fn placeholders_tied(a: &str, b: &str) -> bool { + let (Some((kind_a, path_a)), Some((kind_b, path_b))) = (a.split_once('['), b.split_once('[')) + else { + return false; + }; + let (path_a, path_b) = (path_a.trim_end_matches(']'), path_b.trim_end_matches(']')); + let inside = |outer: &str, inner: &str| { + inner + .strip_prefix(outer) + .is_some_and(|rest| rest.starts_with('.')) + }; + kind_a == kind_b && (path_a == path_b || inside(path_a, path_b) || inside(path_b, path_a)) +} + /// Marker suffixed to a workspace id inside a custom tag's scope (`mytag(prod*)`) to extend the /// entry to that workspace's forks. `*` cannot appear in a workspace id (the `proper_id` check /// constraint restricts them to `^\w+(-\w+)*$`), so it can never collide with a real id. @@ -183,6 +250,22 @@ impl SpecificTagData { pub fn is_fork_scoped(&self) -> bool { self.workspaces.iter().any(|w| w.include_forks) } + + /// The entry named `name` with this scope, as written in the custom tags: `tag(ws1+ws2)`. + pub fn authored(&self, name: &str) -> String { + let separator = self.tag_type.corresponding_separator(); + let mut workspaces = self + .workspaces + .iter() + .map(|w| w.to_string()) + .collect::>() + .join(&*separator.to_string()); + if self.tag_type == SpecificTagType::AllExcluding { + // the AllExcluding tag syntax has a leading separator + workspaces.insert(0, separator); + } + format!("{}({})", name, workspaces) + } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum SpecificTagType { @@ -464,7 +547,9 @@ lazy_static::lazy_static! { - // ^([\w-]+) # Group 1: tag name + // ^( # Group 1: tag name, a pattern when it holds placeholders + // (?:[\w-]|\$workspace|\$(?:args|flow_expr)\[(?:\w+\.)*\w+\])+ + // ) // \( # Literal '(' // ( # Group 2: the full workspace list // (?:[\w-]+\*?\+)*[\w-]+\*? # NoneExcept pattern: ws1+ws2* @@ -473,8 +558,12 @@ lazy_static::lazy_static! { // ) // \)$ # Closing ')' // - // The optional `*` after each workspace id is the fork marker, see [`WorkspaceMatcher`]. - static ref CUSTOM_TAG_REGEX: Regex = Regex::new(r"^([\w-]+)\(((?:[\w-]+\*?\+)*[\w-]+\*?|(?:\^[\w-]+\*?)+)\)$").unwrap(); + // The placeholders are CUSTOM_TAG_PLACEHOLDER's. The optional `*` after each workspace id is + // the fork marker, see [`WorkspaceMatcher`]. + static ref CUSTOM_TAG_REGEX: Regex = Regex::new(r"^((?:[\w-]|\$workspace|\$(?:args|flow_expr)\[(?:\w+\.)*\w+\])+)\(((?:[\w-]+\*?\+)*[\w-]+\*?|(?:\^[\w-]+\*?)+)\)$").unwrap(); + + // The placeholders a job's tag is resolved from when it is pushed, see [`custom_tag_matches`]. + static ref CUSTOM_TAG_PLACEHOLDER: Regex = Regex::new(r"\$workspace|\$(?:args|flow_expr)\[(?:\w+\.)*\w+\]").unwrap(); pub static ref DISABLE_BUNDLING: bool = std::env::var("DISABLE_BUNDLING") .ok() @@ -1082,6 +1171,83 @@ pub struct SqlAnnotations { pub raw_output: bool, } +impl SqlAnnotations { + /// The data table role a query declares as `-- role `, if any. Only meaningful against a + /// `datatable://` database that is under roles; absent means the data table's default role. + /// + /// Hand-written rather than derived because the value matters, not just the presence, and + /// because the executor needs it before it knows the connection is a data table at all. Like + /// every annotation it lives in the leading comment block. + /// + /// A leading comment whose first word is `role` is an annotation *attempt*, and a malformed + /// one is an error. The alternative — ignoring what does not parse — resolves the query to the + /// data table's default role instead, so a typo silently runs it under a login the author did + /// not choose, which is the opposite of what naming a role is for. Only callers that already + /// know the target is a `datatable://` reference ever run this, so ordinary SQL keeps its + /// comments. + pub fn datatable_role(code: &str) -> error::Result> { + for line in code.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + if !line.starts_with("--") { + break; + } + // The keyword may be followed by whitespace, `:` or `=` — `role x`, `role: x`, + // `role=x`, `Role = x` all open an attempt, while `rolexyz` does not. Each accepted + // separator is one spelling that would otherwise take the `continue` below and run the + // query as the data table's default role, which is the silence this exists to remove. + let body = line[2..].trim_start(); + let Some(after) = body + .get(..4) + .filter(|kw| kw.eq_ignore_ascii_case("role")) + .map(|_| &body[4..]) + else { + continue; + }; + if !after.is_empty() + && !after.starts_with(char::is_whitespace) + && !after.starts_with([':', '=']) + { + continue; + } + + // Past this point the line is an attempt to name a role, so a malformed one is an + // error rather than a miss. Falling through would run the query as the data table's + // default role — quietly, and under a login the author did not choose. + let after = after.trim_start(); + let after = after.strip_prefix([':', '=']).unwrap_or(after); + let mut tokens = after.split_whitespace(); + let role = tokens + .next() + .map(|role| role.strip_suffix(';').unwrap_or(role)); + let rest = tokens.next(); + match (role, rest) { + (Some(role), None) + if !role.is_empty() + && role.len() <= 63 + && role + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') => + { + return Ok(Some(role.to_string())); + } + _ => { + return Err(error::Error::BadRequest(format!( + "Malformed data table role annotation: `{line}`. Write it as \ + `-- role ` on a line of its own, where is letters, digits, \ + '_' or '-'. A comment in the leading block that starts with the word \ + 'role' is read as this annotation; move it below the first statement if \ + it is prose." + ))); + } + } + } + Ok(None) + } +} + #[annotations("#")] pub struct BashAnnotations { pub docker: bool, @@ -2653,6 +2819,56 @@ mod tests { use super::*; use std::collections::HashMap; + #[test] + fn datatable_role_is_read_from_the_leading_comment_block() { + let role = |code| SqlAnnotations::datatable_role(code); + assert_eq!( + role("-- role analytics\nSELECT 1").unwrap(), + Some("analytics".to_string()) + ); + // Blank lines and other annotations before it are fine. + assert_eq!( + role("\n-- prepare\n-- role read_only\nSELECT 1").unwrap(), + Some("read_only".to_string()) + ); + // Past the first statement it is an ordinary comment, not an annotation. + assert_eq!(role("SELECT 1;\n-- role analytics").unwrap(), None); + assert_eq!(role("SELECT 1").unwrap(), None); + + // Unambiguous intent is honoured: the keyword matches case-insensitively, a trailing + // semicolon is a habit carried over from SQL rather than a different role, and the colon + // spelling is the one most likely to be typed. + for accepted in [ + "-- Role operator\nSELECT 1", + "-- role operator;\nSELECT 1", + "-- role: operator\nSELECT 1", + "-- role:operator\nSELECT 1", + "-- role=operator\nSELECT 1", + "-- Role = operator\nSELECT 1", + ] { + assert_eq!( + role(accepted).unwrap(), + Some("operator".to_string()), + "not honoured: {accepted}" + ); + } + + // Anything else opening with the word is refused rather than resolved to the default role: + // the whole point of naming one is to not run as something else. + for near_miss in [ + "-- role operator -- why\nSELECT 1", + "-- role an;alytics\nSELECT 1", + "-- role\nSELECT 1", + "-- role:\nSELECT 1", + "-- role based access is handled below\nSELECT 1", + ] { + assert!(role(near_miss).is_err(), "silently ignored: {near_miss}"); + } + + // A word that merely starts with the keyword is not an attempt. + assert_eq!(role("-- rolebased notes\nSELECT 1").unwrap(), None); + } + fn matcher(id: &str) -> WorkspaceMatcher { WorkspaceMatcher { id: id.to_string(), include_forks: false } } @@ -2943,6 +3159,92 @@ mod tests { assert_eq!(result, vec!["foo", "legacy(^ws1^ws2)", "urgent(ws1+ws2)"]); } + #[test] + fn test_custom_tag_matches_resolved_tags() { + let matches = |entry, tag| custom_tag_matches(entry, tag, "ws1"); + + assert!(matches("gpu", "gpu")); + assert!(!matches("gpu", "gpu-large")); + + // The text around a placeholder fences what its value can make of the tag. + assert!(matches("gpu-$args[size]", "gpu-large")); + assert!(matches("gpu-$flow_expr[results.a.size]", "gpu-")); + assert!(!matches("gpu-$args[size]", "prod")); + assert!(!matches("gpu-$args[size]", "xgpu-large")); + assert!(matches("$args[region]-gpu", "eu-gpu")); + assert!(!matches("$args[region]-gpu", "eu-gpu-x")); + assert!(matches("a-$args[x]-b-$args[y]-c", "a-1-b-2-c")); + assert!(!matches("a-$args[x]-b-$args[y]-c", "a-1-c")); + assert!(!matches("ab$args[x]ba", "aba")); + + // A bare placeholder admits every tag. + assert!(matches("$flow_expr[results.a.tag]", "anything")); + + // Tied placeholders are no pattern: their entry admits only its own text. + assert!(!matches("t-$args[id]-$args[id]", "t-a-a")); + assert!(!matches("t-$args[a]-$args[a.b]", "t-x-y")); + assert!(matches("t-$args[a.x]-$args[a.y]", "t-x-y")); + assert!(matches("t-$args[id]-$flow_expr[flow_input.id]", "t-x-y")); + + // `$workspace` stands for the job's own workspace only. + assert!(matches("tag-$workspace", "tag-ws1")); + assert!(!matches("tag-$workspace", "tag-ws2")); + assert!(matches("$workspace-$args[size]", "ws1-large")); + assert!(!matches("$workspace-$args[size]", "ws2-large")); + } + + #[test] + fn test_scoped_custom_tag_patterns_parse_and_round_trip() { + let input = vec![ + "gpu-$args[size](ws1+ws2)".to_string(), + "cpu-$flow_expr[results.a.size](^ws1)".to_string(), + "$workspace-$args[x](prod*)".to_string(), + "t-$args[id]-$args[id](ws1)".to_string(), + ]; + let tags = CustomTags::from(input.clone()); + + assert!(tags.global.is_empty()); + assert_eq!( + tags.specific["gpu-$args[size]"], + SpecificTagData { + tag_type: SpecificTagType::NoneExcept, + workspaces: vec![matcher("ws1"), matcher("ws2")], + } + ); + assert_eq!( + tags.specific["cpu-$flow_expr[results.a.size]"], + SpecificTagData { + tag_type: SpecificTagType::AllExcluding, + workspaces: vec![matcher("ws1")], + } + ); + assert!(tags.specific["$workspace-$args[x]"].is_fork_scoped()); + assert!(tags.specific.contains_key("t-$args[id]-$args[id]")); + + let mut result = tags.to_string_vec(None); + result.sort(); + let mut expected = input; + expected.sort(); + assert_eq!(result, expected); + + let mut for_ws1 = tags.to_string_vec(Some(&chain(&["ws1"]))); + for_ws1.sort(); + assert_eq!(for_ws1, vec!["gpu-$args[size]", "t-$args[id]-$args[id]"]); + + // Only the placeholders a tag resolves from make a name a pattern: anything else is no + // scoped entry at all. + for literal in [ + "gpu-$foo(ws1)", + "gpu-$args[](ws1)", + "gpu-$args[a.](ws1)", + "gpu.x(ws1)", + ] { + let tags = CustomTags::from(vec![literal.to_string()]); + assert_eq!(tags.global, vec![literal]); + assert!(tags.specific.is_empty()); + } + } + #[test] fn test_fork_marker_parses_and_round_trips() { let tags = CustomTags::from(vec![ diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 7cf910ef84..6f82e3a261 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use strum::AsRefStr; use crate::{ + datatable_roles::{ADMIN_DATATABLE_ROLE, CUSTOM_INSTANCE_USER}, error::{self, to_anyhow, Error, Result}, get_database_url, secret_backend::{get_secret_value, is_external_stored_value}, @@ -183,7 +184,7 @@ pub enum ObjectType { DatatableMigration, } -pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28969/sync-script-to-git-repo-windmill"; +pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28971/sync-script-to-git-repo-windmill"; /// Hub script that applies a repository's state back into a workspace /// (the repo → Windmill / "pull" direction). Same script the UI runs from @@ -191,7 +192,7 @@ pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28969/sync-script-to-git-repo /// ignores the slug, so the slug is kept free of characters that would be /// percent-encoded into the run URL (a `:` becomes `%3A`, which some hardened /// reverse proxies reject as double-encoding when the client re-encodes it). -pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28957/git-sync-init-repository-windmill"; +pub const GIT_SYNC_PULL_SCRIPT_PATH: &str = "hub/28970/git-sync-init-repository-windmill"; /// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a /// fork of another workspace. @@ -1292,9 +1293,23 @@ impl Default for DataTableForkBehavior { } } -#[derive(Deserialize, Serialize, Debug)] +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct DataTable { - pub database: DataTableDatabase, + /// Set on a *terminal* entry — one that owns its database. Mutually exclusive with + /// [`DataTable::reference`]; [`validate_datatable_shape`] is the one place that enforces it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub database: Option, + /// Set on a *pointer* entry — one that names another workspace's entry and owns nothing. + /// A keep-original fork gets one of these instead of a copy of the parent's entry, so there is + /// nothing local for a fork admin to widen. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reference: Option, + /// Set on a *clone* — a terminal entry whose database was copied from the entry this names. The + /// copy holds that entry's rows, so who may connect as which role stays that entry's decision: + /// the clone carries no `permissions` of its own and is governed like a pointer, while + /// connecting to its own database. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub governed_by: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub forked_from: Option, /// Whether the SQL-migrations feature is opted in for this data table. @@ -1302,22 +1317,92 @@ pub struct DataTable { /// when migrations already exist (see `datatable_migrations_enabled`). #[serde(default, skip_serializing_if = "Option::is_none")] pub migrations_enabled: Option, + /// Who may connect as which role. Absent = unpermissioned: every caller connects as `admin`, + /// which is how data tables behaved before roles existed. Only meaningful on a terminal entry; + /// a pointer is governed by what it points at. + /// + /// Never leaves the instance: stripped from the workspace export and ignored on import, since + /// tenants are workspace-scoped names and syncing them would make repo write access a second + /// door onto the access decision. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub permissions: Option, } -#[derive(Deserialize, Serialize, Debug)] +/// A pointer at another workspace's data table entry. +#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)] +pub struct DataTableReference { + pub workspace_id: String, + pub datatable: String, +} + +/// The access decision for one data table: which role a caller gets, and who may ask for each. +#[derive(Deserialize, Serialize, Debug, Clone, Default)] +pub struct DataTablePermissions { + /// A role id from the instance catalog, or `admin`. Absent = `admin`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_role: Option, + /// Keyed by instance role id, plus the reserved `admin` key. A role absent from this map + /// cannot be used on this data table at all, whatever the instance catalog says. + #[serde(default)] + pub roles: std::collections::BTreeMap, +} + +impl DataTablePermissions { + pub fn default_role(&self) -> &str { + self.default_role.as_deref().unwrap_or(ADMIN_DATATABLE_ROLE) + } +} + +#[derive(Deserialize, Serialize, Debug, Clone, Default)] +pub struct DataTableRoleTenants { + /// `u/`, `g/`, `f/`, or `*` for every member. + #[serde(default)] + pub tenants: Vec, +} + +/// Every member of the governing workspace. +pub const DATATABLE_TENANT_WILDCARD: &str = "*"; + +/// How deep a chain of pointer entries may go before it is called a loop. Data tables are not +/// expected to chain at all — a fork points at its parent — so this only has to be generous +/// enough to survive a fork of a fork. +const DATATABLE_REFERENCE_MAX_DEPTH: usize = 20; + +/// Exactly one of `database` and `reference` must be set, and a clone's `governed_by` leaves no +/// `permissions` beside it. Called wherever an entry is persisted, so nothing downstream has to +/// handle an entry that is both or neither, or a clone with a decision of its own. +pub fn validate_datatable_shape(name: &str, dt: &DataTable) -> Result<()> { + if dt.governed_by.is_some() && (dt.database.is_none() || dt.permissions.is_some()) { + return Err(Error::BadRequest(format!( + "Data table '{name}' is a clone, which owns a database and takes its roles from the \ + data table it was cloned from" + ))); + } + match (&dt.database, &dt.reference) { + (Some(_), None) | (None, Some(_)) => Ok(()), + (Some(_), Some(_)) => Err(Error::BadRequest(format!( + "Data table '{name}' both owns a database and points at another one" + ))), + (None, None) => Err(Error::BadRequest(format!( + "Data table '{name}' names neither a database nor another data table" + ))), + } +} + +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct DataTableForkedFrom { /// Schema snapshot at fork time #[serde(default, skip_serializing_if = "Option::is_none")] pub schema: Option, } -#[derive(Deserialize, Serialize, Debug)] +#[derive(Deserialize, Serialize, Debug, Clone)] pub struct DataTableDatabase { pub resource_type: DataTableCatalogResourceType, pub resource_path: String, } -#[derive(Deserialize, Serialize, Debug, PartialEq)] +#[derive(Deserialize, Serialize, Debug, PartialEq, Clone, Copy)] #[serde(rename_all = "lowercase")] #[derive(AsRefStr)] #[strum(serialize_all = "lowercase")] @@ -1353,37 +1438,13 @@ fn datatable_not_found_error(name: &str, datatables: Option<&serde_json::Value>) )) } -pub async fn get_datatable_resource_from_db_unchecked( - db: &DB, - w_id: &str, - name: &str, -) -> Result { - get_datatable_resource_inner(db, w_id, name, false).await -} - -/// Same as [`get_datatable_resource_from_db_unchecked`] but for postgres trigger -/// connections: custom-instance datatables resolve to -/// `custom_instance_replication_user` rather than `custom_instance_user`. BYO-postgres -/// datatables resolve to the user's own resource unchanged; configuring it for -/// replication there is the user's responsibility. +/// Read one workspace's data table entry, without following a pointer. /// -/// Authorization: like its `_unchecked` sibling, returns resolved connection -/// credentials and performs no authorization — callers MUST have already authorized -/// access to the datatable (e.g. the trigger's own create-time check). -pub async fn get_datatable_replication_resource_from_db_unchecked( - db: &DB, - w_id: &str, - name: &str, -) -> Result { - get_datatable_resource_inner(db, w_id, name, true).await -} - -async fn get_datatable_resource_inner( - db: &DB, - w_id: &str, - name: &str, - replication: bool, -) -> Result { +/// Disclosure: this is the primitive [`resolve_governing_datatable`] calls on every path, so it is +/// deliberately open to anything that has to resolve a data table, including for a workspace the +/// caller does not belong to. What it returns is not: callers MUST NOT put `permissions` into a +/// response, an export or a log — it names the governing workspace's users, groups and folders. +pub async fn read_datatable_entry(db: &DB, w_id: &str, name: &str) -> Result { let datatables = sqlx::query_scalar!( r#" SELECT ws.datatable->'datatables' AS datatables @@ -1401,37 +1462,674 @@ async fn get_datatable_resource_inner( .and_then(|d| d.get(name)) .filter(|v| !v.is_null()) .ok_or_else(|| datatable_not_found_error(name, datatables.as_ref()))?; - let datatable = serde_json::from_value::(datatable.clone())?; + Ok(serde_json::from_value::(datatable.clone())?) +} - let db_resource = if datatable.database.resource_type == DataTableCatalogResourceType::Instance - { +/// The terminal entry a reference chain lands on, and what governs it: the workspace and name of +/// the entry that owns the database, the entry itself, and — for a clone — the entry its +/// `governed_by` chain lands on. A terminal entry that is not a clone resolves to itself and +/// governs itself. +/// +/// Every decision downstream is taken on this, never on the entry the caller named. Which database +/// to connect to comes from `workspace_id` / `name` / `datatable.database`; whose `permissions` +/// apply (already in `datatable.permissions`), whose members tenants are evaluated against and who +/// may administer it come from [`GoverningDatatable::governing_workspace_id`]. +/// +/// Authorization: resolving deliberately crosses into the governing workspace, so it answers for a +/// workspace the caller may not belong to and checks nothing itself. It is the input to the +/// checks, not one of them: callers MUST pass what it returns to +/// [`can_use_datatable_role_in_governing_workspace`] or [`ensure_datatable_admin_access`] before +/// acting on it, and MUST NOT return its `permissions` or workspace ids to a caller from +/// elsewhere without gating on the answer. +pub struct GoverningDatatable { + pub workspace_id: String, + pub name: String, + pub datatable: DataTable, + /// For a clone, the entry whose `permissions` govern it. `None` when the entry governs itself. + pub governor: Option, +} + +impl GoverningDatatable { + /// Backed by the Windmill instance's own Postgres, which is the only substrate data table + /// roles apply to. + pub fn is_instance(&self) -> bool { + self.datatable + .database + .as_ref() + .is_some_and(|d| d.resource_type == DataTableCatalogResourceType::Instance) + } + + /// The workspace whose admins administer the data table and whose members its tenants are. + pub fn governing_workspace_id(&self) -> &str { + self.governor + .as_ref() + .map_or(&self.workspace_id, |g| &g.workspace_id) + } +} + +pub async fn resolve_governing_datatable( + db: &DB, + w_id: &str, + name: &str, +) -> Result { + let mut workspace_id = w_id.to_string(); + let mut name = name.to_string(); + let mut hops = 0; + // The clone a `governed_by` chain started from: it keeps its database, and takes the + // `permissions` of wherever the chain lands. + let mut clone: Option<(String, String, DataTable)> = None; + for _ in 0..DATATABLE_REFERENCE_MAX_DEPTH { + let datatable = read_datatable_entry(db, &workspace_id, &name) + .await + .map_err(|e| { + if hops == 0 { + e + } else { + // A pointer outlives the workspace it names: deleting one only nulls the fork + // lineage, it does not sweep the entries that pointed at it. Say which one is + // gone rather than reporting a data table this workspace never had. + if clone.is_some() { + Error::NotFound(format!( + "Data table '{name}' of workspace '{workspace_id}', which this clone \ + takes its roles from, no longer exists, so nobody is let into the copy." + )) + } else { + Error::NotFound(format!( + "Data table '{name}' of workspace '{workspace_id}' governs this one and \ + no longer exists. A superadmin can point this data table somewhere \ + else." + )) + } + } + })?; + hops += 1; + validate_datatable_shape(&name, &datatable)?; + let next = match (&datatable.reference, &datatable.governed_by) { + (Some(reference), _) => reference.clone(), + (None, Some(governed_by)) => { + let governed_by = governed_by.clone(); + if clone.is_none() { + clone = Some((workspace_id.clone(), name.clone(), datatable)); + } + governed_by + } + (None, None) => { + return Ok(match clone { + None => GoverningDatatable { workspace_id, name, datatable, governor: None }, + Some((clone_w_id, clone_name, mut clone_datatable)) => { + clone_datatable.permissions = datatable.permissions; + GoverningDatatable { + workspace_id: clone_w_id, + name: clone_name, + datatable: clone_datatable, + governor: Some(DataTableReference { workspace_id, datatable: name }), + } + } + }); + } + }; + workspace_id = next.workspace_id; + name = next.datatable; + } + Err(Error::BadRequest(format!( + "Data table '{name}' points at another data table through more than \ + {DATATABLE_REFERENCE_MAX_DEPTH} hops; the chain is likely a loop" + ))) +} + +/// Every entry of a workspace resolved as [`resolve_governing_datatable`] resolves one, in stored +/// order, reading the settings rows one pointer hop at a time rather than once per entry. An entry +/// that does not resolve — malformed, a dangling pointer, a loop — is left out. Same authorization +/// contract as the single resolution: it checks nothing. +pub async fn resolve_workspace_governing_datatables( + db: &DB, + w_id: &str, +) -> Result> { + type Entries = + std::collections::HashMap>; + async fn load(db: &DB, workspaces: &[String], entries: &mut Entries) -> Result> { + let rows: Vec<(String, String, serde_json::Value)> = sqlx::query_as( + "SELECT ws.workspace_id, dt.key, dt.value FROM workspace_settings ws + CROSS JOIN LATERAL jsonb_each(COALESCE(ws.datatable->'datatables', '{}'::jsonb)) dt + WHERE ws.workspace_id = ANY($1)", + ) + .bind(workspaces) + .fetch_all(db) + .await?; + for ws in workspaces { + entries.entry(ws.clone()).or_default(); + } + let mut keys = Vec::with_capacity(rows.len()); + for (ws, key, value) in rows { + keys.push(key.clone()); + entries.entry(ws).or_default().insert(key, value); + } + Ok(keys) + } + + let mut entries = Entries::new(); + let listed = load(db, &[w_id.to_string()], &mut entries).await?; + // (index into `listed`, workspace, entry name, the clone the chain started from) still to be + // followed. A clone keeps its own database and takes the `permissions` of wherever its + // `governed_by` chain lands, as the single resolution does. + type Clone = Option<(String, String, DataTable)>; + let mut cursors: Vec<(usize, String, String, Clone)> = listed + .iter() + .enumerate() + .map(|(i, name)| (i, w_id.to_string(), name.clone(), None)) + .collect(); + let mut resolved: Vec<(usize, GoverningDatatable)> = vec![]; + + for _ in 0..DATATABLE_REFERENCE_MAX_DEPTH { + let mut next = vec![]; + for (i, ws, name, clone) in cursors.drain(..) { + let Some(value) = entries + .get(&ws) + .and_then(|m| m.get(&name)) + .filter(|v| !v.is_null()) + else { + continue; + }; + let Ok(datatable) = serde_json::from_value::(value.clone()) else { + continue; + }; + if validate_datatable_shape(&name, &datatable).is_err() { + continue; + } + match (&datatable.reference, &datatable.governed_by) { + (Some(reference), _) => next.push(( + i, + reference.workspace_id.clone(), + reference.datatable.clone(), + clone, + )), + (None, Some(governed_by)) => { + let (governor_ws, governor_name) = + (governed_by.workspace_id.clone(), governed_by.datatable.clone()); + let clone = clone.or(Some((ws, name, datatable))); + next.push((i, governor_ws, governor_name, clone)); + } + (None, None) => resolved.push(( + i, + match clone { + None => GoverningDatatable { + workspace_id: ws, + name, + datatable, + governor: None, + }, + Some((clone_ws, clone_name, mut clone_datatable)) => { + clone_datatable.permissions = datatable.permissions; + GoverningDatatable { + workspace_id: clone_ws, + name: clone_name, + datatable: clone_datatable, + governor: Some(DataTableReference { + workspace_id: ws, + datatable: name, + }), + } + } + }, + )), + } + } + if next.is_empty() { + break; + } + let to_load: Vec = next + .iter() + .map(|(_, ws, _, _)| ws.clone()) + .filter(|ws| !entries.contains_key(ws)) + .collect::>() + .into_iter() + .collect(); + if !to_load.is_empty() { + load(db, &to_load, &mut entries).await?; + } + cursors = next; + } + + resolved.sort_by_key(|(i, _)| *i); + Ok(resolved + .into_iter() + .map(|(i, governing)| (listed[i].clone(), governing)) + .collect()) +} + +/// Build the `admin` connection for a governing entry: `custom_instance_user` for an instance +/// database, the user's own resource for a BYO-postgres one. +async fn resolve_datatable_connection_unchecked( + db: &DB, + governing: &GoverningDatatable, + replication: bool, +) -> Result { + let database = governing + .datatable + .database + .as_ref() + .expect("a governing entry owns a database"); + if database.resource_type == DataTableCatalogResourceType::Instance { let mut pg_creds = PgDatabase::parse_uri(&get_database_url().await?.as_str().await)?; - pg_creds.dbname = datatable.database.resource_path.clone(); + pg_creds.dbname = database.resource_path.clone(); if replication { pg_creds.user = Some("custom_instance_replication_user".to_string()); pg_creds.password = Some(get_custom_pg_instance_replication_password(&db).await?); } else { - pg_creds.user = Some("custom_instance_user".to_string()); + pg_creds.user = Some(CUSTOM_INSTANCE_USER.to_string()); pg_creds.password = Some(get_custom_pg_instance_password(&db).await?); } serde_json::to_value(&pg_creds) - .map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e)))? + .map_err(|e| Error::internal_err(format!("Error serializing pg creds: {}", e))) } else { // Name the data table too: the caller asked for one by name, and a bare // "resource f/x/y does not exist" leaves them to work out which one points at it. transform_json_unchecked( - &serde_json::Value::String(format!("$res:{}", datatable.database.resource_path)), - w_id, + &serde_json::Value::String(format!("$res:{}", database.resource_path)), + &governing.workspace_id, db, ) .await .map_err(|e| match e { - Error::NotFound(m) => Error::NotFound(format!("data table {name}: {m}")), + Error::NotFound(m) => Error::NotFound(format!("data table {}: {m}", governing.name)), e => e, - })? + }) + } +} + +/// Resolve a data table to connection credentials **without authorizing anything**: always the +/// `admin` connection. +/// +/// Authorization: callers MUST have authorized access already. Anything that acts for a user or a +/// job wants [`get_datatable_resource_from_db`] instead. +pub async fn get_datatable_resource_from_db_unchecked( + db: &DB, + w_id: &str, + name: &str, +) -> Result { + let governing = resolve_governing_datatable(db, w_id, name).await?; + resolve_datatable_connection_unchecked(db, &governing, false).await +} + +/// Same as [`get_datatable_resource_from_db_unchecked`] but for postgres trigger +/// connections: custom-instance datatables resolve to +/// `custom_instance_replication_user` rather than `custom_instance_user`. BYO-postgres +/// datatables resolve to the user's own resource unchanged; configuring it for +/// replication there is the user's responsibility. +/// +/// Authorization: a replication connection reads every row whatever the roles grant, so no role or +/// admin check makes it safe. Callers MUST refuse a data table under roles outright — the Postgres +/// trigger crate's `ensure_not_under_roles` — and turning roles on is refused while one streams. +pub async fn get_datatable_replication_resource_from_db_unchecked( + db: &DB, + w_id: &str, + name: &str, +) -> Result { + let governing = resolve_governing_datatable(db, w_id, name).await?; + resolve_datatable_connection_unchecked(db, &governing, true).await +} + +/// The identity a resolution is made for. `Unchecked` is for callers that authorized already; +/// everything else is checked against the governing entry's tenants. +pub enum DatatableAccess<'a> { + /// Reaches every role. For callers that already authorized, or that have no user at all. + Unchecked, + Authed(crate::db::AuthedRef<'a>), + /// A job's owner, without reading the job row — only fetched if the data table turns out to + /// be permissioned. + PermissionedAs { + permissioned_as: &'a str, + email: &'a str, + }, + /// A job identified by id; its owner is read from `v2_job`. For agent workers and anything + /// else that authenticates as infrastructure rather than as the job's user. + Job(uuid::Uuid), + /// No identity established. Unpermissioned data tables resolve as before; permissioned ones + /// are refused, so a caller predating this feature fails closed. + NoIdentity, +} + +/// Does one tenant list cover this identity? Admins of the governing workspace pass everything — +/// they can edit the tenant lists anyway, so refusing them would only be theatre. +pub fn can_use_datatable_role( + tenants: &DataTableRoleTenants, + authed: &crate::db::AuthedRef<'_>, +) -> bool { + crate::datatable_roles_oss::can_use_datatable_role(tenants, authed) +} + +/// Evaluate a tenant list **as a member of the governing workspace**, whoever is calling. +/// +/// A caller reaching a data table through a pointer is a member of some other workspace, and being +/// its admin means nothing here — that is the whole point of the pointer. They are looked up in +/// the governing workspace by email and evaluated there, or refused when they are not a member. +/// A `g/` or `f/` permissioned-as from a foreign workspace is refused outright: those names are +/// defined per workspace and mean nothing outside the one that defined them. +pub async fn can_use_datatable_role_in_governing_workspace( + db: &DB, + governing_w_id: &str, + w_id: &str, + tenants: &DataTableRoleTenants, + access: &DatatableAccess<'_>, +) -> Result { + crate::datatable_roles_oss::can_use_datatable_role_in_governing_workspace( + db, + governing_w_id, + w_id, + tenants, + access, + ) + .await +} + +/// Resolve a data table to connection credentials for one identity. +/// +/// This is the chokepoint: everything that opens a connection to a data table on someone's behalf +/// goes through it. `role` is the role name the caller asked for — the `-- role` annotation, the +/// `?role=` on a `datatable://` reference, or `None` for the data table's default. +/// +/// The resolved role **logs in as itself**. Never `SET ROLE`: a script could `RESET ROLE` its way +/// back to admin. +pub async fn get_datatable_resource_from_db( + db: &DB, + w_id: &str, + name: &str, + role: Option<&str>, + access: DatatableAccess<'_>, +) -> Result { + let governing = resolve_governing_datatable(db, w_id, name).await?; + let db_resource = resolve_datatable_connection_unchecked(db, &governing, false).await?; + // Not under roles and asked for none, or for `admin` by name: the `admin` connection, as before + // roles existed, in every edition. Anything else is a role decision. Every migration names + // `admin` explicitly, so an edition without roles must not treat that as one. + if governing.datatable.permissions.is_none() && role.is_none_or(|r| r == ADMIN_DATATABLE_ROLE) { + return Ok(db_resource); + } + crate::datatable_roles_oss::resolve_datatable_role_connection( + db, + w_id, + name, + &governing, + db_resource, + role, + access, + ) + .await +} + +/// Would the chokepoint accept this identity connecting as this role? Answers without resolving +/// credentials, for callers that want to refuse early and say which thing was refused. +/// +/// Not the security boundary — [`get_datatable_resource_from_db`] re-checks when it actually opens +/// the connection. This is what turns "permission denied for table x" into a message naming the +/// migration and the role. +pub async fn ensure_can_use_datatable_role( + db: &DB, + w_id: &str, + name: &str, + role: Option<&str>, + access: &DatatableAccess<'_>, + context: &str, +) -> Result<()> { + crate::datatable_roles_oss::ensure_can_use_datatable_role(db, w_id, name, role, access, context) + .await +} + +/// Gate the operations that see the whole database whatever the roles grant: a migration that +/// declares no role, exports, and editing the permissions themselves. Not replication, which a +/// data table under roles refuses whoever asks (see `ensure_not_under_roles`). Passing +/// means the caller could have connected as `admin` anyway. +pub async fn ensure_datatable_admin_access( + db: &DB, + w_id: &str, + name: &str, + access: &DatatableAccess<'_>, +) -> Result<()> { + crate::datatable_roles_oss::ensure_datatable_admin_access(db, w_id, name, access).await +} + +/// Rewrite the `permissions` of every data table entry of one workspace, in the caller's +/// transaction. `change` reports whether it touched anything; the row is only written when +/// something did. +/// +/// Authorization: writes an access decision for any workspace named, with an arbitrary mutation, +/// and checks nothing. It exists for the cascades below — the transaction that frees or renames a +/// principal — so callers MUST be the operation that made the principal change, and MUST run in +/// its transaction. Anything editing a decision on purpose belongs in the permissions endpoint, +/// which is gated on the workspace that governs the data table. +/// +/// The tenant lists name principals of this workspace, so anything that frees or renames one has +/// to come through here in the same transaction that frees it — otherwise a `u/alice` reused by a +/// later account silently inherits her access. +pub async fn update_datatable_permissions_in_workspace( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + w_id: &str, + change: F, +) -> Result<()> +where + F: Fn(&mut DataTablePermissions) -> bool, +{ + let Some(mut settings) = sqlx::query_scalar!( + "SELECT datatable FROM workspace_settings WHERE workspace_id = $1 FOR UPDATE", + w_id + ) + .fetch_optional(&mut **tx) + .await? + .flatten() else { + return Ok(()); }; - Ok(db_resource) + let Some(datatables) = settings + .get_mut("datatables") + .and_then(|d| d.as_object_mut()) + else { + return Ok(()); + }; + + let mut touched = false; + for entry in datatables.values_mut() { + let Some(permissions) = entry.get("permissions").filter(|p| !p.is_null()) else { + continue; + }; + let Ok(mut permissions) = + serde_json::from_value::(permissions.clone()) + else { + continue; + }; + if change(&mut permissions) { + entry["permissions"] = serde_json::to_value(&permissions) + .map_err(|e| Error::internal_err(format!("serializing permissions: {e}")))?; + touched = true; + } + } + + if touched { + sqlx::query!( + "UPDATE workspace_settings SET datatable = $1 WHERE workspace_id = $2", + settings, + w_id + ) + .execute(&mut **tx) + .await?; + } + Ok(()) +} + +/// Drop a freed principal (`u/alice`, `g/analysts`, `f/finance`) from every tenant list of one +/// workspace. Same contract as [`update_datatable_permissions_in_workspace`]: for the transaction +/// that frees the principal, not for editing a decision. +pub async fn remove_datatable_tenant_in_workspace( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + w_id: &str, + tenant: &str, +) -> Result<()> { + update_datatable_permissions_in_workspace(tx, w_id, |permissions| { + let mut touched = false; + for role in permissions.roles.values_mut() { + let before = role.tenants.len(); + role.tenants.retain(|t| t != tenant); + touched |= role.tenants.len() != before; + } + touched + }) + .await +} + +/// Follow a renamed principal through every tenant list of one workspace. Same contract as +/// [`update_datatable_permissions_in_workspace`]. +pub async fn rename_datatable_tenant_in_workspace( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + w_id: &str, + old: &str, + new: &str, +) -> Result<()> { + update_datatable_permissions_in_workspace(tx, w_id, |permissions| { + let mut touched = false; + for role in permissions.roles.values_mut() { + let mut role_touched = false; + for tenant in role.tenants.iter_mut() { + if tenant == old { + *tenant = new.to_string(); + role_touched = true; + } + } + if role_touched { + // The rename can collide with a name already in the list, and the two need not be + // adjacent — `Vec::dedup` only collapses neighbours, so it would leave the pair. + let mut seen = std::collections::HashSet::new(); + role.tenants.retain(|t| seen.insert(t.clone())); + touched = true; + } + } + touched + }) + .await +} + +/// Strip a deleted instance role from every workspace that had tenanted it, so nothing is left +/// naming a role that no longer exists. +/// +/// Authorization: reaches every workspace on the instance. Callers MUST be the superadmin path +/// dropping the role from the cluster — it exists to follow that, not to edit tenants. +/// +/// Takes that path's transaction rather than opening its own: run afterwards, a failure part-way +/// leaves the catalog row already gone, so the retry answers `NotFound` while some workspaces +/// still name a role nothing can connect as. In the transaction, the cluster drop, the catalog row +/// and every tenant list commit together or not at all. A data table whose default role was the deleted one falls +/// back to `admin` — the one role that is always present. +pub async fn forget_datatable_role_everywhere( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + role_id: &str, +) -> Result<()> { + crate::datatable_roles_oss::forget_datatable_role_everywhere(tx, role_id).await +} + +/// Drop the `permissions` block from a `workspace_settings.datatable` value before it leaves the +/// server. +/// +/// Who may connect as which role is an access decision, not configuration, and its tenants name +/// principals of one workspace — `g/analysts` in dev is a different group from `g/analysts` in +/// prod. Shipping it would both mean nothing at the far end and turn a settings push into a way to +/// widen access, so the decision stays where it was made. [`DataTable`] deserializes fine without +/// it, and the settings-editing endpoint carries the stored block across untouched. +pub fn strip_datatable_permissions( + datatable: Option, +) -> Option { + let mut datatable = datatable?; + if let Some(entries) = datatable + .get_mut("datatables") + .and_then(|d| d.as_object_mut()) + { + for entry in entries.values_mut() { + if let Some(entry) = entry.as_object_mut() { + entry.remove("permissions"); + } + } + } + Some(datatable) +} + +/// As [`parse_datatable_ref`], except that an entry whose stored name itself contains `?` — which +/// names could before they were restricted — resolves by that exact name, without a role. It is +/// looked up first, so `sales?role=x` never reaches a different entry than the one stored so. +/// When `sales` is stored too, the reference means either one, and is refused rather than +/// resolved to whichever is looked up first. +/// +/// Authorization: checks nothing, and its answer reveals whether `w_id` stores that exact name. +/// Callers MUST already act for `w_id` — a job of it, or a caller authenticated into it — and +/// MUST still pass the name to [`get_datatable_resource_from_db`] or an admin-access check. +pub async fn parse_datatable_ref_for( + db: &DB, + w_id: &str, + reference: &str, +) -> Result<(String, Option)> { + if reference.contains('?') { + let role_target = parse_datatable_ref(reference) + .ok() + .and_then(|(name, role)| role.map(|_| name)); + let (exists, target_exists) = sqlx::query_as::<_, (Option, Option)>( + "SELECT (datatable->'datatables') ? $2, (datatable->'datatables') ? $3 + FROM workspace_settings WHERE workspace_id = $1", + ) + .bind(w_id) + .bind(reference) + .bind(role_target) + .fetch_optional(db) + .await? + .unwrap_or((None, None)); + if exists.unwrap_or(false) { + if let (Some(name), Some(true)) = (role_target, target_exists) { + return Err(Error::BadRequest(format!( + "Data table reference '{reference}' names both the data table '{reference}' \ + and a role on the data table '{name}'. Rename '{reference}' to use either." + ))); + } + return Ok((reference.to_string(), None)); + } + } + let (name, role) = parse_datatable_ref(reference)?; + Ok((name.to_string(), role.map(str::to_string))) +} + +/// Split a `datatable://` reference into its name and the role its query string names. +/// +/// A query string that does not parse is an error rather than an absent role. Falling back would +/// resolve the reference to the data table's default role, so `?Role=analytics` or a mistyped +/// `?role=` would quietly connect as something the caller did not ask for — the same trap as a +/// malformed `-- role` annotation, and `role` is the only parameter a reference takes. +pub fn parse_datatable_ref(reference: &str) -> Result<(&str, Option<&str>)> { + let (name, query) = reference.split_once('?').unwrap_or((reference, "")); + let mut role = None; + for param in query.split('&').filter(|p| !p.is_empty()) { + let (key, value) = param.split_once('=').unwrap_or((param, "")); + if !key.eq_ignore_ascii_case("role") { + return Err(Error::BadRequest(format!( + "Data table reference '{name}' carries an unknown parameter '{key}'. \ + The only one it takes is `?role=`." + ))); + } + if role.is_some() { + return Err(Error::BadRequest(format!( + "Data table reference '{name}' names a role more than once." + ))); + } + if value.is_empty() || !is_datatable_role_name(value) { + return Err(Error::BadRequest(format!( + "Data table reference '{name}' has a malformed role '{value}'. Write it as \ + `?role=`, where is letters, digits, '_' or '-'." + ))); + } + role = Some(value); + } + Ok((name, role)) +} + +fn is_datatable_role_name(role: &str) -> bool { + !role.is_empty() + && role.len() <= 63 + && role + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') } #[derive(Deserialize, Serialize, Debug)] @@ -2642,6 +3340,131 @@ async fn transform_json_unchecked( mod tests { use super::*; + fn tenants(list: &[&str]) -> DataTableRoleTenants { + DataTableRoleTenants { tenants: list.iter().map(|t| t.to_string()).collect() } + } + + #[cfg(all(feature = "private", feature = "enterprise"))] + #[test] + fn a_tenant_list_covers_users_groups_folders_and_the_wildcard() { + let groups = vec!["analysts".to_string()]; + let folders = vec![("finance".to_string(), true, false)]; + let scopes = None; + let token_prefix = None; + let is_admin = false; + let is_operator = false; + let authed = crate::db::AuthedRef { + email: "alice@windmill.dev", + username: "alice", + is_admin: &is_admin, + is_operator: &is_operator, + groups: &groups, + folders: &folders, + scopes: &scopes, + token_prefix: &token_prefix, + }; + + assert!(can_use_datatable_role(&tenants(&["u/alice"]), &authed)); + assert!(can_use_datatable_role(&tenants(&["g/analysts"]), &authed)); + assert!(can_use_datatable_role(&tenants(&["f/finance"]), &authed)); + assert!(can_use_datatable_role(&tenants(&["*"]), &authed)); + assert!(!can_use_datatable_role(&tenants(&[]), &authed)); + assert!(!can_use_datatable_role( + &tenants(&["u/bob", "g/ops"]), + &authed + )); + // A bare name is not a principal: only the three prefixes and the wildcard match. + assert!(!can_use_datatable_role(&tenants(&["alice"]), &authed)); + + // An admin of the governing workspace reaches every role: they can edit the lists anyway. + let is_admin = true; + let admin = crate::db::AuthedRef { is_admin: &is_admin, ..authed }; + assert!(can_use_datatable_role(&tenants(&[]), &admin)); + } + + #[cfg(not(all(feature = "private", feature = "enterprise")))] + #[test] + fn without_the_enterprise_edition_no_tenant_list_covers_anyone() { + let groups = vec![]; + let folders = vec![]; + let scopes = None; + let token_prefix = None; + let is_admin = true; + let is_operator = false; + let admin = crate::db::AuthedRef { + email: "alice@windmill.dev", + username: "alice", + is_admin: &is_admin, + is_operator: &is_operator, + groups: &groups, + folders: &folders, + scopes: &scopes, + token_prefix: &token_prefix, + }; + assert!(!can_use_datatable_role(&tenants(&["*"]), &admin)); + assert!(!can_use_datatable_role(&tenants(&["u/alice"]), &admin)); + } + + #[test] + fn a_datatable_ref_splits_off_its_role() { + assert_eq!(parse_datatable_ref("sales").unwrap(), ("sales", None)); + assert_eq!( + parse_datatable_ref("sales?role=analytics").unwrap(), + ("sales", Some("analytics")) + ); + // The key matches case-insensitively, the way the `-- role` annotation does. + assert_eq!( + parse_datatable_ref("sales?Role=analytics").unwrap(), + ("sales", Some("analytics")) + ); + + // A query string that does not parse is refused rather than read as "no role": resolving + // it to the data table's default would connect as a login the caller never asked for. + for malformed in [ + "sales?role=", + "sales?role=an;alytics", + "sales?x=1&role=analytics", + "sales?role=a&role=b", + ] { + assert!( + parse_datatable_ref(malformed).is_err(), + "silently ignored: {malformed}" + ); + } + } + + #[test] + fn an_entry_owns_a_database_or_points_at_one_but_never_both() { + let terminal = DataTable { + database: Some(DataTableDatabase { + resource_type: DataTableCatalogResourceType::Instance, + resource_path: "dt_main".to_string(), + }), + reference: None, + governed_by: None, + forked_from: None, + migrations_enabled: None, + permissions: None, + }; + assert!(validate_datatable_shape("main", &terminal).is_ok()); + + let pointer = DataTable { + database: None, + reference: Some(DataTableReference { + workspace_id: "prod".to_string(), + datatable: "main".to_string(), + }), + ..terminal.clone() + }; + assert!(validate_datatable_shape("main", &pointer).is_ok()); + + let both = DataTable { database: terminal.database.clone(), ..pointer.clone() }; + assert!(validate_datatable_shape("main", &both).is_err()); + + let neither = DataTable { database: None, reference: None, ..terminal.clone() }; + assert!(validate_datatable_shape("main", &neither).is_err()); + } + #[test] fn test_parse_fork_branch() { // Generated fork (`wm-fork-abc`) and dev workspace (`staging`) forms. diff --git a/backend/windmill-common/tests/fixtures/guest_jwt_rsa8192_private.pem b/backend/windmill-common/tests/fixtures/guest_jwt_rsa8192_private.pem new file mode 100644 index 0000000000..bb443cf6bf --- /dev/null +++ b/backend/windmill-common/tests/fixtures/guest_jwt_rsa8192_private.pem @@ -0,0 +1,100 @@ +-----BEGIN PRIVATE KEY----- +MIISQgIBADANBgkqhkiG9w0BAQEFAASCEiwwghIoAgEAAoIEAQC6deMC6KWwkWV2 +9xb4bXLt6ceNGQD3gJH3sYqpulWsZB0L3TdqXwnzL3C/8Uf3K8hLZ4Tw+llHCxaS +Z7aTEYyeLDJuYuJPVS8DAgVnXDluoDg7B1UtvoUa1/MjLQm4f/Qp322i7l0SH9kx +NRb7UZp/xx1tL/kiq9NIv1inK4ZC0DbleybBOoLlwP9Zqhazw3MJdZAyey4FAXTx +bZcjG3UtiZ2COXVP1qdtqhLrQQQsicoLqZuxAfXdIU6xgcYgGapVQNTVbo7w8pHn ++6RzlPb9rqyBs2IiKLFSb467mtZeM9dfbyD4DICy42O/JKAbTZw/f3trbs+tu+sK +rj7ysk+lGzuXj2/irOuvOSbzpxERdZuDyLOhlMej5+aJXkrmVhVPf/3wt/6oKCzo +7Ta24OvGaz6pxK1iDOQvzkCqJpaTjSfpcscHyU3ztXCUINtsq7s78XoOxJZkerfq +LDxuutYviFiB9pQDzYdErL8XMUkRXXY22hpnLHt8GV+lNCJs2KxcECzkgwfjIn9X +Y66U4iO+k9EvY/FHyI25ur0eKWDnJAPL6PypVhx9NOuBkot6nwUugzWzM/MKJ9Hf +1n9pwVNlMam8zgMQ5Ivu3+RiOV3P0iuAaw7d1sZRSSX11l/EyjGFDpwmnFNjleEK +vnop6rCftWAsd2XJHXiD1/80QIgKJHdflpHOcy6Su5w/CRYc/jC2rJjUr9W+hBwg +fnayip8MTMa4cAXzbFmSc5NCzSsqHkVq/4RdfdMephubGmWcPDQnkUf/n/RvoZNi +VktW34lX3YIe97600vSWPfWahj/EUnfI8m+4Qzuz2o6N9basCNftKPDIc5ej3PMS +RVIgBhEXUh1wVFdEUs8RKHg/QV0xrN2qbKcS8XvV3fFfLuFZo4a66nZ5zgGGJUgL +ED41cEqf/sQObfhGgIw25v/315ARiv9thRF3tUMLGQZNQ8tSs2VMuU6szBQciplx +y2y4JzXdv+G0PvheE7tVdsGsMipSHTZDdNZo51MOBSgsNTyNmWMZlsfoZTTZ2o2y +XtKv8xUP01KnlwR6Z8hY1CAPxM1QKDU5nKp9T/df6GEhohps9gSt6cs6SHlH8piq +pyR0xiDvS+vcPN/IbOaRgE9WmfM5py72SNZbetiUHdUTmAC1dd0IH3opte1uVpQQ +sauKVWZuzV8Bcd2O06QFOVJDwoxm00YaF0rqhaisppah5eQMN/ctlqWVDHXR8laV +CNkzWYE/jxAU9dGuCMCIZ7BSs5U9mZ7pWstagXWEFTI9IugjvNnaZDijZQJSPmAZ +gQtAxH/z8R9e/PihW9dDQc/nKBC9Lpta3oFGq74fc7Y+Wu/Uskjf8qtFOOVrvfZx +kwlRR0s5AgMBAAECggQAEEeerHBXlxT6wOpN7R7RPAh/TchzFyj4laHAs5T7SfPr +vU9xOS5XFg58T6XnXKNL95lwOAIDrXpAXEQF4s1Tc7P0K0Mi8z5CvoZ8pkudEBWZ +Ez5Op5HM2U0lg1q5UIyLauSBFAqvwZdaRTuXaBX14VYnMZNQs0BBUEa0nNPmAwte +36Zi+df7+cdiBBgfp05XSuoizuWGXRWFelbRLVWGY5VyVR5jKbdaIu8Ie9XPYXwe +UepbF5dOOiJgVos4GzFid9MTQhdHD65+Ir0L3LOS5Q9NLH3XAZh/8uyej66zLMJl ++YUgYcflrLjByryN5hReogSlZ/ni1A5mNSaFhHchEQ9Roqf5YtcqNMBOsFNT4Cxe +iaEKXf85dwwWSsQa0gWOEsTgDRZje5cWZLfhYRI+Q7K1Y+kyIi9x9OKTLsfzuapH +Y9CLpBhlX/Plgmlj/xb3YAJzX7q2TsuQJzf5hsl1Te0fnSlEPxjAyLQ0cvfwoz1/ +xsZAK+XsWid0/F2S6+H3GoqnAMSX+bLXX2/qnEEqUcdiKjsVo+jUxvSQ8oM2XLf+ +RN1wwfAsyJnIPiXnW6tqrmbyV1Uf0TEs19hIg5lHiLxPT5bFGcJ6nT6bGCBeGnkF +J08xmxjXc91pHQ+8iNgGftGJvK1FcppxbZi33dUJ2sfhP8Ck+GxES26eb5d6yTfD +oZRQmESvikHPU5shm+YbjPrYhEbEgLZKO3wUhCAvoFLN2TG0i2v//aaCSI/XfNWt +1XyE+uN/o6kQEPvaUy/0uHBuzoC4pZ8or22ywmeftCOHuT9YE9LIJuU81qMJvjP7 +BJ6YkcTO6G89iD0g6HHYRiD9I+pgih8B1NPq4VmFuJrvaqgAX9srmmMt7FXBrcqa +JSyLS+WeHTLlOiHN1DqOgpgq0gq6vC1i53xPqSUkFzAKpDOWgGNMVAABFN9EOVmM +zvPnJThz5PHDAuud9eaLjZ0V76IVXmRTz22warkdKhBW5BzDzy+zNp8mF5SMH9mZ ++JQvCGTc2itiKGKMDzvY99pBzHRlrlFL7n5SHLqxclBfnEJcU9wPvFyZ+6k8MGim +0kFrK63NqiceNWp5vdU/6V+boCYoFG8PDbABUxCbb+lhtnYx5AUcKfKX74Uv4gpX +AM0gsaY8U+4ATJbgoCTqplTKvXtyw4zcpJAXCMnhygKyDKNOk+Lckfquwx9r/TQN +hbbjjvwgBkzJtAlylCVYYEJ/03lazRpGR5osCksECxNU4TMDRD2Hd+zwUxdeRunm +7EusRSrKq8swi8LFyV7E8ISwwXegqVarHqXjCHhcn/5Ozw6PkVk39Ug2UE4ibesC +MzVP3lfP67gXgGCzYrImIUuqHGTyLFs8/B8+2OmdyQKCAgEA6PCozdeeQS0P/+ah +E015KECxB0aGbrvdpczXX5szHYb8KhejvHmVV2OLFisL7GyMeBhEZXAeKtMZwd66 +FMORgdFACpckR/a/aVEnebHx5fOKtAQfhLLH/3YE5dFIWxu9SKNZBrX0lIBFXfoK +lZYM7cGm7vN55AK99bYfCAqFs+HI9sDQLjbaxR41UOfZwPjA9VjBQ1vBr0N3UoOT +xf8Rw6hlAd+ED7iJavn6KUb64D5eB4eGSZNXM5HCp2ZtUFN/4bU5lbHPCSK29+Wd +mM5flZtIKQ7PzGyXqv4Kbtz9o1hPOQPeH0faDEcYJLHV/LAxHc1AkqJDXU8HANhI +m/XI44RlJesNntOdJuCumHtTAsreWAYoydXurOWHkDIpx6OLi0F3eJrOV6dhwCCG +v3bX4PIBjYZKyfHukcoCzGetXpu5bDKC1uwwZLJmF/QTO3AeVpWNrbWkBfj/PCio +8RodjVlPkDaoCrDmpca22fNo+93IM2m1SWdSnTlXvgg58cLUrlMUbg24Lo1cyySF +1AnRw0uvaFLdH8iMtKZIA3zgCZyUKV1DcGUKsdcL/9IYD6EO5bCcA6p5gcxxBOan +6MEHIuJKycmZMGutN5rm3uDHQgmNCs25ma3fWQF0s/dRJ2lg947qKUKevqBkd4gt +BuB8VXUGsHXGDWHfz/eLjWYA3k8CggIBAMzrTqnmT27U1SXPwEXI0kc6A44D8skD +a4rqxOMBTGvu+HeVdEBMcxYnGeJV2QtZY96KXwadiWuqdEr4Pc7JWnAa2f9yWNCb +QI0YJSWSNcDuPsSV1K15MM7uxKk/oNcXdbjrAfVLzCmEYDg085QV80rl7Q6WRaYv +acvipeZY82LuGhtAJAN6S8VWmDqZ5vBDd61F09AlBrpIFoSBRnYVkEwaXXwFJauz +S+kEmCIb97tBf7nr08ldoNvDdecRtm+s9odfUhm+ONroZf7OEyTCOXB666oDogpI +hktuliNUVpXeZ5QtkzpxP2SBbHNWZ9LiAGpTv2wa7z7k1PqCVR6ABGMkTkge41iy ++v82KYi4qRnUfVlFcn9xLyBDaaCWJYrsux8OmAW48cVN1Og0tKu/T+bFUvHs0etZ +oijYoYZaf9ERO0X0tuMXKIuT0QcPnkcT+KXFY6uzOC1UmF5ITCeKogGwzkFbvInA +w8OiFYBkgXZ41tE/oOARm1AhuaNc/ZfWujLECjuJgvw0uhdUyihGw8ZJv91WiIjX +Jqq1BNrhvDQqTPSnDvhcBBDGdQA6HJRGAerIx7PgCKzS2WbrfmsFzjfZ3zbrUSNf +e8CM37aDzte25g7HmvFuwOLmE9DXNnE+cWGpb/S1l4ACahnqKF9zj3uKVpN1kG+l +xFH+ADFgNCP3AoICAQDW+fuf9QVdezwP3IX9bXuKVaHLaB/7JDBbqt99oZsb/g7M +vgJjmAXmn7ymGdYoPMmqsCK3RzDgLvDPhkFPlmm8WCR1YaWfMK7YgIgg/MOViB0T +I9b+vYxsTDwa6PmQ89nNpssrsVNmlbbvEysfgFqwNg9c6aEteAZeNNUQs71PFOpG +KA45CHEAiG+GGmNRf4F9LIs4If9vQ6Fkqn//s/ZuDwQhJWoYpCNWchuYaqRvvb9D +zaR2VJ+evT/bBGGQR293G6xoZa//62TXBbdbZeK7DJMdKUlHjVSv/0IbrKH8VSzQ +HqypS8OFnzs96k3lpam+qkWUzmHDRILfGbxxCvV6XB6JTxPD5+JaFfOsT+x9YEKx +FAYit+hM8owY3OLIFzxaqXGcAaq7JIPHL32MSB7VwCMEvNJa64dUTw4kHO7YqHkI +zNkJhvLmi+pC4wO40DF/kQgvL7qPtIvBtnisAoERiUIvgvG05CwJBIqOPOE/yR/R +qxYfB1csONasT6VCZJ+pvozVQJ4hgF8KL8u+s80+IqRgcFEY2xpYQjml1mJhHHt0 +bVkm6mljVWcCkp+YFPjNrB9gFr2Kt+X6MvD1kueGHzgMYJybHlS/Q5scSHA8ltnv +BYvrs5az6u8MhAwGhm9yKRgcOhqNLTNrSWbcuOKmb5lb02WTxicrJHdLaL8WawKC +AgAvXHdK51MWj5RP7+tFdbu8AJmPujPj56l0avxkZwltrMa6hM1giwrSZOkjmanX +aKqfZaW+1qxLORM1vJg0ce84F8yosJn8ZNWPhrQXMerAk4/oSaTlDyyLtz22zGD1 +NZPc0FwcgI9ITTMb6okdgdFAT1Hx3CNu3Gh8Wf7z9T0TcWqRP8rTDvHyQyhd6vIo +N7+ySYBCDDy40y55I5PQeP1nyXQv1NS3EXKLAafTwlkIa4QEyY+ia8Rg5pUaecqL +lxNHiG1mS8PCPJJOSPy9szIpYA+VtM8/xzFav81tjvGQI94Wrk3n5QK8zPju01qd +r+RersqARt1i0Oq0l2jq6rWY4GxDWPg8qOlAERmaYuuab7Uk69shv1wf/dSs6+bz +a/F3pRzXvb4LVwjNPlk/lLD36kVYat69oXLMM/1pGwONo4jqizu10sVpNRnLov12 ++hOu5Kc7qOlj+LhamiUXwn/dGishif1SbT4RuiV0DkapxhYIOMeK4ibeZ10Ld9OY +w2U3lH208vxWJC2adNtgYbKf4q24j2ZxEiD6tWnQ2PlxqREuPmpEVqgN5IBoNAYk +8uVAiXU2iqIWp4jds7H9NFNML0va2ppfLZNhlm2iNmaZDsmvQsz53howlvKfgw4v +MDpuUwXwLgQ87tHs+WSpYwEXkYqe/D4Mv+hW9vi+cLi8UwKCAgAsehP6DOrt9bA3 +pVdtHJG1a9fm/rXC9TQS2oSE2paXRZpxhbk917Gdl2njo7tnoGXXBpQOb51/HUXX +QTARdp2LP1VFn9RJP3CwaK+Sxs1IBa9r1/p2ePDR85QR1HxN+Gj98EcEfKl1RSe6 +RmLOkLIg4pkNrDhwWyYJ1H5//d/yUQlZ015fQAKaHCdaoz7L3+0jOyFXDGrDr+Gc +680SyVqPfRx5dAm8TrfyXW6Q2uZEhIqxbcxTdG98Pxi0qLLAS/nD16IHC1iljHsI +QrfUvFvGg5v7kxeXrP0VPMRM0/Y8vno3Bbi04I0Pk3Wko8UhWuiUMjs+TVbEPKox +8zG/G+R5EpHKDxNyfUUwZ9+lyjFmBZOcVd3dze23VQYCzV/W04yiQdEl2C/sED2m +NycJ3GN/ByqpJJZY3u+B/WAiHPLe1ivIEeIjwdBt2w/3L/rKoRBR/P8LSRXLIIh4 +Xk1BMNygJxHTwsvFGhflx89+1BO/n8HrR0s/2G1QanxGdgso9bwmXMDrmJ+XxrUY +qBmDCU8RkBdUHKOuJlWWn2JaIswsEE5kAk7MgNNaBTv91VBYCDs50NW8VBaKnJjN +j+hXI80qKi7WqeoonFKFLDgUYbKnmzKsX+T0x/H99v+7Or5PmAPN+hRwj54/LEVR +MsjrTlnoCGXKgPQQ5ItWviGjqoCTiQ== +-----END PRIVATE KEY----- diff --git a/backend/windmill-common/tests/fixtures/guest_jwt_rsa8192_public.pem b/backend/windmill-common/tests/fixtures/guest_jwt_rsa8192_public.pem new file mode 100644 index 0000000000..44645f5af2 --- /dev/null +++ b/backend/windmill-common/tests/fixtures/guest_jwt_rsa8192_public.pem @@ -0,0 +1,25 @@ +-----BEGIN PUBLIC KEY----- +MIIEIjANBgkqhkiG9w0BAQEFAAOCBA8AMIIECgKCBAEAunXjAuilsJFldvcW+G1y +7enHjRkA94CR97GKqbpVrGQdC903al8J8y9wv/FH9yvIS2eE8PpZRwsWkme2kxGM +niwybmLiT1UvAwIFZ1w5bqA4OwdVLb6FGtfzIy0JuH/0Kd9tou5dEh/ZMTUW+1Ga +f8cdbS/5IqvTSL9YpyuGQtA25XsmwTqC5cD/WaoWs8NzCXWQMnsuBQF08W2XIxt1 +LYmdgjl1T9anbaoS60EELInKC6mbsQH13SFOsYHGIBmqVUDU1W6O8PKR5/ukc5T2 +/a6sgbNiIiixUm+Ou5rWXjPXX28g+AyAsuNjvySgG02cP397a27PrbvrCq4+8rJP +pRs7l49v4qzrrzkm86cREXWbg8izoZTHo+fmiV5K5lYVT3/98Lf+qCgs6O02tuDr +xms+qcStYgzkL85AqiaWk40n6XLHB8lN87VwlCDbbKu7O/F6DsSWZHq36iw8brrW +L4hYgfaUA82HRKy/FzFJEV12NtoaZyx7fBlfpTQibNisXBAs5IMH4yJ/V2OulOIj +vpPRL2PxR8iNubq9Hilg5yQDy+j8qVYcfTTrgZKLep8FLoM1szPzCifR39Z/acFT +ZTGpvM4DEOSL7t/kYjldz9IrgGsO3dbGUUkl9dZfxMoxhQ6cJpxTY5XhCr56Keqw +n7VgLHdlyR14g9f/NECICiR3X5aRznMukrucPwkWHP4wtqyY1K/VvoQcIH52soqf +DEzGuHAF82xZknOTQs0rKh5Fav+EXX3THqYbmxplnDw0J5FH/5/0b6GTYlZLVt+J +V92CHve+tNL0lj31moY/xFJ3yPJvuEM7s9qOjfW2rAjX7SjwyHOXo9zzEkVSIAYR +F1IdcFRXRFLPESh4P0FdMazdqmynEvF71d3xXy7hWaOGuup2ec4BhiVICxA+NXBK +n/7EDm34RoCMNub/99eQEYr/bYURd7VDCxkGTUPLUrNlTLlOrMwUHIqZcctsuCc1 +3b/htD74XhO7VXbBrDIqUh02Q3TWaOdTDgUoLDU8jZljGZbH6GU02dqNsl7Sr/MV +D9NSp5cEemfIWNQgD8TNUCg1OZyqfU/3X+hhIaIabPYErenLOkh5R/KYqqckdMYg +70vr3DzfyGzmkYBPVpnzOacu9kjWW3rYlB3VE5gAtXXdCB96KbXtblaUELGrilVm +bs1fAXHdjtOkBTlSQ8KMZtNGGhdK6oWorKaWoeXkDDf3LZallQx10fJWlQjZM1mB +P48QFPXRrgjAiGewUrOVPZme6VrLWoF1hBUyPSLoI7zZ2mQ4o2UCUj5gGYELQMR/ +8/EfXvz4oVvXQ0HP5ygQvS6bWt6BRqu+H3O2Plrv1LJI3/KrRTjla732cZMJUUdL +OQIDAQAB +-----END PUBLIC KEY----- diff --git a/backend/windmill-common/tests/runnable_list_fingerprint.rs b/backend/windmill-common/tests/runnable_list_fingerprint.rs new file mode 100644 index 0000000000..d7438f0008 --- /dev/null +++ b/backend/windmill-common/tests/runnable_list_fingerprint.rs @@ -0,0 +1,54 @@ +use sqlx::{Pool, Postgres}; +use windmill_common::runnables::runnable_list_fingerprint; + +/// MCP clients refresh their tool list when this fingerprint moves, so it must move on every +/// change to what the listing shows and stay put on writes it does not show. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn fingerprint_tracks_the_live_runnable_list(db: Pool) { + let fp = || async { + runnable_list_fingerprint(&db, "test-workspace") + .await + .unwrap() + }; + let exec = |sql: &'static str| { + let db = db.clone(); + async move { sqlx::query(sql).execute(&db).await.unwrap() } + }; + + let mut last = fp().await; + let mut assert_moved = async |what: &str, moved: bool| { + let now = fp().await; + assert_eq!(now != last, moved, "{what}"); + last = now; + }; + + exec( + "INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, language, kind) + VALUES ('test-workspace', 1, 'f/test/a', '', '', 'def main(): pass', 'test-user', 'python3', 'script')", + ) + .await; + assert_moved("deploy", true).await; + + exec("UPDATE script SET lock = '' WHERE hash = 1").await; + assert_moved("lock update", false).await; + + exec("UPDATE script SET path = 'f/test/b' WHERE hash = 1").await; + assert_moved("path move", true).await; + + exec("UPDATE script SET archived = true WHERE hash = 1").await; + assert_moved("archive", true).await; + + exec( + "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, schema, versions) + VALUES ('test-workspace', 'f/test/flow', '', '', '{}'::jsonb, 'test-user', '{}'::json, ARRAY[1]::bigint[])", + ) + .await; + assert_moved("flow create", true).await; + + exec("UPDATE flow SET versions = array_append(versions, 2::bigint) WHERE path = 'f/test/flow'") + .await; + assert_moved("flow redeploy", true).await; + + exec("DELETE FROM flow WHERE path = 'f/test/flow'").await; + assert_moved("flow delete", true).await; +} diff --git a/backend/windmill-mcp/src/server/backend.rs b/backend/windmill-mcp/src/server/backend.rs index 7ca1eb08a1..4801bfaa99 100644 --- a/backend/windmill-mcp/src/server/backend.rs +++ b/backend/windmill-mcp/src/server/backend.rs @@ -208,6 +208,11 @@ pub trait McpBackend: Send + Sync + Clone + 'static { workspace_id: &str, ) -> BackendResult; + /// An opaque value that changes whenever the workspace's live scripts or flows are + /// added, removed, moved or redeployed, compared across polls to detect tool-list + /// changes. + async fn runnable_list_fingerprint(&self, workspace_id: &str) -> BackendResult; + // ───────────────────────────────────────────────────────────────── // Endpoint Tools // ───────────────────────────────────────────────────────────────── diff --git a/backend/windmill-mcp/src/server/list_changes.rs b/backend/windmill-mcp/src/server/list_changes.rs new file mode 100644 index 0000000000..8470cff630 --- /dev/null +++ b/backend/windmill-mcp/src/server/list_changes.rs @@ -0,0 +1,104 @@ +//! Detects changes to a workspace's scripts and flows for `subscriptions/listen`. +//! +//! Each process polls a fingerprint of every workspace one of its listeners watches, +//! so a change made on any replica, through any path (UI, CLI, git sync, a user +//! rename), reaches every listener within one poll interval with no cross-replica +//! signalling. + +use crate::server::backend::McpBackend; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; +use tokio::sync::{broadcast, Notify}; + +const POLL_INTERVAL: Duration = Duration::from_secs(30); + +struct Watcher { + changes: broadcast::Sender<()>, + poll_now: Arc, +} + +#[derive(Default)] +pub(crate) struct ListChanges { + watchers: Mutex>, +} + +impl ListChanges { + /// Subscribe to `workspace_id`'s changes, starting its poller if this is the + /// first listener for it in this process. + pub(crate) fn subscribe( + self: &Arc, + backend: &Arc, + workspace_id: &str, + ) -> broadcast::Receiver<()> { + let mut watchers = self.watchers.lock().unwrap(); + if let Some(watcher) = watchers.get(workspace_id) { + return watcher.changes.subscribe(); + } + let (changes, rx) = broadcast::channel(16); + let poll_now = Arc::new(Notify::new()); + watchers.insert( + workspace_id.to_string(), + Watcher { changes: changes.clone(), poll_now: poll_now.clone() }, + ); + tokio::spawn(self.clone().poll( + backend.clone(), + workspace_id.to_string(), + changes, + poll_now, + )); + rx + } + + /// Poll `workspace_id` now rather than at its next interval, after this process + /// changed it: the client that made the change is the one waiting on it. Polling + /// rather than signalling directly keeps the poller's baseline current, so the + /// same change is not reported again on the next tick. + pub(crate) fn poll_now(&self, workspace_id: &str) { + if let Some(watcher) = self.watchers.lock().unwrap().get(workspace_id) { + watcher.poll_now.notify_one(); + } + } + + async fn poll( + self: Arc, + backend: Arc, + workspace_id: String, + changes: broadcast::Sender<()>, + poll_now: Arc, + ) { + let mut interval = tokio::time::interval(POLL_INTERVAL); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut last = None; + loop { + tokio::select! { + _ = interval.tick() => {} + _ = poll_now.notified() => {} + } + { + // Checked under the lock `subscribe` takes, so a listener cannot join a + // watcher that is about to stop. + let mut watchers = self.watchers.lock().unwrap(); + if changes.receiver_count() == 0 { + watchers.remove(&workspace_id); + return; + } + } + match backend.runnable_list_fingerprint(&workspace_id).await { + Ok(fingerprint) => { + // The first fingerprint also notifies: the listener that started this + // watcher has already sent its opening notification, and a change landing + // before this baseline would otherwise never be announced. + if last.as_ref() != Some(&fingerprint) { + let _ = changes.send(()); + } + last = Some(fingerprint); + } + Err(e) => tracing::warn!( + "MCP tool-list poll failed for workspace {workspace_id}: {}", + e.message + ), + } + } + } +} diff --git a/backend/windmill-mcp/src/server/mod.rs b/backend/windmill-mcp/src/server/mod.rs index b97e374e98..ad7c14d320 100644 --- a/backend/windmill-mcp/src/server/mod.rs +++ b/backend/windmill-mcp/src/server/mod.rs @@ -7,6 +7,7 @@ pub mod backend; pub mod endpoints; +mod list_changes; pub mod runner; pub mod tools; diff --git a/backend/windmill-mcp/src/server/runner.rs b/backend/windmill-mcp/src/server/runner.rs index e27eaa8470..177989a82e 100644 --- a/backend/windmill-mcp/src/server/runner.rs +++ b/backend/windmill-mcp/src/server/runner.rs @@ -16,20 +16,22 @@ use crate::server::backend::{McpAuth, McpBackend, McpRequest, PathFilter}; use crate::server::endpoints::{ endpoint_tool_to_mcp_tool, endpoint_tool_to_mcp_tool_multi, list_workspaces_tool, EndpointTool, }; +use crate::server::list_changes::ListChanges; use crate::server::tools::create_tool_from_item; use rmcp::handler::server::ServerHandler; use rmcp::model::{ CacheScope, CallToolRequestParams, CallToolResponse, CallToolResult, ContentBlock, - Implementation, InitializeResult, ListPromptsResult, ListResourceTemplatesResult, - ListResourcesResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion, - ServerCapabilities, ServerInfo, + Implementation, InitializeRequestParams, InitializeResult, ListPromptsResult, + ListResourceTemplatesResult, ListResourcesResult, ListToolsResult, PaginatedRequestParams, + ProtocolVersion, ServerCapabilities, ServerInfo, SubscriptionFilter, }; -use rmcp::service::{RequestContext, RoleServer}; +use rmcp::service::{RequestContext, RoleServer, SubscriptionContext}; use rmcp::ErrorData; use serde_json::Value; use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use tokio::sync::broadcast; /// Protocol revisions this server is willing to speak. `2026-07-28` is served /// statelessly with per-request metadata; the older revisions keep the @@ -46,15 +48,21 @@ const SUPPORTED_PROTOCOL_VERSIONS: &[ProtocolVersion] = &[ /// leaves them unset, and a strict client rejects the response without them. /// /// Zero because nothing here is cacheable: the listing is rebuilt from the -/// workspace's scripts and flows, which change at any time, and this server -/// advertises no `listChanged` capability, so a client that cached a stale list -/// would have no way to learn it had gone stale. +/// workspace's scripts and flows, which change at any time, and `listChanged` +/// covers only some of what the listing depends on (not resources, favorites or +/// permissions), lags by up to a poll interval, and reaches only clients that hold a +/// `subscriptions/listen` stream. const LIST_TTL_MS: u64 = 0; + /// Every listing is filtered by the caller's token scopes and workspace /// membership, so no two callers necessarily see the same tools — a shared /// cache entry would leak one token's view to another. const LIST_CACHE_SCOPE: CacheScope = CacheScope::Private; +/// How long a `subscriptions/listen` stream waits after a change before notifying, +/// collecting the rest of the burst into the same notification. +const LIST_CHANGE_COALESCE: std::time::Duration = std::time::Duration::from_millis(500); + // Re-export from http crate for extracting request parts use http::request::Parts as HttpParts; @@ -83,11 +91,12 @@ fn truncate_tool_result(text: String) -> String { /// to perform the actual operations (database queries, job execution, etc.) pub struct Runner { backend: Arc, + list_changes: Arc, } impl Clone for Runner { fn clone(&self) -> Self { - Self { backend: self.backend.clone() } + Self { backend: self.backend.clone(), list_changes: self.list_changes.clone() } } } @@ -113,7 +122,7 @@ struct McpContext { impl Runner { /// Create a new Runner with the given backend pub fn new(backend: B) -> Self { - Self { backend: Arc::new(backend) } + Self { backend: Arc::new(backend), list_changes: Arc::default() } } /// Extract authentication, the workspace mode and the HTTP request itself @@ -232,6 +241,22 @@ fn endpoint_path_policy(endpoint_name: &str) -> Option { } } +/// Endpoint tools that change which scripts/flows a workspace exposes, or their +/// schemas. The process serving the call polls the workspace right away rather than +/// at its next interval, so a client that just deployed a script can call it. +fn changes_runnable_list(endpoint_name: &str) -> bool { + matches!( + endpoint_name, + "createScript" + | "updateScript" + | "deleteScriptByPath" + | "deleteScriptByHash" + | "createFlow" + | "updateFlow" + | "deleteFlowByPath" + ) +} + /// Whether the token restricts `kind` ("script"/"flow") to specific paths. A /// `*` pattern grants every path (see `is_resource_allowed`), so it does not /// count as confinement. @@ -428,12 +453,17 @@ impl ServerHandler for Runner { .with_title("Windmill") .with_website_url("https://windmill.dev"); - InitializeResult::new(ServerCapabilities::builder().enable_tools().build()) - .with_server_info(server_info) - .with_instructions( - "This server provides a list of scripts and flows the user can run on Windmill. \ + InitializeResult::new( + ServerCapabilities::builder() + .enable_tools() + .enable_tool_list_changed() + .build(), + ) + .with_server_info(server_info) + .with_instructions( + "This server provides a list of scripts and flows the user can run on Windmill. \ Each flow and script is a tool callable with their respective arguments.", - ) + ) } /// Pinned rather than left to rmcp's default (every version the SDK knows), so a @@ -444,6 +474,84 @@ impl ServerHandler for Runner { Cow::Borrowed(SUPPORTED_PROTOCOL_VERSIONS) } + /// Only clients below `2026-07-28` send `initialize`, and those receive server + /// notifications on a session's standalone stream. The transport is sessionless, so + /// there is none: `listChanged` is withheld from them rather than promised and + /// never delivered. `2026-07-28` clients learn it from `server/discover` and + /// receive it on `subscriptions/listen` (see `listen`). + async fn initialize( + &self, + request: InitializeRequestParams, + context: RequestContext, + ) -> Result { + context.peer.set_peer_info(request.clone()); + let mut info = self.get_info(); + if let Some(tools) = info.capabilities.tools.as_mut() { + tools.list_changed = None; + } + if SUPPORTED_PROTOCOL_VERSIONS.contains(&request.protocol_version) { + info.protocol_version = request.protocol_version; + } + Ok(info) + } + + /// `None` for a request that asks for nothing this server sends, so the client is + /// told the subscription is unsupported rather than acked with an empty filter. + fn accepted_subscription_filter( + &self, + requested: &SubscriptionFilter, + ) -> Option { + (requested.tools_list_changed == Some(true)) + .then(|| SubscriptionFilter::builder().tools_list_changed().build()) + } + + async fn listen(&self, subscription: SubscriptionContext) -> Result<(), ErrorData> { + let McpContext { auth, mode, .. } = Self::extract_context(subscription.request_context())?; + // Multi-workspace mode and read-only tokens list no scripts or flows, so their + // tool list never changes. + let workspace_id = match mode { + McpMode::Single(workspace_id) if !auth.read_only() => workspace_id, + _ => { + subscription.cancelled().await; + return Ok(()); + } + }; + + let mut changes = self.list_changes.subscribe(&self.backend, &workspace_id); + // Every stream opens with one notification: a change the watcher recorded between + // this client's `tools/list` and this subscription is never broadcast again. + let mut opening = true; + loop { + if !std::mem::take(&mut opening) { + tokio::select! { + _ = subscription.cancelled() => return Ok(()), + change = changes.recv() => match change { + Ok(()) | Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => return Ok(()), + }, + } + } + // A client creating several scripts in a row: collapse the burst into one + // notification, so it re-lists once. + tokio::select! { + _ = subscription.cancelled() => return Ok(()), + _ = tokio::time::sleep(LIST_CHANGE_COALESCE) => {} + } + while !matches!( + changes.try_recv(), + Err(broadcast::error::TryRecvError::Empty | broadcast::error::TryRecvError::Closed) + ) {} + if subscription + .sink() + .notify_tool_list_changed() + .await + .is_err() + { + return Ok(()); + } + } + } + async fn list_tools( &self, _request: Option, @@ -714,6 +822,9 @@ impl Runner { .backend .call_endpoint(auth, workspace_id, endpoint_tool, args) .await?; + if changes_runnable_list(&endpoint_tool.name) { + self.list_changes.poll_now(workspace_id); + } return Ok(CallToolResult::success(vec![ContentBlock::text( truncate_tool_result( @@ -969,6 +1080,9 @@ impl Runner { .backend .call_endpoint(&resolved_auth, &workspace_id, endpoint_tool, args) .await?; + if changes_runnable_list(&endpoint_tool.name) { + self.list_changes.poll_now(&workspace_id); + } Ok(CallToolResult::success(vec![ContentBlock::text( truncate_tool_result( diff --git a/backend/windmill-native-triggers/src/handler.rs b/backend/windmill-native-triggers/src/handler.rs index 27a50ba07a..2c785a4e26 100644 --- a/backend/windmill-native-triggers/src/handler.rs +++ b/backend/windmill-native-triggers/src/handler.rs @@ -16,7 +16,8 @@ use serde::{Deserialize, Serialize}; use sqlx::PgConnection; use std::sync::Arc; use windmill_api_auth::{ - check_scopes, create_token_internal, require_is_writer, ApiAuthed, NewToken, + build_scope_path_filter, check_scopes, create_token_internal, require_is_writer, ApiAuthed, + NewToken, }; use windmill_audit::{audit_oss::audit_log, ActionKind}; use windmill_common::{ @@ -701,6 +702,7 @@ async fn list_native_triggers_handler( query.per_page, query.path.as_deref(), query.is_flow, + build_scope_path_filter(&authed, "native_triggers", "read"), ) .await?; tx.commit().await?; diff --git a/backend/windmill-native-triggers/src/lib.rs b/backend/windmill-native-triggers/src/lib.rs index 570319ee0e..6398e8a6d9 100644 --- a/backend/windmill-native-triggers/src/lib.rs +++ b/backend/windmill-native-triggers/src/lib.rs @@ -56,7 +56,7 @@ use windmill_queue::PushArgsOwned; #[cfg(feature = "native_trigger")] use windmill_oauth::{ErrorField, ExecuteError, OClient, RefreshToken, Url, OAUTH_HTTP_CLIENT}; -use windmill_api_auth::ApiAuthed; +use windmill_api_auth::{ApiAuthed, ScopePathFilter}; pub mod handler; pub(crate) mod lock; pub mod sync; @@ -1493,9 +1493,17 @@ pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres> per_page: Option, path: Option<&str>, is_flow: Option, + scope: ScopePathFilter, ) -> Result> { let offset = (page.unwrap_or(0) * per_page.unwrap_or(100)) as i64; let limit = per_page.unwrap_or(100) as i64; + // In the WHERE, not a retain after the fetch: the result is paginated, and a + // post-fetch filter would let a page's size report how many rows the token + // may not read. + let (scope_all, scope_exact, scope_prefix) = match scope { + ScopePathFilter::AllowAll => (true, Vec::new(), Vec::new()), + ScopePathFilter::Restricted { exact, prefix } => (false, exact, prefix), + }; let triggers = sqlx::query_as!( NativeTrigger, @@ -1532,7 +1540,12 @@ pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres> WHERE f.workspace_id = nt.workspace_id AND f.path = nt.script_path )) - ) + ) AND + ( $7 + OR nt.script_path = ANY($8) + OR EXISTS ( SELECT 1 FROM unnest($9::text[]) AS pfx + WHERE nt.script_path = pfx + OR left(nt.script_path, length(pfx) + 1) = pfx || '/' ) ) LIMIT $3 OFFSET $4 "#, @@ -1541,7 +1554,10 @@ pub async fn list_native_triggers<'c, E: sqlx::Executor<'c, Database = Postgres> limit, offset, path, - is_flow + is_flow, + scope_all, + &scope_exact[..], + &scope_prefix[..], ) .fetch_all(db) .await?; diff --git a/backend/windmill-native-triggers/src/sync.rs b/backend/windmill-native-triggers/src/sync.rs index bc259f048e..4f75845429 100644 --- a/backend/windmill-native-triggers/src/sync.rs +++ b/backend/windmill-native-triggers/src/sync.rs @@ -214,8 +214,17 @@ pub async fn sync_workspace_triggers( workspace_id ); - let windmill_triggers = - list_native_triggers(db, workspace_id, T::SERVICE_NAME, None, None, None, None).await?; + let windmill_triggers = list_native_triggers( + db, + workspace_id, + T::SERVICE_NAME, + None, + None, + None, + None, + windmill_api_auth::ScopePathFilter::AllowAll, + ) + .await?; if windmill_triggers.is_empty() { tracing::info!( diff --git a/backend/windmill-queue/src/flow_status.rs b/backend/windmill-queue/src/flow_status.rs index e6b64c659d..55d2f6ec90 100644 --- a/backend/windmill-queue/src/flow_status.rs +++ b/backend/windmill-queue/src/flow_status.rs @@ -15,6 +15,9 @@ pub async fn update_flow_status_in_progress( let step = get_step_of_flow_status(db, flow).await?; match step { Step::Step { idx: step, .. } => { + // Children of a parallel module start concurrently and are tracked in `flow_jobs`, so its + // `job` would only hold whichever child started last. Writing it rewrites the parent's + // whole flow_status under its row lock once per child, serializing large parallel loops. sqlx::query!( "UPDATE v2_job_status SET flow_status = jsonb_set( @@ -22,7 +25,11 @@ pub async fn update_flow_status_in_progress( ARRAY['modules', $3::INTEGER::TEXT, 'type'], to_jsonb('InProgress'::text) ) - WHERE id = $2", + WHERE id = $2 + AND NOT COALESCE( + flow_status->'modules'->$3::INTEGER @> '{\"type\": \"InProgress\", \"parallel\": true}', + false + )", job_in_progress, flow, step as i32 diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index ab91d54287..75733f2454 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -1007,7 +1007,7 @@ pub async fn add_completed_job( )); } - // Native script retry: a failed `Script` job that carries a retry policy and + // Native script retry: a failed `Script` or `Script_Hub` job that carries a retry policy and // has attempts left gets its next attempt enqueued here — before the queue // row (which holds the attempt counter) is removed by commit. The failed // attempt is still recorded as a completed job below. `maybe_enqueue_…` @@ -1077,9 +1077,9 @@ pub async fn add_completed_job( // Auto-resolve a retry chain that ultimately worked, from whichever of the two // completions lands last (see resolve_retry_chain_if_succeeded): a success that has a // parent (so is a possible retry attempt), or a failure that just enqueued a retry. - // `retry_pending` already implies a non-flow-step `Script`. + // `retry_pending` already implies a non-flow-step `Script` or `Script_Hub`. let resolve_root = if success && !skipped && !completed_job.is_flow_step() { - matches!(completed_job.kind, JobKind::Script) + matches!(completed_job.kind, JobKind::Script | JobKind::Script_Hub) .then(|| completed_job.parent_job) .flatten() } else if !success && !skipped && retry_pending { @@ -1285,7 +1285,25 @@ async fn commit_completed_job( } let mut _skip_downstream_error_handlers = false; - tx = delete_job(tx, &job_id).warn_after_seconds(10).await?; + let (ntx, canceled_at_delete) = delete_job(tx, &job_id).warn_after_seconds(10).await?; + tx = ntx; + // `canceled_by` is only what the worker last read from the queue row. Deleting the row waits + // for a cancel still being written, so the row it removed is the final word on whether this + // job was canceled. + if canceled_by.is_none() { + if let Some(canceled) = canceled_at_delete { + sqlx::query!( + "UPDATE v2_job_completed SET status = 'canceled'::job_status, canceled_by = $2, \ + canceled_reason = $3 WHERE id = $1", + job_id, + canceled.username, + canceled.reason, + ) + .execute(&mut *tx) + .warn_after_seconds(10) + .await?; + } + } // tracing::error!("3 {:?}", start.elapsed()); if completed_job.is_flow_step() { @@ -1611,6 +1629,22 @@ async fn restart_job_if_perpetual_inner( }; if restart { + // Not `canceled_by`: a worker reads the queue row on a widening interval, up to every 5s + // once a job has run for a minute, so a cancel landing after its last read reaches a + // completion carrying none. `commit_completed_job` records it from the queue row it + // deletes, so the completed row is what says whether this loop was stopped. + let canceled = sqlx::query_scalar!( + "SELECT canceled_by IS NOT NULL AS \"canceled!\" FROM v2_job_completed \ + WHERE id = $1 AND workspace_id = $2", + queued_job.id, + &queued_job.workspace_id + ) + .fetch_optional(db) + .await? + .unwrap_or(false); + if canceled { + return Ok(()); + } let tx = PushIsolationLevel::IsolatedRoot(db.clone()); // perpetual jobs can run one job per 10s max. If the job was faster than 10s, schedule the next one with the appropriate delay @@ -1809,10 +1843,10 @@ async fn eval_retry_if( false } -/// Native script retry. When a failed `Script` job carries a retry policy (via -/// `runnable_settings_handle`) and has attempts left, enqueue a fresh attempt of -/// the same script after the policy's backoff delay — instead of having wrapped -/// it in a one-step flow. Each attempt is a real `Script` job; the attempt +/// Native script retry. When a failed `Script` or `Script_Hub` job carries a retry +/// policy (via `runnable_settings_handle`) and has attempts left, enqueue a fresh +/// attempt of the same script after the policy's backoff delay — instead of having +/// wrapped it in a one-step flow. Each attempt is a job of the same kind; the attempt /// counter lives in the `native_retry_attempt` marker, written here and read only /// on the next failure (never on the hot job-pull path). /// @@ -1833,7 +1867,10 @@ pub async fn maybe_enqueue_native_script_retry( result_fn: &(dyn Fn() -> Option> + Sync), ) -> Result { // Only plain top-level scripts retry natively; cancellation always wins. - if canceled_by.is_some() || !matches!(job.kind, JobKind::Script) || job.is_flow_step() { + if canceled_by.is_some() + || !matches!(job.kind, JobKind::Script | JobKind::Script_Hub) + || job.is_flow_step() + { return Ok(false); } @@ -4650,6 +4687,30 @@ pub fn tag_reads_args(tag: &str) -> bool { RE_ARG_TAG.is_match(tag) } +/// Whether the tag reads the flow's state (`$flow_expr[results.a.foo]`), which only the flow +/// runtime can resolve, right before pushing the step. A malformed placeholder counts too, so it +/// is rejected or dropped instead of queueing the job on its literal text. +pub fn tag_reads_flow_expr(tag: &str) -> bool { + tag.contains("$flow_expr[") +} + +/// Renders the value at the dotted `path` below `root` as a dynamic tag component, shared by +/// `$args[...]` and `$flow_expr[...]`: its JSON text with surrounding quotes trimmed, and empty +/// once a segment is missing. Only object keys are followed, never array indexes. +pub fn render_tag_path(root: Option<&RawValue>, path: &str) -> String { + let mut value = root.map(|x| x.get()).unwrap_or_default().to_string(); + for part in path.split('.').filter(|p| !p.is_empty()) { + match serde_json::from_str::(&value) { + Ok(obj) => value = obj.get(part).map(|v| v.to_string()).unwrap_or_default(), + Err(_) => { + value = String::new(); + break; + } + } + } + value.trim_matches('"').to_string() +} + pub fn interpolate_args(x: String, args: &PushArgs, workspace_id: &str) -> String { // Save this value to avoid parsing twice let workspaced = x.as_str().replace("$workspace", workspace_id).to_string(); @@ -4657,40 +4718,12 @@ pub fn interpolate_args(x: String, args: &PushArgs, workspace_id: &str) -> Strin let mut interpolated = workspaced.clone(); for cap in RE_ARG_TAG.captures_iter(&workspaced) { let arg_name = cap.get(1).unwrap().as_str(); - let arg_value = if arg_name.contains('.') { - let parts: Vec<&str> = arg_name.split('.').collect(); - let root = parts[0]; - let mut value = args - .args - .get(root) - .or(args.extra.as_ref().and_then(|x| x.get(root))) - .map(|x| x.get()) - .unwrap_or_default() - .to_string(); - - for part in parts.iter().skip(1) { - if let Ok(obj) = serde_json::from_str::(&value) { - value = obj - .get(part) - .and_then(|v| Some(v.to_string())) - .unwrap_or_default() - .as_str() - .to_string(); - } else { - value = "".to_string(); // Invalid JSON or missing field - break; - } - } - value.trim_matches('"').to_string() - } else { - args.args - .get(arg_name) - .or(args.extra.as_ref().and_then(|x| x.get(arg_name))) - .map(|x| x.get()) - .unwrap_or_default() - .trim_matches('"') - .to_string() - }; + let (root, rest) = arg_name.split_once('.').unwrap_or((arg_name, "")); + let root_value = args + .args + .get(root) + .or(args.extra.as_ref().and_then(|x| x.get(root))); + let arg_value = render_tag_path(root_value.map(|x| &**x), rest); interpolated = interpolated.replace(format!("$args[{}]", arg_name).as_str(), &arg_value); } @@ -4700,6 +4733,72 @@ pub fn interpolate_args(x: String, args: &PushArgs, workspace_id: &str) -> Strin } } +/// The queue an explicit `tag` sends a job pushed with `args` to, or `None` when `push` drops the +/// tag and the job runs on its default one. +pub async fn resolve_push_tag( + tag: &str, + args: &PushArgs<'_>, + workspace_id: &str, + db: &DB, +) -> Option { + // The flow runtime resolves a step's `$flow_expr[...]` before pushing it, so one still here + // was pushed with no flow state to read (a step test, a dependency job) and would name a + // queue no worker serves: the job runs on its default tag instead. + if tag.is_empty() || tag_reads_flow_expr(tag) { + return None; + } + // `$workspace` must resolve the same way the default tags do, or an explicit tag and a default + // tag from the same workspace address two different worker pools. Resolving costs a lookup, + // so pay it only for tags that actually interpolate `$workspace`. + let tag_ws = if tag.contains("$workspace") { + crate::tags::tag_workspace_id(workspace_id, db).await + } else { + workspace_id.to_string() + }; + Some(interpolate_args(tag.to_string(), args, &tag_ws)) +} + +/// Refuses a `tag` the caller chose that the instance's custom tags do not let `w_id` use, +/// judging the queue it resolves to. `args` must be the ones the job is pushed with: resolving +/// with any others checks a queue the job does not land on. +pub async fn check_tag_available_for_push( + db: &DB, + w_id: &str, + tag: &str, + args: &PushArgs<'_>, + is_super_admin: bool, + scope_tags: Option>, +) -> Result<(), Error> { + check_tag_written_as_available_for_push(db, w_id, tag, tag, args, is_super_admin, scope_tags) + .await +} + +/// [`check_tag_available_for_push`] for a `tag` the flow runtime already partly resolved from +/// `written_tag`, the step's tag as its author wrote it. +pub async fn check_tag_written_as_available_for_push( + db: &DB, + w_id: &str, + written_tag: &str, + tag: &str, + args: &PushArgs<'_>, + is_super_admin: bool, + scope_tags: Option>, +) -> Result<(), Error> { + let Some(resolved_tag) = resolve_push_tag(tag, args, w_id, db).await else { + return Ok(()); + }; + windmill_common::jobs::check_tag_available_for_workspace_internal( + db, + w_id, + written_tag, + Some(&resolved_tag), + crate::tags::tag_workspace_id(w_id, db), + is_super_admin, + scope_tags, + ) + .await +} + pub fn fullpath_with_workspace( workspace_id: &str, script_path: Option<&String>, @@ -5091,34 +5190,44 @@ async fn extract_result_from_job_result( } } +/// Also reports the cancellation the deleted row carried, if any. Unlike a plain read of the queue +/// row, this waits for a cancel that is still being written, so it is the last word on one. pub async fn delete_job<'c>( mut tx: Transaction<'c, Postgres>, job_id: &Uuid, -) -> windmill_common::error::Result> { +) -> windmill_common::error::Result<(Transaction<'c, Postgres>, Option)> { #[cfg(feature = "prometheus")] if METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { QUEUE_DELETE_COUNT.inc(); } otel_incr_queue_delete_count(); - let job_removed = - sqlx::query_scalar!("DELETE FROM v2_job_queue WHERE id = $1 RETURNING 1", job_id,) - .fetch_optional(&mut *tx) - .await; + let job_removed = sqlx::query!( + "DELETE FROM v2_job_queue WHERE id = $1 RETURNING canceled_by, canceled_reason", + job_id, + ) + .fetch_optional(&mut *tx) + .await; - if let Err(job_removed) = job_removed { - tracing::error!( - "Job {job_id} could not be deleted: {job_removed}. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling" - ); - } else { - let job_removed = job_removed.unwrap().flatten().unwrap_or(0); - if job_removed != 1 { - tracing::error!("Job {job_id} could not be deleted, returned not 1: {job_removed}. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling"); + let canceled = match &job_removed { + Err(job_removed) => { + tracing::error!( + "Job {job_id} could not be deleted: {job_removed}. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling" + ); + None } - } + Ok(None) => { + tracing::error!("Job {job_id} could not be deleted, no row was removed. This is not necessarily an error, as the job might have been deleted by another process such as in the case of cancelling"); + None + } + Ok(Some(row)) => row.canceled_by.as_ref().map(|username| CanceledBy { + username: Some(username.clone()), + reason: row.canceled_reason.clone(), + }), + }; tracing::debug!("Job {job_id} deleted"); - Ok(tx) + Ok((tx, canceled)) } pub async fn job_is_complete(db: &DB, id: Uuid, w_id: &str) -> error::Result { @@ -5269,6 +5378,8 @@ pub fn empty_result() -> Box { lazy_static::lazy_static! { pub static ref RE_ARG_TAG: Regex = Regex::new(r#"\$args\[((?:\w+\.)*\w+)\]"#).unwrap(); + pub static ref RE_FLOW_EXPR_TAG: Regex = + Regex::new(r#"\$flow_expr\[((?:\w+\.)*\w+)\]"#).unwrap(); } #[cfg(feature = "cloud")] @@ -5746,10 +5857,10 @@ async fn push_inner<'c, 'd>( dedicated_worker, ..Default::default() }, - JobPayload::FlowNode { id, path } => { + JobPayload::FlowNode { id, path, no_inherited_flow_env } => { let data = cache::flow::fetch_flow(db, id).await?; let value = data.value(); - let status = Some(FlowStatus::new(value)); + let status = Some(FlowStatus { no_inherited_flow_env, ..FlowStatus::new(value) }); // Keep inserting `value` if not all workers are updated. // Starting at `v1.440`, the value is fetched on pull from the flow node id. let value_o = if !MIN_VERSION_IS_AT_LEAST_1_440.met().await { @@ -5986,6 +6097,7 @@ async fn push_inner<'c, 'd>( stream_job: None, chat_input_enabled: None, memory_id: None, + no_inherited_flow_env: false, } } _ => { @@ -6045,10 +6157,12 @@ async fn push_inner<'c, 'd>( // `quickjs` feature it cannot be evaluated and fails closed (no retry); // the flow path is not a fallback, since the flow runtime needs quickjs // too. + // A hub script has no hash and runs as a `Script_Hub` job. + let is_hub = hash.is_none() && path.starts_with("hub/"); let native_retry = !is_flow && skip_handler.is_none() && error_handler_path.is_none() - && hash.is_some() + && (hash.is_some() || is_hub) && language.is_some() && windmill_common::runnable_settings::min_version_supports_runnable_settings_v0() .await; @@ -6078,7 +6192,11 @@ async fn push_inner<'c, 'd>( break 'ssf JobPayloadUntagged { runnable_id: hash.map(|h| h.0), runnable_path: Some(path), - job_kind: JobKind::Script, + job_kind: if is_hub { + JobKind::Script_Hub + } else { + JobKind::Script + }, language, dedicated_worker, concurrency_settings, @@ -6380,6 +6498,7 @@ async fn push_inner<'c, 'd>( stream_job: None, chat_input_enabled: None, memory_id: None, + no_inherited_flow_env: false, }; let value = flow_data.value(); let priority = value.priority; @@ -6537,20 +6656,9 @@ async fn push_inner<'c, 'd>( ); windmill_common::worker::dedicated_worker_tag(workspace_id, &full_path) } else { - if tag == Some("".to_string()) { - tag = None; - } - - // `$workspace` must resolve the same way the default tags below do, or an explicit tag and - // a default tag from the same workspace address two different worker pools. Resolving costs - // a lookup, so pay it only for tags that actually interpolate `$workspace`. let interpolated_tag = match tag { + Some(x) => resolve_push_tag(&x, &args, workspace_id, db).await, None => None, - Some(x) if x.contains("$workspace") => { - let tag_ws = crate::tags::tag_workspace_id(&workspace_id, db).await; - Some(interpolate_args(x, &args, &tag_ws)) - } - Some(x) => Some(interpolate_args(x, &args, workspace_id)), }; let effective_ws = per_workspace_tag(&workspace_id, db).await; @@ -7922,3 +8030,43 @@ mod result_metadata_tests { assert_eq!(meta.wm_failure.as_deref(), Some("boom")); } } + +#[cfg(test)] +mod render_tag_path_tests { + use super::{interpolate_args, render_tag_path, PushArgs}; + use serde_json::value::RawValue; + use std::collections::HashMap; + + fn render(root: &str, path: &str) -> String { + render_tag_path( + Some(&RawValue::from_string(root.to_string()).unwrap()), + path, + ) + } + + // Existing `$args[...]` tags route on exactly these renderings. + #[test] + fn renders_like_args_tags() { + assert_eq!(render(r#""eu""#, ""), "eu"); + assert_eq!(render(r#"{"a": {"b": "eu"}}"#, "a.b"), "eu"); + assert_eq!(render(r#"{"n": 4}"#, "n"), "4"); + assert_eq!(render("null", ""), "null"); + assert_eq!(render(r#"{"a": 1}"#, "b.c"), ""); + assert_eq!(render(r#"{"a": ["eu"]}"#, "a.0"), ""); + assert_eq!(render_tag_path(None, "a"), ""); + + let args = HashMap::from([("cfg".to_string(), raw(r#"{"lang": "eu"}"#))]); + let push_args = PushArgs { + args: &args, + extra: Some(HashMap::from([("e".to_string(), raw(r#""x""#))])), + }; + assert_eq!( + interpolate_args("w-$args[cfg.lang]-$args[e]".to_string(), &push_args, "ws"), + "w-eu-x" + ); + } + + fn raw(json: &str) -> Box { + RawValue::from_string(json.to_string()).unwrap() + } +} diff --git a/backend/windmill-queue/src/schedule.rs b/backend/windmill-queue/src/schedule.rs index 5d5cf005fc..40c1292b74 100644 --- a/backend/windmill-queue/src/schedule.rs +++ b/backend/windmill-queue/src/schedule.rs @@ -6,6 +6,7 @@ * LICENSE-AGPL for a copy of the license. */ +use crate::jobs::HTTP_CLIENT; use crate::push; use crate::PushIsolationLevel; use anyhow::Context; @@ -19,13 +20,13 @@ use windmill_common::ee_oss::LICENSE_KEY_VALID; use windmill_common::flows::Retry; use windmill_common::get_flow_version_info_from_version; use windmill_common::get_latest_flow_version_id_for_path; -use windmill_common::jobs::check_tag_available_for_workspace_internal; use windmill_common::jobs::JobPayload; use windmill_common::jobs::JobTriggerKind; use windmill_common::jobs::OnBehalfOf; use windmill_common::runnable_settings::ConcurrencySettings; use windmill_common::runnable_settings::DebouncingSettings; use windmill_common::schedule::schedule_to_user; +use windmill_common::scripts::get_full_hub_script_by_path; use windmill_common::scripts::ScriptHash; use windmill_common::triggers::TriggerMetadata; use windmill_common::utils::WarnAfterExt; @@ -82,6 +83,8 @@ async fn get_schedule_metadata<'c>( Some(version), parsed_retry, )) + } else if schedule.script_path.starts_with("hub/") { + Ok((None, None, None, None, None, parsed_retry)) } else { let ( hash, @@ -323,6 +326,48 @@ pub async fn push_scheduled_job<'c>( None, on_behalf_of, ) + } else if schedule.script_path.starts_with("hub/") { + let tag = schedule.tag.clone().filter(|t| !t.is_empty()); + let payload = match &schedule.retry { + // The language is what lets `push` materialize this as a native retry + // instead of a flow wrapper, which would queue the next tick at start. + Some(retry) => JobPayload::SingleStepFlow { + path: schedule.script_path.clone(), + hash: None, + flow_version: None, + language: Some( + get_full_hub_script_by_path( + StripPath(schedule.script_path.clone()), + &HTTP_CLIENT, + Some(db), + ) + .await? + .language, + ), + retry: Some(serde_json::from_value::(retry.clone()).map_err(|e| { + error::Error::internal_err(format!( + "Unable to parse retry information from schedule: {e}" + )) + })?), + error_handler_path: None, + error_handler_args: None, + skip_handler: None, + args: args.clone(), + cache_ttl: None, + cache_ignore_s3_path: None, + priority: None, + tag_override: tag.clone(), + trigger_path: None, + apply_preprocessor: false, + concurrency_settings: ConcurrencySettings::default(), + debouncing_settings: DebouncingSettings::default(), + }, + None => JobPayload::ScriptHub { + path: schedule.script_path.clone(), + apply_preprocessor: false, + }, + }; + (payload, tag, None, None) } else { let ( hash, @@ -511,14 +556,15 @@ pub async fn push_scheduled_job<'c>( if let Some(tag) = tag.as_deref().filter(|t| !t.is_empty()) { let is_super_admin = windmill_common::auth::is_super_admin_email(db, &email).await?; - check_tag_available_for_workspace_internal( + crate::check_tag_available_for_push( db, &schedule.workspace_id, &tag, + &crate::PushArgs::from(&args), is_super_admin, None, // no token for schedules so no scopes so no scope_tags ) - .warn_after_seconds_with_sql(1, "check_tag_available_for_workspace_internal".to_string()) + .warn_after_seconds_with_sql(1, "check_tag_available_for_push".to_string()) .await?; } diff --git a/backend/windmill-queue/tests/schedule_push.rs b/backend/windmill-queue/tests/schedule_push.rs index 333d211765..f3ae34251e 100644 --- a/backend/windmill-queue/tests/schedule_push.rs +++ b/backend/windmill-queue/tests/schedule_push.rs @@ -1757,4 +1757,59 @@ mod schedule_push { assert_eq!(count_queued_jobs(&db).await, 1); Ok(()) } + + #[sqlx::test(migrations = "../migrations", fixtures("base", "schedule_push"))] + async fn test_push_hub_script_schedule(db: Pool) -> anyhow::Result<()> { + // Seed the hub cache so the push resolves the script without the network. + let version = "990000001"; + let hub_dir = &*windmill_common::worker::HUB_CACHE_DIR; + tokio::fs::create_dir_all(hub_dir).await?; + tokio::fs::write( + format!("{hub_dir}/{version}"), + r#"{"content":"echo hi","lockfile":null,"language":"bash","schema":{},"summary":null}"#, + ) + .await?; + let hub_path = format!("hub/{version}/test/echo"); + + // A retry must stay a native `script_hub` job: a flow wrapper would queue the + // next tick at start and let slow runs overlap. + let retry = serde_json::json!({ "constant": { "attempts": 2, "seconds": 1 } }); + for (path, retry, dynamic_skip) in [ + ("f/system/hub_plain", None, None), + ("f/system/hub_retry", Some(retry), None), + // A skip handler needs a flow wrapper, so its metadata lookup must not + // go through the `script` table. + ("f/system/hub_skip", None, Some("f/system/skip".to_string())), + ] { + let has_retry = retry.is_some(); + let has_skip = dynamic_skip.is_some(); + let schedule = make_schedule(|s| { + s.path = path.to_string(); + s.script_path = hub_path.clone(); + s.retry = retry; + s.dynamic_skip = dynamic_skip; + }); + let tx = db.begin().await?; + let tx = push_scheduled_job(&db, tx, &schedule, Some(&make_authed()), None).await?; + tx.commit().await?; + + let (job_kind, runnable_path, language, handle) = + sqlx::query_as::<_, (String, Option, Option, Option)>( + "SELECT j.kind::text, j.runnable_path, j.script_lang::text, q.runnable_settings_handle + FROM v2_job j JOIN v2_job_queue q ON j.id = q.id WHERE j.trigger = $1", + ) + .bind(path) + .fetch_one(&db) + .await?; + assert_eq!(runnable_path.as_deref(), Some(hub_path.as_str())); + if has_skip { + assert_eq!(job_kind, "singlestepflow"); + } else { + assert_eq!(job_kind, "script_hub"); + assert_eq!(language.as_deref(), Some("bash")); + assert_eq!(handle.is_some(), has_retry); + } + } + Ok(()) + } } diff --git a/backend/windmill-runtime-nativets/src/lib.rs b/backend/windmill-runtime-nativets/src/lib.rs index d44213d3bc..750bf7ebac 100644 --- a/backend/windmill-runtime-nativets/src/lib.rs +++ b/backend/windmill-runtime-nativets/src/lib.rs @@ -838,6 +838,11 @@ pub async fn eval_fetch_timeout( } } let w_id_for_tracing = w_id_for_tracing; + // nativets delivers logs in-process, so they never reach the masking in + // `handle_child::write_lines` and a `console.log` of `$WM_TOKEN` would be + // persisted verbatim. Mask here rather than in the detached task draining into + // `append_logs`: this loop normally runs while the job is still registered. + let mut masker = windmill_common::sensitive_log_masks::JobMasker::new(job_id); let handle = tokio::spawn(async move { let mut result_stream = String::new(); let mut is_stream = false; @@ -845,10 +850,20 @@ pub async fn eval_fetch_timeout( use windmill_common::result_stream::extract_stream_from_logs; use windmill_common::tracing_init::{OTEL_JOB_LOGS, OTEL_PREFIX}; + let stream = extract_stream_from_logs(&log.trim_end_matches("\n")); + + // A stream chunk is result data, not a log line — it never reaches + // `job_logs`, and `merge_result_stream` can make it the job's result — + // so it stays raw wherever it goes, here and in the mirror below. + // Deliberately unlike `handle_child`, which streams the masked text. + // Routed before masking because the notice is one-shot: spent on a chunk + // no sink persists, a later redaction in `job_logs` would go unexplained. + let logged = stream.is_none().then(|| masker.mask(&log).into_owned()); + // Mirror `process_streaming_log_lines` (EE) + the OTEL_JOB_LOGS // hook from handle_child.rs, neither of which runs for nativets // since nativets delivers logs in-process via the log channel. - for line in log.lines() { + for line in logged.as_deref().unwrap_or(&log).lines() { tracing::info!( target: "windmill:job_log", job_id = ?job_id, @@ -862,7 +877,7 @@ pub async fn eval_fetch_timeout( } } - if let Some(stream) = extract_stream_from_logs(&log.trim_end_matches("\n")) { + if let Some(stream) = stream { if !is_stream { is_stream = true; if let Some(ref f) = stream_notifier_update { @@ -874,8 +889,8 @@ pub async fn eval_fetch_timeout( if let Err(e) = result_stream_sender.send(stream) { tracing::error!("failed to send result stream: {e}"); } - } else { - if let Err(e) = append_logs_sender.send(log) { + } else if let Some(logged) = logged { + if let Err(e) = append_logs_sender.send(logged) { tracing::error!("failed to send log: {e}"); } } diff --git a/backend/windmill-runtime-nativets/src/windmill-client.js b/backend/windmill-runtime-nativets/src/windmill-client.js index 7c7d649042..c2dbd1fef1 100644 --- a/backend/windmill-runtime-nativets/src/windmill-client.js +++ b/backend/windmill-runtime-nativets/src/windmill-client.js @@ -6457,7 +6457,7 @@ var AppService = class { * @param data.workspace * @param data.path * @param data.requestBody update app - * @returns string app updated + * @returns AppDeployed the path the app now lives at and the version this deploy wrote * @throws ApiError */ static updateApp(data) { diff --git a/backend/windmill-trigger-http/src/handler.rs b/backend/windmill-trigger-http/src/handler.rs index 01ddfd4a81..539c8e43d2 100644 --- a/backend/windmill-trigger-http/src/handler.rs +++ b/backend/windmill-trigger-http/src/handler.rs @@ -13,6 +13,7 @@ use windmill_common::global_settings::{validate_allowed_origins, HTTP_ROUTE_WORK use windmill_common::{ db::UserDB, error::{Error, Result}, + utils::check_proper_path, worker::CLOUD_HOSTED, DB, }; @@ -269,6 +270,7 @@ pub async fn create_many_http_triggers( check_scopes(&authed, || { format!("http_triggers:write:{}", &new_http_trigger.base.path) })?; + check_proper_path(&new_http_trigger.base.path)?; // This route inserts directly, bypassing the shared create handler. // `error_wrapper` would turn the rejection into a 500. diff --git a/backend/windmill-trigger-postgres/src/handler.rs b/backend/windmill-trigger-postgres/src/handler.rs index 92dd2e1e19..4294645b89 100644 --- a/backend/windmill-trigger-postgres/src/handler.rs +++ b/backend/windmill-trigger-postgres/src/handler.rs @@ -21,11 +21,12 @@ use windmill_common::{ use windmill_git_sync::DeployedObject; use windmill_api_auth::{check_scopes, ApiAuthed}; -use windmill_trigger::{Trigger, TriggerCrud, TriggerData}; +use windmill_trigger::{Trigger, TriggerCrud, TriggerData, TriggerMode}; use super::{ check_if_valid_publication_for_postgres_version, create_logical_replication_slot, - create_pg_publication, drop_publication, generate_random_string, get_default_pg_connection, + create_pg_publication, drop_publication, ensure_not_under_roles, generate_random_string, + get_default_pg_connection, mapper::{Mapper, MappingInfo}, PostgresConfig, PostgresConfigRequest, PostgresPublicationReplication, PostgresTrigger, PublicationData, Relations, Slot, SlotList, TableToTrack, TemplateScript, TestPostgresConfig, @@ -64,6 +65,29 @@ impl TriggerCrud for PostgresTrigger { DeployedObject::PostgresTrigger { path, parent_path } } + async fn validate_config( + &self, + db: &DB, + config: &Self::TriggerConfigRequest, + workspace_id: &str, + ) -> Result<()> { + ensure_not_under_roles(db, workspace_id, &config.postgres_resource_path).await + } + + async fn authorize_set_trigger_mode( + &self, + _authed: &ApiAuthed, + tx: &mut PgConnection, + _workspace_id: &str, + _path: &str, + mode: &TriggerMode, + ) -> Result<()> { + if *mode != TriggerMode::Disabled { + windmill_common::datatable_roles::lock_datatable_streams(tx, false).await?; + } + Ok(()) + } + async fn create_trigger( &self, db: &DB, @@ -72,6 +96,7 @@ impl TriggerCrud for PostgresTrigger { w_id: &str, trigger: TriggerData, ) -> Result<()> { + windmill_common::datatable_roles::lock_datatable_streams(&mut *tx, false).await?; let resolved_edited_by = trigger.base.resolve_edited_by(authed); let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed); let Self::TriggerConfigRequest { @@ -161,6 +186,7 @@ impl TriggerCrud for PostgresTrigger { path: &str, trigger: TriggerData, ) -> Result<()> { + windmill_common::datatable_roles::lock_datatable_streams(&mut *tx, false).await?; let resolved_edited_by = trigger.base.resolve_edited_by(authed); let resolved_permissioned_as = trigger.base.resolve_permissioned_as(authed); let Self::TriggerConfigRequest { diff --git a/backend/windmill-trigger-postgres/src/lib.rs b/backend/windmill-trigger-postgres/src/lib.rs index 571aac0a66..b92860662d 100644 --- a/backend/windmill-trigger-postgres/src/lib.rs +++ b/backend/windmill-trigger-postgres/src/lib.rs @@ -374,6 +374,35 @@ pub async fn get_raw_postgres_connection( Ok(client) } +/// A replication stream reads every row of every table whatever the data table's roles grant, so +/// the two don't mix: a data table under roles takes no triggers or captures, and roles cannot be +/// turned on while one is enabled on it. +/// +/// Authorization: checks nothing, and its refusal says whether `w_id`'s data table is under roles. +/// Callers MUST have established that the caller may manage triggers in `w_id` first. +pub(crate) async fn ensure_not_under_roles( + db: &DB, + w_id: &str, + postgres_resource_path: &str, +) -> Result<()> { + let Some(datatable_name) = postgres_resource_path.strip_prefix("datatable://") else { + return Ok(()); + }; + if windmill_common::workspaces::resolve_governing_datatable(db, w_id, datatable_name) + .await? + .datatable + .permissions + .is_some() + { + return Err(Error::BadRequest(format!( + "Data table '{datatable_name}' is under roles, and a Postgres trigger or capture \ + cannot read one: a replication stream sees every row whatever the roles grant. \ + Turn its roles off to stream it." + ))); + } + Ok(()) +} + pub async fn resolve_postgres_resource( authed: &ApiAuthed, user_db: Option, @@ -382,6 +411,7 @@ pub async fn resolve_postgres_resource( w_id: &str, ) -> Result { if let Some(datatable_name) = postgres_resource_path.strip_prefix("datatable://") { + ensure_not_under_roles(db, w_id, postgres_resource_path).await?; // Trigger connections (publication/slot management + logical replication) run // as the dedicated replication user on custom-instance databases. let resource_value = diff --git a/backend/windmill-trigger-postgres/src/listener.rs b/backend/windmill-trigger-postgres/src/listener.rs index 14d348b5f2..876b75661e 100644 --- a/backend/windmill-trigger-postgres/src/listener.rs +++ b/backend/windmill-trigger-postgres/src/listener.rs @@ -20,7 +20,8 @@ use windmill_common::{ use windmill_trigger::{listener::ListeningTrigger, trigger_helpers::TriggerJobArgs, Listener}; use super::{ - drop_publication, get_default_pg_connection, get_raw_postgres_connection, + drop_publication, ensure_not_under_roles, get_default_pg_connection, + get_raw_postgres_connection, handler::drop_logical_replication_slot, relation::RelationConverter, replication_message::{ @@ -135,8 +136,8 @@ impl PostgresSimpleClient { /// Resolves the Postgres resource, validates that the configured publication and /// replication slot still exist, and opens a fresh logical replication stream. /// -/// Returns `Error::BadConfig` when the publication or slot is missing (an -/// unrecoverable misconfiguration). Any other error is treated as transient +/// Returns `Error::BadConfig` when the publication or slot is missing, or the +/// data table is under roles (unrecoverable misconfigurations). Any other error is treated as transient /// (connection refused, network interruption, ...) and is retried by the caller. /// The resource is re-resolved on every call so credential rotations are picked /// up across reconnections. @@ -149,6 +150,14 @@ async fn connect_logical_replication_stream( let PostgresConfig { postgres_resource_path, publication_name, replication_slot_name, .. } = trigger_config; + // Retrying cannot lift roles, so this disables the trigger like a missing slot does. + ensure_not_under_roles(db, workspace_id, postgres_resource_path) + .await + .map_err(|e| match e { + Error::BadRequest(msg) => Error::BadConfig(msg), + e => e, + })?; + let database = resolve_postgres_resource( authed, Some(UserDB::new(db.clone())), diff --git a/backend/windmill-trigger/src/handler.rs b/backend/windmill-trigger/src/handler.rs index 66d6770354..ba40ed2c48 100644 --- a/backend/windmill-trigger/src/handler.rs +++ b/backend/windmill-trigger/src/handler.rs @@ -22,7 +22,7 @@ use windmill_common::{ fetch_draft_only_list_rows, overlay_or_draft_only, UserDraftItemKind, WithDraftOverlay, WithDraftQuery, }, - utils::{paginate, Pagination, StripPath}, + utils::{check_proper_path, paginate, Pagination, StripPath}, worker::CLOUD_HOSTED, DB, }; @@ -547,6 +547,7 @@ async fn create_trigger( &new_trigger.base.path ) })?; + check_proper_path(&new_trigger.base.path)?; if *CLOUD_HOSTED && !T::IS_ALLOWED_ON_CLOUD { return Err(Error::BadRequest(format!( @@ -817,6 +818,9 @@ async fn update_trigger( &edit_trigger.base.path ) })?; + if edit_trigger.base.path != path { + check_proper_path(&edit_trigger.base.path)?; + } edit_trigger.error_handling.validate()?; diff --git a/backend/windmill-trigger/src/trigger_helpers.rs b/backend/windmill-trigger/src/trigger_helpers.rs index 6a37bd0da1..7e47613d1a 100644 --- a/backend/windmill-trigger/src/trigger_helpers.rs +++ b/backend/windmill-trigger/src/trigger_helpers.rs @@ -880,7 +880,8 @@ async fn trigger_script_with_retry_and_error_handler<'c>( delete_after_secs, ); - check_tag_available_for_workspace(&db, &workspace_id, &tag, &authed).await?; + let push_args = PushArgs { args: &args.args, extra: args.extra }; + check_tag_available_for_workspace(&db, &workspace_id, &tag, &push_args, &authed).await?; let return_tx = tx_o.is_some(); @@ -907,8 +908,6 @@ async fn trigger_script_with_retry_and_error_handler<'c>( ) }; - let push_args = PushArgs { args: &args.args, extra: args.extra }; - let retryable_job_payload = match job_payload { JobPayload::ScriptHash { hash, diff --git a/backend/windmill-types/src/flow_status.rs b/backend/windmill-types/src/flow_status.rs index 79890e3c6e..6dff8430b7 100644 --- a/backend/windmill-types/src/flow_status.rs +++ b/backend/windmill-types/src/flow_status.rs @@ -41,6 +41,11 @@ pub struct FlowStatus { pub chat_input_enabled: Option, #[serde(skip_serializing_if = "Option::is_none")] pub memory_id: Option, + /// Set at push on a branch/loop sub-flow whose enclosing flow had no `flow_env`, own or + /// inherited, so the worker can skip the ancestor walk for it. `false` only means unknown: + /// the worker then walks the ancestors as usual. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub no_inherited_flow_env: bool, } #[derive(Serialize, Deserialize, Debug, Clone, Default)] @@ -568,6 +573,7 @@ impl FlowStatus { stream_job: None, chat_input_enabled: f.chat_input_enabled, memory_id: None, + no_inherited_flow_env: false, } } diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index 5190efe380..4e0c5340f5 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -519,6 +519,8 @@ pub enum JobPayload { FlowNode { id: FlowNodeId, path: String, + /// See [`crate::flow_status::FlowStatus::no_inherited_flow_env`]. + no_inherited_flow_env: bool, }, FlowScript { id: FlowNodeId, diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index 9a059da6e8..9e29731505 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -507,6 +507,10 @@ pub struct ScriptHistory { pub deployment_msg: Option, #[serde(skip_serializing_if = "Option::is_none")] pub created_at: Option>, + /// Who deployed this version — the version picker names it, so a reader can + /// tell their own deploys from a teammate's. + #[serde(skip_serializing_if = "Option::is_none")] + pub created_by: Option, } #[derive(Deserialize)] diff --git a/backend/windmill-worker/src/agent_workers.rs b/backend/windmill-worker/src/agent_workers.rs index 8dccf691fa..e0971c08ce 100644 --- a/backend/windmill-worker/src/agent_workers.rs +++ b/backend/windmill-worker/src/agent_workers.rs @@ -64,16 +64,25 @@ pub async fn get_ducklake_from_agent_http( .await } +/// An agent worker authenticates as infrastructure, not as the job's user, so the job id travels +/// with the request: the server reads the job's owner from it and evaluates the data table's +/// tenants against them. A worker predating this sends neither, and the server fails it closed on +/// a data table under roles. #[allow(dead_code)] pub async fn get_datatable_resource_from_agent_http( client: &HttpClient, name: &str, w_id: &str, + role: Option<&str>, + job_id: &uuid::Uuid, ) -> anyhow::Result { + let role_query = role + .map(|r| format!("&role={}", urlencoding::encode(r))) + .unwrap_or_default(); client .get(&format!( - "/api/w/{}/agent_workers/get_datatable_resource/{}", - w_id, &name + "/api/w/{}/agent_workers/get_datatable_resource/{}?job_id={}{}", + w_id, &name, job_id, role_query )) .await } diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index efc7ffa1b3..3c3a26875b 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -79,6 +79,15 @@ lazy_static::lazy_static! { const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10; const HARD_MAX_AGENT_ITERATIONS: usize = 1000; +/// What a run stopped by `max_iterations` reports back. `Message` rather than +/// `OpenAIMessage` is load-bearing: `agent_action` is `skip_serializing` on the +/// latter and reaches JSON only through this wrapper, so serializing these raw +/// drops every tool name and job id and leaves the partial run unreadable. +#[derive(serde::Serialize)] +struct MaxIterPartialResult<'a> { + messages: Vec>, +} + fn strip_system_messages(messages: &[OpenAIMessage]) -> Vec { messages .iter() @@ -1785,10 +1794,6 @@ pub async fn run_agent( step_id: Option<&'a str>, result: MaxIterPartialResult<'a>, } - #[derive(serde::Serialize)] - struct MaxIterPartialResult<'a> { - messages: &'a [OpenAIMessage], - } return Err(Error::ExecutionRawError( serde_json::value::to_raw_value(&MaxIterError { message: format!( @@ -1797,7 +1802,15 @@ pub async fn run_agent( ), name: "ExecutionErr", step_id: effective_flow_step_id, - result: MaxIterPartialResult { messages: &messages }, + result: MaxIterPartialResult { + messages: messages + .iter() + .map(|m| Message { + message: m, + agent_action: m.agent_action.as_ref(), + }) + .collect(), + }, })?, )); } @@ -2322,6 +2335,33 @@ mod tests { assert!(!streaming_requested(Some(false))); } + #[test] + fn max_iterations_partial_result_keeps_the_action_tags() { + let messages = vec![OpenAIMessage { + role: "tool".to_string(), + content: Some(OpenAIContent::Text("{\"rows\":2}".to_string())), + tool_call_id: Some("call_1".to_string()), + agent_action: Some(AgentAction::ToolCall { + job_id: uuid::Uuid::nil(), + function_name: "list_payouts".to_string(), + module_id: "b".to_string(), + }), + ..Default::default() + }]; + + let partial = MaxIterPartialResult { + messages: messages + .iter() + .map(|m| Message { message: m, agent_action: m.agent_action.as_ref() }) + .collect(), + }; + let json = serde_json::to_value(&partial).unwrap(); + + let action = &json["messages"][0]["agent_action"]; + assert_eq!(action["type"], "tool_call"); + assert_eq!(action["function_name"], "list_payouts"); + } + /// Over 64 characters OpenAI rejects the key outright, which costs a wasted round /// trip per run and silently leaves that step with no prompt caching at all. #[test] diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 117fbda086..841ea8861a 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -2689,13 +2689,13 @@ try {{ /// Resolve a module file from the parent script's modules map. /// For Script jobs, fetches from the `script` table by hash. /// For Preview jobs, fetches from `v2_job.raw_code` (modules stored inline). -fn resolve_parent_module( - modules: &Option>, +fn resolve_parent_module<'a>( + modules: &'a Option>, module_key: &str, -) -> error::Result { +) -> error::Result<&'a windmill_common::scripts::ScriptModule> { if let Some(modules) = modules { if let Some(module) = modules.get(module_key) { - return Ok(module.clone()); + return Ok(module); } } Err(error::Error::ExecutionErr(format!( @@ -2716,7 +2716,7 @@ pub async fn handle_wac_v2_output( ) -> error::Result> { use crate::wac_executor::{ load_checkpoint, parse_wac_output, update_checkpoint_for_dispatch, - wac_cancelled_mid_segment, WacOutput, WacPark, + wac_cancelled_mid_segment, WacOutput, WacPark, WacStepDispatch, }; use serde_json::Value; use windmill_common::get_latest_flow_version_info_for_path; @@ -2724,7 +2724,7 @@ pub async fn handle_wac_v2_output( use windmill_common::runnable_settings::{ ConcurrencySettings, ConcurrencySettingsWithCustom, DebouncingSettings, }; - use windmill_queue::{push, PushArgs, PushIsolationLevel}; + use windmill_queue::{check_tag_available_for_push, push, PushArgs, PushIsolationLevel}; let output = parse_wac_output(&result)?; @@ -2950,6 +2950,129 @@ pub async fn handle_wac_v2_output( ))), }?; + // Resolve what every task runs, and check any tag it picks, before the parent parks: + // a task that cannot be pushed then fails the dispatch with no sibling queued. The + // parent's code and input are built per child at push time, so a fan-out never holds + // a copy of them for each child at once. + enum ChildRunnable<'a> { + // Re-runs the parent with `_executing_key`. + Parent, + // A `./` module of the parent script. + Module(&'a windmill_common::scripts::ScriptModule), + // A deployed script or flow. + Deployed(JobPayload), + } + struct ResolvedChild<'a> { + runnable: ChildRunnable<'a>, + email: String, + permissioned_as: String, + } + // The input a task with a runnable of its own is pushed with; the parent re-run gets + // the parent's. Both the tag check and the push build it here, so the check resolves + // `$args[...]` against what the child actually receives. + let own_args = |step: &WacStepDispatch| -> HashMap> { + step.args + .iter() + .map(|(k, v)| { + let raw = serde_json::value::to_raw_value(v).unwrap(); + (k.clone(), raw) + }) + .collect() + }; + let mut children: Vec = Vec::with_capacity(num_steps); + for step in &steps { + let (runnable, on_behalf_of) = match step.dispatch_type.as_str() { + "script" if step.script.starts_with("./") => { + // Module-relative path: resolve from parent script's modules + let module_key = step.script.strip_prefix("./").unwrap(); + let module = resolve_parent_module(modules, module_key)?; + // Inline module code, not a separate runnable: it has no + // identity of its own and runs as the parent. + (ChildRunnable::Module(module), None) + } + "script" => { + // Resolve script path to job payload (handles hash, lang, etc.) + let (payload, _, _, _, _, on_behalf_of) = script_path_to_payload( + &step.script, + None, // no authed db for background workers + db.clone(), + &job.workspace_id, + Some(true), // skip preprocessor + ) + .await?; + (ChildRunnable::Deployed(payload), on_behalf_of) + } + "flow" => { + let flow_info = get_latest_flow_version_info_for_path( + None, + db, + &job.workspace_id, + &step.script, + true, + ) + .await?; + let payload = JobPayload::Flow { + path: step.script.clone(), + dedicated_worker: flow_info.dedicated_worker, + apply_preprocessor: false, + version: flow_info.version, + labels: flow_info.labels.clone(), + }; + let on_behalf_of = flow_info.on_behalf_of(&job.workspace_id, db).await?; + (ChildRunnable::Deployed(payload), on_behalf_of) + } + // "inline" — re-run parent with _executing_key + _ => (ChildRunnable::Parent, None), + }; + + // A target runnable that opts into on-behalf-of runs under its own + // identity, never the caller's, so a step that reaches it through a + // workflow cannot widen or narrow its permissions. `created_by` still + // credits the caller, matching how the run API pushes these jobs. + let (email, permissioned_as) = match on_behalf_of { + Some(on_behalf_of) => (on_behalf_of.email, on_behalf_of.permissioned_as), + None => ( + job.permissioned_as_email.clone(), + job.permissioned_as.clone(), + ), + }; + + // A task inheriting the parent's tag skips the check: that tag was + // checked when the parent was pushed, and a dedicated worker's tag is + // never in CUSTOM_TAGS. + if let Some(tag) = step + .tag + .as_deref() + .filter(|t| !t.is_empty() && *t != job.tag.as_str()) + { + let is_super_admin = + windmill_common::auth::is_super_admin_email(db, &email).await?; + let step_args = + (!matches!(runnable, ChildRunnable::Parent)).then(|| own_args(step)); + let args = + PushArgs { args: step_args.as_ref().unwrap_or(&parent_args), extra: None }; + check_tag_available_for_push( + db, + &job.workspace_id, + tag, + &args, + is_super_admin, + None, + ) + .warn_after_seconds_with_sql(1, "check_tag_available_for_push".to_string()) + .await + .map_err(|e| match e { + error::Error::BadRequest(msg) => error::Error::BadRequest(format!( + "task '{}' cannot run on tag '{tag}': {msg}", + step.name + )), + e => e, + })?; + } + + children.push(ResolvedChild { runnable, email, permissioned_as }); + } + // Step 1: Save checkpoint, suspend parent, and seed child checkpoints // in a single transaction — all BEFORE children become visible. let segment_ms; @@ -3034,106 +3157,37 @@ pub async fn handle_wac_v2_output( // partial failure (e.g. pushing child 3 of 5 fails). let mut pushed_ids: Vec = Vec::with_capacity(num_steps); let push_result: error::Result<()> = async { - for (step, (_, child_uuid)) in steps.iter().zip(job_ids.iter()) { + for ((step, (_, child_uuid)), child) in + steps.iter().zip(job_ids.iter()).zip(children) + { // A task with a runnable of its own (a deployed script or flow) queues // at that runnable's priority; any other task is the parent's code and // queues at the parent's. let own_runnable = matches!(step.dispatch_type.as_str(), "script" | "flow") && !step.script.starts_with("./"); - // Resolve job payload based on dispatch_type - let (job_payload, child_args, is_external, on_behalf_of) = - match step.dispatch_type.as_str() { - "script" if step.script.starts_with("./") => { - // Module-relative path: resolve from parent script's modules - let module_key = step.script.strip_prefix("./").unwrap(); - let module = resolve_parent_module(modules, module_key)?; - let payload = JobPayload::Code(RawCode { - content: module.content, - path: job.runnable_path.clone(), - hash: None, - language: module.language, - lock: module.lock, - cache_ttl: None, - cache_ignore_s3_path: None, - dedicated_worker: None, - concurrency_settings: ConcurrencySettingsWithCustom::default(), - debouncing_settings: DebouncingSettings::default(), - modules: None, - tag: None, - }); - let step_args: HashMap> = step - .args - .iter() - .map(|(k, v)| { - let raw = serde_json::value::to_raw_value(v).unwrap(); - (k.clone(), raw) - }) - .collect(); - // Inline module code, not a separate runnable: it has no - // identity of its own and runs as the parent. - (payload, step_args, true, None) - } - "script" => { - // Resolve script path to job payload (handles hash, lang, etc.) - let (payload, _, _, _, _, on_behalf_of) = script_path_to_payload( - &step.script, - None, // no authed db for background workers - db.clone(), - &job.workspace_id, - Some(true), // skip preprocessor - ) - .await?; - let step_args: HashMap> = step - .args - .iter() - .map(|(k, v)| { - let raw = serde_json::value::to_raw_value(v).unwrap(); - (k.clone(), raw) - }) - .collect(); - (payload, step_args, true, on_behalf_of) - } - "flow" => { - let flow_info = get_latest_flow_version_info_for_path( - None, - db, - &job.workspace_id, - &step.script, - true, - ) - .await?; - let payload = JobPayload::Flow { - path: step.script.clone(), - dedicated_worker: flow_info.dedicated_worker, - apply_preprocessor: false, - version: flow_info.version, - labels: flow_info.labels.clone(), - }; - let on_behalf_of = - flow_info.on_behalf_of(&job.workspace_id, db).await?; - let step_args: HashMap> = step - .args - .iter() - .map(|(k, v)| { - let raw = serde_json::value::to_raw_value(v).unwrap(); - (k.clone(), raw) - }) - .collect(); - (payload, step_args, true, on_behalf_of) - } - _ => { - // "inline" — re-run parent with _executing_key - ( - job_payload_template.clone(), - parent_args.clone(), - false, - None, - ) - } - }; - - let push_args = PushArgs { args: &child_args, extra: None }; + let is_external = !matches!(child.runnable, ChildRunnable::Parent); + let job_payload = match child.runnable { + ChildRunnable::Parent => job_payload_template.clone(), + ChildRunnable::Module(module) => JobPayload::Code(RawCode { + content: module.content.clone(), + path: job.runnable_path.clone(), + hash: None, + language: module.language, + lock: module.lock.clone(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + concurrency_settings: ConcurrencySettingsWithCustom::default(), + debouncing_settings: DebouncingSettings::default(), + modules: None, + tag: None, + }), + ChildRunnable::Deployed(payload) => payload, + }; + let step_args = is_external.then(|| own_args(step)); + let push_args = + PushArgs { args: step_args.as_ref().unwrap_or(&parent_args), extra: None }; // Apply step-level overrides to payload (cache, concurrency) let mut job_payload = job_payload; @@ -3181,21 +3235,6 @@ pub async fn handle_wac_v2_output( } } - // A target runnable that opts into on-behalf-of runs under its own - // identity, never the caller's, so a step that reaches it through a - // workflow cannot widen or narrow its permissions. `created_by` still - // credits the caller, matching how the run API pushes these jobs. - let (child_email, child_permissioned_as) = match on_behalf_of.as_ref() { - Some(on_behalf_of) => ( - on_behalf_of.email.as_str(), - on_behalf_of.permissioned_as.clone(), - ), - None => ( - job.permissioned_as_email.as_str(), - job.permissioned_as.clone(), - ), - }; - let (_, mut tx) = push( db, PushIsolationLevel::IsolatedRoot(db.clone()), @@ -3203,8 +3242,8 @@ pub async fn handle_wac_v2_output( job_payload, push_args, &job.created_by, - child_email, - child_permissioned_as, + &child.email, + child.permissioned_as, None, None, None, diff --git a/backend/windmill-worker/src/duckdb_executor.rs b/backend/windmill-worker/src/duckdb_executor.rs index b2e69d26d6..aecdb738b3 100644 --- a/backend/windmill-worker/src/duckdb_executor.rs +++ b/backend/windmill-worker/src/duckdb_executor.rs @@ -13,8 +13,8 @@ use windmill_common::error::{to_anyhow, Error, Result}; use windmill_common::utils::sanitize_string_from_password; use windmill_common::worker::{get_memory, to_raw_value, Connection, SqlResultCollectionStrategy}; use windmill_common::workspaces::{ - get_datatable_resource_from_db_unchecked, get_ducklake_from_db_unchecked, - strip_fork_reserved_attach_args, DucklakeCatalogResourceType, + get_datatable_resource_from_db, get_ducklake_from_db_unchecked, + strip_fork_reserved_attach_args, DatatableAccess, DucklakeCatalogResourceType, }; use windmill_common::PgDatabase; use windmill_object_store::S3_PROXY_LAST_ERRORS_CACHE; @@ -1494,13 +1494,9 @@ pub async fn do_duckdb( .await? { probe_blocks.extend(q); - } else if let Some(q) = transform_attach_datatable( - &query_block, - conn, - &mut hidden_passwords, - &job.workspace_id, - ) - .await? + } else if let Some(q) = + transform_attach_datatable(&query_block, conn, &mut hidden_passwords, job) + .await? { probe_blocks.extend(q); } else { @@ -1575,13 +1571,9 @@ pub async fn do_duckdb( .await? { v.extend(ducklake_query); - } else if let Some(datatable_query) = transform_attach_datatable( - &query_block, - conn, - &mut hidden_passwords, - &job.workspace_id, - ) - .await? + } else if let Some(datatable_query) = + transform_attach_datatable(&query_block, conn, &mut hidden_passwords, job) + .await? { v.extend(datatable_query); } else { @@ -2609,33 +2601,79 @@ fn fork_defer_statements( Ok(stmts) } +struct AttachedDatatable<'a> { + /// The data table reference, query string included; a bare `datatable` is `main`. + reference: String, + alias: &'a str, +} + +/// `ATTACH 'datatable[://][?role=]' AS `. A bare `datatable` names the default +/// data table, so the role query string has to be accepted with and without an explicit name. The +/// reference is split only once the workspace can be read, because a stored name may contain `?`. +fn parse_attach_datatable(query: &str) -> Option> { + lazy_static::lazy_static! { + static ref RE: regex::Regex = regex::Regex::new( + r"(?i)ATTACH\s*'datatable(://[^':]+|\?[^':]*)?'\s*AS\s+([^ ;]+)" + ).unwrap(); + } + let cap = RE.captures(query)?; + let reference = match cap.get(1).map(|m| m.as_str()) { + Some(named) if named.starts_with("://") => named[3..].to_string(), + Some(query) => format!("main{query}"), + None => "main".to_string(), + }; + let alias = cap.get(2).map(|m| m.as_str()).unwrap_or(""); + Some(AttachedDatatable { reference, alias }) +} + async fn transform_attach_datatable( query: &str, conn: &Connection, hidden_passwords: &mut Arc>>, - w_id: &str, + job: &MiniPulledJob, ) -> Result>> { - lazy_static::lazy_static! { - static ref RE: regex::Regex = regex::Regex::new(r"(?i)ATTACH\s*'datatable(://[^':]+)?'\s*AS\s+([^ ;]+)").unwrap(); - } - let Some(cap) = RE.captures(query) else { + let Some(attached) = parse_attach_datatable(query) else { return Ok(None); }; - let name = cap.get(1).map(|m| &m.as_str()[3..]).unwrap_or("main"); - let alias_name = cap.get(2).map(|m| m.as_str()).unwrap_or(""); + // A query string that does not parse is refused rather than dropped: attaching under the + // default role when the statement asked for another one is the failure this guards. let db_resource = match conn { Connection::Http(client) => { - get_datatable_resource_from_agent_http(client, name, w_id).await? + let (name, role) = + windmill_common::workspaces::parse_datatable_ref(&attached.reference)?; + get_datatable_resource_from_agent_http(client, name, &job.workspace_id, role, &job.id) + .await? + } + Connection::Sql(db) => { + let (name, role) = windmill_common::workspaces::parse_datatable_ref_for( + db, + &job.workspace_id, + &attached.reference, + ) + .await?; + get_datatable_resource_from_db( + db, + &job.workspace_id, + &name, + role.as_deref(), + DatatableAccess::PermissionedAs { + permissioned_as: &job.permissioned_as, + email: &job.permissioned_as_email, + }, + ) + .await? } - Connection::Sql(db) => get_datatable_resource_from_db_unchecked(db, w_id, name).await?, }; if let Some(pwd) = db_resource.get("password").and_then(|p| p.as_str()) { hidden_passwords.lock().unwrap().push(pwd.to_string()); } - Ok(Some(pg_secret_attach_statements(db_resource, alias_name)?)) + Ok(Some(pg_secret_attach_statements( + db_resource, + attached.alias, + )?)) } // Secret names must be plain identifiers; the hash keeps two aliases distinct even @@ -2680,6 +2718,11 @@ fn pg_secret_attach_statements(db_resource: Value, alias_name: &str) -> Result' (TYPE postgres, SECRET …)` would reach a database nobody + // authorized this job for, as this role. + format!("DROP TEMPORARY SECRET {secret_name};"), ]) } @@ -2753,6 +2796,45 @@ pub struct Arg { mod tests { use super::*; + #[test] + fn attach_datatable_parses_name_and_role() { + let reference_of = |q: &str| parse_attach_datatable(q).unwrap().reference; + let named = + parse_attach_datatable("ATTACH 'datatable://sales?role=analytics' AS dt").unwrap(); + assert_eq!( + (named.reference.as_str(), named.alias), + ("sales?role=analytics", "dt") + ); + // A bare `datatable` is the default one, and still takes a role. + assert_eq!( + reference_of("ATTACH 'datatable?role=analytics' AS dt"), + "main?role=analytics" + ); + assert_eq!(reference_of("ATTACH 'datatable://sales' AS dt"), "sales"); + assert_eq!(reference_of("ATTACH 'datatable' AS dt"), "main"); + assert!(parse_attach_datatable("SELECT 1").is_none()); + // A stored name can contain `?`, so that is left to the workspace lookup to split. + assert_eq!(reference_of("ATTACH 'datatable://a?b' AS dt"), "a?b"); + + // The key matches case-insensitively, as the `-- role` annotation does, and a query string + // that does not parse is refused rather than attached under the default role. + let parse = |q: &str| { + windmill_common::workspaces::parse_datatable_ref(&reference_of(q)) + .map(|(name, role)| (name.to_string(), role.map(str::to_string))) + }; + assert_eq!( + parse("ATTACH 'datatable://sales?Role=analytics' AS dt").unwrap(), + ("sales".to_string(), Some("analytics".to_string())) + ); + for malformed in [ + "ATTACH 'datatable://sales?role=' AS dt", + "ATTACH 'datatable://sales?role=an;alytics' AS dt", + "ATTACH 'datatable://sales?x=1&role=analytics' AS dt", + ] { + assert!(parse(malformed).is_err(), "silently ignored: {malformed}"); + } + } + #[test] fn decode_ffi_error_unescapes_multiline_and_strips_quotes() { // Mirror the FFI: JSON-encode the raw DuckDB message, prefix "ERROR ". @@ -3868,6 +3950,8 @@ mod tests { stmts[3], format!("ATTACH 'sslmode=require' AS dt (TYPE postgres, SECRET {secret_name});") ); + assert_eq!(stmts[4], format!("DROP TEMPORARY SECRET {secret_name};")); + assert_eq!(stmts.len(), 5); } #[test] diff --git a/backend/windmill-worker/src/pg_executor.rs b/backend/windmill-worker/src/pg_executor.rs index 1413924bf3..69aa3ee0fb 100644 --- a/backend/windmill-worker/src/pg_executor.rs +++ b/backend/windmill-worker/src/pg_executor.rs @@ -26,9 +26,11 @@ use windmill_common::azure_workload_identity::WORKLOAD_IDENTITY_PASSWORD; use windmill_common::error::to_anyhow; use windmill_common::error::{self, Error}; use windmill_common::worker::{ - to_raw_value, Connection, SqlResultCollectionStrategy, CLOUD_HOSTED, + to_raw_value, Connection, SqlAnnotations, SqlResultCollectionStrategy, CLOUD_HOSTED, +}; +use windmill_common::workspaces::{ + get_datatable_resource_from_db, parse_datatable_ref, parse_datatable_ref_for, DatatableAccess, }; -use windmill_common::workspaces::get_datatable_resource_from_db_unchecked; use windmill_common::{PgDatabase, PrepareQueryColumnInfo, PrepareQueryResult, DB}; use windmill_parser::{Arg, Typ}; use windmill_parser_sql::{ @@ -680,15 +682,36 @@ pub async fn do_postgresql( } else { match pg_args.get("database").cloned() { Some(Value::String(db_str)) if db_str.starts_with("datatable://") => { - let db_str = db_str.trim_start_matches("datatable://"); + let reference = db_str.trim_start_matches("datatable://"); + // The annotation wins: a generated query can carry a `?role=` in the reference it + // was handed, but only the script's author writes the leading comment block. + let annotated = SqlAnnotations::datatable_role(&query)?; Some(match conn { Connection::Http(client) => { - get_datatable_resource_from_agent_http(client, &db_str, &job.workspace_id) - .await? + let (name, uri_role) = parse_datatable_ref(reference)?; + get_datatable_resource_from_agent_http( + client, + name, + &job.workspace_id, + annotated.as_deref().or(uri_role), + &job.id, + ) + .await? } Connection::Sql(db) => { - get_datatable_resource_from_db_unchecked(db, &job.workspace_id, &db_str) - .await? + let (name, uri_role) = + parse_datatable_ref_for(db, &job.workspace_id, reference).await?; + get_datatable_resource_from_db( + db, + &job.workspace_id, + &name, + annotated.as_deref().or(uri_role.as_deref()), + DatatableAccess::PermissionedAs { + permissioned_as: &job.permissioned_as, + email: &job.permissioned_as_email, + }, + ) + .await? } }) } diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 56b4b26fb0..55a698a9dd 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -3859,8 +3859,6 @@ pub async fn run_worker( let arc_job = Arc::new(job); - windmill_common::sensitive_log_masks::register_running_job(arc_job.id); - let span = create_span_with_name(&arc_job, &worker_name, Some(hostname), "job"); let log_ctx = log_context_for_job(&arc_job, &worker_name, Some(hostname)); @@ -3976,8 +3974,6 @@ pub async fn run_worker( _ => {} } - windmill_common::sensitive_log_masks::unregister_running_job(job_id); - #[cfg(feature = "prometheus")] if let Some(duration) = _timer.map(|x| x.stop_and_record()) { register_metric( @@ -4512,6 +4508,30 @@ async fn detect_and_store_runtime_assets_from_job_args( } } +/// Holds a job's entry in the log-masking registry for as long as it executes, so +/// that secrets it fetches can be registered against it, and masks the job's own +/// token from the start: `$WM_TOKEN` stays valid well past the run, and a script +/// that echoes it would otherwise leave a live credential in the persisted logs. +/// +/// Lives here rather than at the call sites so that every way of running a job — +/// the poller, the interactive worker shell, an inline AI agent tool — is covered +/// by construction. +struct RunningJobMasks(Uuid); + +impl RunningJobMasks { + fn register(job_id: Uuid, token: &str) -> Self { + windmill_common::sensitive_log_masks::register_running_job(job_id); + windmill_common::sensitive_log_masks::register_secret_for_job(job_id, token); + RunningJobMasks(job_id) + } +} + +impl Drop for RunningJobMasks { + fn drop(&mut self) { + windmill_common::sensitive_log_masks::unregister_running_job(self.0); + } +} + pub async fn handle_queued_job( job: Arc, raw_code: Option, @@ -4533,6 +4553,8 @@ pub async fn handle_queued_job( flow_runners: Option>, #[cfg(feature = "benchmark")] _bench: &mut BenchmarkIter, ) -> windmill_common::error::Result { + let _masks = RunningJobMasks::register(job.id, &client.token); + if job.canceled_by.is_some() { return Err(Error::JsonErr(canceled_job_to_result(&job))); } diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 6c0dcaf279..6e01378448 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -44,8 +44,7 @@ use windmill_common::flow_status::{ }; use windmill_common::flows::{add_virtual_items_if_necessary, Branch, FlowNodeId, StopAfterIf}; use windmill_common::jobs::{ - check_tag_available_for_workspace_internal, script_path_to_payload, JobKind, JobPayload, - OnBehalfOf, RawCode, ENTRYPOINT_OVERRIDE, + script_path_to_payload, JobKind, JobPayload, OnBehalfOf, RawCode, ENTRYPOINT_OVERRIDE, }; use windmill_common::runnable_settings::{ ConcurrencySettingsWithCustom, DebouncingSettings, RunnableSettingsTrait, @@ -68,11 +67,12 @@ use windmill_common::{ }; use windmill_queue::schedule::get_schedule_opt; use windmill_queue::{ - add_completed_job, add_completed_job_error, append_logs, get_mini_pulled_job, - insert_concurrency_key_capped, interpolate_args, - report_error_to_workspace_handler_or_critical_side_channel, tag_reads_args, - try_schedule_next_job, CanceledBy, FlowRunners, MiniCompletedJob, MiniPulledJob, PushArgs, - PushIsolationLevel, SameWorkerPayload, WrappedError, + add_completed_job, add_completed_job_error, append_logs, + check_tag_written_as_available_for_push, get_mini_pulled_job, insert_concurrency_key_capped, + render_tag_path, report_error_to_workspace_handler_or_critical_side_channel, resolve_push_tag, + tag_reads_args, tag_reads_flow_expr, try_schedule_next_job, CanceledBy, FlowRunners, + MiniCompletedJob, MiniPulledJob, PushArgs, PushIsolationLevel, SameWorkerPayload, WrappedError, + RE_FLOW_EXPR_TAG, }; use windmill_audit::audit_oss::audit_log; @@ -537,15 +537,22 @@ pub async fn update_flow_status_after_job_completion_internal( if let Some(cached) = RESOLVED_FLOW_ENV_CACHE.get(&flow) { Some(cached) } else { - resolve_flow_env_for_status_update(db, client, flow, w_id, flow_value) - .await - .map(|(env, is_cacheable)| { - let arc = Arc::new(env); - if is_cacheable { - RESOLVED_FLOW_ENV_CACHE.insert(flow, arc.clone()); - } - arc - }) + resolve_flow_env_for_status_update( + db, + client, + flow, + w_id, + flow_value, + old_status.no_inherited_flow_env, + ) + .await + .map(|(env, is_cacheable)| { + let arc = Arc::new(env); + if is_cacheable { + RESOLVED_FLOW_ENV_CACHE.insert(flow, arc.clone()); + } + arc + }) } } else { None @@ -1520,15 +1527,6 @@ pub async fn update_flow_status_after_job_completion_internal( .is_some_and(|ck| ck.contains("$args")) }); let require_args = concurrency_requires_args || has_debouncing; - let mut tag = tag_and_concurrency_key.as_ref().and_then(|x| x.tag.clone()); - // `$workspace` does not depend on the preprocessor's output, so it has to resolve even - // when nothing forced us to fetch args. Leaving it to the `$args` branch below writes a - // `$workspace`-only tag back verbatim, naming a queue no worker serves. - if let Some(t) = tag.as_ref().filter(|t| t.contains("$workspace")) { - let tag_ws = - windmill_queue::tags::tag_workspace_id(&flow_job.workspace_id, db).await; - tag = Some(t.replace("$workspace", &tag_ws)); - } let concurrency_key = tag_and_concurrency_key .as_ref() .and_then(|x| x.concurrency_key.clone()); @@ -1576,10 +1574,6 @@ pub async fn update_flow_status_after_job_completion_internal( ) .await?; } - if let Some(t) = tag { - // `$workspace` is already resolved above; this fills in `$args`. - tag = Some(interpolate_args(t, &args, &flow_job.workspace_id)); - } } else if concurrent_limit.is_some() { insert_concurrency_key_capped( &flow_job.workspace_id, @@ -1594,6 +1588,23 @@ pub async fn update_flow_status_after_job_completion_internal( .await?; } + // Resolved as `push` resolves a tag, so a `$flow_expr[...]` or empty one comes out as + // `None` and the job keeps its current tag instead of queueing on the literal text. + let tag = match tag_and_concurrency_key + .as_ref() + .and_then(|x| x.tag.as_deref()) + { + // `run_flow` checked this tag for whoever ran the flow, an `$args[...]` in it as + // written since only this point knows its values: an entry admitting it as + // written admits every value here. + Some(t) => { + let no_args = HashMap::new(); + let args = PushArgs::from(fetched_args.as_ref().unwrap_or(&no_args)); + resolve_push_tag(t, &args, &flow_job.workspace_id, db).await + } + None => None, + }; + let scheduled_for: Option> = { #[cfg(feature = "private")] { @@ -2517,6 +2528,7 @@ async fn resolve_flow_env_for_status_update( flow_job_id: Uuid, workspace_id: &str, flow_value: &FlowValue, + no_inherited_flow_env: bool, ) -> Option<(HashMap>, bool)> { // Fetch the env source. For the inherited path, we first need to know whether the // flow even has a parent — `fetch_root_flow_env` runs a recursive CTE on `v2_job` @@ -2525,6 +2537,8 @@ async fn resolve_flow_env_for_status_update( let (env, mini): (HashMap>, Option) = if let Some(ref e) = flow_value.flow_env { (e.clone(), None) + } else if no_inherited_flow_env { + return None; } else { let mini = match get_mini_pulled_job(db, &flow_job_id).await { Ok(Some(j)) => j, @@ -2538,7 +2552,13 @@ async fn resolve_flow_env_for_status_update( // No own flow_env and no parent to inherit from — nothing to resolve. return None; } - let env = fetch_root_flow_env(db, flow_job_id, workspace_id).await?; + let env = match fetch_root_flow_env(db, flow_job_id, workspace_id).await { + Ok(env) => env?, + Err(e) => { + tracing::warn!("Failed to look up the inherited flow_env: {e:#}"); + return None; + } + }; (env, Some(mini)) }; if env.is_empty() { @@ -2604,7 +2624,7 @@ async fn fetch_root_flow_env( db: &DB, flow_job_id: Uuid, workspace_id: &str, -) -> Option>> { +) -> Result>>, sqlx::Error> { sqlx::query_scalar!( r#"WITH RECURSIVE chain(id, parent_job, flow_innermost_root_job, runnable_id, runnable_path, raw_flow, depth) AS ( SELECT id, parent_job, flow_innermost_root_job, runnable_id, runnable_path, raw_flow, 0 @@ -2641,10 +2661,7 @@ async fn fetch_root_flow_env( ) .fetch_optional(db) .await - .ok() - .flatten() - .flatten() - .map(|json| json.0) + .map(|row| row.flatten().map(|json| json.0)) } struct FailureContext { @@ -2789,18 +2806,31 @@ pub async fn handle_flow( ) -> anyhow::Result<()> { let flow = flow_data.value(); + let status = flow_job + .parse_flow_status() + .with_context(|| "Unable to parse flow status")?; + // Sub-flows spawned by `payload_from_modules` for branches/loops don't // carry the parent's `flow_env` in their own FlowValue. Fall back to the // nearest enclosing scope's `flow_env` so predicates like `skip_if`, // `stop_after_if`, and branch conditions see the same env as input // transforms. - let inherited_env: Option>> = - if flow.flow_env.is_none() && flow_job.parent_job.is_some() { - fetch_root_flow_env(db, flow_job.id, &flow_job.workspace_id).await - } else { - None - }; + let (inherited_env, inherited_env_known) = if flow.flow_env.is_none() + && flow_job.parent_job.is_some() + && !status.no_inherited_flow_env + { + match fetch_root_flow_env(db, flow_job.id, &flow_job.workspace_id).await { + Ok(env) => (env, true), + Err(e) => { + tracing::warn!("Failed to look up the inherited flow_env: {e:#}"); + (None, false) + } + } + } else { + (None, true) + }; let env_source = flow.flow_env.as_ref().or(inherited_env.as_ref()); + let no_flow_env = inherited_env_known && env_source.is_none_or(|e| e.is_empty()); // Resolve $var: and $res: references in flow_env. // We resolve into a separate variable to avoid cloning the entire FlowValue @@ -2846,10 +2876,6 @@ pub async fn handle_flow( } } - let status = flow_job - .parse_flow_status() - .with_context(|| "Unable to parse flow status")?; - let schedule_path = flow_job.schedule_path(); if !flow_job.is_flow_step() && status.retry.fail_count == 0 @@ -2993,6 +3019,7 @@ pub async fn handle_flow( status, flow, flow_env, + no_flow_env, db, client, last_result.clone(), @@ -3115,6 +3142,66 @@ fn resolve_flow_step_tag( } } +/// Resolves each `$flow_expr[root.key.path]` of a step tag by reading `key.path` from `results` +/// (where `key` is a step id), `flow_input` or `flow_env`, rendered as `$args[key.path]` would be. +async fn interpolate_flow_expr_tag( + tag: &str, + db: &DB, + flow_job: &MiniPulledJob, + flow_input: &HashMap>, + flow_env: Option<&HashMap>>, +) -> error::Result { + if RE_FLOW_EXPR_TAG + .replace_all(tag, "") + .contains("$flow_expr[") + { + return Err(Error::ExecutionErr(format!( + "Could not resolve the step tag `{tag}`: each `$flow_expr[...]` must hold a dotted \ + path such as `results.a.b.c`" + ))); + } + let mut rendered: HashMap<&str, String> = HashMap::new(); + for cap in RE_FLOW_EXPR_TAG.captures_iter(tag) { + let path = cap.get(1).unwrap().as_str(); + if rendered.contains_key(path) { + continue; + } + let (root, key_path) = path.split_once('.').unwrap_or((path, "")); + let (key, rest) = key_path.split_once('.').unwrap_or((key_path, "")); + if key.is_empty() || !matches!(root, "results" | "flow_input" | "flow_env") { + return Err(Error::ExecutionErr(format!( + "Could not resolve the step tag `{tag}`: `{path}` must start with \ + `results.`, `flow_input.` or `flow_env.`" + ))); + } + let value = match root { + "flow_input" => render_tag_path(flow_input.get(key).map(|x| &**x), rest), + "flow_env" => render_tag_path(flow_env.and_then(|e| e.get(key)).map(|x| &**x), rest), + _ => match windmill_queue::get_result_by_id( + db.clone(), + flow_job.workspace_id.clone(), + flow_job.id, + key.to_string(), + None, + ) + .await + { + Ok(result) => render_tag_path(Some(&*result), rest), + Err(Error::NotFound(_)) => String::new(), + Err(e) => { + return Err(Error::ExecutionErr(format!( + "Could not resolve the step tag `{tag}`: {e}" + ))) + } + }, + }; + rendered.insert(path, value); + } + Ok(RE_FLOW_EXPR_TAG + .replace_all(tag, |cap: ®ex::Captures| rendered[&cap[1]].clone()) + .into_owned()) +} + #[cfg(test)] mod tag_resolution_tests { use super::resolve_flow_step_tag; @@ -3176,6 +3263,8 @@ async fn push_next_flow_job( mut status: FlowStatus, flow: &FlowValue, flow_env: Option<&HashMap>>, + // Neither this flow nor any ancestor defines `flow_env`. + no_flow_env: bool, db: &sqlx::Pool, client: &AuthedClient, last_job_result: Option>>, @@ -4165,7 +4254,14 @@ async fn push_next_flow_job( let mut tx = db.begin().warn_after_seconds(3).await?; let nargs = args.as_ref(); - for (i, payload_tag) in job_payloads.into_iter().enumerate() { + for (i, mut payload_tag) in job_payloads.into_iter().enumerate() { + // A `FlowNode` here is a branch/loop sub-flow from `payload_from_modules`: it never defines + // its own `flow_env`, so it inherits exactly this flow's. The mark lives in the child's + // status, not its definition: a `RawFlow` child's stored definition is replayed by nested + // restarts, whose root may have gained a `flow_env` since. + if let JobPayload::FlowNode { no_inherited_flow_env, .. } = &mut payload_tag.payload { + *no_inherited_flow_env = no_flow_env; + } if i % 100 == 0 && i != 0 { tracing::info!(id = %flow_job.id, root_id = %job_root, "pushed (non-commited yet) first {i} subflows of {len}"); // Ping on the pool, outside `tx`, so the zombie flow monitor sees it before the @@ -4193,6 +4289,10 @@ async fn push_next_flow_job( None }; + // The `flow_input` the step's input transforms read, which a `$flow_expr[flow_input...]` + // tag must read too: the body of a simple for-loop also sees `iter` there. + let mut step_flow_input = arc_flow_job_args.clone(); + let marc; let me; let args = match &next_status { @@ -4217,8 +4317,10 @@ async fn push_next_flow_job( if let Some(input_transforms) = simple_input_transforms { //previous id is none because we do not want to use previous id if we are in a for loop let ctx = get_transform_context(&flow_job, "", &status); + let args = Marc::new(args); + step_flow_input = args.clone(); let ti = transform_input( - Marc::new(args), + args, flow_env, arc_last_job_result.clone(), input_transforms, @@ -4397,17 +4499,21 @@ async fn push_next_flow_job( payload_tag.tag.as_deref(), ); - // `push_args` is empty once the input transforms failed, so a tag reading `$args[...]` - // interpolates to a queue nobody serves and the step sits there instead of reporting - // the error. Send it to the flow's tag, which a worker is provably serving right now. + // A step whose inputs failed to evaluate, or whose `$flow_expr[...]` tag failed to resolve, + // is pushed only to report the error, and a computed tag can then name a queue nobody + // serves (`push_args` is empty, so `$args[...]` reads nothing), leaving the step stuck + // instead. Send it to the flow's tag, which a worker is provably serving right now. // // A step handed over by id, or one whose tag `push` replaces, never reaches a worker // through its tag, so rewriting theirs would be noise. let step_is_pulled_by_tag = !continue_on_same_worker && !continue_with_runners && !payload_tag.payload.is_dedicated_worker(); - let reroute_to_flow_tag = - err.is_some() && step_is_pulled_by_tag && tag.as_deref().is_some_and(tag_reads_args); + let reroute_to_flow_tag = err.is_some() + && step_is_pulled_by_tag + && tag + .as_deref() + .is_some_and(|t| tag_reads_args(t) || tag_reads_flow_expr(t)); let tag = if reroute_to_flow_tag { Some(flow_job.tag.clone()) } else { @@ -4424,8 +4530,32 @@ async fn push_next_flow_job( ) }; - // Check tag availability for flow substeps to prevent abuse. - // Skip when the substep inherits the parent flow's tag: that tag was already + let written_tag = tag.clone(); + let mut tag_err = None; + let tag = match tag { + Some(t) if err.is_none() && tag_reads_flow_expr(&t) => { + match interpolate_flow_expr_tag(&t, db, &flow_job, &step_flow_input, flow_env) + .warn_after_seconds(3) + .await + { + Ok(resolved) => Some(resolved), + Err(e) => { + tag_err = Some(e); + Some(if step_is_pulled_by_tag { + flow_job.tag.clone() + } else { + t + }) + } + } + } + t => t, + }; + let err = err.or(tag_err.as_ref()); + + // Check tag availability for flow substeps to prevent abuse. It runs on the tag with its + // `$flow_expr[...]` filled in, as `push` fills in the rest, since that is what picks the + // queue. Skip when the substep inherits the parent flow's tag: that tag was already // validated at flow push time, and for dedicated flows it is the auto-generated // `{workspace_id}:flow/{path}` tag which is never in CUSTOM_TAGS. if let Some(tag_str) = tag @@ -4435,17 +4565,16 @@ async fn push_next_flow_job( // A step with its own on-behalf-of carries a cached dispatch address, up to one // notify poll stale; accepted, see `get_email_from_permissioned_as`. let is_super_admin = windmill_common::auth::is_super_admin_email(db, email).await?; - check_tag_available_for_workspace_internal( + check_tag_written_as_available_for_push( db, &flow_job.workspace_id, + written_tag.as_deref().unwrap_or(tag_str), tag_str, + &push_args, is_super_admin, None, // no token for flow substeps so no scopes so no scope_tags ) - .warn_after_seconds_with_sql( - 1, - "check_tag_available_for_workspace_internal".to_string(), - ) + .warn_after_seconds_with_sql(1, "check_tag_available_for_push".to_string()) .await?; } @@ -5121,7 +5250,7 @@ fn payload_from_modules<'a>( } if let Some(id) = modules_node { - return Some(JobPayload::FlowNode { id, path: path() }); + return Some(JobPayload::FlowNode { id, path: path(), no_inherited_flow_env: false }); } add_virtual_items_if_necessary(&mut modules); diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 55735ed9fd..1e6cfdd474 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.814.0"; +export const VERSION = "v1.817.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/benchmarks/pulumi/package-lock.json b/benchmarks/pulumi/package-lock.json deleted file mode 100644 index ddd1f09e83..0000000000 --- a/benchmarks/pulumi/package-lock.json +++ /dev/null @@ -1,2465 +0,0 @@ -{ - "name": "aws-bench", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "aws-bench", - "dependencies": { - "@pulumi/aws": "^5.0.0", - "@pulumi/awsx": "^1.0.4", - "@pulumi/pulumi": "^3.0.0", - "@pulumi/tailscale": "^0.12.2", - "@pulumi/tls": "^4.10.0" - }, - "devDependencies": { - "@types/node": "^16", - "pulumi": "^0.0.1" - } - }, - "node_modules/@grpc/grpc-js": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", - "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", - "license": "Apache-2.0", - "dependencies": { - "@grpc/proto-loader": "^0.8.0", - "@js-sdsl/ordered-map": "^4.4.2" - }, - "engines": { - "node": ">=12.10.0" - } - }, - "node_modules/@grpc/proto-loader": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.0.tgz", - "integrity": "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==", - "license": "Apache-2.0", - "dependencies": { - "lodash.camelcase": "^4.3.0", - "long": "^5.0.0", - "protobufjs": "^7.5.3", - "yargs": "^17.7.2" - }, - "bin": { - "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/@js-sdsl/ordered-map": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", - "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/js-sdsl" - } - }, - "node_modules/@logdna/tail-file": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@logdna/tail-file/-/tail-file-2.2.0.tgz", - "integrity": "sha512-XGSsWDweP80Fks16lwkAUIr54ICyBs6PsI4mpfTLQaWgEJRtY9xEV+PeyDpJ+sJEGZxqINlpmAwe/6tS1pP8Ng==", - "engines": { - "node": ">=10.3.0" - } - }, - "node_modules/@opentelemetry/api": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.4.1.tgz", - "integrity": "sha512-O2yRJce1GOc6PAy3QxFM4NzFiWzvScDC1/5ihYBL6BUEVdq0XMWN01sppE+H6bBXbaFYipjwFLEWLg5PaSOThA==", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/api-metrics": { - "version": "0.32.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-metrics/-/api-metrics-0.32.0.tgz", - "integrity": "sha512-g1WLhpG8B6iuDyZJFRGsR+JKyZ94m5LEmY2f+duEJ9Xb4XRlLHrZvh6G34OH6GJ8iDHxfHb/sWjJ1ZpkI9yGMQ==", - "deprecated": "Please use @opentelemetry/api >= 1.3.0", - "dependencies": { - "@opentelemetry/api": "^1.0.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/context-async-hooks": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.15.2.tgz", - "integrity": "sha512-VAMHG67srGFQDG/N2ns5AyUT9vUcoKpZ/NpJ5fDQIPfJd7t3ju+aHwvDsMcrYBWuCh03U3Ky6o16+872CZchBg==", - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.5.0" - } - }, - "node_modules/@opentelemetry/core": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-1.15.2.tgz", - "integrity": "sha512-+gBv15ta96WqkHZaPpcDHiaz0utiiHZVfm2YOYSqFGrUaJpPkMoSuLBB58YFQGi6Rsb9EHos84X6X5+9JspmLw==", - "dependencies": { - "@opentelemetry/semantic-conventions": "1.15.2" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.5.0" - } - }, - "node_modules/@opentelemetry/exporter-zipkin": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-1.15.2.tgz", - "integrity": "sha512-j9dPe8tyx4KqIqJAfZ/LCYfkF9+ggsT0V1+bVg9ZKTBNcLf5dTsTMdcxUxc/9s599kgcn6UERnti/tozbzwa6Q==", - "dependencies": { - "@opentelemetry/core": "1.15.2", - "@opentelemetry/resources": "1.15.2", - "@opentelemetry/sdk-trace-base": "1.15.2", - "@opentelemetry/semantic-conventions": "1.15.2" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/instrumentation": { - "version": "0.32.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.32.0.tgz", - "integrity": "sha512-y6ADjHpkUz/v1nkyyYjsQa/zorhX+0qVGpFvXMcbjU4sHnBnC02c6wcc93sIgZfiQClIWo45TGku1KQxJ5UUbQ==", - "dependencies": { - "@opentelemetry/api-metrics": "0.32.0", - "require-in-the-middle": "^5.0.3", - "semver": "^7.3.2", - "shimmer": "^1.2.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/instrumentation-grpc": { - "version": "0.32.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-grpc/-/instrumentation-grpc-0.32.0.tgz", - "integrity": "sha512-Az6wdkPx/Mi26lT9LKFV6GhCA9prwQFPz5eCNSExTnSP49YhQ7XCjzPd2POPeLKt84ICitrBMdE1mj0zbPdLAQ==", - "dependencies": { - "@opentelemetry/api-metrics": "0.32.0", - "@opentelemetry/instrumentation": "0.32.0", - "@opentelemetry/semantic-conventions": "1.6.0" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.0.0" - } - }, - "node_modules/@opentelemetry/instrumentation-grpc/node_modules/@opentelemetry/semantic-conventions": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.6.0.tgz", - "integrity": "sha512-aPfcBeLErM/PPiAuAbNFLN5sNbZLc3KZlar27uohllN8Zs6jJbHyJU1y7cMA6W/zuq+thkaG8mujiS+3iD/FWQ==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@opentelemetry/instrumentation/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@opentelemetry/propagator-b3": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-1.15.2.tgz", - "integrity": "sha512-ZSrL3DpMEDsjD8dPt9Ze3ue53nEXJt512KyxXlLgLWnSNbe1mrWaXWkh7OLDoVJh9LqFw+tlvAhDVt/x3DaFGg==", - "dependencies": { - "@opentelemetry/core": "1.15.2" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.5.0" - } - }, - "node_modules/@opentelemetry/propagator-jaeger": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-1.15.2.tgz", - "integrity": "sha512-6m1yu7PVDIRz6BwA36lacfBZJCfAEHKgu+kSyukNwVdVjsTNeyD9xNPQnkl0WN7Rvhk8/yWJ83tLPEyGhk1wCQ==", - "dependencies": { - "@opentelemetry/core": "1.15.2" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.5.0" - } - }, - "node_modules/@opentelemetry/resources": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-1.15.2.tgz", - "integrity": "sha512-xmMRLenT9CXmm5HMbzpZ1hWhaUowQf8UB4jMjFlAxx1QzQcsD3KFNAVX/CAWzFPtllTyTplrA4JrQ7sCH3qmYw==", - "dependencies": { - "@opentelemetry/core": "1.15.2", - "@opentelemetry/semantic-conventions": "1.15.2" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.5.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-base": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-1.15.2.tgz", - "integrity": "sha512-BEaxGZbWtvnSPchV98qqqqa96AOcb41pjgvhfzDij10tkBhIu9m0Jd6tZ1tJB5ZHfHbTffqYVYE0AOGobec/EQ==", - "dependencies": { - "@opentelemetry/core": "1.15.2", - "@opentelemetry/resources": "1.15.2", - "@opentelemetry/semantic-conventions": "1.15.2" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.5.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-node": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-1.15.2.tgz", - "integrity": "sha512-5deakfKLCbPpKJRCE2GPI8LBE2LezyvR17y3t37ZI3sbaeogtyxmBaFV+slmG9fN8OaIT+EUsm1QAT1+z59gbQ==", - "dependencies": { - "@opentelemetry/context-async-hooks": "1.15.2", - "@opentelemetry/core": "1.15.2", - "@opentelemetry/propagator-b3": "1.15.2", - "@opentelemetry/propagator-jaeger": "1.15.2", - "@opentelemetry/sdk-trace-base": "1.15.2", - "semver": "^7.5.1" - }, - "engines": { - "node": ">=14" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.5.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-node/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@opentelemetry/semantic-conventions": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.15.2.tgz", - "integrity": "sha512-CjbOKwk2s+3xPIMcd5UNYQzsf+v94RczbdNix9/kQh38WiQkM90sUOi3if8eyHFgiBjBjhwXrA7W3ydiSQP9mw==", - "engines": { - "node": ">=14" - } - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", - "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.1.tgz", - "integrity": "sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", - "license": "BSD-3-Clause" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", - "license": "BSD-3-Clause" - }, - "node_modules/@pulumi/aws": { - "version": "5.43.0", - "resolved": "https://registry.npmjs.org/@pulumi/aws/-/aws-5.43.0.tgz", - "integrity": "sha512-ZWI+QvEaFy27wUW8j8EVadgvl6u3926AAgpghflSPxF/9mG5XoDy0rqkRZOgO13ZJinC1qkQDzoMz0ACgmwTiw==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@pulumi/pulumi": "^3.0.0", - "aws-sdk": "^2.0.0", - "builtin-modules": "3.0.0", - "mime": "^2.0.0", - "read-package-tree": "^5.2.1", - "resolve": "^1.7.1" - } - }, - "node_modules/@pulumi/awsx": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@pulumi/awsx/-/awsx-1.0.6.tgz", - "integrity": "sha512-zTBsRO6EeSg6V2sEkw1b8OtvExaYMg1rUlaJVfui54W86M3KvFANtGI99a+aScUxJMxGqkS9cb6LdzZjTlKQGA==", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@pulumi/aws": "^5.35.0", - "@pulumi/docker": "^3.6.1", - "@pulumi/pulumi": "^3.0.0", - "@types/aws-lambda": "^8.10.23", - "mime": "^2.0.0" - } - }, - "node_modules/@pulumi/docker": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/@pulumi/docker/-/docker-3.6.1.tgz", - "integrity": "sha512-BZME50QkT556v+LvmTXPT8ssB2xxNkp9+msB5xYFEnUnWcdGAx5yUysQw70RJCb+U0GbkJSbxtlgMJgOQf/now==", - "hasInstallScript": true, - "dependencies": { - "@pulumi/pulumi": "^3.0.0", - "semver": "^5.4.0" - } - }, - "node_modules/@pulumi/pulumi": { - "version": "3.77.1", - "resolved": "https://registry.npmjs.org/@pulumi/pulumi/-/pulumi-3.77.1.tgz", - "integrity": "sha512-smeip4uKWkaKcNhMiAmR5uW4nXRvmHSjq93C7zx+mMneNxeaD9HNIBnYW0H0FFgp7j1AdRYuiIa2ie9Ay51bpw==", - "dependencies": { - "@grpc/grpc-js": "^1.8.16", - "@logdna/tail-file": "^2.0.6", - "@opentelemetry/api": "^1.2.0", - "@opentelemetry/exporter-zipkin": "^1.6.0", - "@opentelemetry/instrumentation": "^0.32.0", - "@opentelemetry/instrumentation-grpc": "^0.32.0", - "@opentelemetry/resources": "^1.6.0", - "@opentelemetry/sdk-trace-base": "^1.6.0", - "@opentelemetry/sdk-trace-node": "^1.6.0", - "@opentelemetry/semantic-conventions": "^1.6.0", - "@pulumi/query": "^0.3.0", - "execa": "^5.1.0", - "google-protobuf": "^3.5.0", - "ini": "^2.0.0", - "js-yaml": "^3.14.0", - "minimist": "^1.2.6", - "normalize-package-data": "^3.0.0", - "pkg-dir": "^7.0.0", - "read-package-tree": "^5.3.1", - "require-from-string": "^2.0.1", - "semver": "^7.5.2", - "source-map-support": "^0.5.6", - "ts-node": "^7.0.1", - "typescript": "~3.8.3", - "upath": "^1.1.0" - }, - "engines": { - "node": ">=8.13.0 || >=10.10.0" - } - }, - "node_modules/@pulumi/pulumi/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@pulumi/query": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@pulumi/query/-/query-0.3.0.tgz", - "integrity": "sha512-xfo+yLRM2zVjVEA4p23IjQWzyWl1ZhWOGobsBqRpIarzLvwNH/RAGaoehdxlhx4X92302DrpdIFgTICMN4P38w==" - }, - "node_modules/@pulumi/tailscale": { - "version": "0.12.2", - "resolved": "https://registry.npmjs.org/@pulumi/tailscale/-/tailscale-0.12.2.tgz", - "integrity": "sha512-eDzXu4I7awua494d6is9nwV/8G56dTI4oL53G/WTqWtl1ezVHb+v6bKxMrcPvhhYGugbIhK56ECWPiqg9k5gJg==", - "hasInstallScript": true, - "dependencies": { - "@pulumi/pulumi": "^3.0.0" - } - }, - "node_modules/@pulumi/tls": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@pulumi/tls/-/tls-4.10.0.tgz", - "integrity": "sha512-aK1LXJzDbeVYBIlxycUUboZJSjMr2wIkrpngNRrOFPMIyO4QYNzJHWIwRbNkS463hhpbp1it3IC3htEqtP4lzg==", - "hasInstallScript": true, - "dependencies": { - "@pulumi/pulumi": "^3.0.0" - } - }, - "node_modules/@types/aws-lambda": { - "version": "8.10.119", - "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.119.tgz", - "integrity": "sha512-Vqm22aZrCvCd6I5g1SvpW151jfqwTzEZ7XJ3yZ6xaZG31nUEOEyzzVImjRcsN8Wi/QyPxId/x8GTtgIbsy8kEw==" - }, - "node_modules/@types/node": { - "version": "16.18.40", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.18.40.tgz", - "integrity": "sha512-+yno3ItTEwGxXiS/75Q/aHaa5srkpnJaH+kdkTVJ3DtJEwv92itpKbxU+FjPoh2m/5G9zmUQfrL4A4C13c+iGA==" - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", - "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", - "dependencies": { - "call-bind": "^1.0.2", - "is-array-buffer": "^3.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.reduce": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/array.prototype.reduce/-/array.prototype.reduce-1.0.5.tgz", - "integrity": "sha512-kDdugMl7id9COE8R7MHF5jWk7Dqt/fs4Pv+JXoICnYwqpjjjbUurz6w5fT5IG6brLdJhv6/VoHB0H7oyIBXd+Q==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4", - "es-array-method-boxes-properly": "^1.0.0", - "is-string": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.1.tgz", - "integrity": "sha512-09x0ZWFEjj4WD8PDbykUwo3t9arLn8NIzmmYEJFpYekOAQjpkGSyrQhNoRTcwwcFRu+ycWF78QZ63oWTqSjBcw==", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "get-intrinsic": "^1.2.1", - "is-array-buffer": "^3.0.2", - "is-shared-array-buffer": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/arrify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", - "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" - }, - "node_modules/available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/aws-sdk": { - "version": "2.1693.0", - "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1693.0.tgz", - "integrity": "sha512-cJmb8xEnVLT+R6fBS5sn/EFJiX7tUnDaPtOPZ1vFbOJtd0fnZn/Ky2XGgsvvoeliWeH7mL3TWSX5zXXGSQV6gQ==", - "deprecated": "The AWS SDK for JavaScript (v2) has reached end-of-support, and no longer receives updates. Please migrate your code to use AWS SDK for JavaScript (v3). More info https://a.co/cUPnyil", - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "buffer": "4.9.2", - "events": "1.1.1", - "ieee754": "1.1.13", - "jmespath": "0.16.0", - "querystring": "0.2.0", - "sax": "1.2.1", - "url": "0.10.3", - "util": "^0.12.4", - "uuid": "8.0.0", - "xml2js": "0.6.2" - }, - "engines": { - "node": ">= 10.0.0" - } - }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" - }, - "node_modules/base64-js": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, - "node_modules/brace-expansion": { - "version": "1.1.14", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", - "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/buffer": { - "version": "4.9.2", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", - "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", - "dependencies": { - "base64-js": "^1.0.2", - "ieee754": "^1.1.4", - "isarray": "^1.0.0" - } - }, - "node_modules/buffer-from": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", - "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" - }, - "node_modules/builtin-modules": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.0.0.tgz", - "integrity": "sha512-hMIeU4K2ilbXV6Uv93ZZ0Avg/M91RaKXucQ+4me2Do1txxBDyDZWCBa5bJSLqoNTRpXTLwEzIk1KmloenDDjhg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/call-bind": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.2.tgz", - "integrity": "sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==", - "dependencies": { - "function-bind": "^1.1.1", - "get-intrinsic": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/cliui": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", - "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "license": "ISC", - "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/debuglog": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/debuglog/-/debuglog-1.0.1.tgz", - "integrity": "sha512-syBZ+rnAK3EgMsH2aYEOLUW7mZSY9Gb+0wUMCFsZvcmiz+HigA0LOcq/HoQqVuGG+EKykunc7QG2bzrponfaSw==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "engines": { - "node": "*" - } - }, - "node_modules/define-properties": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.0.tgz", - "integrity": "sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==", - "dependencies": { - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/dezalgo": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", - "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", - "dependencies": { - "asap": "^2.0.0", - "wrappy": "1" - } - }, - "node_modules/diff": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.1.tgz", - "integrity": "sha512-Z3u54A8qGyqFOSr2pk0ijYs8mOE9Qz8kTvtKeBI+upoG9j04Sq+oI7W8zAJiQybDcESET8/uIdHzs0p3k4fZlw==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "license": "MIT" - }, - "node_modules/es-abstract": { - "version": "1.22.1", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.1.tgz", - "integrity": "sha512-ioRRcXMO6OFyRpyzV3kE1IIBd4WG5/kltnzdxSCqoP8CMGs/Li+M1uF5o7lOkZVFjDs+NLesthnF66Pg/0q0Lw==", - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "arraybuffer.prototype.slice": "^1.0.1", - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "es-set-tostringtag": "^2.0.1", - "es-to-primitive": "^1.2.1", - "function.prototype.name": "^1.1.5", - "get-intrinsic": "^1.2.1", - "get-symbol-description": "^1.0.0", - "globalthis": "^1.0.3", - "gopd": "^1.0.1", - "has": "^1.0.3", - "has-property-descriptors": "^1.0.0", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.5", - "is-array-buffer": "^3.0.2", - "is-callable": "^1.2.7", - "is-negative-zero": "^2.0.2", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "is-string": "^1.0.7", - "is-typed-array": "^1.1.10", - "is-weakref": "^1.0.2", - "object-inspect": "^1.12.3", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.0", - "safe-array-concat": "^1.0.0", - "safe-regex-test": "^1.0.0", - "string.prototype.trim": "^1.2.7", - "string.prototype.trimend": "^1.0.6", - "string.prototype.trimstart": "^1.0.6", - "typed-array-buffer": "^1.0.0", - "typed-array-byte-length": "^1.0.0", - "typed-array-byte-offset": "^1.0.0", - "typed-array-length": "^1.0.4", - "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.10" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/es-array-method-boxes-properly": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", - "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==" - }, - "node_modules/es-set-tostringtag": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.1.tgz", - "integrity": "sha512-g3OMbtlwY3QewlqAiMLI47KywjWZoEytKr8pf6iTC8uJq5bIAH52Z9pnQ8pVL6whrCto53JZDuUIsifGeLorTg==", - "dependencies": { - "get-intrinsic": "^1.1.3", - "has": "^1.0.3", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-to-primitive": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", - "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", - "dependencies": { - "is-callable": "^1.1.4", - "is-date-object": "^1.0.1", - "is-symbol": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/esprima": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", - "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/events": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", - "integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==", - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/execa": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", - "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dependencies": { - "cross-spawn": "^7.0.3", - "get-stream": "^6.0.0", - "human-signals": "^2.1.0", - "is-stream": "^2.0.0", - "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.1", - "onetime": "^5.1.2", - "signal-exit": "^3.0.3", - "strip-final-newline": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sindresorhus/execa?sponsor=1" - } - }, - "node_modules/find-up": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", - "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", - "dependencies": { - "locate-path": "^7.1.0", - "path-exists": "^5.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", - "dependencies": { - "is-callable": "^1.1.3" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==" - }, - "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" - }, - "node_modules/function.prototype.name": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.5.tgz", - "integrity": "sha512-uN7m/BzVKQnCUF/iW8jYea67v++2u7m5UgENbHRtdDVclOUP+FMPlCNdmk0h/ysGyo2tavMJEDqJAkJdRa1vMA==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.3", - "es-abstract": "^1.19.0", - "functions-have-names": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-caller-file": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", - "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", - "license": "ISC", - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/get-intrinsic": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", - "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", - "dependencies": { - "function-bind": "^1.1.1", - "has": "^1.0.3", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/globalthis": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.3.tgz", - "integrity": "sha512-sFdI5LyBiNTHjRd7cGPWapiHWMOXKyuBNX/cWJ3NfzrZQVa8GI/8cofCl74AOVqq9W5kNmguTIzJ/1s2gyI9wA==", - "dependencies": { - "define-properties": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/google-protobuf": { - "version": "3.21.2", - "resolved": "https://registry.npmjs.org/google-protobuf/-/google-protobuf-3.21.2.tgz", - "integrity": "sha512-3MSOYFO5U9mPGikIYCzK0SaThypfGgS6bHqrUGXG3DPHCrb+txNqeEcns1W0lkGfk0rCyNXm7xB9rMxnCiZOoA==" - }, - "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dependencies": { - "get-intrinsic": "^1.1.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" - }, - "node_modules/has": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", - "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", - "dependencies": { - "function-bind": "^1.1.1" - }, - "engines": { - "node": ">= 0.4.0" - } - }, - "node_modules/has-bigints": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.0.2.tgz", - "integrity": "sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-property-descriptors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz", - "integrity": "sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ==", - "dependencies": { - "get-intrinsic": "^1.1.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hosted-git-info": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz", - "integrity": "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/human-signals": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", - "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "engines": { - "node": ">=10.17.0" - } - }, - "node_modules/ieee754": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", - "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==" - }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "node_modules/ini": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", - "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", - "engines": { - "node": ">=10" - } - }, - "node_modules/internal-slot": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", - "integrity": "sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==", - "dependencies": { - "get-intrinsic": "^1.2.0", - "has": "^1.0.3", - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/is-arguments": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.1.tgz", - "integrity": "sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==", - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", - "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "is-typed-array": "^1.1.10" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", - "integrity": "sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==", - "dependencies": { - "has-bigints": "^1.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-boolean-object": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.1.2.tgz", - "integrity": "sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==", - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-core-module": { - "version": "2.13.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.0.tgz", - "integrity": "sha512-Z7dk6Qo8pOCp3l4tsX2C5ZVas4V+UxwQodwZhLopL91TX8UyyHEXafPcyoeeWuLrwzHcr3igO78wNLwHJHsMCQ==", - "dependencies": { - "has": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-date-object": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", - "integrity": "sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-generator-function": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", - "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-number-object": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.0.7.tgz", - "integrity": "sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", - "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", - "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-stream": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", - "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-string": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.0.7.tgz", - "integrity": "sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==", - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", - "integrity": "sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==", - "dependencies": { - "has-symbols": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-typed-array": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", - "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", - "dependencies": { - "which-typed-array": "^1.1.11" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakref": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.0.2.tgz", - "integrity": "sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==", - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" - }, - "node_modules/jmespath": { - "version": "0.16.0", - "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", - "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", - "engines": { - "node": ">= 0.6.0" - } - }, - "node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/json-parse-even-better-errors": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", - "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" - }, - "node_modules/locate-path": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", - "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", - "dependencies": { - "p-locate": "^6.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "license": "MIT" - }, - "node_modules/long": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", - "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "license": "Apache-2.0" - }, - "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==" - }, - "node_modules/merge-stream": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", - "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==" - }, - "node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, - "node_modules/module-details-from-path": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.3.tgz", - "integrity": "sha512-ySViT69/76t8VhE1xXHK6Ch4NcDd26gx0MzKXLO+F7NOtnqH68d9zF94nT8ZWSxXh8ELOERsnJO/sWt1xZYw5A==" - }, - "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, - "node_modules/normalize-package-data": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-3.0.3.tgz", - "integrity": "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA==", - "dependencies": { - "hosted-git-info": "^4.0.1", - "is-core-module": "^2.5.0", - "semver": "^7.3.4", - "validate-npm-package-license": "^3.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/normalize-package-data/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm-normalize-package-bin": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz", - "integrity": "sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==" - }, - "node_modules/npm-run-path": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", - "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dependencies": { - "path-key": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/object-inspect": { - "version": "1.12.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.12.3.tgz", - "integrity": "sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", - "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "has-symbols": "^1.0.3", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.getownpropertydescriptors": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.6.tgz", - "integrity": "sha512-lq+61g26E/BgHv0ZTFgRvi7NMEPuAxLkFU7rukXjc/AlwH4Am5xXVnIXy3un1bg/JPbXHrixRkK1itUzzPiIjQ==", - "dependencies": { - "array.prototype.reduce": "^1.0.5", - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.21.2", - "safe-array-concat": "^1.0.0" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dependencies": { - "wrappy": "1" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-limit": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", - "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", - "dependencies": { - "yocto-queue": "^1.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-locate": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", - "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", - "dependencies": { - "p-limit": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/path-exists": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", - "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" - }, - "node_modules/pkg-dir": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", - "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", - "dependencies": { - "find-up": "^6.3.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/protobufjs": { - "version": "7.5.6", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.6.tgz", - "integrity": "sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==", - "hasInstallScript": true, - "license": "BSD-3-Clause", - "dependencies": { - "@protobufjs/aspromise": "^1.1.2", - "@protobufjs/base64": "^1.1.2", - "@protobufjs/codegen": "^2.0.5", - "@protobufjs/eventemitter": "^1.1.0", - "@protobufjs/fetch": "^1.1.0", - "@protobufjs/float": "^1.0.2", - "@protobufjs/inquire": "^1.1.1", - "@protobufjs/path": "^1.1.2", - "@protobufjs/pool": "^1.1.0", - "@protobufjs/utf8": "^1.1.1", - "@types/node": ">=13.7.0", - "long": "^5.0.0" - }, - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/pulumi": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/pulumi/-/pulumi-0.0.1.tgz", - "integrity": "sha512-Sow9mG2Yf3vRwQV5pux3EfZNvs7uoEjhYGTUWeGDTVrRT/6w3xybp8/a+SpDkH7dooHnGWDJW4QrSAbd+HvK5A==", - "deprecated": "To install Pulumi, use the @pulumi/pulumi package: 'npm -i @pulumi/pulumi'", - "dev": true - }, - "node_modules/punycode": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", - "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==" - }, - "node_modules/querystring": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", - "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", - "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", - "engines": { - "node": ">=0.4.x" - } - }, - "node_modules/read-package-json": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-2.1.2.tgz", - "integrity": "sha512-D1KmuLQr6ZSJS0tW8hf3WGpRlwszJOXZ3E8Yd/DNRaM5d+1wVRZdHlpGBLAuovjr28LbWvjpWkBHMxpRGGjzNA==", - "dependencies": { - "glob": "^7.1.1", - "json-parse-even-better-errors": "^2.3.0", - "normalize-package-data": "^2.0.0", - "npm-normalize-package-bin": "^1.0.0" - } - }, - "node_modules/read-package-json/node_modules/hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==" - }, - "node_modules/read-package-json/node_modules/normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "dependencies": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" - } - }, - "node_modules/read-package-tree": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/read-package-tree/-/read-package-tree-5.3.1.tgz", - "integrity": "sha512-mLUDsD5JVtlZxjSlPPx1RETkNjjvQYuweKwNVt1Sn8kP5Jh44pvYuUHCp6xSVDZWbNxVxG5lyZJ921aJH61sTw==", - "deprecated": "The functionality that this package provided is now in @npmcli/arborist", - "dependencies": { - "read-package-json": "^2.0.0", - "readdir-scoped-modules": "^1.0.0", - "util-promisify": "^2.1.0" - } - }, - "node_modules/readdir-scoped-modules": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz", - "integrity": "sha512-asaikDeqAQg7JifRsZn1NJZXo9E+VwlyCfbkZhwyISinqk5zNS6266HS5kah6P0SaQKGF6SkNnZVHUzHFYxYDw==", - "deprecated": "This functionality has been moved to @npmcli/fs", - "dependencies": { - "debuglog": "^1.0.1", - "dezalgo": "^1.0.0", - "graceful-fs": "^4.1.2", - "once": "^1.3.0" - } - }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.0.tgz", - "integrity": "sha512-0SutC3pNudRKgquxGoRGIz946MZVHqbNfPjBdxeOhBrdgDKlRoXmYLQN9xRbrR09ZXWeGAdPuif7egofn6v5LA==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "functions-have-names": "^1.2.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/require-directory": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", - "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/require-in-the-middle": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-5.2.0.tgz", - "integrity": "sha512-efCx3b+0Z69/LGJmm9Yvi4cqEdxnoGnxYxGxBghkkTTFeXRtTCmmhO0AnAfHz59k957uTSuy8WaHqOs8wbYUWg==", - "dependencies": { - "debug": "^4.1.1", - "module-details-from-path": "^1.0.3", - "resolve": "^1.22.1" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/resolve": { - "version": "1.22.4", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.4.tgz", - "integrity": "sha512-PXNdCiPqDqeUou+w1C2eTQbNfxKSuMxqTCuvlmmMsk1NWHL5fRrhY6Pl0qEYYc6+QqGClco1Qj8XnjPego4wfg==", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-array-concat": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.0.tgz", - "integrity": "sha512-9dVEFruWIsnie89yym+xWTAYASdpw3CJV7Li/6zBewGf9z2i1j31rP6jnY0pHEO4QZh6N0K11bFjWmdR8UGdPQ==", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "has-symbols": "^1.0.3", - "isarray": "^2.0.5" - }, - "engines": { - "node": ">=0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/safe-array-concat/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" - }, - "node_modules/safe-regex-test": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", - "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "is-regex": "^1.1.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/sax": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", - "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==", - "license": "ISC" - }, - "node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "engines": { - "node": ">=8" - } - }, - "node_modules/shimmer": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/shimmer/-/shimmer-1.2.1.tgz", - "integrity": "sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==" - }, - "node_modules/side-channel": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", - "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dependencies": { - "call-bind": "^1.0.0", - "get-intrinsic": "^1.0.2", - "object-inspect": "^1.9.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" - }, - "node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, - "node_modules/spdx-correct": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", - "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-exceptions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.3.0.tgz", - "integrity": "sha512-/tTrYOC7PPI1nUAgx34hUpqXuyJG+DTHJTnIULG4rDygi4xu/tfgmq1e1cIRwRzwZgo4NLySi+ricLkZkw4i5A==" - }, - "node_modules/spdx-expression-parse": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", - "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/spdx-license-ids": { - "version": "3.0.13", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.13.tgz", - "integrity": "sha512-XkD+zwiqXHikFZm4AX/7JSCXA98U5Db4AFd5XUg/+9UNtnH75+Z9KxtpYiJZx36mUDVOwH83pl7yvCer6ewM3w==" - }, - "node_modules/sprintf-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" - }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string.prototype.trim": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.7.tgz", - "integrity": "sha512-p6TmeT1T3411M8Cgg9wBTMRtY2q9+PNy9EV1i2lIXUN/btt763oIfxwN3RR8VU6wHX8j/1CFy0L+YuThm6bgOg==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimend": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.6.tgz", - "integrity": "sha512-JySq+4mrPf9EsDBEDYMOb/lM7XQLulwg5R/m1r0PXEFqrV0qHvl58sdTilSXtKOflCsK2E8jxf+GKC0T07RWwQ==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/string.prototype.trimstart": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.6.tgz", - "integrity": "sha512-omqjMDaY92pbn5HOX7f9IccLA+U1tA9GvtU4JrodiXFfYB7jPzzHpRzpglLAjtUV6bB557zwClJezTqnAiYnQA==", - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", - "es-abstract": "^1.20.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-final-newline": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", - "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/ts-node": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-7.0.1.tgz", - "integrity": "sha512-BVwVbPJRspzNh2yfslyT1PSbl5uIk03EZlb493RKHN4qej/D06n1cEhjlOJG69oFsE7OT8XjpTUcYf6pKTLMhw==", - "dependencies": { - "arrify": "^1.0.0", - "buffer-from": "^1.1.0", - "diff": "^3.1.0", - "make-error": "^1.1.1", - "minimist": "^1.2.0", - "mkdirp": "^0.5.1", - "source-map-support": "^0.5.6", - "yn": "^2.0.0" - }, - "bin": { - "ts-node": "dist/bin.js" - }, - "engines": { - "node": ">=4.2.0" - } - }, - "node_modules/typed-array-buffer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", - "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", - "is-typed-array": "^1.1.10" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/typed-array-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", - "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", - "dependencies": { - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", - "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", - "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typed-array-length": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", - "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", - "dependencies": { - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "is-typed-array": "^1.1.9" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typescript": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-3.8.3.tgz", - "integrity": "sha512-MYlEfn5VrLNsgudQTVJeNaQFUAI7DkhnOjdpAp4T+ku1TfQClewlbSuTVHiA+8skNBgaf02TL/kLOvig4y3G8w==", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, - "node_modules/unbox-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", - "integrity": "sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==", - "dependencies": { - "call-bind": "^1.0.2", - "has-bigints": "^1.0.2", - "has-symbols": "^1.0.3", - "which-boxed-primitive": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/upath": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", - "integrity": "sha512-aZwGpamFO61g3OlfT7OQCHqhGnW43ieH9WZeP7QxN/G/jS4jfqUkZxoryvJgVPEcrl5NL/ggHsSmLMHuH64Lhg==", - "engines": { - "node": ">=4", - "yarn": "*" - } - }, - "node_modules/url": { - "version": "0.10.3", - "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", - "integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==", - "dependencies": { - "punycode": "1.3.2", - "querystring": "0.2.0" - } - }, - "node_modules/util": { - "version": "0.12.5", - "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", - "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "dependencies": { - "inherits": "^2.0.3", - "is-arguments": "^1.0.4", - "is-generator-function": "^1.0.7", - "is-typed-array": "^1.1.3", - "which-typed-array": "^1.1.2" - } - }, - "node_modules/util-promisify": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/util-promisify/-/util-promisify-2.1.0.tgz", - "integrity": "sha512-K+5eQPYs14b3+E+hmE2J6gCZ4JmMl9DbYS6BeP2CHq6WMuNxErxf5B/n0fz85L8zUuoO6rIzNNmIQDu/j+1OcA==", - "dependencies": { - "object.getownpropertydescriptors": "^2.0.3" - } - }, - "node_modules/uuid": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", - "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==", - "bin": { - "uuid": "dist/bin/uuid" - } - }, - "node_modules/validate-npm-package-license": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", - "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/which-boxed-primitive": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.0.2.tgz", - "integrity": "sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==", - "dependencies": { - "is-bigint": "^1.0.1", - "is-boolean-object": "^1.1.0", - "is-number-object": "^1.0.4", - "is-string": "^1.0.5", - "is-symbol": "^1.0.3" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-typed-array": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.11.tgz", - "integrity": "sha512-qe9UWWpkeG5yzZ0tNYxDmd7vo58HDBc39mZ0xWWpolAGADdFOzkfamWLDxkOWcvHQKVmdTyQdLD4NOfjLWTKew==", - "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", - "for-each": "^0.3.3", - "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" - }, - "node_modules/xml2js": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", - "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", - "license": "MIT", - "dependencies": { - "sax": ">=0.6.0", - "xmlbuilder": "~11.0.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/xmlbuilder": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", - "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "license": "MIT", - "engines": { - "node": ">=4.0" - } - }, - "node_modules/y18n": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", - "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "license": "ISC", - "engines": { - "node": ">=10" - } - }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, - "node_modules/yargs": { - "version": "17.7.2", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", - "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "license": "MIT", - "dependencies": { - "cliui": "^8.0.1", - "escalade": "^3.1.1", - "get-caller-file": "^2.0.5", - "require-directory": "^2.1.1", - "string-width": "^4.2.3", - "y18n": "^5.0.5", - "yargs-parser": "^21.1.1" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/yargs-parser": { - "version": "21.1.1", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", - "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "license": "ISC", - "engines": { - "node": ">=12" - } - }, - "node_modules/yn": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/yn/-/yn-2.0.0.tgz", - "integrity": "sha512-uTv8J/wiWTgUTg+9vLTi//leUl5vDQS6uii/emeTb2ssY7vl6QWf2fFbIIGjnhjvbdKlU0ed7QPgY1htTC86jQ==", - "engines": { - "node": ">=4" - } - }, - "node_modules/yocto-queue": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", - "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - } - } -} diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index 83475ba1c8..ff470d4ffe 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-chat", - "version": "1.814.0", + "version": "1.817.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-chat", - "version": "1.814.0", + "version": "1.817.0", "license": "Apache-2.0", "devDependencies": { "@ai-sdk/react": "^4.0.102", diff --git a/chat-sdk/package.json b/chat-sdk/package.json index 57df9050f6..5655adc64e 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "windmill-chat", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.814.0", + "version": "1.817.0", "author": "Ruben Fiszel", "license": "Apache-2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme", diff --git a/cli/build-npm.ts b/cli/build-npm.ts index 1dbc9261de..776726c3a0 100644 --- a/cli/build-npm.ts +++ b/cli/build-npm.ts @@ -72,7 +72,7 @@ const packageJson = { url: "https://github.com/windmill-labs/windmill/issues", }, dependencies: { - esbuild: "0.28.0", + esbuild: "0.28.2", ...Object.fromEntries(parserPackages.map(p => [p, cliDeps[p] ?? "*"])), }, optionalDependencies: { diff --git a/cli/bun.lock b/cli/bun.lock index cf3a87bf41..0e0885f563 100644 --- a/cli/bun.lock +++ b/cli/bun.lock @@ -11,7 +11,7 @@ "@cliffy/table": "npm:@jsr/cliffy__table@1.0.0", "@windmill-labs/shared-utils": "^1.0.13", "diff": "^5.2.0", - "esbuild": "0.28.0", + "esbuild": "0.28.2", "get-port": "7.1.0", "jszip": "3.8.0", "minimatch": "^10.0.0", @@ -34,7 +34,7 @@ "windmill-parser-wasm-ts": "1.695.0", "windmill-parser-wasm-yaml": "1.770.0", "windmill-yaml-validator": "1.1.1", - "ws": "8.18.0", + "ws": "8.21.3", "yaml": "^2.7.0", }, "devDependencies": { @@ -57,57 +57,57 @@ "@cliffy/table": ["@jsr/cliffy__table@1.0.0", "https://npm.jsr.io/~/11/@jsr/cliffy__table/1.0.0.tgz", { "dependencies": { "@jsr/std__fmt": "^1.0.9" } }, "sha512-VoLxH0DjofHWPWKUc5N+oCwXB6O6e+carnhp23yJTa7qokBb+SCrTIABEgQdIe/p0bxgmZhz17xt2efaAxXvbQ=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.28.0", "", { "os": "android", "cpu": "arm" }, "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.0", "", { "os": "android", "cpu": "arm64" }, "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.28.0", "", { "os": "android", "cpu": "x64" }, "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.0", "", { "os": "linux", "cpu": "none" }, "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.0", "", { "os": "linux", "cpu": "x64" }, "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.0", "", { "os": "none", "cpu": "x64" }, "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.0", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.0", "", { "os": "none", "cpu": "arm64" }, "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.0", "", { "os": "sunos", "cpu": "x64" }, "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.0", "", { "os": "win32", "cpu": "x64" }, "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -211,7 +211,7 @@ "diff": ["diff@5.2.2", "", {}, "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A=="], - "esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": "bin/esbuild" }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], + "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], "esm-env": ["esm-env@1.2.2", "", {}, "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="], @@ -321,7 +321,7 @@ "windmill-yaml-validator": ["windmill-yaml-validator@1.1.1", "", { "dependencies": { "@stoplight/yaml": "^4.3.0", "ajv": "^8.17.1" } }, "sha512-CVgAwEoBdJhF39q2N012QffhlGPRIyIWd8gj7NnfG+/lMWgH2k5CBLtKIt6cPF8Bxz+6DGC3st1ARSsecDtbTg=="], - "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], diff --git a/cli/package.json b/cli/package.json index 03497dc887..5e7623a7ab 100644 --- a/cli/package.json +++ b/cli/package.json @@ -23,7 +23,7 @@ "@cliffy/table": "npm:@jsr/cliffy__table@1.0.0", "@windmill-labs/shared-utils": "^1.0.13", "diff": "^5.2.0", - "esbuild": "0.28.0", + "esbuild": "0.28.2", "get-port": "7.1.0", "jszip": "3.8.0", "minimatch": "^10.0.0", @@ -46,7 +46,7 @@ "windmill-parser-wasm-ts": "1.695.0", "windmill-parser-wasm-yaml": "1.770.0", "windmill-yaml-validator": "1.1.1", - "ws": "8.18.0", + "ws": "8.21.3", "yaml": "^2.7.0" }, "devDependencies": { diff --git a/cli/src/commands/app/raw_apps.ts b/cli/src/commands/app/raw_apps.ts index 8d82edb9df..36c8260e25 100644 --- a/cli/src/commands/app/raw_apps.ts +++ b/cli/src/commands/app/raw_apps.ts @@ -55,6 +55,8 @@ export interface AppFile { tables?: string[]; datatable?: string; schema?: string; + /** The role the app uses each data table through, by data table name. */ + roles?: Record; }; // Mirrors granular ACLs on the raw_app path. Synced via /acls/* by // applyExtraPermsDiff — never through update_app_raw — so a perm-only diff --git a/cli/src/commands/init/template.ts b/cli/src/commands/init/template.ts index 9032cee397..8a433d69d4 100644 --- a/cli/src/commands/init/template.ts +++ b/cli/src/commands/init/template.ts @@ -140,6 +140,7 @@ export const CONFIG_REFERENCE: ConfigOption[] = [ type: "object", properties: { relative_path: { type: "string", description: "Path to the codebase directory" }, + extra_digest_paths: { type: "array", items: { type: "string" }, description: "Paths outside relative_path that scripts import, relative to wmill.yaml; hashed recursively" }, includes: { type: "array", items: { type: "string" }, description: "Glob patterns for files to include in bundle" }, excludes: { type: "array", items: { type: "string" }, description: "Glob patterns for files to exclude from bundle" }, format: { type: "string", enum: ["cjs", "esm"], description: "Bundle output format" }, @@ -159,6 +160,7 @@ export const CONFIG_REFERENCE: ConfigOption[] = [ example: [ "# codebases:", '# - relative_path: ./shared # path to the codebase', + '# # extra_digest_paths: ["../packages/utils/src"] # imported paths outside relative_path, hashed recursively (skip node_modules/dist)', '# includes: ["**/*.ts"] # files to include in bundle', '# excludes: ["node_modules/**"] # files to exclude', '# format: esm # bundle format: "cjs" or "esm"', diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index fe193e5d85..06db3db1bf 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -61,7 +61,11 @@ import { readConfigFile, readEffectiveSyncBehavior, } from "../../core/conf.ts"; -import { SyncCodebase, listSyncCodebases } from "../../utils/codebase.ts"; +import { + SyncCodebase, + listSyncCodebases, + uncoveredBundleInputs, +} from "../../utils/codebase.ts"; import { pollJobWithQueueLogging } from "../../utils/job_polling.ts"; import fs from "node:fs"; import { createTarBlob, type TarEntry } from "../../utils/tar.ts"; @@ -446,8 +450,19 @@ export async function handleFile( target: format == "cjs" ? "node20.15.1" : "esnext", banner: codebase.banner, // ...(codebase.banner != null && { banner: codebase.banner }), + metafile: true, }); const endTime = performance.now(); + const uncovered = uncoveredBundleInputs( + codebase, + path, + Object.keys(out.metafile?.inputs ?? {}) + ); + if (uncovered.length > 0) { + log.warnAlways( + `${path} bundles files outside codebase ${codebase.relative_path}; edits to them won't be detected as changes unless listed in extra_digest_paths: ${uncovered.join(", ")}` + ); + } bundleContent = out.outputFiles[0].text; outputFiles = out.outputFiles ?? []; if (outputFiles.length == 0) { diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 138a346594..6185decc5a 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -1153,7 +1153,7 @@ export function ZipFSElement( ? "script" : p.endsWith(".resource.json") ? "resource" - : p.startsWith("dependencies/") + : isWorkspaceDependencies(p) ? "dependencies" : "other"; @@ -2546,9 +2546,9 @@ export function preservePendingScriptLocks( } // `sync push` never applies the workspace's display name from settings.yaml and -// applies its color only when the local file carries one (see -// pushWorkspaceSettings), so on a push the fields it would not apply must -// compare equal, or the row is listed on every run. +// applies its color and auto_invite.instance_groups only when the local file +// carries them (see pushWorkspaceSettings), so on a push the fields it would not +// apply must compare equal, or the row is listed on every run. const isWorkspaceSettingsFile = (p: string) => /^settings(\.[^./\\]+)?\.(yaml|json)$/.test(p); function stripUnappliedSettingsFields(local: any, remote: any) { @@ -2558,6 +2558,20 @@ function stripUnappliedSettingsFields(local: any, remote: any) { delete local?.color; delete remote?.color; } + // push reads a missing auto_invite as {} on both sides + if (local) local.auto_invite ??= {}; + if (remote) remote.auto_invite ??= {}; + const localInvite = local?.auto_invite; + const remoteInvite = remote?.auto_invite; + if (localInvite?.instance_groups == null) { + for (const invite of [localInvite, remoteInvite]) { + delete invite?.instance_groups; + delete invite?.instance_groups_roles; + } + } else { + localInvite.instance_groups_roles ??= {}; + if (remoteInvite) remoteInvite.instance_groups_roles ??= {}; + } } export async function compareDynFSElement( @@ -2738,7 +2752,7 @@ export async function compareDynFSElement( if (skipMetadata) { continue; } - if (k.startsWith("dependencies/")) { + if (isWorkspaceDependencies(k)) { if (!workspaceDependenciesPathToLanguageAndFilename(k)) { log.warn(`Skipping unrecognized workspace dependencies file: ${k}`); continue; @@ -3580,12 +3594,15 @@ export async function pull( ) { if ((opts as any).jsonOutput) log.setSilent(true); const originalCliOpts = { ...opts }; - opts = await mergeConfigWithConfigFile(opts); - - // --include-secrets overrides skipSecrets from wmill.yaml - if ((originalCliOpts as any).includeSecrets) { - opts.skipSecrets = false; - } + const withConfigFile = async () => { + const merged = await mergeConfigWithConfigFile({ ...originalCliOpts }); + // --include-secrets overrides skipSecrets from wmill.yaml + if ((originalCliOpts as any).includeSecrets) { + merged.skipSecrets = false; + } + return merged; + }; + opts = await withConfigFile(); // Resolve workspace name for config lookups. // --branch resolves git branch → workspace name (deprecated but still supported). @@ -3620,10 +3637,6 @@ export async function pull( throw error; } - if (opts.stateful) { - await mkdir(path.join(process.cwd(), ".wmill"), { recursive: true }); - } - const workspace = await resolveWorkspace(opts, wsNameForConfig); await requireLogin(opts); @@ -3715,6 +3728,14 @@ export async function pull( }); return; } + + // The pull writes into the branch now checked out, so its wmill.yaml + // applies, not the cloned branch's: a fork branch that turned on + // `dedupeLockfiles` would otherwise get one lockfile per script back. + if (getCurrentGitBranch() !== clonedBranchName) { + opts = await withConfigFile(); + wsNameForConfig = resolveWsNameForConfigFromFlags(opts); + } } // If wsNameForConfig wasn't set from flags, infer from the resolved profile @@ -3749,6 +3770,10 @@ export async function pull( // Merge CLI flags with resolved settings (CLI flags take precedence only for explicit overrides) opts = mergeCliWithEffectiveOptions(originalCliOpts, effectiveOpts); + if (opts.stateful) { + await mkdir(path.join(process.cwd(), ".wmill"), { recursive: true }); + } + const codebases = await listSyncCodebases(opts); log.info( diff --git a/cli/src/commands/workspace/fork.ts b/cli/src/commands/workspace/fork.ts index 219252bb7a..0aca0984ff 100644 --- a/cli/src/commands/workspace/fork.ts +++ b/cli/src/commands/workspace/fork.ts @@ -239,6 +239,7 @@ async function createWorkspaceFork( interface ForkedDatatableInfo { name: string; new_dbname: string; + fork_behavior?: "schema_only" | "schema_and_data"; } const forkedDatatables: ForkedDatatableInfo[] = []; @@ -285,6 +286,23 @@ async function createWorkspaceFork( const newDbName = `${trueWorkspaceId.replace(/-/g, "_")}__${dt.name}`; + const forkBehavior = dtBehavior as "schema_only" | "schema_and_data"; + // A server that reports `permissioned` copies each data table in the fork request itself, + // and refuses a database copied beforehand. An older one only takes a database copied here. + if (typeof dt.permissioned === "boolean") { + log.info( + colors.blue( + ` Datatable "${dt.name}" will be cloned (${forkBehavior === "schema_only" ? "schema" : "schema + data"}) into "${newDbName}" when the fork is created.` + ) + ); + forkedDatatables.push({ + name: dt.name, + new_dbname: newDbName, + fork_behavior: forkBehavior, + }); + continue; + } + try { log.info( colors.blue(` Creating database "${newDbName}" for datatable "${dt.name}"...`) @@ -300,7 +318,7 @@ async function createWorkspaceFork( log.info( colors.blue( - ` Importing ${dtBehavior === "schema_only" ? "schema" : "schema + data"}...` + ` Importing ${forkBehavior === "schema_only" ? "schema" : "schema + data"}...` ) ); @@ -310,7 +328,7 @@ async function createWorkspaceFork( source: `datatable://${dt.name}`, target: `datatable://${dt.name}`, target_dbname_override: newDbName, - fork_behavior: dtBehavior as "schema_only" | "schema_and_data", + fork_behavior: forkBehavior, }, }); @@ -336,6 +354,8 @@ async function createWorkspaceFork( id: trueWorkspaceId, name: opts.createWorkspaceName ?? workspaceName ?? trueWorkspaceId, color: forkColor, + // So a clone the fork would refuse is refused before any branch is created. + forked_datatables: forkedDatatables, }, }); if (gitSyncJobIds && gitSyncJobIds.length > 0) { diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index 0c746d2261..7c6eb13274 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -115,6 +115,7 @@ export interface SyncOptions { export interface Codebase { relative_path: string; + extra_digest_paths?: string[]; includes?: string[]; excludes?: string[]; assets?: { diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index a399ec1b85..9728b6edd7 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.814.0"; +export const VERSION = "1.817.0"; diff --git a/cli/src/core/settings.ts b/cli/src/core/settings.ts index 4c95090bcf..6a9358b468 100644 --- a/cli/src/core/settings.ts +++ b/cli/src/core/settings.ts @@ -239,8 +239,19 @@ export async function pushWorkspaceSettings( }); } - // Handle auto_invite using grouped format - if (!deepEqual(localSettings.auto_invite, settings.auto_invite)) { + // Handle auto_invite using grouped format. The domain invite and the instance groups + // are applied by separate endpoints, each rewriting only its own keys. + const { + instance_groups: localGroups, + instance_groups_roles: localGroupRoles, + ...localDomainInvite + } = localSettings.auto_invite ?? {}; + const { + instance_groups: remoteGroups, + instance_groups_roles: remoteGroupRoles, + ...remoteDomainInvite + } = settings.auto_invite ?? {}; + if (!deepEqual(localDomainInvite, remoteDomainInvite)) { log.debug(`Updating auto invite...`); const localAutoInvite = localSettings.auto_invite; @@ -278,6 +289,20 @@ export async function pushWorkspaceSettings( } } + // Only when settings.yaml declares instance_groups: clearing a group removes the + // workspace members it granted, so an absent key must never clear it. + if ( + localGroups != undefined && + (!deepEqual(localGroups, remoteGroups) || + !deepEqual(localGroupRoles ?? {}, remoteGroupRoles ?? {})) + ) { + log.debug(`Updating instance groups...`); + await wmill.editInstanceGroups({ + workspace, + requestBody: { groups: localGroups, roles: localGroupRoles ?? {} }, + }); + } + if (!deepEqual(localSettings.ai_config, settings.ai_config)) { log.debug(`Updating copilot settings...`); await wmill.editCopilotConfig({ @@ -393,10 +418,17 @@ export async function pushWorkspaceSettings( if (!deepEqual(localSettings.datatable, settings.datatable)) { log.debug(`Updating datatable config...`); - await wmill.editDataTableConfig({ + const { stranded_references } = await wmill.editDataTableConfig({ workspace, requestBody: { settings: localSettings.datatable ?? { datatables: {} } }, }); + if (stranded_references?.length) { + log.warn( + `Removed data tables governed data tables in other workspaces, which no longer resolve: ${stranded_references + .map((r) => `${r.workspace_id}/${r.datatable}`) + .join(", ")}. A superadmin can point them somewhere else.`, + ); + } } if (localSettings.slack_command_script != settings.slack_command_script) { diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 035c25a574..81895997b9 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -1110,6 +1110,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord /** * Create a SQL template function for PostgreSQL/datatable queries * @param name - Database/datatable name (default: "main") + * @param opts.role - Connect as this data table role instead of the data table's default one. + * Only meaningful on a data table under roles, and only for a role you are a tenant of. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -1119,8 +1121,11 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * SELECT * FROM friends * WHERE name = \${name} AND age = \${age}::int * \`.fetch() + * @example + * // Read through a restricted role + * let sql = wmill.datatable("main", { role: "analytics" }) */ -datatable(name: string = "main"): DatatableSqlTemplateFunction +datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction /** * Create a SQL template function for DuckDB/ducklake queries @@ -1901,6 +1906,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord /** * Create a SQL template function for PostgreSQL/datatable queries * @param name - Database/datatable name (default: "main") + * @param opts.role - Connect as this data table role instead of the data table's default one. + * Only meaningful on a data table under roles, and only for a role you are a tenant of. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -1910,8 +1917,11 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * SELECT * FROM friends * WHERE name = \${name} AND age = \${age}::int * \`.fetch() + * @example + * // Read through a restricted role + * let sql = wmill.datatable("main", { role: "analytics" }) */ -datatable(name: string = "main"): DatatableSqlTemplateFunction +datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction /** * Create a SQL template function for DuckDB/ducklake queries @@ -2786,6 +2796,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord /** * Create a SQL template function for PostgreSQL/datatable queries * @param name - Database/datatable name (default: "main") + * @param opts.role - Connect as this data table role instead of the data table's default one. + * Only meaningful on a data table under roles, and only for a role you are a tenant of. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -2795,8 +2807,11 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * SELECT * FROM friends * WHERE name = \${name} AND age = \${age}::int * \`.fetch() + * @example + * // Read through a restricted role + * let sql = wmill.datatable("main", { role: "analytics" }) */ -datatable(name: string = "main"): DatatableSqlTemplateFunction +datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction /** * Create a SQL template function for DuckDB/ducklake queries @@ -4405,10 +4420,13 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca # # Args: # name: Database name (default: "main") +# role: Connect as this data table role instead of the data table's default one. +# Only meaningful on a data table under roles, and only for a role you are a +# tenant of. # # Returns: # DataTableClient instance -def datatable(name: str = 'main') +def datatable(name: str = 'main', *, role: Optional[str] = None) # Get a DuckLake client for DuckDB queries. # @@ -4626,7 +4644,7 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]] # # @task(retry={"attempts": 3, "delay": 30, "multiplier": 2}) # async def call_api(payload: dict): ... -def task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) +def task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) # Create a task that dispatches to a separate Windmill script. # @@ -4639,7 +4657,7 @@ def task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, ti # @workflow # async def main(): # data = await extract(url="https://...") -def task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) +def task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) # Create a task that dispatches to a separate Windmill flow. # @@ -4652,7 +4670,7 @@ def task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = N # @workflow # async def main(): # result = await pipeline(input=data) -def task_flow(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) +def task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) # Decorator marking an async function as a workflow-as-code entry point. # @@ -4717,7 +4735,7 @@ async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_ # ... # # results = await parallel(items, process, concurrency=5) -async def parallel(items, fn, concurrency: Optional[int] = None) +async def parallel(items, fn, *, concurrency: Optional[int] = None) # Commit Kafka offsets for a trigger with auto_commit disabled. # @@ -5808,6 +5826,8 @@ data: tables: - main/users # Table in public schema - main/app_schema:items # Table in specific schema + roles: # Optional: the role the app uses each datatable through + main: analyst \`\`\` **Table reference formats:** @@ -5815,6 +5835,8 @@ data: - \`/\` — Specific table in public schema - \`/:
\` — Table in specific schema +**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. \`roles\` records the role the app uses each datatable through; the app's code must pass the same role: \`wmill.datatable('main', { role: 'analyst' })\` in TypeScript, \`wmill.datatable('main', role='analyst')\` in Python. A datatable without an entry is used as its default role. + ## SQL Migrations (sql_to_apply/) The \`sql_to_apply/\` folder is for creating/modifying database tables during development. diff --git a/cli/src/types.ts b/cli/src/types.ts index 5713d06390..866b2be21e 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -391,7 +391,7 @@ export function getTypeStrFromPath( if (isSharedLockPath(p)) { return "shared_lock"; } - if (p.startsWith("dependencies" + SEP)) { + if (isWorkspaceDependencies(p)) { return "workspace_dependencies"; } if (isFileResource(p) || isFilesetResource(p)) { diff --git a/cli/src/utils/codebase.ts b/cli/src/utils/codebase.ts index e665d060b2..2eb63caeec 100644 --- a/cli/src/utils/codebase.ts +++ b/cli/src/utils/codebase.ts @@ -1,6 +1,44 @@ import { Codebase, SyncOptions } from "../core/conf.ts"; import * as log from "../core/log.ts"; -import { digestDir } from "./utils.ts"; +import { digestDir, generateHash, generateHashFromBuffer } from "./utils.ts"; +import { readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +// A bare string is accepted, like `includes`/`excludes` in findCodebase. +function extraDigestPaths(codebase: Codebase): string[] { + return ([] as string[]).concat(codebase.extra_digest_paths ?? []); +} + +async function digestPath(p: string): Promise { + let s; + try { + s = await stat(p); + } catch { + throw new Error( + `Codebase extra_digest_paths entry not found: ${p} (resolved to ${path.resolve(p)})` + ); + } + return s.isDirectory() + ? await digestDir(p, "") + : await generateHashFromBuffer(await readFile(p)); +} + +/** Bundle inputs that no codebase digest covers, so edits to them would not trigger a re-push. */ +export function uncoveredBundleInputs( + codebase: Codebase, + entry: string, + inputs: string[] +): string[] { + const entryAbs = path.resolve(entry); + const roots = [codebase.relative_path, ...extraDigestPaths(codebase)] + .map((r) => path.resolve(r)); + return inputs.filter((i) => { + // non-file namespaces ("", "(disabled):x", "ns:path") and installed packages + if (/^[<(]|^[a-z-]{2,}:/i.test(i) || i.includes("node_modules")) return false; + const abs = path.resolve(i); + return abs != entryAbs && !roots.some((r) => abs == r || abs.startsWith(r + path.sep)); + }); +} export type SyncCodebase = Codebase & { getDigest: (forceTar?: boolean) => Promise; @@ -23,13 +61,20 @@ export function listSyncCodebases(options: SyncOptions): SyncCodebase[] { forceTar?: boolean ) => { if (_digest == undefined) { - _digest = await digestDir( + // Concurrent callers read `_digest` across these awaits: assign it only once complete. + let digest = await digestDir( codebase.relative_path, JSON.stringify(codebase) ); - if (codebase.format == "esm") { - _digest += ".esm"; + const extra = extraDigestPaths(codebase); + if (extra.length > 0) { + const hashes = await Promise.all(extra.map(digestPath)); + digest = await generateHash(digest + hashes.join("")); } + if (codebase.format == "esm") { + digest += ".esm"; + } + _digest = digest; if (!alreadyPrinted) { alreadyPrinted = true; log.info(`Codebase ${codebase.relative_path}, digest: ${_digest}`); diff --git a/cli/src/utils/esbuild_loader.ts b/cli/src/utils/esbuild_loader.ts index 4801c51a46..83d44f4534 100644 --- a/cli/src/utils/esbuild_loader.ts +++ b/cli/src/utils/esbuild_loader.ts @@ -25,7 +25,7 @@ type Esbuild = typeof import("esbuild"); // Version to fall back to if the native host's version can't be read. Keep in // sync with the "esbuild" pin in cli/package.json. -const FALLBACK_VERSION = "0.28.0"; +const FALLBACK_VERSION = "0.28.2"; let cached: Esbuild | undefined; let inFlight: Promise | undefined; diff --git a/cli/src/utils/script_common.ts b/cli/src/utils/script_common.ts index d804318491..611268c27c 100644 --- a/cli/src/utils/script_common.ts +++ b/cli/src/utils/script_common.ts @@ -45,7 +45,9 @@ export const workspaceDependenciesLanguages: WorkspaceDependenciesLanguage[] = [ ] as const; export function workspaceDependenciesPathToLanguageAndFilename(path: string): { name: string | undefined, language: ScriptLanguage } | undefined { - const relativePath = path.replace("dependencies/", ""); + // Sync paths carry the platform separator, while a set's name is always + // forward-slashed. + const relativePath = path.replaceAll("\\", "/").replace("dependencies/", ""); for (const { filename, language } of workspaceDependenciesLanguages) { if (relativePath.endsWith(filename)) { return { diff --git a/cli/src/utils/utils.ts b/cli/src/utils/utils.ts index 0801c03911..526dffbde4 100644 --- a/cli/src/utils/utils.ts +++ b/cli/src/utils/utils.ts @@ -223,8 +223,9 @@ export function isRawAppFile(path: string): boolean { return isRawAppPath(path); } +/** Sync paths carry the platform separator: `dependencies\x` on Windows. */ export function isWorkspaceDependencies(path: string): boolean { - return path.startsWith("dependencies/"); + return path.startsWith("dependencies/") || path.startsWith("dependencies\\"); } export function printSync(input: string | Uint8Array) { diff --git a/cli/test/codebase_digest_unit.test.ts b/cli/test/codebase_digest_unit.test.ts new file mode 100644 index 0000000000..f12b928de4 --- /dev/null +++ b/cli/test/codebase_digest_unit.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + listSyncCodebases, + uncoveredBundleInputs, +} from "../src/utils/codebase.ts"; + +test("extra_digest_paths edits change the codebase digest", async () => { + const root = await mkdtemp(join(tmpdir(), "wmill-codebase-")); + await mkdir(join(root, "f")); + await mkdir(join(root, "packages/utils"), { recursive: true }); + await writeFile(join(root, "f/main.ts"), "export * from '../packages/utils/a'"); + await writeFile(join(root, "packages/utils/a.ts"), "export const a = 1"); + + const digest = () => + listSyncCodebases({ + codebases: [ + { + relative_path: join(root, "f"), + extra_digest_paths: [join(root, "packages/utils")], + }, + ], + } as any)[0].getDigest(); + + try { + const before = await digest(); + await writeFile(join(root, "packages/utils/a.ts"), "export const a = 2"); + expect(await digest()).not.toBe(before); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("uncoveredBundleInputs reports only files outside the digested roots", () => { + const codebase = { relative_path: "f", extra_digest_paths: ["packages/a"] }; + expect( + uncoveredBundleInputs(codebase, "g/entry.ts", [ + "g/entry.ts", + "f/main.ts", + "packages/a/x.ts", + "packages/ab/y.ts", + "node_modules/pg/index.js", + "", + ]) + ).toEqual(["packages/ab/y.ts"]); +}); diff --git a/cli/test/gitsync_deploy_branch_config.test.ts b/cli/test/gitsync_deploy_branch_config.test.ts new file mode 100644 index 0000000000..eeab7a0120 --- /dev/null +++ b/cli/test/gitsync_deploy_branch_config.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from "bun:test"; +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createRemoteWorkspaceDeps, withTestBackend } from "./test_backend.ts"; + +function git(cwd: string, ...args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); +} + +// The git-sync deploy callback starts in a clone of the tracked branch and +// switches to the fork's branch before pulling. What it writes there must follow +// that branch's wmill.yaml: a fork branch that turned on `dedupeLockfiles` keeps +// its shared lockfile instead of getting a `.script.lock` per script back. +test("git-sync fork deploy follows the fork branch's wmill.yaml", async () => { + await withTestBackend(async (backend) => { + const post = (path: string, body: unknown) => + backend.apiRequest!(`/api/w/${backend.workspace}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + await createRemoteWorkspaceDeps(backend, "python3", "wmill\n"); + await post("/folders/create", { name: "dedupe" }); + const script = await post("/scripts/create", { + path: "f/dedupe/a", + summary: "", + description: "", + content: "def main():\n pass\n", + language: "python3", + lock: "wmill==1.0.0\n", + }); + expect(script.ok).toBe(true); + + const bare = await mkdtemp(join(tmpdir(), "wmill_deploy_cfg_bare_")); + const seed = await mkdtemp(join(tmpdir(), "wmill_deploy_cfg_seed_")); + const work = await mkdtemp(join(tmpdir(), "wmill_deploy_cfg_work_")); + try { + execFileSync("git", ["init", "--bare", "--initial-branch=main", bare]); + git(seed, "init", "--initial-branch=main"); + git(seed, "config", "user.email", "seed@windmill.dev"); + git(seed, "config", "user.name", "seed"); + git(seed, "remote", "add", "origin", `file://${bare}`); + const wmillYaml = "defaultTs: bun\nincludes:\n - f/dedupe/**\nexcludes: []\n"; + await writeFile(join(seed, "wmill.yaml"), wmillYaml); + git(seed, "add", "-A"); + git(seed, "commit", "-m", "main"); + git(seed, "push", "origin", "main"); + + // Any parent id makes the callback deploy as a fork, to this branch. + const forkBranch = `wm-fork/main/${backend.workspace}`; + git(seed, "checkout", "-b", forkBranch); + await writeFile( + join(seed, "wmill.yaml"), + wmillYaml + "dedupeLockfiles: true\n", + ); + const pulled = await backend.runCLICommand(["sync", "pull", "--yes"], seed); + expect(pulled.code).toBe(0); + expect(existsSync(join(seed, "locks/requirements.in.lock"))).toBe(true); + git(seed, "add", "-A"); + git(seed, "commit", "-m", "dedupe"); + git(seed, "push", "origin", forkBranch); + + // Git for Windows defaults to autocrlf=true, which checks the pulled LF + // files out as CRLF; the deploy's pull would rewrite every one of them + // and the clean-tree assertion below would read that as a change. + git(work, "clone", "-c", "core.autocrlf=false", `file://${bare}`, "."); + const deployed = await backend.runCLICommand( + [ + "sync", + "git-deploy", + "--repository", + "u/test/unused", + "--git-deploy-items", + JSON.stringify([ + { path_type: "script", path: "f/dedupe/a", commit_msg: "deploy" }, + ]), + "--parent-workspace-id", + "parent", + ], + work, + ); + expect(deployed.code).toBe(0); + expect(git(work, "rev-parse", "--abbrev-ref", "HEAD")).toBe(forkBranch); + expect(git(work, "status", "--porcelain")).toBe(""); + } finally { + await rm(bare, { recursive: true, force: true }); + await rm(seed, { recursive: true, force: true }); + await rm(work, { recursive: true, force: true }); + } + }); +}); diff --git a/cli/test/push_diff_convergence_unit.test.ts b/cli/test/push_diff_convergence_unit.test.ts index 67f6d5b4dd..a6f2c132c0 100644 --- a/cli/test/push_diff_convergence_unit.test.ts +++ b/cli/test/push_diff_convergence_unit.test.ts @@ -320,3 +320,29 @@ test("push: settings.yaml differing only by name or an unset color is not a chan }); expect(await diff(otherColor, remote, skips)).toEqual(["edited settings.yaml"]); }); + +// A push applies auto_invite.instance_groups only when the local file declares +// them (see pushWorkspaceSettings). +test("push: settings.yaml without instance_groups is not a change", async () => { + const remote = local({ + "settings.yaml": + "name: prod\nauto_invite:\n enabled: false\n instance_groups:\n - eng\n instance_groups_roles:\n eng: developer\n", + }); + const undeclared = local({ + "settings.yaml": "name: prod\nauto_invite:\n enabled: false\n", + }); + const skips = { includeSettings: true }; + expect(await diff(undeclared, remote, skips)).toEqual([]); + + const groupsOnlyRemote = local({ + "settings.yaml": "name: prod\nauto_invite:\n instance_groups:\n - eng\n", + }); + const noAutoInvite = local({ "settings.yaml": "name: prod\n" }); + expect(await diff(noAutoInvite, groupsOnlyRemote, skips)).toEqual([]); + + const otherGroups = local({ + "settings.yaml": + "name: prod\nauto_invite:\n enabled: false\n instance_groups: []\n", + }); + expect(await diff(otherGroups, remote, skips)).toEqual(["edited settings.yaml"]); +}); diff --git a/cli/test/push_workspace_settings_auto_invite_unit.test.ts b/cli/test/push_workspace_settings_auto_invite_unit.test.ts new file mode 100644 index 0000000000..b04d895056 --- /dev/null +++ b/cli/test/push_workspace_settings_auto_invite_unit.test.ts @@ -0,0 +1,82 @@ +/** + * Regression guard: `sync push` (pushWorkspaceSettings) applies the domain invite and + * the instance groups of `auto_invite` through their own endpoints, and never clears + * instance groups that settings.yaml does not declare. + */ + +import { expect, test, describe, beforeEach, mock } from "bun:test"; + +let editAutoInviteCalls: unknown[] = []; +let editInstanceGroupsCalls: unknown[] = []; +const remoteAutoInvite = { + enabled: true, + domain: "*", + operator: false, + mode: "invite", + instance_groups: ["eng"], + instance_groups_roles: { eng: "developer" }, +}; + +// Every wmill.* call reachable from pushWorkspaceSettings is stubbed: bun shares one +// mocked module across test files, and names missing from whichever mock loads first +// stay missing for the others. +mock.module("../gen/services.gen.ts", () => ({ + getSettings: async () => ({ auto_invite: remoteAutoInvite }), + getWorkspaceName: async () => "phoenix", + changeWorkspaceName: async () => {}, + changeWorkspaceColor: async () => {}, + editWebhook: async () => {}, + editAutoInvite: async (a: unknown) => { + editAutoInviteCalls.push(a); + }, + editInstanceGroups: async (a: unknown) => { + editInstanceGroupsCalls.push(a); + }, + editErrorHandler: async () => {}, + editSuccessHandler: async () => {}, + editCopilotConfig: async () => {}, + editLargeFileStorageConfig: async () => {}, + editWorkspaceGitSyncConfig: async () => {}, + editWorkspaceDefaultApp: async () => {}, + editDefaultScripts: async () => {}, + workspaceMuteCriticalAlertsUi: async () => {}, + updateOperatorSettings: async () => {}, + editDataTableConfig: async () => {}, + editSlackCommand: async () => {}, + setWorkspaceSlackOauthConfig: async () => {}, + deleteWorkspaceSlackOauthConfig: async () => {}, +})); + +const { pushWorkspaceSettings } = await import("../src/core/settings.ts"); + +describe("pushWorkspaceSettings auto_invite", () => { + beforeEach(() => { + editAutoInviteCalls = []; + editInstanceGroupsCalls = []; + }); + + test("an instance-group-only change updates the groups and leaves the domain invite", async () => { + await pushWorkspaceSettings("phoenix", "settings", undefined, { + name: "phoenix", + auto_invite: { ...remoteAutoInvite, instance_groups_roles: { eng: "admin" } }, + }); + expect(editAutoInviteCalls.length).toBe(0); + expect(editInstanceGroupsCalls).toEqual([ + { + workspace: "phoenix", + requestBody: { groups: ["eng"], roles: { eng: "admin" } }, + }, + ]); + }); + + test("a settings.yaml without instance_groups does not clear them", async () => { + const { instance_groups: _g, instance_groups_roles: _r, ...domainInvite } = + remoteAutoInvite; + await pushWorkspaceSettings("phoenix", "settings", undefined, { + name: "phoenix", + auto_invite: { ...domainInvite, operator: true }, + }); + expect(editAutoInviteCalls.length).toBe(1); + expect(editInstanceGroupsCalls.length).toBe(0); + }); +}); diff --git a/cli/test/push_workspace_settings_identity_unit.test.ts b/cli/test/push_workspace_settings_identity_unit.test.ts index c583a53282..5dcf9815ef 100644 --- a/cli/test/push_workspace_settings_identity_unit.test.ts +++ b/cli/test/push_workspace_settings_identity_unit.test.ts @@ -31,6 +31,7 @@ mock.module("../gen/services.gen.ts", () => ({ editWebhookCalls.push(a); }, editAutoInvite: async () => {}, + editInstanceGroups: async () => {}, editErrorHandler: async () => {}, editSuccessHandler: async () => {}, editCopilotConfig: async () => {}, diff --git a/cli/test/sync_pull_push.test.ts b/cli/test/sync_pull_push.test.ts index 24c4a282a3..fa52510e6c 100644 --- a/cli/test/sync_pull_push.test.ts +++ b/cli/test/sync_pull_push.test.ts @@ -2185,18 +2185,20 @@ describe("settings sync", () => { const files = await listFilesRecursive(tempDir); expect(files).toContain("settings.yaml"); - // Read and modify a safe setting (webhook URL) + // Read and modify a safe setting (webhook URL). The server resolves the + // webhook host when it is saved and refuses one that does not resolve to a + // public address, so this must be a real public hostname. + const webhook = "https://example.com/hook"; const settingsContent = await readFile(`${tempDir}/settings.yaml`, "utf-8"); let modifiedSettings: string; if (settingsContent.includes("webhook:")) { modifiedSettings = settingsContent.replace( /webhook:.*/, - 'webhook: "https://test-webhook.example.com/hook"' + `webhook: "${webhook}"` ); } else { - modifiedSettings = - settingsContent + '\nwebhook: "https://test-webhook.example.com/hook"\n'; + modifiedSettings = settingsContent + `\nwebhook: "${webhook}"\n`; } await writeFile(`${tempDir}/settings.yaml`, modifiedSettings, "utf-8"); @@ -2213,7 +2215,7 @@ describe("settings sync", () => { ); expect(apiResp.status).toEqual(200); const settingsData = await apiResp.json(); - expect(settingsData.webhook).toEqual("https://test-webhook.example.com/hook"); + expect(settingsData.webhook).toEqual(webhook); }); }); }); diff --git a/cli/test/workspace_deps_path_unit.test.ts b/cli/test/workspace_deps_path_unit.test.ts new file mode 100644 index 0000000000..45d725c6e5 --- /dev/null +++ b/cli/test/workspace_deps_path_unit.test.ts @@ -0,0 +1,23 @@ +import { expect, test } from "bun:test"; +import { isWorkspaceDependencies } from "../src/utils/utils.ts"; +import { workspaceDependenciesPathToLanguageAndFilename } from "../src/utils/script_common.ts"; + +// Sync paths carry the platform separator. A Windows path parsed to a different +// set name pushes the file to a set the workspace does not have. +test("workspace dependency paths parse the same with either separator", () => { + for (const sep of ["/", "\\"]) { + expect(isWorkspaceDependencies(`dependencies${sep}requirements.in`)).toBe(true); + expect( + workspaceDependenciesPathToLanguageAndFilename(`dependencies${sep}requirements.in`), + ).toEqual({ name: undefined, language: "python3" }); + expect( + workspaceDependenciesPathToLanguageAndFilename(`dependencies${sep}team.package.json`), + ).toEqual({ name: "team", language: "bun" }); + expect( + workspaceDependenciesPathToLanguageAndFilename( + `dependencies${sep}team${sep}python.requirements.in`, + ), + ).toEqual({ name: "team/python", language: "python3" }); + } + expect(isWorkspaceDependencies("f\\dependencies\\requirements.in")).toBe(false); +}); diff --git a/cli/wmill.schema.json b/cli/wmill.schema.json index b4b10087a8..022156e5c9 100644 --- a/cli/wmill.schema.json +++ b/cli/wmill.schema.json @@ -147,6 +147,13 @@ "type": "string", "description": "Path to the codebase directory" }, + "extra_digest_paths": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Paths outside relative_path that scripts import, relative to wmill.yaml; hashed recursively" + }, "includes": { "type": "array", "items": { diff --git a/docker/DockerfileCuda b/docker/DockerfileCuda index 098670f9a3..031921070b 100644 --- a/docker/DockerfileCuda +++ b/docker/DockerfileCuda @@ -1,4 +1,5 @@ -FROM ghcr.io/windmill-labs/windmill-ee:dev +ARG BASE_IMAGE=ghcr.io/windmill-labs/windmill-ee:dev +FROM ${BASE_IMAGE} # Install any necessary dependencies for managing repositories and packages RUN apt-get update && apt-get install -y curl gnupg2 diff --git a/docker/DockerfileExtra b/docker/DockerfileExtra index 9eb2a987f2..8aa24206a4 100644 --- a/docker/DockerfileExtra +++ b/docker/DockerfileExtra @@ -110,11 +110,12 @@ WORKDIR /multiplayer # Copy multiplayer server files COPY multiplayer/package.json . +COPY multiplayer/package-lock.json . COPY multiplayer/server.mjs . COPY multiplayer/gateway.mjs . # Install dependencies -RUN npm install +RUN npm ci --omit=dev # ============================================================================ # Entrypoint Setup diff --git a/docker/DockerfileFull b/docker/DockerfileFull index b827208c1d..8ef6be78bb 100644 --- a/docker/DockerfileFull +++ b/docker/DockerfileFull @@ -1,4 +1,5 @@ -FROM ghcr.io/windmill-labs/windmill:dev +ARG BASE_IMAGE=ghcr.io/windmill-labs/windmill:dev +FROM ${BASE_IMAGE} # Rust COPY --from=rust:1.97.0 /usr/local/cargo /usr/local/cargo diff --git a/docker/DockerfileFullEe b/docker/DockerfileFullEe index 5b5347558a..88ed619c19 100644 --- a/docker/DockerfileFullEe +++ b/docker/DockerfileFullEe @@ -1,3 +1,5 @@ +ARG BASE_IMAGE=ghcr.io/windmill-labs/windmill-ee:dev + FROM alpine:3.14 AS oracledb-client ARG TARGETPLATFORM @@ -17,7 +19,7 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then \ mv /opt/oracle/23/amd64/lib /opt/oracle/23/lib; \ fi -FROM ghcr.io/windmill-labs/windmill-ee:dev +FROM ${BASE_IMAGE} # Rust COPY --from=rust:1.97.0 /usr/local/cargo /usr/local/cargo diff --git a/docker/DockerfileSlim b/docker/DockerfileSlim index 64fd1b735e..6418a2cfda 100644 --- a/docker/DockerfileSlim +++ b/docker/DockerfileSlim @@ -1,4 +1,7 @@ ARG DEBIAN_IMAGE=debian:trixie-slim +ARG BASE_IMAGE=ghcr.io/windmill-labs/windmill:dev + +FROM ${BASE_IMAGE} AS windmill_image FROM debian:trixie-slim AS nsjail @@ -103,7 +106,7 @@ RUN arch="$(dpkg --print-architecture)"; \ WORKDIR ${APP} -COPY --from=ghcr.io/windmill-labs/windmill:dev --chmod=755 ${APP}/windmill ${APP}/windmill +COPY --from=windmill_image --chmod=755 ${APP}/windmill ${APP}/windmill RUN ln -s ${APP}/windmill /usr/local/bin/windmill diff --git a/docker/DockerfileSlimEe b/docker/DockerfileSlimEe index de0d61a780..24ef941b87 100644 --- a/docker/DockerfileSlimEe +++ b/docker/DockerfileSlimEe @@ -1,4 +1,7 @@ ARG DEBIAN_IMAGE=debian:trixie-slim +ARG BASE_IMAGE=ghcr.io/windmill-labs/windmill-ee:dev + +FROM ${BASE_IMAGE} AS windmill_image FROM debian:trixie-slim AS nsjail @@ -103,7 +106,7 @@ RUN arch="$(dpkg --print-architecture)"; \ WORKDIR ${APP} -COPY --from=ghcr.io/windmill-labs/windmill-ee:dev --chmod=755 ${APP}/windmill ${APP}/windmill +COPY --from=windmill_image --chmod=755 ${APP}/windmill ${APP}/windmill RUN ln -s ${APP}/windmill /usr/local/bin/windmill diff --git a/docs/auth-surface.md b/docs/auth-surface.md index abeba1fe2b..15f2538cc5 100644 --- a/docs/auth-surface.md +++ b/docs/auth-surface.md @@ -12,18 +12,92 @@ Symbols, not line numbers, are cited: they drift less. by `create_session_token` (`windmill-api-users/src/users.rs`). `GET /api/users/refresh_token` mints one for any non-job token but returns plain text, no redirect. - **`tokens/impersonate`** (superadmin) returns a multi-use token and sets no cookie. -- **A token's label decides whether its expiry raises alerts.** When `delete_expired_items` - removes an expired `token` row, the monitor emails the owner and raises a critical alert (if - enabled); rows registered by `register_token_expiry_notification` also get an "expiring soon" - warning first. Neither happens when `is_user_token` (`windmill-common/src/auth.rs`) reserves - the label, so a token the system mints for itself, whether from the backend or from the frontend - through `tokens/create`, needs a reserved label. An `ephemeral-` prefix needs no other change - (keep it clear of `is_server_minted_label` if minted through `tokens/create`); a new prefix - also goes into the SQL and Svelte mirrors that function's doc lists. +- **`max_token_expiration_days`** caps `POST /users/tokens/create` and `tokens/impersonate`, by + shortening the stored expiration (`cap_token_expiration`), never by refusing: the CLI + authorization page, `wmill user create-token` and the editor's language-server token all pick a + lifetime without reading the setting, and CLIs already installed never will. The CLI signs in + again on its own when its token expires, which is why the authorization page labels it + `cli-login:`, reserved in `is_user_token` so its expiry does not email the user. A token + owned by a service account is exempt: one in the workspace the token names, or in any workspace + for a workspace-less token (for `tokens/impersonate`, the impersonated account). Any workspace + admin can therefore create and impersonate a service account to hold an uncapped token, so the + ceiling bounds personal tokens only. Only the stored expiration is capped: the auth lookup never + reads the setting, so tokens that exist when it is turned on or lowered keep theirs, including + none. Deliberately outside it: server-side mints (`create_token_internal` callers such as native + trigger webhook tokens, which never expire for GitHub and Nextcloud), and tokens with their own + fixed lifetime that outlive a short ceiling: sessions (`MAX_SESSION_VALIDITY_SECONDS`, 3 days, and + re-mintable through `GET /users/refresh_token`) and MCP OAuth access tokens (7 days, with a + rotating 30-day refresh token). Any logged-in user can read the setting through `GET + /settings/global/{key}`, which the token form uses to offer only expirations within it. The + settings API and config sync reject any value `parse_max_token_expiration_days` cannot read, since + the token routes would read it as no ceiling; `parseMaxTokenExpirationDays` in the frontend must + accept exactly the same values. +- **A token's label decides whether its expiry raises alerts.** When `delete_expired_items` removes + an expired `token` row, the monitor emails the owner and raises a critical alert (if enabled); + rows registered by `register_token_expiry_notification` also get an "expiring soon" warning first, + except a token whose whole lifetime fits in `TOKEN_EXPIRY_WARNING_DAYS` (7), which gets no row + since the warning would arrive minutes after it was created. Neither happens when `is_user_token` + (`windmill-common/src/auth.rs`) reserves the label, so a token the system mints for itself, + whether from the backend or from the frontend through `tokens/create`, needs a reserved label. An + `ephemeral-` prefix needs no other change (keep it clear of `is_server_minted_label` if minted + through `tokens/create`); a new prefix also goes into the SQL and Svelte mirrors that function's + doc lists. - **Every superadmin route refuses a job token**: `require_super_admin` (`windmill-api-auth/src/lib.rs`) errors on `authed.job_id.is_some()`. A script that needs `users/create`, `tokens/impersonate`, `set_login_type`, … must use a dedicated superadmin user token stored as a secret, never `$WM_TOKEN`. Token scopes cannot narrow superadmin routes. +- **Write access to a folder steers its readers' AI chat.** An `ai_instruction` resource's body is + delivered to the global chat of everyone who can read the resource, with no opt-in, whenever the + chat touches a path under its folder (`copilot/chat/folderInstructions.ts`); the chat then acts + with that reader's permissions, outside the folder too. `ai_skill` bodies are likewise in play + for every reader until turned off. The resource ACL is the only control, so granting write on a + folder grants that influence. +- **A remote deploy token is a credential for another instance**, held per account and workspace + (`remote_deploy_token`, encrypted under the workspace key, re-keyed by `set_encryption_key`). + Its `email` references `password(email)` with `ON DELETE/UPDATE CASCADE`, so whatever deletes or + renames an account takes the token along and a recycled address cannot inherit it; removal from + a workspace deletes it explicitly (`delete_workspace_user_internal`, both `leave_workspace`). The + row records the target it was granted for, and + `remote_deploy::proxy` only sends it to a target still matching the workspace setting, so + re-pointing the setting cannot redirect anyone's token to a URL of the admin's choosing. + `require_own_credentials` refuses job, scoped and read-only tokens (on `set_target` too): the + stored token carries none of their restrictions. The proxy turns the local session into a remote + bearer credential, so every ambient-cookie vector becomes one on the remote: its URL carries the + row's random `proxy_key` (a link riding the `SameSite=Lax` cookie cannot know it; a header would + do, but the frontend's only per-call hook is the global `OpenAPI.HEADERS`, whose mere presence + switches every download to in-memory blobs). The key is served `no-store`, withheld from the + credentials `require_own_credentials` refuses, and masked in this instance's own request logs + (`RedactedUri`, used by the request span and the log context); a reverse proxy in front still + writes the full path to its access log. `connect` names the target the token was obtained for + and is refused, before the token is sent anywhere, if the workspace now points elsewhere; the + token only ever goes to that target. It takes no lock against what clears these rows (a removal + from the workspace, a target change, a key rotation): their writers take the account, membership, + key and settings rows in every order, so any lock held there could close a deadlock. A row can + therefore land after one of them ran, however long its remote call took, and every read + (`load_connection`) voids it instead, by identity rather than by comparing times: the connect + records, before its remote call, the target setting's version (`remote_deploy_target_changed_at`) + and the `created_at` of the owner's membership, and the row counts only while both are still + the same (or the owner is a superadmin) and it decrypts. So a target set A → B → A, or a + removal and re-add, voids it whatever the clocks say. A superadmin with no membership is bound + through the credential making the request instead: the insert requires its `token` row to + still exist, and `delete_user`, offboarding and SCIM removal delete an account's tokens, so an + address deleted that way and re-created during the call does not inherit the connection (a JWT, + having no row, cannot connect a workspace its owner is not a member of). `leave_instance` + deletes only the `password` row, leaving the tokens and the memberships, which is not covered. + Neither is a member's connect across an account re-creation: an address is not expected to pass + to another person. A disconnect empties and stamps the row + (inserting one if needed) rather than deleting it, `connected_at` is when a connect started, + and the connect's upsert leaves a row stamped after that alone, so an older connect cannot undo + a disconnect or a newer connect. Connecting by redirect: the remote's + `/user/remote_deploy_authorize` page + mints a token bound to the one remote workspace (`remote-deploy:`, a label + reserved in `is_user_token` so its expiry emails nobody) only on an + explicit Authorize, only for a callback whose path is `/remote_deploy/callback`, and refuses to + render inside a frame; the token travels in the fragment, and the callback checks a single-use + `state` the drawer stored, so no other page can plant a token as the user's. The proxy also refuses a path the URL parser would rewrite, + serves every response under `CSP: sandbox` + `nosniff`, forwards only the method, query, body, + content-type and accept — never this instance's cookie or token — and turns the target's 401 + into a 502, because the browser logs the user out of *this* instance on an unhandled 401. - **`login_type`** (`password` table) is a free-form `VARCHAR(50)`. Password login and password reset require `login_type = 'password'`; `set_password` also accepts `pending_oauth` and turns the account into a `password` one in the same statement (an account created ahead of its owner diff --git a/docs/feature-telemetry.md b/docs/feature-telemetry.md index ae62d46b94..9dacfc1cbd 100644 --- a/docs/feature-telemetry.md +++ b/docs/feature-telemetry.md @@ -4,10 +4,10 @@ anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick" without any identifying data leaving the instance. -It currently carries 51 registered actions across nineteen features (`ai_session`, `ai_chat`, -`ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `flow_editor`, `flow_run`, -`flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`, `hub_script`, -`usage_meter`, `sso_groups_claim`, `cloud_trial_offer`). Nearly all of the +It currently carries 58 registered actions across twenty features (`ai_session`, `ai_chat`, +`ai_fix`, `ai_agent`, `ai_agent_eval`, `app_sandbox`, `datatable`, `db_manager`, `flow_editor`, +`flow_run`, `flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`, +`hub_script`, `usage_meter`, `sso_groups_claim`, `cloud_trial_offer`). Nearly all of the product is uninstrumented, so new user-facing work is the opportunity to change that. ## When to instrument diff --git a/flake.nix b/flake.nix index 9062fdb4fd..f005644e3d 100644 --- a/flake.nix +++ b/flake.nix @@ -65,7 +65,10 @@ # Misc libtool - postgresql + # Must not trail the server the dev database runs (postgres:18): pg_dump refuses a + # server newer than itself by a major version, which takes out every data table + # export, clone and fork-with-data. + postgresql_18 # Build tooling pkg-config diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 935a1aa475..611cd0c91f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.814.0", + "version": "1.817.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.814.0", + "version": "1.817.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -19,8 +19,10 @@ "@codingame/monaco-vscode-standalone-json-language-features": "=25.0.0", "@codingame/monaco-vscode-standalone-languages": "=25.0.0", "@codingame/monaco-vscode-standalone-typescript-language-features": "=25.0.0", + "@floating-ui/core": "^1.3.1", "@json2csv/plainjs": "^7.0.6", "@leeoniya/ufuzzy": "^1.0.8", + "@melt-ui/svelte": "^0.86.2", "@popperjs/core": "^2.11.8", "@redocly/json-to-json-schema": "^0.0.1", "@scalar/openapi-parser": "^0.15.0", @@ -29,6 +31,7 @@ "@windmill-labs/svelte-dnd-action": "^0.9.44", "@xterm/addon-fit": "^0.10.0", "@xyflow/svelte": "^1.0.0", + "@zerodevx/svelte-toast": "^0.9.6", "acorn": "^8.15.0", "ag-charts-community": "^9.0.1", "ag-charts-enterprise": "^9.0.1", @@ -39,10 +42,11 @@ "chartjs-adapter-date-fns": "^3.0.0", "chartjs-plugin-zoom": "^2.0.0", "clone": "^2.1.2", + "d3-dag": "^0.11.5", "d3-zoom": "^3.0.0", "date-fns": "^2.30.0", - "diff": "^7.0.0", - "dompurify": "^3.3.1", + "diff": "^8.0.3", + "dompurify": "^3.4.13", "driver.js": "^1.3.0", "esm-env": "^1.0.0", "fast-equals": "^5.0.1", @@ -54,8 +58,8 @@ "lru-cache": "^11.1.0", "lucide-svelte": "^0.540.0", "mdast-util-find-and-replace": "^3.0.2", - "mermaid": "^11.15.0", - "minimatch": "^10.0.1", + "mermaid": "^11.16.1", + "minimatch": "^10.2.3", "modern-screenshot": "^4.7.0", "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0", "monaco-languageclient": "10.6.0", @@ -67,15 +71,23 @@ "panzoom": "^9.4.3", "pdfjs-dist": "4.8.69", "quicktype-core": "^23.2.6", - "quill": "^1.3.7", + "quill": "^2.0.3", "rehype-github-alerts": "^3.0.0", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "rfc4648": "^1.5.3", "runed": "^0.36.0", + "style-to-object": "^0.4.1", + "svelte": "^5.55.7", + "svelte-awesome-color-picker": "^3.0.4", "svelte-carousel": "^1.0.25", "svelte-exmarkdown": "^5.0.0", + "svelte-floating-ui": "^1.5.8", + "svelte-highlight": "^7.6.0", "svelte-infinite-loading": "^1.4.0", + "svelte-popperjs": "^1.3.2", + "svelte-range-slider-pips": "^2.3.1", + "svelte-splitpanes": "^8.0.9", "tailwind-merge": "^1.13.2", "unist-util-visit": "^5.0.0", "vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0", @@ -102,33 +114,28 @@ "xterm-readline": "^1.1.2", "y-monaco": "^0.1.4", "y-websocket": "^1.5.4", - "yaml": "^2.8.0", + "yaml": "^2.8.3", "yjs": "^13.6.7", "zod": "^4.1.10" }, "devDependencies": { - "@floating-ui/core": "^1.3.1", "@hey-api/openapi-ts": "^0.43.0", "@melt-ui/pp": "^0.3.2", - "@melt-ui/svelte": "^0.86.2", "@playwright/test": "^1.57.0", "@sveltejs/adapter-static": "^3.0.6", - "@sveltejs/kit": "^2.53.4", + "@sveltejs/kit": "^2.69.1", "@sveltejs/package": "^2.5.7", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/forms": "^0.5.3", "@tailwindcss/typography": "^0.5.8", "@types/d3": "^7.4.0", "@types/d3-zoom": "^3.0.3", - "@types/diff": "^7.0.1", "@types/lodash": "^4.14.195", "@types/vscode": "^1.83.5", "@typescript-eslint/eslint-plugin": "^5.59.8", "@typescript-eslint/parser": "^5.60.0", - "@zerodevx/svelte-toast": "^0.9.6", "autoprefixer": "^10.4.13", "cssnano": "^6.0.1", - "d3-dag": "^0.11.5", "dts-bundle-generator": "^9.5.1", "eslint": "^8.47.0", "eslint-config-prettier": "^8.6.0", @@ -142,25 +149,17 @@ "postcss-load-config": "^4.0.1", "prettier": "^3.1.0", "prettier-plugin-svelte": "^3.3.3", - "style-to-object": "^0.4.1", "stylelint-config-recommended": "^13.0.0", - "svelte": "^5.53.5", - "svelte-awesome-color-picker": "^3.0.4", "svelte-check": "^4.4.3", "svelte-fast-check": "^0.4.5", - "svelte-floating-ui": "^1.5.8", - "svelte-highlight": "^7.6.0", - "svelte-popperjs": "^1.3.2", "svelte-preprocess": "^6.0.0", - "svelte-range-slider-pips": "^2.3.1", - "svelte-splitpanes": "^8.0.9", "tailwindcss": "^3.4.1", - "tar": "^7.5.4", + "tar": "^7.5.18", "tslib": "^2.6.1", "typescript": "^5.5.0", "vite": "^8.2.0", "vite-plugin-mkcert": "^2.0.0", - "vitest": "^4.1.0", + "vitest": "^4.1.11", "vitest-browser-svelte": "^2.0.1" }, "optionalDependencies": { @@ -1152,9 +1151,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -1176,9 +1175,9 @@ "license": "MIT" }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1220,7 +1219,6 @@ "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", - "dev": true, "license": "MIT", "dependencies": { "@floating-ui/utils": "^0.2.10" @@ -1230,7 +1228,6 @@ "version": "1.7.4", "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", - "dev": true, "license": "MIT", "dependencies": { "@floating-ui/core": "^1.7.3", @@ -1241,7 +1238,6 @@ "version": "0.2.10", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", - "dev": true, "license": "MIT" }, "node_modules/@glideapps/ts-necessities": { @@ -1290,9 +1286,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -1345,33 +1341,11 @@ "version": "3.10.0", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.10.0.tgz", "integrity": "sha512-oxDR/NTEJ1k+UFVQElaNIk65E/Z83HK1z1WI3lQyhTtnNg4R5oVXaPzK3jcpKG8UHKDVuDQHzn+wsxSz8RP3aw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@swc/helpers": "^0.5.0" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1598,7 +1572,6 @@ "version": "0.86.6", "resolved": "https://registry.npmjs.org/@melt-ui/svelte/-/svelte-0.86.6.tgz", "integrity": "sha512-Jer+M7DgIwT5IHfTayb4Iw/fkkxWNmC/mqn/nMh9JrbPbkxmyabfLQnhJ+JDn5HK77f84j34lubO3iqFtYAfMg==", - "dev": true, "license": "MIT", "dependencies": { "@floating-ui/core": "^1.3.1", @@ -1613,12 +1586,12 @@ } }, "node_modules/@mermaid-js/parser": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.1.tgz", - "integrity": "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.2.1.tgz", + "integrity": "sha512-n12NohV3mrUyUL2o93IgG/ifeW9FTyeJn3zDxkhwa8MJ9Fxg3HQMlA3RiGmD/3UnJvheztkjjQAjA2T4LmUcpw==", "license": "MIT", "dependencies": { - "@chevrotain/types": "~11.1.1" + "@chevrotain/types": "~11.1.2" } }, "node_modules/@noble/hashes": { @@ -2045,9 +2018,9 @@ } }, "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.6.tgz", - "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -2064,18 +2037,18 @@ } }, "node_modules/@sveltejs/kit": { - "version": "2.53.4", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.53.4.tgz", - "integrity": "sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==", + "version": "2.70.3", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.3.tgz", + "integrity": "sha512-UDvEYuZqAMbfB/oXIoqKvbKcb7YczK5zYrzmsGV1zRJk03jntwp8dXiYoIJotxAndsKvcPFtx9H1GRSKFdSHgg==", "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", - "@sveltejs/acorn-typescript": "^1.0.5", + "@sveltejs/acorn-typescript": "^1.0.9", "@types/cookie": "^0.6.0", - "acorn": "^8.14.1", + "acorn": "^8.16.0", "cookie": "^0.6.0", - "devalue": "^5.6.3", + "devalue": "^5.8.1", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", @@ -2093,7 +2066,7 @@ "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", "svelte": "^4.0.0 || ^5.0.0-next.0", - "typescript": "^5.3.3", + "typescript": "^5.3.3 || ^6.0.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" }, "peerDependenciesMeta": { @@ -2182,7 +2155,6 @@ "version": "0.5.17", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.17.tgz", "integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.8.0" @@ -2240,16 +2212,6 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@trysound/sax": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", - "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/@tutorlatin/svelte-tiny-virtual-list": { "version": "3.0.16", "resolved": "https://registry.npmjs.org/@tutorlatin/svelte-tiny-virtual-list/-/svelte-tiny-virtual-list-3.0.16.tgz", @@ -2552,13 +2514,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/diff": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@types/diff/-/diff-7.0.2.tgz", - "integrity": "sha512-JSWRMozjFKsGlEjiiKajUjIJVKuKdE3oVy2DNtK+fUo8q82nhFZ2CPQwicAIkXrofahDXrWJ7mjelvZphMS98Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2642,7 +2597,8 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/@types/unist": { "version": "3.0.3", @@ -2987,31 +2943,31 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", - "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", - "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.0", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -3020,7 +2976,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -3032,26 +2988,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", - "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", - "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -3066,14 +3022,14 @@ "license": "MIT" }, "node_modules/@vitest/snapshot": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", - "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -3089,9 +3045,9 @@ "license": "MIT" }, "node_modules/@vitest/spy": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", - "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -3099,15 +3055,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", - "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -3184,7 +3140,6 @@ "version": "0.9.6", "resolved": "https://registry.npmjs.org/@zerodevx/svelte-toast/-/svelte-toast-0.9.6.tgz", "integrity": "sha512-nHlTrCjverlPK9yukK6fqbG3e/R+f10ldrc4nJHOe2qNDScuPTuYVSFEk2dDDtzWAwTN5pmdEXgA3M2RbT8jiw==", - "dev": true, "license": "ISC", "peerDependencies": { "svelte": "^3.57.0 || ^4.0.0 || ^5.0.0" @@ -3221,9 +3176,9 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -3274,9 +3229,9 @@ } }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -3556,13 +3511,16 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.8.16", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz", - "integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==", + "version": "2.11.24", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz", + "integrity": "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==", "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/bezier-easing": { @@ -3614,9 +3572,9 @@ "license": "ISC" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -3651,9 +3609,9 @@ "license": "MIT" }, "node_modules/browserslist": { - "version": "4.26.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", - "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", "dev": true, "funding": [ { @@ -3671,11 +3629,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.8.9", - "caniuse-lite": "^1.0.30001746", - "electron-to-chromium": "^1.5.227", - "node-releases": "^2.0.21", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -3781,28 +3739,11 @@ "node": ">=8.10.0" } }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3816,6 +3757,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -3937,9 +3879,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001750", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz", - "integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -4185,7 +4127,6 @@ "version": "2.9.3", "resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz", "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", - "dev": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -4263,9 +4204,9 @@ "license": "MIT" }, "node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "devOptional": true, "license": "MIT", "engines": { @@ -4711,7 +4652,6 @@ "version": "0.11.5", "resolved": "https://registry.npmjs.org/d3-dag/-/d3-dag-0.11.5.tgz", "integrity": "sha512-sNHvYqjzDlvV2fyEkoOCSuLs2GeWliIg7pJcAiKXgtUSxl0kIX0C2q1J8JzzA9CQWptKxYtzxFCXiKptTW8qsQ==", - "dev": true, "license": "MIT", "dependencies": { "d3-array": "^3.1.6", @@ -5257,26 +5197,6 @@ "dev": true, "license": "MIT" }, - "node_modules/deep-equal": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", - "integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==", - "license": "MIT", - "dependencies": { - "is-arguments": "^1.1.1", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "regexp.prototype.flags": "^1.5.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -5319,44 +5239,10 @@ "node": ">=6" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "dev": true, "license": "MIT" }, @@ -5406,9 +5292,9 @@ } }, "node_modules/devalue": { - "version": "5.6.3", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.3.tgz", - "integrity": "sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.2.tgz", + "integrity": "sha512-po4PAY5c53tw5XMocSnf8A/5OHhbbUftpr93aEN6BBoAdntUmK7vu7wOATqvt7cXO7m1Cl4gMVn6p7n6n4mj0w==", "license": "MIT" }, "node_modules/devlop": { @@ -5443,9 +5329,9 @@ "license": "Apache-2.0" }, "node_modules/diff": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", - "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -5542,9 +5428,9 @@ } }, "node_modules/dompurify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", - "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", + "version": "3.4.15", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", + "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", "license": "(MPL-2.0 OR Apache-2.0)", "optionalDependencies": { "@types/trusted-types": "^2.0.7" @@ -5605,6 +5491,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.1", @@ -5640,9 +5527,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.235", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.235.tgz", - "integrity": "sha512-i/7ntLFwOdoHY7sgjlTIDo4Sl8EdoTjWIaKinYOVfC6bOp71bmwenyZthWHcasxgHDNWbWxvG9M3Ia116zIaYQ==", + "version": "1.5.430", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz", + "integrity": "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==", "dev": true, "license": "ISC" }, @@ -5719,6 +5606,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5728,6 +5616,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -5744,6 +5633,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0" @@ -5963,9 +5853,9 @@ } }, "node_modules/eslint-plugin-svelte/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6014,9 +5904,9 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -6065,9 +5955,9 @@ "license": "MIT" }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -6087,7 +5977,6 @@ "version": "0.0.3", "resolved": "https://registry.npmjs.org/esm-env-robust/-/esm-env-robust-0.0.3.tgz", "integrity": "sha512-90Gnuw2DALOqlL1581VxP3GHPUNHX9U+fQ+8FNcTTFClhY5gEggAAnJ3q1b2Oq23knRsjv8YpNeMRPaMLUymOA==", - "dev": true, "license": "MIT", "dependencies": { "esm-env": "^1.0.0" @@ -6148,15 +6037,6 @@ "node": ">=4.0" } }, - "node_modules/esrap": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.3.tgz", - "integrity": "sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==", - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - } - }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -6220,9 +6100,9 @@ } }, "node_modules/eventemitter3": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz", - "integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", "license": "MIT" }, "node_modules/events": { @@ -6277,9 +6157,9 @@ "license": "MIT" }, "node_modules/fast-diff": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz", - "integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", "license": "Apache-2.0" }, "node_modules/fast-equals": { @@ -6343,9 +6223,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -6358,6 +6238,15 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fastdom": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fastdom/-/fastdom-1.0.12.tgz", + "integrity": "sha512-LB+xjSTEbjHE1cWsxu+tN2Xqr1kpi+V9aADI7sVM5ZMaXyYGPHULQMzpJMYqOTULK/73pUkWVzzObFRBkPr+hg==", + "license": "MIT", + "dependencies": { + "strictdom": "^1.0.1" + } + }, "node_modules/fastest-levenshtein": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", @@ -6373,7 +6262,6 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/fastpriorityqueue/-/fastpriorityqueue-0.7.2.tgz", "integrity": "sha512-5DtIKh6vtOmEGkYdEPNNb+mxeYCnBiKbK3s4gq52l6cX8I5QaTDWWw0Wx/iYo80fVOblSycHu1/iJeqeNxG8Jw==", - "dev": true, "license": "Apache-2.0" }, "node_modules/fastq": { @@ -6445,9 +6333,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", "dev": true, "license": "ISC" }, @@ -6455,7 +6343,6 @@ "version": "7.6.5", "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.6.5.tgz", "integrity": "sha512-7Ke1jyybbbPZyZXFxEftUtxFGLMpE2n6A+z//m4CRDlj0hW+o3iYSmh8nFlYMurOiJVDmJRilUQtJr08KfIxlg==", - "dev": true, "license": "MIT", "dependencies": { "tabbable": "^6.2.0" @@ -6479,17 +6366,17 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -6557,15 +6444,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6604,6 +6483,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.2", @@ -6628,6 +6508,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -6706,9 +6587,9 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -6811,6 +6692,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6861,9 +6743,9 @@ } }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6903,22 +6785,11 @@ "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -6931,6 +6802,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -6949,9 +6821,10 @@ "license": "MIT" }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -7338,7 +7211,6 @@ "version": "0.1.1", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==", - "dev": true, "license": "MIT" }, "node_modules/internmap": { @@ -7350,22 +7222,6 @@ "node": ">=12" } }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -7403,22 +7259,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -7509,24 +7349,6 @@ "@types/estree": "^1.0.6" } }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "license": "MIT", - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-url": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", @@ -7576,7 +7398,6 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/javascript-lp-solver/-/javascript-lp-solver-0.4.24.tgz", "integrity": "sha512-5edoDKnMrt/u3M6GnZKDDIPxOyFOg+WrwDv8mjNiMC2DePhy2H9/FFQgf4ggywaXT1utvkxusJcjQUER72cZmA==", - "dev": true, "license": "Unlicense" }, "node_modules/jiti": { @@ -7604,10 +7425,20 @@ "peer": true }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7752,9 +7583,9 @@ } }, "node_modules/jsdom/node_modules/undici": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", - "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", "dev": true, "license": "MIT", "engines": { @@ -7845,9 +7676,9 @@ } }, "node_modules/json-refs/node_modules/js-yaml": { - "version": "3.14.2", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", - "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { @@ -8546,9 +8377,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash-es": { @@ -8711,6 +8542,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8896,9 +8728,9 @@ } }, "node_modules/mdast-util-to-hast": { - "version": "13.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", - "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.0", @@ -9010,26 +8842,27 @@ } }, "node_modules/mermaid": { - "version": "11.15.0", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.15.0.tgz", - "integrity": "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw==", + "version": "11.17.2", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.17.2.tgz", + "integrity": "sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==", "license": "MIT", "dependencies": { - "@braintree/sanitize-url": "^7.1.1", + "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", - "@mermaid-js/parser": "^1.1.1", + "@mermaid-js/parser": "^1.2.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", - "cytoscape": "^3.33.1", + "cytoscape": "^3.34.0", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", - "dayjs": "^1.11.19", - "dompurify": "^3.3.1", + "dayjs": "^1.11.21", + "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", - "katex": "^0.16.25", + "fastdom": "1.0.12", + "katex": "^0.16.47", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", @@ -9697,20 +9530,41 @@ } }, "node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", - "license": "ISC", + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^5.0.8" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimatch/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/minimatch/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -9896,10 +9750,9 @@ } }, "node_modules/nanoid": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", - "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", - "dev": true, + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", "funding": [ { "type": "github", @@ -10022,11 +9875,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.23", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.23.tgz", - "integrity": "sha512-cCmFDMSm26S6tQSDpBCg/NR8NENrVPhAJSf+XbxBG4rPFaaonlEoE9wHQmun+cls499TQGSb7ZyPBRlzgKfpeg==", + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-package-data": { "version": "3.0.3", @@ -10139,31 +9995,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -10356,9 +10187,9 @@ } }, "node_modules/parchment": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz", - "integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", + "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==", "license": "BSD-3-Clause" }, "node_modules/parent-module": { @@ -10735,9 +10566,9 @@ } }, "node_modules/postcss-calc/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -10953,9 +10784,9 @@ } }, "node_modules/postcss-merge-rules/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11035,9 +10866,9 @@ } }, "node_modules/postcss-minify-selectors/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11075,9 +10906,9 @@ } }, "node_modules/postcss-nested/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11380,9 +11211,9 @@ } }, "node_modules/postcss-unique-selectors/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -11401,9 +11232,9 @@ "license": "MIT" }, "node_modules/postcss/node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "devOptional": true, "funding": [ { @@ -11509,9 +11340,9 @@ } }, "node_modules/protocol-buffers-schema": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz", - "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", "license": "MIT" }, "node_modules/prr": { @@ -11543,13 +11374,14 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -11562,7 +11394,6 @@ "version": "1.6.1", "resolved": "https://registry.npmjs.org/quadprog/-/quadprog-1.6.1.tgz", "integrity": "sha512-fN5Jkcjlln/b3pJkseDKREf89JkKIyu6cKIVXisgL6ocKPQ0yTp9n6NZUAq3otEPPw78WZMG9K0o9WsfKyMWJw==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.x" @@ -11676,31 +11507,32 @@ } }, "node_modules/quill": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz", - "integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", + "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", "license": "BSD-3-Clause", "dependencies": { - "clone": "^2.1.1", - "deep-equal": "^1.0.1", - "eventemitter3": "^2.0.3", - "extend": "^3.0.2", - "parchment": "^1.1.4", - "quill-delta": "^3.6.2" + "eventemitter3": "^5.0.1", + "lodash-es": "^4.17.21", + "parchment": "^3.0.0", + "quill-delta": "^5.1.0" + }, + "engines": { + "npm": ">=8.2.3" } }, "node_modules/quill-delta": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz", - "integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", "license": "MIT", "dependencies": { - "deep-equal": "^1.0.1", - "extend": "^3.0.2", - "fast-diff": "1.1.2" + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" }, "engines": { - "node": ">=0.10" + "node": ">= 12.0.0" } }, "node_modules/rbush": { @@ -11873,26 +11705,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "license": "MIT", - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/rehype-github-alerts": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/rehype-github-alerts/-/rehype-github-alerts-3.0.0.tgz", @@ -12240,6 +12052,16 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -12279,38 +12101,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "license": "MIT", - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", @@ -12341,15 +12131,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -12361,14 +12151,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -12646,6 +12436,12 @@ "dev": true, "license": "MIT" }, + "node_modules/strictdom": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strictdom/-/strictdom-1.0.1.tgz", + "integrity": "sha512-cEmp9QeXXRmjj/rVp9oyiqcvyocWab/HaoN4+bwFeZ7QzykJD6L3yD4v12K1x0tHpqRqVpJevN3gW7kyM39Bqg==", + "license": "MIT" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -12750,7 +12546,6 @@ "version": "0.4.4", "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.4.tgz", "integrity": "sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==", - "dev": true, "license": "MIT", "dependencies": { "inline-style-parser": "0.1.1" @@ -12774,9 +12569,9 @@ } }, "node_modules/stylehacks/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12907,9 +12702,9 @@ "peer": true }, "node_modules/stylelint/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "peer": true, @@ -12969,9 +12764,9 @@ "license": "MIT" }, "node_modules/sucrase/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -12989,9 +12784,10 @@ } }, "node_modules/sucrase/node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -13010,13 +12806,13 @@ } }, "node_modules/sucrase/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -13094,23 +12890,22 @@ } }, "node_modules/svelte": { - "version": "5.53.5", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.5.tgz", - "integrity": "sha512-YkqERnF05g8KLdDZwZrF8/i1eSbj6Eoat8Jjr2IfruZz9StLuBqo8sfCSzjosNKd+ZrQ8DkKZDjpO5y3ht1Pow==", + "version": "5.57.0", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.0.tgz", + "integrity": "sha512-NdbDn7fl4be1ViUG0oq/lvG6OZy3oENolV2ONjiqqsfVoeAfzaQAKUcEX3MrQod/Bebv1PgwET9rfXhgn9s4Kg==", "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.5", + "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", - "devalue": "^5.6.3", + "devalue": "^5.8.1", "esm-env": "^1.2.1", - "esrap": "^2.2.2", + "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", @@ -13124,7 +12919,6 @@ "version": "3.1.4", "resolved": "https://registry.npmjs.org/svelte-awesome-color-picker/-/svelte-awesome-color-picker-3.1.4.tgz", "integrity": "sha512-tiFakxvSpCwodOSFW6CflnLVSldSlDY77rDipHfW7hvB+4gTP3y6uO+Dm9O9e7P5N7K+CNyNjjdjbZOHYkMyNQ==", - "dev": true, "license": "MIT", "dependencies": { "colord": "^2.9.3", @@ -13138,7 +12932,6 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/svelte-awesome-slider/-/svelte-awesome-slider-1.1.2.tgz", "integrity": "sha512-HFIWwq6rtX6aXyc6ns2R0P6pDLYIr6nRjTPyAddAiTAgZqsohAoEfOEwKKqMmZ6Q+CH48zzcFdyo6lFkBJvyQw==", - "dev": true, "license": "MIT", "peerDependencies": { "svelte": "^3.54.0 || ^4.0.0 || ^5.0.0" @@ -13311,7 +13104,6 @@ "version": "1.6.2", "resolved": "https://registry.npmjs.org/svelte-floating-ui/-/svelte-floating-ui-1.6.2.tgz", "integrity": "sha512-EC+DZtBey50P6l3NSzNQWon3cip8a1bzwdpmCdc45kymqEWL4BKhPemAq7SQ9QLebDPaMECW6YodxFbs2d+O/w==", - "dev": true, "license": "MIT", "dependencies": { "@floating-ui/dom": "^1.6.13" @@ -13321,7 +13113,6 @@ "version": "7.8.4", "resolved": "https://registry.npmjs.org/svelte-highlight/-/svelte-highlight-7.8.4.tgz", "integrity": "sha512-aVp+Q0hH9kI7PlSDrklmFTF4Uj7wYj7UGuqkREnkXlqpEffxr2g6esZcMMaTgECdg5rb2mJqM88+nwS10ecoTg==", - "dev": true, "license": "MIT", "dependencies": { "highlight.js": "11.11.1" @@ -13337,7 +13128,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/svelte-popperjs/-/svelte-popperjs-1.3.2.tgz", "integrity": "sha512-fwrErlkvngL876WXRnL3OLlfk/n9YkZwwLxuKRpZOYCJLt1zrwhoKTXS+/sRgDveD/zd6GQ35hV89EOip+NBGA==", - "dev": true, "license": "MIT", "peerDependencies": { "@popperjs/core": ">=2", @@ -13404,14 +13194,12 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/svelte-range-slider-pips/-/svelte-range-slider-pips-2.3.1.tgz", "integrity": "sha512-P29PNqHld+SiaDuHzf98rLvhSYWXb3TVL9p7U5RG9UX8emUgypZgp9zuIIwpmIXysGQC6JG8duMc5FuaPnSVdg==", - "dev": true, "license": "MPL-2.0" }, "node_modules/svelte-splitpanes": { "version": "8.0.9", "resolved": "https://registry.npmjs.org/svelte-splitpanes/-/svelte-splitpanes-8.0.9.tgz", "integrity": "sha512-L3oLXTC99M191FInTXJ/f/2i0welRql1QuVbPaU8iy6nvCR6X9VyjHCsCpLqKGWHwqkWo/AM9CQ1c0nzlb+MkA==", - "dev": true, "license": "MIT", "dependencies": { "esm-env-robust": "0.0.3" @@ -13420,6 +13208,38 @@ "svelte": "^4.2.19 || ^5.1.0" } }, + "node_modules/svelte/node_modules/@typescript-eslint/types": { + "version": "8.70.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", + "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/svelte/node_modules/esrap": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.7.tgz", + "integrity": "sha512-n2nf7fZR3c9yXf0BPEuHuXqT+KW0SJVj4cN5FMEkpCZ3scLjOQWpiccyCxVzCC2q1wubTghuEGzngJY/7Ah0Ow==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, "node_modules/svelte2tsx": { "version": "0.7.49", "resolved": "https://registry.npmjs.org/svelte2tsx/-/svelte2tsx-0.7.49.tgz", @@ -13443,19 +13263,19 @@ "peer": true }, "node_modules/svgo": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.2.tgz", - "integrity": "sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==", + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.5.tgz", + "integrity": "sha512-8SQMzdrvWaD8deUmrnYB+ASyxBVgWUOilg+A75nE/76WdLpj6LopCwiAVvkzkcqy/9b7t2Mg7faFLjg0ZRcZ3w==", "dev": true, "license": "MIT", "dependencies": { - "@trysound/sax": "0.2.0", "commander": "^7.2.0", "css-select": "^5.1.0", "css-tree": "^2.3.1", "css-what": "^6.1.0", "csso": "^5.0.5", - "picocolors": "^1.0.0" + "picocolors": "^1.0.0", + "sax": "^1.5.0" }, "bin": { "svgo": "bin/svgo" @@ -13489,7 +13309,6 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz", "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==", - "dev": true, "license": "MIT" }, "node_modules/table": { @@ -13597,9 +13416,9 @@ } }, "node_modules/tailwindcss/node_modules/postcss-selector-parser": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", - "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -13624,9 +13443,9 @@ } }, "node_modules/tar": { - "version": "7.5.11", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.11.tgz", - "integrity": "sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ==", + "version": "7.5.22", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz", + "integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -13786,9 +13605,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -14021,9 +13840,9 @@ } }, "node_modules/undici": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-8.1.0.tgz", - "integrity": "sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==", + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", "dev": true, "license": "MIT", "engines": { @@ -14156,9 +13975,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { @@ -14418,19 +14237,19 @@ } }, "node_modules/vitest": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", - "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.0", - "@vitest/mocker": "4.1.0", - "@vitest/pretty-format": "4.1.0", - "@vitest/runner": "4.1.0", - "@vitest/snapshot": "4.1.0", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -14441,8 +14260,8 @@ "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -14458,13 +14277,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.0", - "@vitest/browser-preview": "4.1.0", - "@vitest/browser-webdriverio": "4.1.0", - "@vitest/ui": "4.1.0", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -14485,6 +14306,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -14621,18 +14448,18 @@ "license": "MIT" }, "node_modules/vscode-languageclient/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/vscode-languageclient/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" @@ -15158,9 +14985,9 @@ } }, "node_modules/y-websocket/node_modules/ws": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", - "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", + "version": "6.2.6", + "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.6.tgz", + "integrity": "sha512-XTrf1gv7kXoVf1hbC3PAyAiPgR8Wz1blcrYIjEsUmr08BLksT41R8KbjmS9408C2ERx7v1JDLD/BkpLEttjfKA==", "license": "MIT", "optional": true, "dependencies": { @@ -15188,15 +15015,18 @@ } }, "node_modules/yaml": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.1.tgz", - "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { diff --git a/frontend/package.json b/frontend/package.json index eedca8f2c8..46db11793f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.814.0", + "version": "1.817.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", @@ -22,28 +22,23 @@ "test:e2e": "playwright test" }, "devDependencies": { - "@floating-ui/core": "^1.3.1", "@hey-api/openapi-ts": "^0.43.0", "@melt-ui/pp": "^0.3.2", - "@melt-ui/svelte": "^0.86.2", "@playwright/test": "^1.57.0", "@sveltejs/adapter-static": "^3.0.6", - "@sveltejs/kit": "^2.53.4", + "@sveltejs/kit": "^2.69.1", "@sveltejs/package": "^2.5.7", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/forms": "^0.5.3", "@tailwindcss/typography": "^0.5.8", "@types/d3": "^7.4.0", "@types/d3-zoom": "^3.0.3", - "@types/diff": "^7.0.1", "@types/lodash": "^4.14.195", "@types/vscode": "^1.83.5", "@typescript-eslint/eslint-plugin": "^5.59.8", "@typescript-eslint/parser": "^5.60.0", - "@zerodevx/svelte-toast": "^0.9.6", "autoprefixer": "^10.4.13", "cssnano": "^6.0.1", - "d3-dag": "^0.11.5", "dts-bundle-generator": "^9.5.1", "eslint": "^8.47.0", "eslint-config-prettier": "^8.6.0", @@ -57,32 +52,27 @@ "postcss-load-config": "^4.0.1", "prettier": "^3.1.0", "prettier-plugin-svelte": "^3.3.3", - "style-to-object": "^0.4.1", "stylelint-config-recommended": "^13.0.0", - "svelte": "^5.53.5", - "svelte-awesome-color-picker": "^3.0.4", "svelte-check": "^4.4.3", "svelte-fast-check": "^0.4.5", - "svelte-floating-ui": "^1.5.8", - "svelte-highlight": "^7.6.0", - "svelte-popperjs": "^1.3.2", "svelte-preprocess": "^6.0.0", - "svelte-range-slider-pips": "^2.3.1", - "svelte-splitpanes": "^8.0.9", "tailwindcss": "^3.4.1", - "tar": "^7.5.4", + "tar": "^7.5.18", "tslib": "^2.6.1", "typescript": "^5.5.0", "vite": "^8.2.0", "vite-plugin-mkcert": "^2.0.0", - "vitest": "^4.1.0", + "vitest": "^4.1.11", "vitest-browser-svelte": "^2.0.1" }, "overrides": { "monaco-graphql": { "monaco-editor": "$monaco-editor" }, - "tar": "$tar" + "tar": "$tar", + "dompurify": "$dompurify", + "cookie": "^0.7.0", + "handlebars": "^4.7.9" }, "type": "module", "dependencies": { @@ -95,8 +85,10 @@ "@codingame/monaco-vscode-standalone-json-language-features": "=25.0.0", "@codingame/monaco-vscode-standalone-languages": "=25.0.0", "@codingame/monaco-vscode-standalone-typescript-language-features": "=25.0.0", + "@floating-ui/core": "^1.3.1", "@json2csv/plainjs": "^7.0.6", "@leeoniya/ufuzzy": "^1.0.8", + "@melt-ui/svelte": "^0.86.2", "@popperjs/core": "^2.11.8", "@redocly/json-to-json-schema": "^0.0.1", "@scalar/openapi-parser": "^0.15.0", @@ -105,6 +97,7 @@ "@windmill-labs/svelte-dnd-action": "^0.9.44", "@xterm/addon-fit": "^0.10.0", "@xyflow/svelte": "^1.0.0", + "@zerodevx/svelte-toast": "^0.9.6", "acorn": "^8.15.0", "ag-charts-community": "^9.0.1", "ag-charts-enterprise": "^9.0.1", @@ -115,10 +108,11 @@ "chartjs-adapter-date-fns": "^3.0.0", "chartjs-plugin-zoom": "^2.0.0", "clone": "^2.1.2", + "d3-dag": "^0.11.5", "d3-zoom": "^3.0.0", "date-fns": "^2.30.0", - "diff": "^7.0.0", - "dompurify": "^3.3.1", + "diff": "^8.0.3", + "dompurify": "^3.4.13", "driver.js": "^1.3.0", "esm-env": "^1.0.0", "fast-equals": "^5.0.1", @@ -130,8 +124,8 @@ "lru-cache": "^11.1.0", "lucide-svelte": "^0.540.0", "mdast-util-find-and-replace": "^3.0.2", - "mermaid": "^11.15.0", - "minimatch": "^10.0.1", + "mermaid": "^11.16.1", + "minimatch": "^10.2.3", "modern-screenshot": "^4.7.0", "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0", "monaco-languageclient": "10.6.0", @@ -143,15 +137,23 @@ "panzoom": "^9.4.3", "pdfjs-dist": "4.8.69", "quicktype-core": "^23.2.6", - "quill": "^1.3.7", + "quill": "^2.0.3", "rehype-github-alerts": "^3.0.0", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "rfc4648": "^1.5.3", "runed": "^0.36.0", + "style-to-object": "^0.4.1", + "svelte": "^5.55.7", + "svelte-awesome-color-picker": "^3.0.4", "svelte-carousel": "^1.0.25", "svelte-exmarkdown": "^5.0.0", + "svelte-floating-ui": "^1.5.8", + "svelte-highlight": "^7.6.0", "svelte-infinite-loading": "^1.4.0", + "svelte-popperjs": "^1.3.2", + "svelte-range-slider-pips": "^2.3.1", + "svelte-splitpanes": "^8.0.9", "tailwind-merge": "^1.13.2", "unist-util-visit": "^5.0.0", "vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0", @@ -178,7 +180,7 @@ "xterm-readline": "^1.1.2", "y-monaco": "^0.1.4", "y-websocket": "^1.5.4", - "yaml": "^2.8.0", + "yaml": "^2.8.3", "yjs": "^13.6.7", "zod": "^4.1.10" }, diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index 87ce066c87..68c1c558b1 100644 --- a/frontend/scripts/ui_builder_artifact.json +++ b/frontend/scripts/ui_builder_artifact.json @@ -1,5 +1,5 @@ { "baseUrl": "https://pub-06154ed168a24e73a86ab84db6bf15d8.r2.dev", - "version": "1522e43", - "sha256": "0c44db3a52e02e6530860d0e9e9031fcdad7a09a98897b6c97e6f522c1d4754c" + "version": "60b6904", + "sha256": "85b68e8418c97d96f3a419b3e345ef8a1987976943b8439a7df841f9217a9b34" } diff --git a/frontend/src/lib/accentColor.test.ts b/frontend/src/lib/accentColor.test.ts new file mode 100644 index 0000000000..6d3b855240 --- /dev/null +++ b/frontend/src/lib/accentColor.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest' +import { accentStylesheet } from './accentColor' + +describe('accentStylesheet', () => { + it('refuses anything but #rrggbb, since the value lands in a stylesheet', () => { + for (const bad of ['#000;}body{display:none', '#fff', 'red', '1f9d55', 123, null]) { + expect(accentStylesheet(bad)).toBeUndefined() + } + }) + + it('overrides every accent token with an in-range r g b triplet in each theme', () => { + // A saturated color exercises the gamut clamp, a grey the zero-chroma path. + for (const color of ['#ff0000', '#808080']) { + const rules = accentStylesheet(color)!.split('\n') + expect(rules).toHaveLength(3) + for (const rule of rules) { + const triplets = [...rule.matchAll(/--color-[a-z-]+: (\d+) (\d+) (\d+);/g)] + expect(triplets).toHaveLength(10) + for (const [, ...channels] of triplets) { + for (const c of channels) expect(Number(c)).toBeLessThanOrEqual(255) + } + } + } + }) +}) diff --git a/frontend/src/lib/accentColor.ts b/frontend/src/lib/accentColor.ts new file mode 100644 index 0000000000..f11a96bb79 --- /dev/null +++ b/frontend/src/lib/accentColor.ts @@ -0,0 +1,138 @@ +import tokensFile from './assets/tokens/tokens.json' +import githubDarkTokens from './assets/tokens/githubDark.json' +import { darkModeName, lightModeName } from './assets/tokens/colorTokensConfig' + +/** Instance-wide accent color, `#rrggbb` (mirrors `ACCENT_COLOR_SETTING` in the backend). */ +export const ACCENT_COLOR_SETTING = 'accent_color' + +const ACCENT_TOKENS = [ + 'surface-accent-primary', + 'surface-accent-hover', + 'surface-accent-clicked', + 'surface-accent-selected', + 'surface-accent-secondary', + 'surface-accent-secondary-hover', + 'surface-accent-secondary-clicked', + 'text-accent', + 'border-accent', + 'border-selected' +] as const + +const STYLE_ID = 'wm-accent-color' +const ACCENT_ATTR = 'data-wm-accent' + +/** + * The stored setting as a color, or `undefined` for "use the default theme". + * + * The backend already rejects anything but `#rrggbb`, but the value is interpolated into a + * stylesheet, so this is the last place that can refuse a row written around the validator. + */ +export function parseAccentColor(value: unknown): string | undefined { + return typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value) + ? value.toLowerCase() + : undefined +} + +type Oklch = [l: number, c: number, h: number] + +function srgbToLinear(v: number): number { + return v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4) +} + +function linearToSrgb(v: number): number { + return v <= 0.0031308 ? v * 12.92 : 1.055 * Math.pow(v, 1 / 2.4) - 0.055 +} + +function hexToOklch(hex: string): Oklch { + const [r, g, b] = [1, 3, 5].map((i) => srgbToLinear(parseInt(hex.slice(i, i + 2), 16) / 255)) + const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b) + const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b) + const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b) + const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s + const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s + const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s + return [L, Math.hypot(A, B), Math.atan2(B, A)] +} + +/** Linear sRGB channels, possibly out of [0, 1] when the color is outside the gamut. */ +function oklchToLinearRgb([L, C, h]: Oklch): [number, number, number] { + const A = C * Math.cos(h) + const B = C * Math.sin(h) + const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3 + const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3 + const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3 + return [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s + ] +} + +/** `r g b` triplet (the format the `--color-*` variables use), chroma reduced into gamut. */ +function oklchToRgbTriplet([L, C, h]: Oklch): string { + const inGamut = (c: number) => + oklchToLinearRgb([L, c, h]).every((v) => v >= -1e-4 && v <= 1 + 1e-4) + let lo = 0 + let hi = C + if (!inGamut(C)) { + for (let i = 0; i < 20; i++) { + const mid = (lo + hi) / 2 + if (inGamut(mid)) lo = mid + else hi = mid + } + } else lo = C + return oklchToLinearRgb([L, lo, h]) + .map((v) => Math.round(Math.min(1, Math.max(0, linearToSrgb(v))) * 255)) + .join(' ') +} + +// Below this chroma the chosen color reads as grey, and the accents fade with it. +const FULL_CHROMA = 0.1 + +/** + * Move each accent token to the chosen hue while keeping its own lightness, so every + * text/background pairing keeps the contrast the default palette was designed with. + * Taking the chosen color verbatim would put white text on a pale yellow button. + */ +function recolor(tokens: Record, accent: Oklch): string { + const chromaScale = Math.min(1, accent[1] / FULL_CHROMA) + return ACCENT_TOKENS.map((name) => { + const [l, c] = hexToOklch(tokens[name]) + return `--color-${name}: ${oklchToRgbTriplet([l, c * chromaScale, accent[2]])};` + }).join('') +} + +/** The accent overrides for every theme, or `undefined` when `value` is not `#rrggbb`. */ +export function accentStylesheet(value: unknown): string | undefined { + const color = parseAccentColor(value) + if (color == undefined) return undefined + const accent = hexToOklch(color) + // `:root[attr]` outranks the `html`, `html.dark` and `html.dark.github-dark` rules + // that define the default tokens, whatever order the stylesheets load in. + return [ + `:root[${ACCENT_ATTR}]{${recolor(tokensFile.tokens[lightModeName], accent)}` + + `--sidebar-bg-light:color-mix(in oklab, ${color} 18%, var(--sidebar-bg-light-base));}`, + `:root[${ACCENT_ATTR}].dark{${recolor(tokensFile.tokens[darkModeName], accent)}` + + `--sidebar-bg-dark:color-mix(in oklab, ${color} 22%, var(--sidebar-bg-dark-base));}`, + `:root[${ACCENT_ATTR}].dark.github-dark{${recolor(githubDarkTokens, accent)}}` + ].join('\n') +} + +/** Apply the instance accent to the whole document, or restore the default with `undefined`. */ +export function applyAccentColor(value: string | undefined): void { + const root = document.documentElement + let style = document.getElementById(STYLE_ID) + const css = accentStylesheet(value) + if (css == undefined) { + style?.remove() + root.removeAttribute(ACCENT_ATTR) + return + } + if (!style) { + style = document.createElement('style') + style.id = STYLE_ID + document.head.appendChild(style) + } + style.textContent = css + root.setAttribute(ACCENT_ATTR, '') +} diff --git a/frontend/src/lib/aiStore.ts b/frontend/src/lib/aiStore.ts index 6ac98d69e8..ff02157e4a 100644 --- a/frontend/src/lib/aiStore.ts +++ b/frontend/src/lib/aiStore.ts @@ -51,6 +51,8 @@ export const copilotInfo = writable<{ aiModels: AIProviderModel[] customPrompts?: Record maxTokensPerModel?: Record + /** Context windows per `provider:model`, overriding the built-in window table. */ + contextWindowPerModel?: Record /** Negotiated rates per `provider:model`, overriding the built-in price table. */ modelPricing?: Record webSearchEnabledProviders?: Partial> @@ -67,6 +69,7 @@ export const copilotInfo = writable<{ aiModels: [], customPrompts: {}, maxTokensPerModel: {}, + contextWindowPerModel: {}, modelPricing: {}, webSearchEnabledProviders: {} }) @@ -144,6 +147,7 @@ export function setCopilotInfo(aiConfig: AIConfig) { aiModels: aiModels, customPrompts: aiConfig.custom_prompts ?? {}, maxTokensPerModel: aiConfig.max_tokens_per_model ?? {}, + contextWindowPerModel: aiConfig.context_window_per_model ?? {}, webSearchEnabledProviders, modelPricing: aiConfig.model_pricing ?? {}, freeTier: aiConfig.free_tier @@ -160,6 +164,7 @@ export function setCopilotInfo(aiConfig: AIConfig) { aiModels: [], customPrompts: {}, maxTokensPerModel: {}, + contextWindowPerModel: {}, webSearchEnabledProviders: {}, modelPricing: {}, // An exhausted free grant lands here — no providers, but the reason AI is off diff --git a/frontend/src/lib/assets/app.css b/frontend/src/lib/assets/app.css index f6dc648234..4217c95831 100644 --- a/frontend/src/lib/assets/app.css +++ b/frontend/src/lib/assets/app.css @@ -3,7 +3,10 @@ @tailwind components; @tailwind utilities; -@media (min-width: 1760px) { +/* Keyed on the screen, not the window: a viewport query rescales the whole app + whenever the window is resized or split. Mirrored in editorFontSize.svelte.ts, + TextInput.svelte's leading classes and FlowGraphPreviewButton.svelte. */ +@media (min-device-width: 1760px) { :root { font-size: 18px; } diff --git a/frontend/src/lib/components/AIAgentLogViewer.svelte b/frontend/src/lib/components/AIAgentLogViewer.svelte deleted file mode 100644 index 2549fbb29f..0000000000 --- a/frontend/src/lib/components/AIAgentLogViewer.svelte +++ /dev/null @@ -1,281 +0,0 @@ - - -{#if job} -
- {}} - mode="aiagent" - /> -
-{/if} diff --git a/frontend/src/lib/components/AIProviderPicker.svelte b/frontend/src/lib/components/AIProviderPicker.svelte index 19b1346b84..f69bb6e8f4 100644 --- a/frontend/src/lib/components/AIProviderPicker.svelte +++ b/frontend/src/lib/components/AIProviderPicker.svelte @@ -3,11 +3,13 @@ import Select from './select/Select.svelte' import { fetchAvailableModels, AI_PROVIDERS } from './copilot/lib' import type { AIProvider, ProviderConfig } from '$lib/gen' - import { workspaceStore } from '$lib/stores' import ResourcePicker from './ResourcePicker.svelte' import Toggle from './Toggle.svelte' import { saveConfig, removeConfig, isSameAsStoredConfig } from './aiProviderStorage' import AIReasoningEffortPicker from './AIReasoningEffortPicker.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { value: ProviderConfig | undefined @@ -26,7 +28,7 @@ workspace = undefined }: Props = $props() - let effectiveWorkspace = $derived(workspace ?? $workspaceStore ?? '') + let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace ?? '') let value = $derived.by(() => { if (!_uncheckedValue || typeof _uncheckedValue !== 'object') return undefined diff --git a/frontend/src/lib/components/AgentResultDisplay.svelte b/frontend/src/lib/components/AgentResultDisplay.svelte new file mode 100644 index 0000000000..ad84cc263b --- /dev/null +++ b/frontend/src/lib/components/AgentResultDisplay.svelte @@ -0,0 +1,109 @@ + + +
+ {#if trace.length > 0} + + {/if} + {#if reasoning} + + (reasoningExpanded = !reasoningExpanded)} + contentClass="font-main" + > + + + {/if} + {#if trace.length > 0 || reasoning} + + Output + + {/if} +
+ {#if textOutput !== undefined} + {#if textOutput === ''} + The agent returned no answer + {:else} + + + {/if} + {:else} + {@render structuredOutput(result.output)} + {/if} +
+ + +
+ {#if summary.toolCalls > 0} + + {summary.toolCalls} + {summary.toolCalls === 1 ? 'tool call' : 'tool calls'} + + {/if} + {#if summary.webSearches > 0} + + {summary.webSearches} + {summary.webSearches === 1 ? 'web search' : 'web searches'} + + {/if} + {#if summary.tokens !== undefined} + {formatTokenCount(summary.tokens)} tokens + {/if} + {#if summary.cachedTokens} + {formatTokenCount(summary.cachedTokens)} cached + {/if} +
+
diff --git a/frontend/src/lib/components/AgentStreamDisplay.svelte b/frontend/src/lib/components/AgentStreamDisplay.svelte new file mode 100644 index 0000000000..cf1646a614 --- /dev/null +++ b/frontend/src/lib/components/AgentStreamDisplay.svelte @@ -0,0 +1,111 @@ + + + +
+ {#each stream.entries as entry, index (entry.kind === 'tool' ? entry.callId : index)} + {#if entry.kind === 'tool'} + {}} + labelClass={entry.success === false ? 'text-red-500' : ''} + /> + {:else} +
+ +
+ {/if} + {/each} + + {#if stream.current !== ''} +
+ + +
+ {:else if stream.reasoning !== ''} + +
+ +
+ {/if} +
diff --git a/frontend/src/lib/components/AgentTrace.svelte b/frontend/src/lib/components/AgentTrace.svelte new file mode 100644 index 0000000000..7f2a90e621 --- /dev/null +++ b/frontend/src/lib/components/AgentTrace.svelte @@ -0,0 +1,155 @@ + + +
+ {#each entries as entry, index (index)} + {#if entry.kind === 'assistant'} +
+ +
+ {:else if entry.kind === 'search'} + + { + if (!collapsedSearches.delete(index)) collapsedSearches.add(index) + }} + > + {#if entry.sources} + + {/if} + + {:else} + {@const job = jobOf(entry.jobId)} + {@const failed = job?.type === 'CompletedJob' && !job.success} + {@const search = webSearchResultOf(entry.result)} + toggle(index, entry)} + contentClass="space-y-3" + > + {#if entry.args} + + {/if} + {#if job?.logs} + + {/if} + {#if search && !failed} + + {:else} + + {/if} + {#if entry.resourcePath} +
+ + {entry.resourcePath} +
+ {:else if entry.jobId} + + + Open job + + {/if} +
+ {/if} + {/each} +
diff --git a/frontend/src/lib/components/ApiConnectForm.svelte b/frontend/src/lib/components/ApiConnectForm.svelte index dedeee90e2..2255817c5e 100644 --- a/frontend/src/lib/components/ApiConnectForm.svelte +++ b/frontend/src/lib/components/ApiConnectForm.svelte @@ -1,7 +1,6 @@ @@ -20,7 +22,7 @@ Setup the wmill cli for this workspace & remote:
diff --git a/frontend/src/lib/components/ContextualVariableEditor.svelte b/frontend/src/lib/components/ContextualVariableEditor.svelte index 43277787ba..9adde76e74 100644 --- a/frontend/src/lib/components/ContextualVariableEditor.svelte +++ b/frontend/src/lib/components/ContextualVariableEditor.svelte @@ -1,7 +1,6 @@ - +
- {#if dbSelector} - {@render dbSelector()} - {/if} - {#if dbSupportsSchemas && !multiSelectMode} - e.stopPropagation()} - onchange={() => toggleSchemaSelection(schemaKey)} - /> - - {/if} - {schemaKey} - - {schemaTables.length} - - - -
- - {#each schemaTables as tableKey} - {@const isDisabled = isTableDisabled(schemaKey, tableKey)} - {@const isChecked = isTableSelected(schemaKey, tableKey) || isDisabled} - {@const isCurrentPreview = - selected.schemaKey === schemaKey && selected.tableKey === tableKey} -
{ - selectTable(schemaKey, tableKey) - toggleTableSelection(schemaKey, tableKey) - }} - onkeydown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - selectTable(schemaKey, tableKey) - toggleTableSelection(schemaKey, tableKey) - } - }} - > - - e.stopPropagation()} - onchange={() => toggleTableSelection(schemaKey, tableKey)} - /> - - -

{tableKey}

- + {#if dtOpen} + {#if root.error} +

{root.error}

+ {/if} + {#each root.schemas as sc (sc.schemaKey)} + {@const schemaOpen = isExpanded(root.datatable, sc.schemaKey)} + {@const indent = root.datatable !== undefined ? 'pl-7' : 'pl-3'} + {#if dbSupportsSchemas} -
- {/each} - - - {/each} - {:else} - - {#each filteredTableKeys as tableKey} - - - {/each} - {/if} + ]} + btnId={'db-manager-schema-actions-' + onlyAlphaNumAndUnderscore(sc.schemaKey)} + /> + {/if} + + + {/if} + + + {#if schemaOpen || !dbSupportsSchemas} + {@const tableIndent = dbSupportsSchemas + ? root.datatable !== undefined + ? 'pl-11' + : 'pl-7' + : root.datatable !== undefined + ? 'pl-7' + : 'pl-3'} + {#each sc.tables as tableKey (tableKey)} + {@const entry = { + datatable: root.datatable, + schema: sc.schemaKey, + table: tableKey + }} + {@const hasMenu = !multiSelectMode} + {@const isSelected = + root.datatable === currentDatatable && + selected.schemaKey === sc.schemaKey && + selected.tableKey === tableKey} + + {/each} + {#if canCreateTableIn(root.datatable, sc.schemaKey)} + + {/if} + {/if} + + {/each} + {#if dbSupportsSchemas && search.trim() === '' && canCreateSchemaIn(root.datatable)} + + {/if} + {/if} + {/each} - {#if !multiSelectMode} - - {/if}
- - {#if tableKey && colDefs?.[tableKey]?.length} + + {#if mainPane} + {@render mainPane()} + {:else if viewMode === 'diagram'} + { + onViewMode?.('data') + selectTable(currentDatatable, schema, table) + }} + /> + {:else if tableKey && colDefs?.[tableKey]?.length} {@const dbTableOps = dbTableOpsFactory({ colDefs: colDefs[tableKey], tableKey, whereClause })} - {#if !!$userStore?.is_admin || !!$superadmin} + {#if !!actingUser?.is_admin || !!$superadmin} {/snippet} diff --git a/frontend/src/lib/components/DBManagerContent.svelte b/frontend/src/lib/components/DBManagerContent.svelte index b0ef2859df..b88b1d1510 100644 --- a/frontend/src/lib/components/DBManagerContent.svelte +++ b/frontend/src/lib/components/DBManagerContent.svelte @@ -1,16 +1,21 @@ + + (open = false) }} +> + { + // The row underneath folds on click, and picking a role is not that. + e.stopPropagation() + open = !open + }} + > + + {role} + + + anchorEl!.getBoundingClientRect())} + onSelectValue={(item) => { + open = false + if (item.value !== role) onSelect(item.value) + }} + /> + diff --git a/frontend/src/lib/components/DdlMigrationGuard.svelte b/frontend/src/lib/components/DdlMigrationGuard.svelte index 3bda6aa216..0dab262fd0 100644 --- a/frontend/src/lib/components/DdlMigrationGuard.svelte +++ b/frontend/src/lib/components/DdlMigrationGuard.svelte @@ -6,8 +6,18 @@ import { joinSqlStatements, splitSqlRuns } from './sqlDdl' import { logDdlGuardChoice } from './workspaceSettings/datatableTelemetry' import { CornerDownLeft } from 'lucide-svelte' + import { withMigrationRole } from './datatableMigrationRole' - let { workspace, datatable }: { workspace: string; datatable: string } = $props() + let { + workspace, + datatable, + role + }: { + workspace: string + datatable: string + /** The role the editor runs as. The migration declares it, or it would run as admin. */ + role?: string + } = $props() type Choice = 'run' | 'migrate' | 'cancel' @@ -73,7 +83,7 @@ function openMigrationModal(sql: string): Promise { return new Promise((resolve) => { resolveMigrationClosed = (created: boolean) => resolve(created) - newMigrationModal?.open({ codeUp: sql }) + newMigrationModal?.open({ codeUp: withMigrationRole(sql, role) }) }) } @@ -145,6 +155,11 @@ migrations rather than run ad-hoc. Create a migration for it instead? {/if}

+ {#if role} +

+ It will run as role {role}. +

+ {/if}
{promptSql}
- import { userStore } from '$lib/stores' import { SettingsIcon } from 'lucide-svelte' import { Button } from './common' import Drawer from './common/drawer/Drawer.svelte' import DrawerContent from './common/drawer/DrawerContent.svelte' import DefaultScriptsInner from './DefaultScriptsInner.svelte' + import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte' + + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) interface Props { placement?: 'left' | 'right' @@ -17,7 +20,7 @@ let drawer: Drawer | undefined = $state() -{#if $userStore?.is_admin || $userStore?.is_super_admin} +{#if actingUser?.is_admin || actingUser?.is_super_admin} diff --git a/frontend/src/lib/components/DefaultScriptsInner.svelte b/frontend/src/lib/components/DefaultScriptsInner.svelte index eaa3d989a0..b062fb4c49 100644 --- a/frontend/src/lib/components/DefaultScriptsInner.svelte +++ b/frontend/src/lib/components/DefaultScriptsInner.svelte @@ -1,10 +1,13 @@ + + +
+
+ It was saved from another tab or session since this one read it, so your changes here are no + longer being saved. +
+
+ + +
+
+
diff --git a/frontend/src/lib/components/DropdownSubmenuItem.svelte b/frontend/src/lib/components/DropdownSubmenuItem.svelte index 60f7ad2d64..a67411a039 100644 --- a/frontend/src/lib/components/DropdownSubmenuItem.svelte +++ b/frontend/src/lib/components/DropdownSubmenuItem.svelte @@ -13,9 +13,10 @@ item: Item builders: ReturnType['builders'] meltItem: MenubarMenuElements['item'] + close?: (afterClose?: () => void) => void } - let { item, builders, meltItem }: Props = $props() + let { item, builders, meltItem, close }: Props = $props() const { elements: { subTrigger, subMenu }, @@ -23,6 +24,8 @@ } = untrack(() => builders).createSubmenu() let subItems = $derived((item.submenuItems ?? []).filter((i) => !i.hide)) + const CustomSubmenu = $derived(item.customSubmenu) + const closeMenu = $derived(close ?? (() => {})) + +{:else} +
+

+ Deploys to {target.workspace_id} on {target.base_url} run with your own account on that instance, + so its permissions, protection rules and audit logs apply. Sign in there to connect; the token + it issues is stored encrypted and only ever sent to that instance. +

+
+ + {#if waitingForRemote} + Waiting for {host}… + {/if} + +
+ {#if showPaste} +
+ + Create a token on {host} + + +
+
+ e.key == 'Enter' && connect()} + /> +
+ +
+
+ {/if} + {#if error} +

{error}

+ {/if} +
+{/if} diff --git a/frontend/src/lib/components/RemoteDeployTargetSetting.svelte b/frontend/src/lib/components/RemoteDeployTargetSetting.svelte new file mode 100644 index 0000000000..91f876ec17 --- /dev/null +++ b/frontend/src/lib/components/RemoteDeployTargetSetting.svelte @@ -0,0 +1,111 @@ + + + + {#if !$enterpriseLicense} + + Deploying to another instance from the web UI is only available with an enterprise license + + {:else} +
+ + +
+ + {#if saved} + + {/if} +
+ {#if saved} +

+ Changing the instance URL or workspace drops every token stored for the current target, so + everyone connects again. +

+ {/if} +
+ {/if} +
diff --git a/frontend/src/lib/components/ResourceDescriptionField.svelte b/frontend/src/lib/components/ResourceDescriptionField.svelte new file mode 100644 index 0000000000..2f065176dc --- /dev/null +++ b/frontend/src/lib/components/ResourceDescriptionField.svelte @@ -0,0 +1,59 @@ + + + +
+

{label} + + {#if canWrite} +

+ {#if canWrite && editing} +
+
GH Markdown
+ +
+ {:else if description == undefined || description == ''} +
No description provided
+ {:else} + + {/if} +
diff --git a/frontend/src/lib/components/ResourceEditor.svelte b/frontend/src/lib/components/ResourceEditor.svelte index bcb886ee7d..9847454aca 100644 --- a/frontend/src/lib/components/ResourceEditor.svelte +++ b/frontend/src/lib/components/ResourceEditor.svelte @@ -9,7 +9,6 @@ } from '$lib/gen' import { canWrite } from '$lib/utils' import { createEventDispatcher, onDestroy, untrack } from 'svelte' - import { workspaceStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' import { clearJsonSchemaResourceCache } from './schema/jsonSchemaResource.svelte' import ResourceForm from './ResourceForm.svelte' @@ -20,8 +19,13 @@ import { useActingUser } from '$lib/actingUser.svelte' import { UserDraft, draftValuesEqual, type UserDraftHandle } from '$lib/userDraft.svelte' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' + import { useDraftConflictSession } from '$lib/draftConflictSession.svelte' + import DraftConflictAlert from './DraftConflictAlert.svelte' import { setLocalDraftHint } from '$lib/localDraftHints.svelte' import { onUserInput } from '$lib/userDraftEditGate' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { canSave?: boolean @@ -46,6 +50,9 @@ * so it can hide the banner's Discard button in read-only mode (matches * the trigger editors' `disabled={!can_write}` wiring). */ onCanWriteChange?: (canWrite: boolean) => void + /** The drawer renders the conflict alert: inside this scrollable form it can land above the + * viewport on a long resource, leaving the fixed banner claiming the edits are saved. */ + onDraftConflictChange?: (state: { conflicted: boolean; busy: boolean }) => void } let { @@ -59,7 +66,8 @@ selected: selectedProp = $bindable(), viewJsonSchema = $bindable(), onDraftStateChange, - onCanWriteChange + onCanWriteChange, + onDraftConflictChange }: Props = $props() type ResourceState = { @@ -74,7 +82,7 @@ // Sole ambient read in this file: the acting workspace is an input, and only its // default comes from the navigation store. - let effectiveWorkspace = $derived(workspace ?? $workspaceStore!) + let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace!) // Fallback to `effectiveWorkspace` insulates against reactify-style // parents that re-spread props without `selected` — otherwise it // transiently resets and the form below remounts on every keystroke. @@ -261,6 +269,132 @@ ) const anyDirty = $derived(dirtyWorkspaces.length > 0) + /** Scoped by `selected` so switching workspace-specific versions is a new session. Ending is + * exported on top of that because the drawer, not this component, knows when its own session + * is over: this component outlives it. */ + const conflictSession = useDraftConflictSession(() => selected) + export function endEditingSession(): void { + conflictSession.end() + } + onDestroy(endEditingSession) + const resolvingConflict = $derived(conflictSession.busy) + /** The server refused this tab's autosave because the row moved under it: another tab, or the + * AI chat, which writes these drafts too. Nothing typed here reaches the server until the user + * picks a version, and the unsaved-changes banner says the opposite — that the edits are held + * as a draft — so without this they are told their work is safe while it is being dropped. */ + const draftConflict = $derived( + selected && initialPath + ? UserDraftDbSyncer.getConflict({ + workspace: selected, + itemKind: 'resource', + path: initialPath + }).conflict + : undefined + ) + + async function resolveDraftConflict(keepMine: boolean): Promise { + const ws = selected + const p = initialPath + if (!ws || !p || resolvingConflict) return + const query = { workspace: ws, itemKind: 'resource' as const, path: p } + const token = conflictSession.start() + const stillOurs = () => conflictSession.holds(token) && selected === ws + try { + if (keepMine) { + // Settle the key first: an ordinary autosave still queued would displace the forced + // write below, and being conditional it would be refused — so "Keep mine" would + // finish without keeping anything and leave the alert standing. + await UserDraftDbSyncer.quiesce(query) + // A resolution belongs to the session that started it. One that outlives its editor + // stops here rather than writing on: whatever replaced it — another session on the + // same draft, or its own resolution — owns the key now, and the edit this one was + // keeping is still parked for a later flush either way. + if (!stillOurs()) return + // A parked `null` is this tab's "no draft any more" — a discard, or an edit that + // landed back on the deployed value. Keeping that means removing the row, not + // writing the baseline back as a draft with no dirty banner to discard it through. + const parked = UserDraftDbSyncer.peekPending(query) + const mine = parked?.value === null ? null : $state.snapshot(states[ws]?.draft) + // Forced, so it goes over the row that refused us, and its response reseeds + // `last_sync` so the next ordinary save is conditional again. + if (mine !== undefined) await UserDraftDbSyncer.overwrite({ ...query, value: mine }) + // Say so rather than leave the alert up with no explanation: a write displaced by + // something typed meanwhile can still lose the race. + if (UserDraftDbSyncer.getConflict(query).conflict) { + sendUserToast('Could not keep your version — try again', true) + } + return + } + // Settle the key BEFORE reading, so what comes back is the version the server is left + // holding: a write this tab started can still be in flight — a forced one it walked + // away from included — and a response fetched past it describes a version about to be + // replaced, which would then be seeded along with its already-stale `last_sync`. + // Nothing is given up by waiting; `quiesce` only stops the pipeline. + await UserDraftDbSyncer.quiesce(query) + if (!stillOurs()) return + // Read BEFORE giving anything up: until the server has answered, the refused payload is + // still the only copy of this tab's edit, and the conflict is still true. + const r = await ResourceService.getResource({ workspace: ws, path: p, getDraft: true }) + const deployedState: ResourceState = { + path: r.path, + args: (r.value ?? {}) as Record, + description: r.description ?? '', + labels: r.labels ?? undefined, + wsSpecific: r.ws_specific ?? false + } + // Everything below writes shared editor state, so first make sure it is still this + // resource's: the drawer stays closable while the read is out, and another resource + // opened meanwhile would otherwise get this one's baseline — and with it this one's + // path as its save target. + if (!stillOurs()) return + // Again, because the form stayed editable while the read was out: `quiesce` settles what + // is running when it is called, not the key for the rest of the resolution, so a + // keystroke since can have started a save of its own. Left running, its rejection lands + // after the baseline below and raises the conflict this just resolved. + await UserDraftDbSyncer.quiesce(query) + if (!stillOurs()) return + // Now, and not in `quiesce`: the refused payload belongs to the version being replaced, + // but until this point it was still the only copy of the edit, and a resolution that + // gave up before here has to leave it behind. + UserDraftDbSyncer.dropPending(query) + UserDraftDbSyncer.clearConflict(query) + initialStates[ws] = structuredClone(deployedState) + // Everything else the load path takes from this same response. The item can have been + // deleted, recreated under another type, or had its permissions changed while the + // conflict stood, and the fields below decide create-vs-update, the schema and write + // access — so refreshing only what is displayed would leave those deciding on the + // version the user just replaced. + fetchedResources[ws] = r + existedInitially[ws] = !(r as any).no_deployed + if (ws === effectiveWorkspace) resource_type = r.resource_type + UserDraftDbSyncer.recordRemoteSync(query, (r as any).draft_saved_at) + const loadedDraft = (r as any).draft as ResourceState | undefined + // Loading a draft makes this workspace one that opened with a draft, whatever it opened + // with before — a refused deletion opens gated, and gated the settling absorber would + // fold the version just loaded into the deployed baseline, leaving it silently clean + // with Save disabled. Ungate here rather than leaving it to the effect, so no write + // between the two is absorbed. + openedOnDraft[ws] = !!loadedDraft + if (loadedDraft) { + setGated(ws, false) + } else { + // Accepting "there is no draft" has to shut the gate, the way discarding one does. + // The edit that raised the conflict set `userEdited`, and left open, the form's + // settling writes — schema defaults materializing over the loaded value — read as + // the user's and recreate the draft just accepted away. + userEdited[ws] = false + setGated(ws, true) + } + UserDraft.seed('resource', p, loadedDraft ?? deployedState, { workspace: ws }) + } catch (e) { + // Nothing was given up above, so the conflict stands and the edit is still here to + // resolve again — which is the whole point of reading first. + sendUserToast(`Could not load the other version: ${e}`, true) + } finally { + conflictSession.finish(token) + } + } + // The syncer owns the list-page `*` hint; the editor only CLEARS it when a // workspace is at the deployed baseline (so a draft discarded elsewhere // vanishes on reopen). Never SET here. See VariableEditor for the full note. @@ -336,15 +470,33 @@ labels: r.labels ?? undefined, wsSpecific: r.ws_specific ?? false } - // Open with the saved draft if present, else the deployed. - const s: ResourceState = savedDraftState ?? deployedState - openedOnDraft[ws] = !!savedDraftState + // A refused save leaves this tab's own version parked. `.draft` is the + // version that refused it, so opening on that would quietly drop the edit + // the alert is about and leave "Keep mine" offering to keep the other one. + const conflictQuery = { + workspace: ws, + itemKind: 'resource' as const, + path: initialPath + } + const refused = UserDraftDbSyncer.getConflict(conflictQuery).conflict + ? UserDraftDbSyncer.peekPending(conflictQuery) + : undefined + // A parked `null` is this tab's "no draft any more", which on screen is the + // deployed value — so only a payload with content counts as a local draft. + const refusedDraft = (refused?.value ?? undefined) as ResourceState | undefined + const hasLocalDraft = refused ? refused.value !== null : !!savedDraftState + // Open with this tab's refused version if there is one, else the saved + // draft, else the deployed. + const s: ResourceState = refused + ? (refusedDraft ?? deployedState) + : (savedDraftState ?? deployedState) + openedOnDraft[ws] = hasLocalDraft // Gate BEFORE the handle is acquired: `stopSync` queues on a // not-yet-live entry, and the form can settle before the effect // above gets a chance to run. Only worth doing when no draft exists // yet — where one does, there is no phantom to prevent and // suspending could only drop a write. - if (!savedDraftState) setGated(ws, true) + if (!hasLocalDraft) setGated(ws, true) ensureHandle(ws, s) initialStates[ws] = structuredClone(deployedState) // Draft-only paths (`no_deployed`) have no row — saving must @@ -424,6 +576,13 @@ $effect(() => { onCanWriteChange?.(can_write === true) }) + $effect(() => { + onDraftConflictChange?.({ conflicted: !!draftConflict, busy: resolvingConflict }) + }) + + export function resolveDraftConflictFromBanner(keepMine: boolean): void { + void resolveDraftConflict(keepMine) + } export function localDraftDeployed(): ResourceState | undefined { return selected ? initialStates[selected] : undefined @@ -471,6 +630,12 @@ current.path = npath } + /** The path the resource has in `ws`: after a save, the one it was saved under. Each + * workspace-specific version keeps its own, so the selected one says nothing about `ws`. */ + export function pathIn(ws: string): string | undefined { + return initialStates[ws]?.path + } + /** Whether the write landed. It toasts its own failure, so most callers ignore this; * one that follows the save with bookkeeping of its own has to know not to. */ export async function save(): Promise { @@ -531,6 +696,18 @@
+ + {#if draftConflict && !onDraftConflictChange} + void resolveDraftConflict(false)} + onOverwrite={() => void resolveDraftConflict(true)} + /> + {/if} + {#if otherDirty.length > 0} You are going to edit the value in: {otherDirty.join(', ')} diff --git a/frontend/src/lib/components/ResourceEditorDrawer.svelte b/frontend/src/lib/components/ResourceEditorDrawer.svelte index 052c8c9e92..b37ebf356f 100644 --- a/frontend/src/lib/components/ResourceEditorDrawer.svelte +++ b/frontend/src/lib/components/ResourceEditorDrawer.svelte @@ -5,10 +5,10 @@ import { History, Loader2, Save } from 'lucide-svelte' import WsSpecificVersions from './WsSpecificVersions.svelte' - import { workspaceStore } from '$lib/stores' import { isOwner } from '$lib/utils' import { useActingUser } from '$lib/actingUser.svelte' import LocalDraftBanner from './LocalDraftBanner.svelte' + import DraftConflictAlert from './DraftConflictAlert.svelte' import OpenInSessionButton from './sessions/OpenInSessionButton.svelte' import { clearPageDrawerAnchor, @@ -20,10 +20,15 @@ import IconedResourceType from './IconedResourceType.svelte' import { addResourceTitle } from './resourceTypeDisplay' import { loadResourceTypeDisplayName } from './displayNameLoaders' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() let { workspace = undefined, disableChatOffset = false, + inline = false, + onClose = undefined, onRestored = undefined, onSaved = undefined }: { @@ -31,10 +36,17 @@ * once, at `effectiveWorkspace`, and nowhere else in this file. */ workspace?: string disableChatOffset?: boolean + /** Render in place, filling the parent, with no drawer or close button — for a host + * that gives the editor a whole pane. Saving and restoring then leave it open: the + * host remounts it on what was written. */ + inline?: boolean + /** With `inline`, closes whatever hosts the editor; the header has a close button only when set. */ + onClose?: () => void onRestored?: () => void /** Fires after Save has written, for a caller showing state derived from the - * resource — `onRestored` only covers restoring an old version. */ - onSaved?: () => void + * resource — `onRestored` only covers restoring an old version. `path` is where the + * resource now lives in this drawer's workspace, undefined when the save failed. */ + onSaved?: (path: string | undefined) => void } = $props() let drawer: Drawer | undefined = $state() @@ -45,20 +57,24 @@ let resourceEditor: | { - save: () => void + save: () => Promise + pathIn: (ws: string) => string | undefined localDraftDeployed: () => unknown localDraftCurrent: () => unknown discardLocalDraft: () => void + endEditingSession: () => void + resolveDraftConflictFromBanner: (keepMine: boolean) => void } | undefined = $state(undefined) let hasLocalDraft = $state(false) + let draftConflict = $state({ conflicted: false, busy: false }) let canWriteSelected = $state(true) let path: string | undefined = $state(undefined) let selected: string | undefined = $state(undefined) let viewJsonSchema = $state(false) - let effectiveWorkspace = $derived(workspace ?? $workspaceStore!) + let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace!) // The editor renders whichever workspace-specific variant `selected` points at, so history has // to follow it too — otherwise a restore would write over the variant the user is not looking at. let historyWorkspace = $derived(selected ?? effectiveWorkspace) @@ -126,22 +142,46 @@ ) - { - if (keepAnchorOnClose) { - keepAnchorOnClose = false - return - } - clearPageDrawerAnchor(RESOURCES_PATH) - }} -> +{#if inline} + + {#if path !== undefined || resource_type !== undefined} + {@render content()} + {/if} +{:else} + { + // The editor outlives this drawer, so tell it the session is over: a conflict resolution + // still in flight must not land on whatever the next opening shows. + resourceEditor?.endEditingSession?.() + if (keepAnchorOnClose) { + keepAnchorOnClose = false + return + } + clearPageDrawerAnchor(RESOURCES_PATH) + }} + > + {@render content()} + +{/if} + +{#snippet content()} { + // Inline has no drawer to emit a close, so the session ends here instead. + if (inline) { + resourceEditor?.endEditingSession?.() + onClose?.() + } else { + drawer?.closeDrawer() + } + }} > {#snippet titleExtra()} {#if mode == 'new' && resource_type} @@ -162,10 +202,18 @@ bind:selected bind:viewJsonSchema onDraftStateChange={(v) => (hasLocalDraft = v)} + onDraftConflictChange={(v) => (draftConflict = v)} onCanWriteChange={(v) => (canWriteSelected = v)} /> {/await} {#snippet banner()} + {#if draftConflict.conflicted} + resourceEditor?.resolveDraftConflictFromBanner?.(false)} + onOverwrite={() => resourceEditor?.resolveDraftConflictFromBanner?.(true)} + /> + {/if} @@ -212,7 +260,7 @@ {/snippet} - +{/snippet} diff --git a/frontend/src/lib/components/ResourceForm.svelte b/frontend/src/lib/components/ResourceForm.svelte index df1aa5bd99..1d4f091236 100644 --- a/frontend/src/lib/components/ResourceForm.svelte +++ b/frontend/src/lib/components/ResourceForm.svelte @@ -1,4 +1,5 @@ + + diff --git a/frontend/src/lib/components/ShareModal.svelte b/frontend/src/lib/components/ShareModal.svelte index 7f3e3d2a3e..da6cc9a674 100644 --- a/frontend/src/lib/components/ShareModal.svelte +++ b/frontend/src/lib/components/ShareModal.svelte @@ -10,7 +10,7 @@ } from '$lib/gen' import type { Folder } from '$lib/gen/types.gen' import { createEventDispatcher } from 'svelte' - import { userStore, workspaceStore } from '$lib/stores' + import { Alert, Button, Drawer } from './common' import DrawerContent from './common/drawer/DrawerContent.svelte' import { sendUserToast } from '$lib/toast' @@ -22,15 +22,22 @@ import Toggle from './Toggle.svelte' import { Trash } from 'lucide-svelte' import { DEMO_RESTRICTION_HINT, isDemoWorkspaceRestricted } from '$lib/cloud' - import { getDetailWorkspace } from '$lib/components/details/detailWorkspace' + import { + useOperatingWorkspace, + useOperatingUser + } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() const dispatch = createEventDispatcher() - const detailWs = getDetailWorkspace() - let ws = $derived(detailWs?.() ?? $workspaceStore) - let restricted = $derived( - isDemoWorkspaceRestricted(ws, $userStore?.is_admin, $userStore?.is_super_admin) + isDemoWorkspaceRestricted( + $operatingWorkspace, + operatingUser.current?.is_admin, + operatingUser.current?.is_super_admin + ) ) type Kind = @@ -85,7 +92,7 @@ defaultPermsLabel = `Folder f/${folderName} permissions` try { const folder: Folder = await FolderService.getFolder({ - workspace: ws!, + workspace: $operatingWorkspace!, name: folderName }) if (path !== currentPath) return @@ -138,7 +145,7 @@ const currentPath = path try { const resource = await ResourceService.getResource({ - workspace: ws!, + workspace: $operatingWorkspace!, path: currentPath }) if (path !== currentPath) return @@ -168,25 +175,27 @@ } async function loadOwner() { - own = isOwner(path, $userStore!, ws!) + own = isOwner(path, operatingUser.current!, $operatingWorkspace!) } async function loadAcls() { - acls = Object.entries(await GranularAclService.getGranularAcls({ workspace: ws!, path, kind })) + acls = Object.entries( + await GranularAclService.getGranularAcls({ workspace: $operatingWorkspace!, path, kind }) + ) } async function loadGroups(): Promise { - groups = await GroupService.listGroupNames({ workspace: ws! }) + groups = await GroupService.listGroupNames({ workspace: $operatingWorkspace! }) } async function loadUsernames(): Promise { - usernames = await UserService.listUsernames({ workspace: ws! }) + usernames = await UserService.listUsernames({ workspace: $operatingWorkspace! }) } async function deleteAcl(owner: string) { try { await GranularAclService.removeGranularAcls({ - workspace: ws!, + workspace: $operatingWorkspace!, path, kind, requestBody: { owner } @@ -195,7 +204,7 @@ for (const varPath of linkedVarPaths) { try { await GranularAclService.removeGranularAcls({ - workspace: ws!, + workspace: $operatingWorkspace!, path: varPath, kind: 'variable', requestBody: { owner } @@ -214,7 +223,7 @@ async function addAcl(owner: string, write: boolean) { await GranularAclService.addGranularAcls({ - workspace: ws!, + workspace: $operatingWorkspace!, path, kind, requestBody: { owner, write } @@ -223,7 +232,7 @@ for (const varPath of linkedVarPaths) { try { await GranularAclService.addGranularAcls({ - workspace: ws!, + workspace: $operatingWorkspace!, path: varPath, kind: 'variable', requestBody: { owner, write } diff --git a/frontend/src/lib/components/SqlRepl.svelte b/frontend/src/lib/components/SqlRepl.svelte index e19da629bd..d58ac65898 100644 --- a/frontend/src/lib/components/SqlRepl.svelte +++ b/frontend/src/lib/components/SqlRepl.svelte @@ -3,7 +3,6 @@ import Button from './common/button/Button.svelte' import { runScriptAndPollResult } from './jobs/utils' import { writingJobOptions } from './jobs/writingJob' - import { workspaceStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' import { untrack } from 'svelte' import { getLanguageByResourceType } from './apps/components/display/dbtable/utils' @@ -14,6 +13,9 @@ import { wrapDucklakeQuery } from './ducklake' import { splitSqlStatements, pruneComments } from './sqlDdl' import DdlMigrationGuard from './DdlMigrationGuard.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() type Props = { input: DbInput @@ -39,7 +41,7 @@ workspace = undefined, tag = undefined }: Props = $props() - let ws = $derived(workspace ?? $workspaceStore) + let ws = $derived(workspace ?? $operatingWorkspace) let dbType = $derived(getDbType(input)) // A datatable REPL targets `datatable://`; surface DDL statements as @@ -103,14 +105,21 @@ // We don't always put the fix by default for row ordering concerns let transformedCode = code if (doPostgresRowToJsonFix) { - transformedCode = statements - .map((statement) => { - if (READ_OPS.some((op) => statement.trim().toUpperCase().startsWith(op))) { - return `SELECT row_to_json(__t__) FROM (${statement}) __t__` - } - return statement - }) - .join(';') + // Rebuilt from the pruned statements, which drops the leading comment block — and + // with it the `-- role ` annotation that decides which login the query runs + // as. Carry it over, or the retry connects as the data table's default role and a + // query the first attempt was denied succeeds on the second. + const leadingAnnotations = code.match(/^(?:[^\S\n]*\n|[^\S\n]*--[^\n]*\n)*/)?.[0] ?? '' + transformedCode = + leadingAnnotations + + statements + .map((statement) => { + if (READ_OPS.some((op) => statement.trim().toUpperCase().startsWith(op))) { + return `SELECT row_to_json(__t__) FROM (${statement}) __t__` + } + return statement + }) + .join(';') } const dbArg = getDatabaseArg(input) @@ -216,5 +225,10 @@ {#if datatableName && ws} - + {/if} diff --git a/frontend/src/lib/components/Star.svelte b/frontend/src/lib/components/Star.svelte index dd91f671c8..4f0f169109 100644 --- a/frontend/src/lib/components/Star.svelte +++ b/frontend/src/lib/components/Star.svelte @@ -9,9 +9,10 @@ kind: FavoriteKind summary?: string workspaceId?: string + size?: number } - let { path, kind, workspaceId, summary }: Props = $props() + let { path, kind, workspaceId, summary, size = 16 }: Props = $props() let buttonHover = $state(false) let starred = $derived(favoriteManager.isStarred(path, kind)) @@ -31,14 +32,14 @@ > {#if starred} {#if buttonHover} - + {:else} - + {/if} {:else} {/if} diff --git a/frontend/src/lib/components/SummaryPathDisplay.svelte b/frontend/src/lib/components/SummaryPathDisplay.svelte index 790ca5b0f8..22925e98e7 100644 --- a/frontend/src/lib/components/SummaryPathDisplay.svelte +++ b/frontend/src/lib/components/SummaryPathDisplay.svelte @@ -4,13 +4,21 @@ import Popover from '$lib/components/meltComponents/Popover.svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import Path from '$lib/components/Path.svelte' - import { userStore, workspaceStore } from '$lib/stores' + import { userStore } from '$lib/stores' import { sendUserToast } from '$lib/toast' import { updateItemPathAndSummary, checkFlowOnBehalfOf } from './moveRenameManager' import Label from './Label.svelte' import LabelsInput from './LabelsInput.svelte' import InheritedLabels from './InheritedLabels.svelte' import Badge from './common/badge/Badge.svelte' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) interface Props { summary?: string @@ -36,7 +44,11 @@ let editPath = $state('') let dirtyPath = $state(false) let popoverOpen = $state(false) - let own = $state(false) + let ownPath = $state(undefined) + // Derived: ownership answers about the operating workspace, whose user resolves asynchronously. + const own = $derived( + ownPath === undefined ? false : isOwner(ownPath, actingUser, $operatingWorkspace) + ) let onBehalfOfEmail = $state(undefined) let summaryInput: ReturnType | undefined = $state() let labelsDirty = $state(false) @@ -47,10 +59,10 @@ editSummary = summary ?? '' editPath = path ?? '' labelsDirty = false - own = isOwner(path ?? '', $userStore, $workspaceStore) + ownPath = path ?? '' onBehalfOfEmail = undefined - if (kind === 'flow' && $workspaceStore && path) { - checkFlowOnBehalfOf($workspaceStore, path).then((email) => { + if (kind === 'flow' && $operatingWorkspace && path) { + checkFlowOnBehalfOf($operatingWorkspace, path).then((email) => { onBehalfOfEmail = email }) } @@ -63,7 +75,7 @@ try { await updateItemPathAndSummary({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, kind, initialPath, newPath, diff --git a/frontend/src/lib/components/SupabaseConnect.svelte b/frontend/src/lib/components/SupabaseConnect.svelte index e44399ee84..0b457e2aec 100644 --- a/frontend/src/lib/components/SupabaseConnect.svelte +++ b/frontend/src/lib/components/SupabaseConnect.svelte @@ -12,10 +12,13 @@ import autosize from '$lib/autosize' import { ResourceService, VariableService } from '$lib/gen' - import { oauthStore, workspaceStore } from '$lib/stores' + import { oauthStore } from '$lib/stores' import Password from './Password.svelte' import { createEventDispatcher } from 'svelte' import HighlightTheme from './HighlightTheme.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() let drawer: Drawer | undefined = $state() let token: undefined | string = $state(undefined) @@ -78,7 +81,7 @@ async function save() { if (!path) return await VariableService.createVariable({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, requestBody: { path, value: password, @@ -89,7 +92,7 @@ }) await ResourceService.createResource({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, requestBody: { resource_type: 'postgresql', path, diff --git a/frontend/src/lib/components/TestConnection.svelte b/frontend/src/lib/components/TestConnection.svelte index 80ec59d206..105d594ce8 100644 --- a/frontend/src/lib/components/TestConnection.svelte +++ b/frontend/src/lib/components/TestConnection.svelte @@ -13,7 +13,9 @@ import Button from './common/button/Button.svelte' import Tooltip from './meltComponents/Tooltip.svelte' import { sendUserToast } from '$lib/toast' - import { workspaceStore } from '$lib/stores' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() import { tryEvery } from '$lib/utils' interface Props { @@ -268,7 +270,7 @@ export async function main(bucket: any, api_token: string) { loading = true const resourceScript = scripts[resourceType] - const workspace = workspaceOverride ?? $workspaceStore! + const workspace = workspaceOverride ?? $operatingWorkspace! const objectStorageArgs: Record | undefined = resourceType in objectStorageBody ? objectStorageBody[resourceType](args) : undefined diff --git a/frontend/src/lib/components/Toggle.svelte b/frontend/src/lib/components/Toggle.svelte index 16e1086a94..200b449ee7 100644 --- a/frontend/src/lib/components/Toggle.svelte +++ b/frontend/src/lib/components/Toggle.svelte @@ -93,6 +93,8 @@ +
+
diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index 7c935be45c..fe6ccd1f27 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -1,7 +1,6 @@ - clearPageDrawerAnchor(VARIABLES_PATH)}> +{#if inline} + {@render content()} +{:else} + { + endEditingSession() + clearPageDrawerAnchor(VARIABLES_PATH) + }} + > + {@render content()} + +{/if} + +{#snippet content()} { + // Inline has no drawer to emit a close, so the session ends here instead. + if (inline) { + endEditingSession() + onClose?.() + } else { + drawer?.closeDrawer() + } + }} > {#snippet banner()} + {#if draftConflict} + void resolveDraftConflict(false)} + onOverwrite={() => void resolveDraftConflict(true)} + /> + {/if} {/snippet} - +{/snippet} diff --git a/frontend/src/lib/components/VariableForm.svelte b/frontend/src/lib/components/VariableForm.svelte index 61945eef08..63fdbf7425 100644 --- a/frontend/src/lib/components/VariableForm.svelte +++ b/frontend/src/lib/components/VariableForm.svelte @@ -10,10 +10,13 @@ import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' import { Loader2, RotateCcw } from 'lucide-svelte' import autosize from '$lib/autosize' - import { workspaceStore, type UserExt } from '$lib/stores' + import { type UserExt } from '$lib/stores' import { isOwner } from '$lib/utils' import { isEncryptedDraftValue } from '$lib/encryptedDraft' import EncryptedDraftField from './EncryptedDraftField.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Variable { value: string @@ -56,7 +59,7 @@ actingUser }: Props = $props() - let ws = $derived(workspace ?? $workspaceStore) + let ws = $derived(workspace ?? $operatingWorkspace) // Loading the deployed secret overwrites the draft row this form shares with the AI // chat, so every path that would trigger it has to be blocked while that row stages a diff --git a/frontend/src/lib/components/WorkerTagPicker.svelte b/frontend/src/lib/components/WorkerTagPicker.svelte index ccc3266ced..dae4967e05 100644 --- a/frontend/src/lib/components/WorkerTagPicker.svelte +++ b/frontend/src/lib/components/WorkerTagPicker.svelte @@ -2,6 +2,7 @@ import { Button } from '$lib/components/common' import { ExternalLink, RotateCw, Loader2 } from 'lucide-svelte' import { workerTags, workspaceStore } from '$lib/stores' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' import AssignableTags from './AssignableTags.svelte' import { WorkerService } from '$lib/gen' import WorkerTagSelect from './WorkerTagSelect.svelte' @@ -11,8 +12,8 @@ popupPlacement?: 'bottom-end' | 'top-end' disabled?: boolean placeholder?: string - // Workspace to read tags from; defaults to $workspaceStore. A fork-scoped - // session passes its effective workspace so the picker matches the deploy target. + // Workspace to read tags from; defaults to the operating workspace (see + // `useOperatingWorkspace`). workspaceId?: string } @@ -26,8 +27,11 @@ // See WorkerTagSelect: the shared `workerTags` cache is navigation-scoped, so a // different target workspace reads/writes a local list to avoid clobbering it. - let effectiveWorkspace = $derived(workspaceId ?? $workspaceStore) - let usesLocal = $derived(workspaceId != undefined && workspaceId !== $workspaceStore) + const operatingWorkspace = useOperatingWorkspace() + let effectiveWorkspace = $derived(workspaceId ?? $operatingWorkspace) + let usesLocal = $derived( + effectiveWorkspace != undefined && effectiveWorkspace !== $workspaceStore + ) let localWorkerTags = $state(undefined) let currentTags = $derived(usesLocal ? localWorkerTags : $workerTags) diff --git a/frontend/src/lib/components/WorkerTagSelect.svelte b/frontend/src/lib/components/WorkerTagSelect.svelte index fe00f07f9d..503c8f7f66 100644 --- a/frontend/src/lib/components/WorkerTagSelect.svelte +++ b/frontend/src/lib/components/WorkerTagSelect.svelte @@ -1,5 +1,6 @@ {#snippet startSnippet({ item })} - {#if tagsToWorkerExists} + + {#if tagsToWorkerExists && !item.__is_create && item.value in tagsToWorkerExists} {#if tagsToWorkerExists[item.value]} {#snippet text()} @@ -160,7 +167,7 @@ {/if} {/if} {/snippet} -
+
{#if !noLabel}
{placeholder ?? 'tag'}
{/if} @@ -174,22 +181,30 @@ placeholder={nullTag ? nullTag : (placeholder ?? 'lang default')} items={safeSelectItems(items)} bind:value={() => tag, (value) => ((tag = value), dispatch('change', value))} + onCreateItem={(value) => ((tag = value), dispatch('change', value))} + createText="Press Enter to use this tag" {startSnippet} bottomSnippet={refreshAll} />
{#snippet refreshAll()} - +
+ + $workspace, $args[…] and $flow_expr[…] are filled in when + the job runs, and must land on an allowed tag + + +
{/snippet} diff --git a/frontend/src/lib/components/WorkflowTimeline.svelte b/frontend/src/lib/components/WorkflowTimeline.svelte index 2ea5ebba50..868f215c20 100644 --- a/frontend/src/lib/components/WorkflowTimeline.svelte +++ b/frontend/src/lib/components/WorkflowTimeline.svelte @@ -9,11 +9,19 @@ import ObjectViewer from './propertyPicker/ObjectViewer.svelte' import { CheckCircle2, XCircle } from 'lucide-svelte' import { JobService, type Job, type WorkflowStatus } from '$lib/gen' - import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores' + import { enterpriseLicense } from '$lib/stores' import { Button } from '$lib/components/common' import { Alert } from '$lib/components/common' import SchemaForm from '$lib/components/SchemaForm.svelte' import { sendUserToast } from '$lib/toast' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) interface Props { flow_status: Record @@ -97,7 +105,7 @@ } async function fetchChildJob(id: string) { - const ws = $workspaceStore + const ws = $operatingWorkspace if (!ws) return loadingJobs[id] = true try { @@ -126,7 +134,7 @@ let approvalFormArgs: Record> = $state({}) async function handleApprove(key: string, formSchema: any) { - const ws = $workspaceStore + const ws = $operatingWorkspace if (!ws || !jobId) return approvalLoading[key] = true try { @@ -147,7 +155,7 @@ let cancelLoading = $state(false) async function handleCancel() { - const ws = $workspaceStore + const ws = $operatingWorkspace if (!ws || !jobId) return cancelLoading = true try { @@ -260,7 +268,7 @@ {msToSec(v.duration_ms ?? 0)}s {/if}
- {#if canApprove && selfApprovalDisabled && $userStore?.is_admin} + {#if canApprove && selfApprovalDisabled && actingUser?.is_admin}
Self-approval is disabled but allowed because you are an admin/owner
diff --git a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte index 39724265a9..ec285ce4dd 100644 --- a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte +++ b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte @@ -5,15 +5,15 @@ the workspace-specific public API (kinds, scope = `{ kind, dir? }`, currentItem, leaf/branch icons) so callers (BreadcrumbSegment, EditorHeader) don't need to know about the generic tree model underneath. -Surfaces AI-created localStorage drafts (via `listGlobalDrafts`) as extra -items alongside the backend-loaded list, so chat-scaffolded scripts/flows/ -apps that haven't been deployed yet are still navigable. Gated on +Surfaces the session's drafts (via `listGlobalDrafts`: backend draft rows +overlaid with live editor cells) as extra items alongside the backend-loaded +list, so drafts the listing does not show yet (unsaved cells, a rename typed +in a live editor) are still navigable. Gated on `isGlobalAiEnabled()` — without sessions, the only UserDrafts present are standalone editor autosaves and surfacing those in the breadcrumb picker would be surprising. --> ', label: 'click' } + ])('renders a non-navigating href %j as plain text', (value) => { + const el = render(value) + expect(el.tagName).toBe('SPAN') + expect(el.hasAttribute('href')).toBe(false) + }) +}) diff --git a/frontend/src/lib/components/apps/components/display/table/utils.ts b/frontend/src/lib/components/apps/components/display/table/utils.ts index 015cdc54db..8b85a5894c 100644 --- a/frontend/src/lib/components/apps/components/display/table/utils.ts +++ b/frontend/src/lib/components/apps/components/display/table/utils.ts @@ -8,6 +8,7 @@ import type { TableAction } from '$lib/components/apps/editor/component' import { mount, unmount } from 'svelte' import { Button } from '$lib/components/common' import { Trash2 } from 'lucide-svelte' +import { safeHref } from '$lib/utils/safeHref' export type WindmillColumnDef = ColDef & { _isActionsColumn?: boolean @@ -110,15 +111,25 @@ export function isLinkObject(value: any): value is LinkObject { export function defaultCellRenderer(cellRendererType: string) { if (cellRendererType === 'link') { + // AG Grid assigns a string returned by a cell renderer to innerHTML, and cell values are + // run results, so the link is built as a DOM node that never parses them as markup. return (params: ICellRendererParams) => { - if (isLinkObject(params.value)) { - const value = params.value - return `${value.label}` - } else if (params.value) { - return `${params.value}` - } else { + if (!params.value) { return params.value } + const { href, label } = isLinkObject(params.value) + ? params.value + : { href: params.value, label: params.value } + const allowedHref = safeHref(String(href), window.location.href) + const el = document.createElement(allowedHref ? 'a' : 'span') + el.textContent = String(label) + if (allowedHref) { + el.setAttribute('href', allowedHref) + el.setAttribute('target', '_blank') + el.setAttribute('rel', 'noopener noreferrer') + el.className = 'underline' + } + return el } } else { return undefined diff --git a/frontend/src/lib/components/apps/components/inputs/AppQuillEditor.svelte b/frontend/src/lib/components/apps/components/inputs/AppQuillEditor.svelte index 9d1302233f..83976a3ecd 100644 --- a/frontend/src/lib/components/apps/components/inputs/AppQuillEditor.svelte +++ b/frontend/src/lib/components/apps/components/inputs/AppQuillEditor.svelte @@ -8,7 +8,7 @@ import InputValue from '../helpers/InputValue.svelte' import InitializeComponent from '../helpers/InitializeComponent.svelte' - let editor = $state() + let editor: HTMLElement | undefined = $state() let quill: any = $state() interface Props { id: string @@ -24,7 +24,7 @@ toolbarOptions = [ [{ header: 1 }, { header: 2 }, 'blockquote', 'link', 'image', 'video'], ['bold', 'italic', 'underline', 'strike'], - [{ list: 'ordered' }, { list: 'ordered' }], + [{ list: 'ordered' }, { list: 'bullet' }], [{ align: [] }], ['clean'] ] @@ -42,6 +42,7 @@ async function loadQuill() { const { default: Quill } = await import('quill') + if (!editor) return quill = new Quill(editor, { modules: { @@ -51,33 +52,73 @@ placeholder: placeholder }) - if (defaultValue) { - quill.root.innerHTML = defaultValue - } - quill.on('text-change', function (delta, oldDelta, source) { setOutput() }) + + if (defaultValue) { + setHtml(defaultValue) + } + } + + // Load HTML through Quill's clipboard converter rather than assigning `root.innerHTML`: + // Quill only keeps markup it has a blot for, and writes to the DOM bypass that mapping. + // Quill 2 renders every list as `
    `, so a `
      ` from stored content would be dropped + // outright instead of becoming a bullet list. + function setHtml(html: string | undefined) { + quill.setContents(quill.clipboard.convert({ html: html ?? '', text: '' })) } $componentControl[untrack(() => id)] = { setValue(nvalue: string) { if (quill) { - quill.root.innerHTML = nvalue + setHtml(nvalue) setOutput() } } } + // Quill 2 keeps every list in an `
        ` and marks the kind with `data-list` on each + // item (`bullet`, `ordered`, `checked`, `unchecked`), plus a `span.ql-ui` that only its + // stylesheet renders. Consumers of the output (an HTML component, stored content) render + // it outside that stylesheet, where that markup reads as a numbered list. Emit the + // standard shape instead: `
          `/`
            ` runs, checklists as `
              `, which + // is what the clipboard converter reads back on load (`matchList`). + function toStandardHtml(root: HTMLElement): string { + const clone = root.cloneNode(true) as HTMLElement + clone.querySelectorAll('span.ql-ui').forEach((s) => s.remove()) + clone.querySelectorAll('ol').forEach((ol) => { + const lists: HTMLElement[] = [] + for (const li of Array.from(ol.children)) { + const kind = li.getAttribute('data-list') + li.removeAttribute('data-list') + const tag = kind === 'ordered' ? 'ol' : 'ul' + const checked = kind === 'checked' ? 'true' : kind === 'unchecked' ? 'false' : null + const last = lists.at(-1) + if ( + last?.tagName.toLowerCase() !== tag || + last.getAttribute('data-checked') !== checked + ) { + const list = document.createElement(tag) + if (checked !== null) list.setAttribute('data-checked', checked) + lists.push(list) + } + lists.at(-1)!.appendChild(li) + } + ol.replaceWith(...lists) + }) + return clone.innerHTML + } + function setOutput() { if (quill) { - outputs?.result.set(quill.root.innerHTML) + outputs?.result.set(toStandardHtml(quill.root)) } } function handleDefault(defaultValue: string | undefined) { if (quill) { - quill.root.innerHTML = defaultValue + setHtml(defaultValue) setOutput() } } diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index 565ac5d5d3..073d6c3e7d 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -85,7 +85,8 @@ loadedFromDraft = false, othersDraftsCount = 0, onOpenOthersDrafts, - onRestore + onRestore, + onDeploy }: AppEditorProps = $props() migrateApp(untrack(() => app)) @@ -893,6 +894,7 @@ {othersDraftsCount} {onOpenOthersDrafts} {onRestore} + {onDeploy} {policy} {fromHub} bind:this={appEditorHeader} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index ab043f81a1..8a0f11c5a1 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -32,7 +32,7 @@ Zap, Globe } from 'lucide-svelte' - import { getContext, untrack } from 'svelte' + import { getContext, onDestroy, untrack } from 'svelte' import { orderedJsonStringify, type Value, replaceFalseWithUndefined } from '../../../utils' import type { App, AppEditorContext, AppViewerContext } from '../types' import { toStatic } from '../utils' @@ -110,6 +110,10 @@ // (not `on:restore` forwarding): forwarding a `createEventDispatcher` // event up through these runes-mode components silently drops it. onRestore?: (restoredApp: any) => void + // Fired after a successful deploy, which keeps this editor open: `version` is what + // the deploy wrote, for the next draft's fork base, and `head` what is deployed + // now, with its author and time. + onDeploy?: (e: { version?: number; head?: number; headBy?: string; headAt?: string }) => void } let { @@ -137,7 +141,8 @@ loadedFromDraft = false, othersDraftsCount = 0, onOpenOthersDrafts, - onRestore + onRestore, + onDeploy }: Props = $props() /** Mirror of the path the user is editing in the pen popover. Initialized @@ -329,13 +334,24 @@ } } - async function syncWithDeployed() { + /** The opening this editor claimed last. A path change remounts this editor while the + * drawer stays mounted, so its teardown hands that opening back rather than leaving + * the drawer on the app the user left. */ + let lastOpening: number | undefined = undefined + onDestroy(() => { + if (lastOpening != null) diffDrawer?.abandonOpening(lastOpening) + }) + + async function syncWithDeployed(opening?: number) { const deployedApp = await AppService.getAppByPath({ workspace: $workspaceStore!, path: $appPath!, withStarredInfo: true }) + // A superseded opening must not write these: the current one would then render + // against the older deployed value. + if (opening != null && !diffDrawer?.ownsOpening(opening)) return deployedBy = deployedApp.created_by // Strip off extra information @@ -351,7 +367,7 @@ async function updateApp(npath: string) { policy = await updatePolicy($app, policy) - await AppService.updateApp({ + const deployed = await AppService.updateApp({ workspace: $workspaceStore!, path: $appPath!, requestBody: { @@ -377,16 +393,26 @@ custom_path: customPath, labels: $state.snapshot(labels) } - const appHistory = await AppService.getAppHistoryByPath({ + const head = await AppService.getAppLatestVersion({ workspace: $workspaceStore!, path: npath }) - version = appHistory[0]?.version - // Re-pin the fork base to the just-deployed head: the editor stays open, so a + // `version` is what is deployed now, which is the deploy guard's fallback head; the + // deploy's own answer is the base, and the two differ when another landed beside it. + version = head?.version + // Re-pin the fork base to the version just written: the editor stays open, so a // follow-up deploy (or a new edit) would otherwise compare against the now- // superseded base and falsely warn. parent_version is in // DRAFT_COMPARE_IGNORED_FIELDS, so this write can't spawn a spurious draft. - if ($app) $app.parent_version = version + if ($app) $app.parent_version = deployed.version + // The route owns the pair the out-of-date prompt reads, and this editor stays open + // across the deploy, so hand both over rather than leaving it on the old ones. + onDeploy?.({ + version: deployed.version, + head: version, + headBy: head?.created_by, + headAt: head?.created_at + }) closeSaveDrawer() sendUserToast('App deployed successfully') @@ -430,6 +456,9 @@ } let onLatest = $state(true) + /** The last comparison could not read the head, so the confirmation it raises is + * caution and not an observed deploy. Cleared by the next reading comparison. */ + let headUnknown = $state(false) async function compareVersions() { // Compare the draft's pinned fork base (`$app.parent_version`) against the // current head when editing a draft, else the load-time head. Catches both a @@ -445,9 +474,14 @@ path: $appPath }) onLatest = appVersion?.version === undefined || base === appVersion?.version + headUnknown = false } catch (e) { console.error('Error comparing versions', e) - onLatest = true + // The head is what this compares against, so an unanswered read is not + // evidence of being current: confirm, as an unclaimable deploy does, and say + // that is why rather than claiming a version that was never seen. + onLatest = false + headUnknown = true } } @@ -624,23 +658,33 @@ if (!savedApp || newApp) { return } + // The fetch below is awaited, so a reopen (or a path change, which remounts + // this editor but not the drawer) while it runs must not have the older one + // land last. The drawer counts the openings for that reason. + const opening = diffDrawer?.beginOpening() + lastOpening = opening + if (opening == null) return // deployedValue should be syncronized when we open Diff - await syncWithDeployed() + await syncWithDeployed(opening) - diffDrawer?.openDrawer() - diffDrawer?.setDiff({ - mode: 'normal', - deployed: deployedValue ?? savedApp, - current: { - summary: $summary, - value: $app, - path: newEditedPath || savedApp.path, - policy, - custom_path: customPath, - labels - } - }) + if (!diffDrawer?.ownsOpening(opening)) return + diffDrawer.openDrawer(opening) + diffDrawer.setDiff( + { + mode: 'normal', + deployed: deployedValue ?? savedApp, + current: { + summary: $summary, + value: $app, + path: newEditedPath || savedApp.path, + policy, + custom_path: customPath, + labels + } + }, + opening + ) }, disabled: !savedApp || newApp }, @@ -721,6 +765,8 @@ {confirmCallback} bind:open {diffDrawer} + claimOpening={() => (lastOpening = diffDrawer?.beginOpening())} + {headUnknown} bind:deployedValue currentValue={{ summary: $summary, @@ -759,33 +805,41 @@ if (!savedApp || newApp) { return } + // The other entry point into the same drawer, so it takes an opening too. + const opening = diffDrawer?.beginOpening() + lastOpening = opening + if (opening == null) return // deployedValue should be syncronized when we open Diff - await syncWithDeployed() + await syncWithDeployed(opening) + if (!diffDrawer?.ownsOpening(opening)) return saveDrawerOpen = false - diffDrawer?.openDrawer() - diffDrawer?.setDiff({ - mode: 'normal', - deployed: deployedValue ?? savedApp, - current: { - summary: $summary, - value: $app, - path: newEditedPath || savedApp.path, - policy, - custom_path: customPath, - labels - }, - button: { - text: 'Looks good, deploy', - onClick: () => { - if (newApp) { - createApp(newEditedPath) - } else { - handleUpdateApp(newEditedPath) + diffDrawer.openDrawer(opening) + diffDrawer.setDiff( + { + mode: 'normal', + deployed: deployedValue ?? savedApp, + current: { + summary: $summary, + value: $app, + path: newEditedPath || savedApp.path, + policy, + custom_path: customPath, + labels + }, + button: { + text: 'Looks good, deploy', + onClick: () => { + if (newApp) { + createApp(newEditedPath) + } else { + handleUpdateApp(newEditedPath) + } } } - } - }) + }, + opening + ) }} >
              @@ -818,6 +872,7 @@ {setPublishState} appPath={$appPath} {onLatest} + {headUnknown} {savedApp} bind:summary={$summary} bind:customPath diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 836af68db6..4f27eb6b26 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -2,7 +2,7 @@ import { Alert } from '$lib/components/common' import Badge from '$lib/components/common/badge/Badge.svelte' import Toggle from '$lib/components/Toggle.svelte' - import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores' + import { enterpriseLicense, userStore } from '$lib/stores' import { Loader2 } from 'lucide-svelte' import Tooltip from '$lib/components/Tooltip.svelte' @@ -26,6 +26,14 @@ import { canUserBypassRuleKind, protectionRulesState } from '$lib/workspaceProtectionRules.svelte' import { FRONTEND_SDK_SCOPES } from '$lib/components/raw_apps/sdkScopes' import { logFeatureUsage } from '$lib/utils/featureUsage' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspaceStore = useOperatingWorkspace() + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) const WM_DEPLOYERS_GROUP = 'wm_deployers' @@ -35,6 +43,7 @@ appPath, customPath = $bindable(), onLatest, + headUnknown = false, savedApp, summary = $bindable(), deploymentMsg = $bindable(), @@ -54,6 +63,9 @@ appPath: string customPath: string | undefined onLatest: boolean + /** The comparison behind `onLatest` could not read the deployed head, so the warning + * is caution rather than an observed newer version. */ + headUnknown?: boolean savedApp: any summary: string deploymentMsg: string | undefined @@ -74,29 +86,27 @@ * (`/secret_of/...` 404s with no `app` row) and renders a placeholder * instead of the eternally-spinning link. */ newApp?: boolean - /** Workspace the app is deployed to — the session's acting workspace when - * embedded in a session preview, else the navigation `$workspaceStore`. - * The secret-URL / custom-path / folder / on-behalf-of lookups must target - * it, not `$workspaceStore` (which stays on the nav workspace in a session). */ + /** Workspace the app is deployed to. The secret-URL / custom-path / folder / + * on-behalf-of lookups target it; defaults to the operating workspace. */ operatingWorkspace?: string } = $props() - const opWs = $derived(operatingWorkspace ?? $workspaceStore) + const opWs = $derived(operatingWorkspace ?? $operatingWorkspaceStore) - let isDeployer = $derived($userStore?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false) + let isDeployer = $derived(actingUser?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false) // Admins always pass the backend check. For everyone else, fail closed // while the workspace protection rules are still loading so the toggle // is never briefly enabled for a user the rules will end up restricting. let rulesetsLoaded = $derived(protectionRulesState.rulesets !== undefined) let canSetAnonymous = $derived( - !!$userStore?.is_admin || - !!$userStore?.is_super_admin || + !!actingUser?.is_admin || + !!actingUser?.is_super_admin || (rulesetsLoaded && canUserBypassRuleKind('RestrictAnonymousAppDeployment', $userStore ?? undefined)) ) let canSetGuest = $derived( - !!$userStore?.is_admin || - !!$userStore?.is_super_admin || + !!actingUser?.is_admin || + !!actingUser?.is_super_admin || (rulesetsLoaded && canUserBypassRuleKind('RestrictGuestAppDeployment', $userStore ?? undefined)) ) @@ -143,7 +153,7 @@ setPublishState() } } - let canPreserve = $derived(!!$userStore?.is_admin || !!$userStore?.is_super_admin || isDeployer) + let canPreserve = $derived(!!actingUser?.is_admin || !!actingUser?.is_super_admin || isDeployer) let savedOnBehalfOfEmail = $derived(savedApp?.policy?.on_behalf_of_email) let savedOnBehalfOf = $derived(savedApp?.policy?.on_behalf_of) let onBehalfOfChoice: OnBehalfOfChoice = $state(undefined) @@ -239,8 +249,15 @@ {#if !onLatest} - - By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff. + + {headUnknown + ? "Deploying may overwrite changes made by other users. Press 'Deploy' to see diff." + : "By deploying, you may overwrite changes made by other users. Press 'Deploy' to see diff."}
              {/if} @@ -596,7 +613,7 @@ {/if}
              - {#if !($userStore?.is_admin || $userStore?.is_super_admin)} + {#if !(actingUser?.is_admin || actingUser?.is_super_admin)} Custom path can only be set by workspace admins @@ -617,7 +634,7 @@ options={{ right: 'Use a custom URL' }} - disabled={!$enterpriseLicense || !($userStore?.is_admin || $userStore?.is_super_admin)} + disabled={!$enterpriseLicense || !(actingUser?.is_admin || actingUser?.is_super_admin)} /> {#if customPath !== undefined} @@ -625,7 +642,7 @@
              Custom path
              @@ -192,7 +194,7 @@
              @@ -219,7 +221,7 @@ {#if job != undefined && 'result' in job && job?.result != undefined}
              diff --git a/frontend/src/lib/components/apps/editor/DeploymentHistory.svelte b/frontend/src/lib/components/apps/editor/DeploymentHistory.svelte index 614d1f4fa4..63bc17a8eb 100644 --- a/frontend/src/lib/components/apps/editor/DeploymentHistory.svelte +++ b/frontend/src/lib/components/apps/editor/DeploymentHistory.svelte @@ -14,12 +14,14 @@ } from '$lib/utils' import { orderedYamlStringify } from '$lib/utils/orderedYaml' import { AppService, type AppWithLastVersion, type AppHistory } from '$lib/gen' - import { workspaceStore } from '$lib/stores' import { Skeleton } from '$lib/components/common' import Button from '$lib/components/common/button/Button.svelte' import { createEventDispatcher, untrack } from 'svelte' import { Pencil, ArrowRight, X, Loader2 } from 'lucide-svelte' import Select from '$lib/components/select/Select.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { appPath: string | undefined @@ -48,7 +50,7 @@ return cached } - const app = await AppService.getAppByVersion({ workspace: $workspaceStore!, id: version }) + const app = await AppService.getAppByVersion({ workspace: $operatingWorkspace!, id: version }) versionCache[version] = app return app } @@ -60,7 +62,7 @@ loading = true versions = await AppService.getAppHistoryByPath({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, path: appPath }) loading = false @@ -90,7 +92,7 @@ return } await AppService.updateAppHistory({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, id: appId, version: appVersion, requestBody: { @@ -107,7 +109,9 @@ } function toVersionLabel(version: AppHistory): string { - return emptyString(version.deployment_msg) ? `Version ${version.version}` : version.deployment_msg! + return emptyString(version.deployment_msg) + ? `Version ${version.version}` + : version.deployment_msg! } let availableVersions = $derived( diff --git a/frontend/src/lib/components/apps/editor/appDeploy.svelte.ts b/frontend/src/lib/components/apps/editor/appDeploy.svelte.ts index 122c42bcf9..f799cb864a 100644 --- a/frontend/src/lib/components/apps/editor/appDeploy.svelte.ts +++ b/frontend/src/lib/components/apps/editor/appDeploy.svelte.ts @@ -1,7 +1,7 @@ -import { base } from "$lib/base" -import { workspaceStore } from "$lib/stores" -import { get } from "svelte/store" +import { base } from '$lib/base' +import { workspaceStore } from '$lib/stores' +import { get } from 'svelte/store' export function computeSecretUrl(secretUrl: string) { - return `${window.location.origin}${base}/public/${get(workspaceStore)}/${secretUrl}` + return `${window.location.origin}${base}/public/${get(workspaceStore)}/${secretUrl}` } diff --git a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte index 3089809f12..293d83b4ae 100644 --- a/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte +++ b/frontend/src/lib/components/apps/editor/inlineScriptsPanel/InlineScriptRunnableByPath.svelte @@ -19,7 +19,6 @@ CtxAppInput } from '../../inputType' import type { AppViewerContext } from '../../types' - import { workspaceStore } from '$lib/stores' import { createEventDispatcher } from 'svelte' import { deepEqual } from 'fast-equals' import { computeFields } from './utils' @@ -35,6 +34,9 @@ import FlowEditorDrawer from '$lib/components/flows/content/FlowEditorDrawer.svelte' import { FlowService, ScriptService, type OpenFlow } from '$lib/gen' import { replaceScriptPlaceholderWithItsValues } from '$lib/hub' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { runnable: RunnableByPath @@ -131,7 +133,7 @@ return } - const loaded = await loadSchema($workspaceStore ?? '', runnable.path, 'flow') + const loaded = await loadSchema($operatingWorkspace ?? '', runnable.path, 'flow') const schema = loaded?.schema ?? emptySchema() if (!deepEqual(runnable.schema, schema)) { runnable.schema = schema @@ -167,7 +169,7 @@ async function openScriptEditor(path: string) { try { const script = await ScriptService.getScriptByPath({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, path }) scriptEditorDrawer?.openDrawer(script.hash, () => { @@ -344,7 +346,7 @@ startIcon={{ icon: Eye }} endIcon={{ icon: ExternalLink }} target="_blank" - href="{base}/flows/get/{runnable.path}?workspace={$workspaceStore}" + href="{base}/flows/get/{runnable.path}?workspace={$operatingWorkspace}" > Details @@ -425,7 +427,7 @@ {#if runnable.runType == 'flow' && isHubFlowPath(runnable.path)} Hub flow not found at {runnable.path} {:else} - {runnable.runType} not found at {runnable.path} in workspace {$workspaceStore} + {runnable.runType} not found at {runnable.path} in workspace {$operatingWorkspace} {/if}
              {:else if runnable.runType == 'script' || runnable.runType == 'hubscript'} diff --git a/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/RunnableSelector.svelte b/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/RunnableSelector.svelte index 8543fc0683..08b8ac6219 100644 --- a/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/RunnableSelector.svelte +++ b/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/RunnableSelector.svelte @@ -11,15 +11,12 @@ import type { Schema } from '$lib/common' import { emptySchema } from '$lib/utils' import { loadSchema } from '$lib/infer' - import { workspaceStore } from '$lib/stores' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() import { buildPathRunnableSelection } from './runnableSelectorUtils' - type TabType = - | 'hubscripts' - | 'hubflows' - | 'workspacescripts' - | 'workspaceflows' - | 'inlinescripts' + type TabType = 'hubscripts' | 'hubflows' | 'workspacescripts' | 'workspaceflows' | 'inlinescripts' interface Props { defaultUserInput?: boolean @@ -60,7 +57,7 @@ path: string, runType: 'script' | 'flow' | 'hubscript' ): Promise<{ schema: Schema; summary: string | undefined }> { - const schema = await loadSchema($workspaceStore!, path, runType) + const schema = await loadSchema($operatingWorkspace!, path, runType) if (!schema.schema.order) { schema.schema.order = Object.keys(schema.schema.properties ?? {}) } diff --git a/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/WorkspaceFlowList.svelte b/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/WorkspaceFlowList.svelte index b2cc91fc01..7831e83458 100644 --- a/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/WorkspaceFlowList.svelte +++ b/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/WorkspaceFlowList.svelte @@ -7,9 +7,11 @@ import NoItemFound from '$lib/components/home/NoItemFound.svelte' import RowIcon from '$lib/components/common/table/RowIcon.svelte' import { FlowService, type Flow } from '$lib/gen' - import { workspaceStore } from '$lib/stores' import { emptyString } from '$lib/utils' import { Skeleton } from '$lib/components/common' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { filter?: string @@ -26,7 +28,7 @@ async function loadFlow(): Promise { const loadedFlows = await FlowService.listFlows({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, perPage: 300, withoutDescription: true }) diff --git a/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/WorkspaceScriptList.svelte b/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/WorkspaceScriptList.svelte index be261ca5e8..13447c4787 100644 --- a/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/WorkspaceScriptList.svelte +++ b/frontend/src/lib/components/apps/editor/settingsPanel/mainInput/WorkspaceScriptList.svelte @@ -7,9 +7,11 @@ import NoItemFound from '$lib/components/home/NoItemFound.svelte' import RowIcon from '$lib/components/common/table/RowIcon.svelte' import { type Script, ScriptService } from '$lib/gen' - import { workspaceStore } from '$lib/stores' import { emptyString } from '$lib/utils' import { Skeleton } from '$lib/components/common' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { filter?: string @@ -26,7 +28,7 @@ async function loadScripts(): Promise { const loadedScripts = await ScriptService.listScripts({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, perPage: 300, withoutDescription: true }) diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 960e273ac3..75de2d7faf 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -183,6 +183,11 @@ export interface AppEditorProps { // AppEditorHeader as a callback prop rather than `on:restore` forwarding, // which does not propagate through these runes-mode components. onRestore?: (restoredApp: any) => void + // Fired after a successful deploy, which keeps this editor open: `version` is what + // the deploy wrote, for the next draft's fork base, and `head` what is deployed now, + // with its author and time. `version` and `head` differ when another deploy landed + // beside this one, which is when the out-of-date prompt has something to say. + onDeploy?: (e: { version?: number; head?: number; headBy?: string; headAt?: string }) => void } export type App = { diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte index 4b252b9338..6050c2d1cd 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetGraphDetailsPane.svelte @@ -48,7 +48,10 @@ import AssetRunsPanel from './AssetRunsPanel.svelte' import { Pane, Splitpanes } from 'svelte-splitpanes' import { fade } from 'svelte/transition' - import { userStore } from '$lib/stores' + import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte' + + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) interface Props { // Regular selection — loads the script by path for inline editing. @@ -662,7 +665,7 @@ // hand-rolled here because we need *two* confirm buttons, not one. let removeOpen = $state(false) let removing = $state(false) - let canHardDelete = $derived(!!($userStore?.is_admin || $userStore?.is_super_admin)) + let canHardDelete = $derived(!!(actingUser?.is_admin || actingUser?.is_super_admin)) // React to the parent's remove-signal counter and pop the same modal // the in-pane trash button uses. Skipped for drafts (the parent calls diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte index 815db36b18..e719bfe2bd 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetNode.svelte @@ -19,12 +19,15 @@ XCircle } from 'lucide-svelte' import type { ScriptLang } from '$lib/gen' - import { enterpriseLicense, workspaceStore } from '$lib/stores' + import { enterpriseLicense } from '$lib/stores' import { sendUserToast } from '$lib/utils' import { PIPELINE_LANGUAGES } from './pipelineLanguages' import type { PipelineOutputKind } from './pipelineTemplates' import type { DbtAssetProvenance } from './types' import DbtIcon from '$lib/components/icons/DbtIcon.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() // Shape used for both the data prop and the run callback. Drafts carry // `content` / `language` so the page-level run handler can dispatch to @@ -118,7 +121,7 @@ async function runProducers(e: MouseEvent) { e.stopPropagation() - if (!$workspaceStore || running || !data.onRunProducer) return + if (!$operatingWorkspace || running || !data.onRunProducer) return if (scriptProducers.length === 0) return running = true const handler = data.onRunProducer @@ -170,14 +173,14 @@ } const cols = Object.entries(d.columns ?? {}) if (cols.length) { - lines.push( - `columns: ${cols.map(([c, desc]) => (desc ? `${c} (${desc})` : c)).join(', ')}` - ) + lines.push(`columns: ${cols.map(([c, desc]) => (desc ? `${c} (${desc})` : c)).join(', ')}`) } if (d.freshness) { const f = d.freshness as Record const window = (k: string) => - f[k]?.count != null ? `${k.replace('_after', '')} after ${f[k].count}${f[k].period?.[0] ?? ''}` : '' + f[k]?.count != null + ? `${k.replace('_after', '')} after ${f[k].count}${f[k].period?.[0] ?? ''}` + : '' const windows = ['warn_after', 'error_after'].map(window).filter(Boolean) if (windows.length) lines.push(`freshness: ${windows.join(', ')}`) } diff --git a/frontend/src/lib/components/assets/AssetGraph/AssetRunsPanel.svelte b/frontend/src/lib/components/assets/AssetGraph/AssetRunsPanel.svelte index f43259b561..7cee1deb76 100644 --- a/frontend/src/lib/components/assets/AssetGraph/AssetRunsPanel.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/AssetRunsPanel.svelte @@ -8,7 +8,6 @@ // seconds so the user sees status transitions without refreshing — // JobLoader handles streaming for the *selected* job. import { JobService, type Job } from '$lib/gen' - import { workspaceStore } from '$lib/stores' import { onDestroy, untrack } from 'svelte' import { displayDate } from '$lib/utils' import { CheckCircle2, Clock, History, Loader2, XCircle, Ban } from 'lucide-svelte' @@ -21,6 +20,9 @@ import { Popover } from '$lib/components/meltComponents' import { twMerge } from 'tailwind-merge' import DispatchEventsButton from '$lib/components/runs/DispatchEventsButton.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { // Producers of this asset. Each contributes its own listExtendedJobs @@ -88,7 +90,7 @@ // Run only when the *content* of the producer set changes (string // key) or when the parent explicitly bumps `refreshKey` after // dispatching a new run. The producerKey debounce alone isn't - // enough: refresh() synchronously reads $workspaceStore and + // enough: refresh() synchronously reads $operatingWorkspace and // runnableProducers before the first await, and Svelte 5 records // those as deps of the surrounding $effect — which would then // re-fire whenever the parent re-derives `producers` (i.e. on @@ -174,11 +176,11 @@ async function refresh(): Promise { if (refreshInFlight) return refreshInFlight - if (!$workspaceStore || runnableProducers.length === 0) { + if (!$operatingWorkspace || runnableProducers.length === 0) { jobs = [] return } - const ws = $workspaceStore + const ws = $operatingWorkspace // Capture the producer paths *now* — using runnableProducers // directly inside the await would re-read after the array // identity churned, defeating the in-flight guard. @@ -286,12 +288,12 @@ Open ↗ - {#if $workspaceStore} + {#if $operatingWorkspace} {/if} @@ -397,7 +399,7 @@
              diff --git a/frontend/src/lib/components/assets/AssetGraph/DataTablePreview.svelte b/frontend/src/lib/components/assets/AssetGraph/DataTablePreview.svelte index 1b86fa6853..5f5dc98b60 100644 --- a/frontend/src/lib/components/assets/AssetGraph/DataTablePreview.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/DataTablePreview.svelte @@ -9,7 +9,6 @@ // just be noise. import DBTable from '$lib/components/DBTable.svelte' import { resource } from 'runed' - import { workspaceStore } from '$lib/stores' import { loadAllTablesMetaData } from '$lib/components/apps/components/display/dbtable/metadata' import { dbTableOpsWithPreviewScripts } from '$lib/components/dbOps' import { WorkspaceService } from '$lib/gen' @@ -19,6 +18,9 @@ import Button from '$lib/components/common/button/Button.svelte' import { base } from '$lib/base' import { twMerge } from 'tailwind-merge' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { // Asset path as parsed from `datatable:///
` — @@ -71,7 +73,7 @@ // query, and the user needs to configure it before pipeline scripts // can write to it. let datatables = resource( - () => $workspaceStore, + () => $operatingWorkspace, async (ws) => { if (!ws) return [] as string[] try { @@ -117,9 +119,9 @@ () => [input, refreshKey], async ([_input]) => { colDefsError = undefined - if (!_input || !$workspaceStore) return undefined + if (!_input || !$operatingWorkspace) return undefined try { - return await loadAllTablesMetaData($workspaceStore, _input) + return await loadAllTablesMetaData($operatingWorkspace, _input) } catch (e) { colDefsError = (e as Error)?.message || String(e) return undefined @@ -138,12 +140,12 @@ }) let dbTableOps = $derived( - input && tableColDefs && parsed.table && $workspaceStore + input && tableColDefs && parsed.table && $operatingWorkspace ? dbTableOpsWithPreviewScripts({ input, tableKey: parsed.table, colDefs: tableColDefs, - workspace: $workspaceStore + workspace: $operatingWorkspace }) : undefined ) diff --git a/frontend/src/lib/components/assets/AssetGraph/DucklakeResultPreview.svelte b/frontend/src/lib/components/assets/AssetGraph/DucklakeResultPreview.svelte index f21ca486af..d2d3c03b8a 100644 --- a/frontend/src/lib/components/assets/AssetGraph/DucklakeResultPreview.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/DucklakeResultPreview.svelte @@ -9,7 +9,6 @@ // ("This partition") or show the full table ("Whole table") via a toggle. import DBTable from '$lib/components/DBTable.svelte' import { resource } from 'runed' - import { workspaceStore } from '$lib/stores' import { loadAllTablesMetaData } from '$lib/components/apps/components/display/dbtable/metadata' import { dbTableOpsWithPreviewScripts } from '$lib/components/dbOps' import type { DbInput } from '$lib/components/dbTypes' @@ -18,6 +17,9 @@ import { twMerge } from 'tailwind-merge' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { // Full asset URI, e.g. `ducklake://main/orders_daily`. @@ -61,9 +63,9 @@ () => [input, refreshKey] as const, async ([_input]) => { colDefsError = undefined - if (!_input || !$workspaceStore) return undefined + if (!_input || !$operatingWorkspace) return undefined try { - return await loadAllTablesMetaData($workspaceStore, _input) + return await loadAllTablesMetaData($operatingWorkspace, _input) } catch (e) { colDefsError = (e as Error)?.message || String(e) return undefined @@ -89,12 +91,12 @@ }) let dbTableOps = $derived.by(() => { - if (!(input && tableColDefs && tableKey && $workspaceStore)) return undefined + if (!(input && tableColDefs && tableKey && $operatingWorkspace)) return undefined const ops = dbTableOpsWithPreviewScripts({ input, tableKey, colDefs: tableColDefs, - workspace: $workspaceStore, + workspace: $operatingWorkspace, whereClause }) // Read-only preview: drop the mutation handlers so DBTable hides its diff --git a/frontend/src/lib/components/assets/AssetGraph/DucklakeVersionPreview.svelte b/frontend/src/lib/components/assets/AssetGraph/DucklakeVersionPreview.svelte index 5afb51fe66..35eff3beae 100644 --- a/frontend/src/lib/components/assets/AssetGraph/DucklakeVersionPreview.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/DucklakeVersionPreview.svelte @@ -7,7 +7,6 @@ // hand is shown above the grid so the affordance is self-documenting. import DBTable from '$lib/components/DBTable.svelte' import { resource } from 'runed' - import { workspaceStore } from '$lib/stores' import { fetchDucklakeColumnsAtVersion, dbTableOpsWithPreviewScripts @@ -17,6 +16,9 @@ import { AlertTriangle, Loader2, ClipboardCopy } from 'lucide-svelte' import { Button } from '$lib/components/common' import { twMerge } from 'tailwind-merge' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { // Full asset URI, e.g. `ducklake://main/orders_daily`. @@ -56,9 +58,10 @@ let columns = resource( () => [ducklake, tableKey, version] as const, async ([_ducklake, _tableKey, _version]) => { - if (!_ducklake || !_tableKey || _version == undefined || !$workspaceStore) return undefined + if (!_ducklake || !_tableKey || _version == undefined || !$operatingWorkspace) + return undefined const colDefs = await fetchDucklakeColumnsAtVersion({ - workspace: $workspaceStore, + workspace: $operatingWorkspace, ducklake: _ducklake, tableKey: _tableKey, version: _version @@ -73,13 +76,13 @@ let tableColDefs = $derived(ready ? columns.current!.colDefs : undefined) let dbTableOps = $derived.by(() => { - if (!(input && tableColDefs && tableKey && $workspaceStore && version != undefined)) + if (!(input && tableColDefs && tableKey && $operatingWorkspace && version != undefined)) return undefined const ops = dbTableOpsWithPreviewScripts({ input, tableKey, colDefs: tableColDefs, - workspace: $workspaceStore, + workspace: $operatingWorkspace, version }) // Historical reads are immutable: drop every mutation handler so DBTable diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineScriptView.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineScriptView.svelte index 79a1cf6d47..2450595b6d 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PipelineScriptView.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineScriptView.svelte @@ -6,7 +6,9 @@ import PipelineRunForm from './PipelineRunForm.svelte' import AssetRunsPanel from './AssetRunsPanel.svelte' import { Pane, Splitpanes } from 'svelte-splitpanes' - import { workspaceStore } from '$lib/stores' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() import { parsePipelineAnnotations } from './parsePipelineAnnotations' interface Props { @@ -201,7 +203,7 @@ bind:args bind:isValid {partitionSpec} - workspace={$workspaceStore ?? ''} + workspace={$operatingWorkspace ?? ''} {materializeTarget} {upstreamAssets} /> diff --git a/frontend/src/lib/components/assets/AssetGraph/PipelineTriggerEditors.svelte b/frontend/src/lib/components/assets/AssetGraph/PipelineTriggerEditors.svelte index e364b29883..4f97039a1c 100644 --- a/frontend/src/lib/components/assets/AssetGraph/PipelineTriggerEditors.svelte +++ b/frontend/src/lib/components/assets/AssetGraph/PipelineTriggerEditors.svelte @@ -1,5 +1,4 @@ { open = false @@ -33,7 +44,11 @@ on:confirmed={() => confirmCallback()} >
- A new version was deployed while you were editing this one. + + {headUnknown + ? 'This editor could not check whether a newer version is deployed, so it cannot tell whether this overwrites newer work.' + : 'A new version was deployed while you were editing this one.'} + {#if diffDrawer} diff --git a/frontend/src/lib/components/common/confirmationModal/DraftChangesConfirmationModal.svelte b/frontend/src/lib/components/common/confirmationModal/DraftChangesConfirmationModal.svelte index dadf814a80..5a51b4cfba 100644 --- a/frontend/src/lib/components/common/confirmationModal/DraftChangesConfirmationModal.svelte +++ b/frontend/src/lib/components/common/confirmationModal/DraftChangesConfirmationModal.svelte @@ -9,9 +9,12 @@ import { Bot, Star } from 'lucide-svelte' import ToggleButtonGroup from '../toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '../toggleButton-v2/ToggleButton.svelte' - import { userStore } from '$lib/stores' import Badge from '../badge/Badge.svelte' import type { LinkedAgentDraft } from '$lib/components/flows/linkedAgentDrafts' + import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte' + + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) interface Props { open?: boolean @@ -80,8 +83,8 @@ // Creating http trigger is forbidden for non-admin users const adminOnly = trigger.type === 'http' && - !$userStore?.is_admin && - !$userStore?.is_super_admin && + !actingUser?.is_admin && + !actingUser?.is_super_admin && trigger.isDraft const invalidConfig = !trigger.draftConfig?.canSave diff --git a/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte b/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte index 26366325b5..20c29286b5 100644 --- a/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte +++ b/frontend/src/lib/components/common/confirmationModal/DraftEditorModals.svelte @@ -13,6 +13,10 @@ import type { UserDraftItemKind } from '$lib/gen' import DraftSyncConflictModal from './DraftSyncConflictModal.svelte' import OtherUsersDraftsModal, { type OtherDraftUser } from './OtherUsersDraftsModal.svelte' + import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' + import { sendUserToast } from '$lib/toast' + import { base } from '$app/paths' + import { goto } from '$app/navigation' import StaleDraftModal from './StaleDraftModal.svelte' import ConfirmationModal from './ConfirmationModal.svelte' import { OtherUserDraftLoad } from '$lib/components/otherUserDraftLoad.svelte' @@ -38,15 +42,27 @@ draftSavedAt?: string | undefined /** ISO timestamp of the latest deploy at this path. */ deployedAt?: string | undefined - /** Precise staleness inputs (flows/apps): the deployed version the draft was - * forked from, and the current deployed head. When both are set they drive - * `isStale` and the dedup key instead of the timestamps — exact, and stable - * across autosaves (the timestamp drifts past `deployedAt` as you keep - * editing). Absent (pre-feature drafts, scripts) ⇒ timestamp fallback. */ - draftBaseVersion?: number | undefined - deployedHeadVersion?: number | undefined + /** Precise staleness inputs: the deployed version the draft forked from + * (`draft_base` on the get-by-path response) and the current deployed head, + * both as text whatever the kind. When both are set they drive `isStale` and + * the dedup key instead of the timestamps, which drift past `deployedAt` as + * you keep editing. Absent (a draft never forked from a deploy) ⇒ timestamp + * fallback. */ + draftBaseVersion?: string | undefined + deployedHeadVersion?: string | undefined + /** Who deployed the head, named in the stale prompt. */ + deployedBy?: string | undefined /** Discard the draft and reload deployed (same as "Reset to deployed"). */ onLoadLatestDeploy?: () => void | Promise + /** Opens the editor's Deployed↔Current diff from the stale prompt, so the + * choice between keeping and discarding is informed. Omit where the editor + * has no diff drawer; the action is then not rendered. */ + onViewDiff?: () => void | Promise + /** Runs before this editor follows its draft to the item's new path: the + * editor's own draft save, which materializes text the code editor still + * holds. Without it, keystrokes typed since the relocating save are lost + * to the navigation. */ + onBeforeRelocate?: () => void | Promise /** Defaults to true; set to false to suppress all modals. */ enabled?: boolean } @@ -65,7 +81,10 @@ deployedAt = undefined, draftBaseVersion = undefined, deployedHeadVersion = undefined, + deployedBy = undefined, onLoadLatestDeploy, + onViewDiff, + onBeforeRelocate, enabled = true }: Props = $props() @@ -74,9 +93,9 @@ let staleAlertKey = $state(undefined) let staleModalOpen = $state(false) - // Prefer the exact version comparison (flows/apps) over the timestamp: the - // draft's pinned fork base never drifts, whereas `draftSavedAt` advances past - // `deployedAt` once you keep editing a stale draft, hiding the staleness. + // Prefer the version comparison over the timestamp for every kind that supplies + // one: `draftSavedAt` advances past `deployedAt` as you keep editing, hiding the + // staleness outright. const useVersion = $derived(draftBaseVersion != null && deployedHeadVersion != null) const isStale = $derived( !!onLoadLatestDeploy && @@ -105,6 +124,48 @@ } }) }) + + const EDITOR_SEGMENT: Partial> = { + script: 'scripts/edit', + flow: 'flows/edit', + app: 'apps/edit', + raw_app: 'apps_raw/edit' + } + + // The item was moved while this editor was open: the draft row followed it + // and the save just landed there. Follow it too — the route reloads the item + // at its new path, and the stale prompt above then says what changed. Edits + // typed since that save are flushed first, so leaving this path drops none. + $effect(() => { + if (!enabled || !workspace || !path) return + const seg = EDITOR_SEGMENT[itemKind] + if (!seg) return + const query = { workspace, itemKind, path } + // The flush below saves again and can land here a second time, and a second move + // can land while it runs: the last destination reported is the one to follow. + let relocating = false + let destination: string | undefined = undefined + return UserDraftDbSyncer.onRelocated(query, async (newPath) => { + destination = newPath + if (relocating) return + relocating = true + await onBeforeRelocate?.() + await UserDraftDbSyncer.flush(query) + // `flush` resolves on a failed or rejected save as well, and leaving the + // route drops what it was carrying: stay, so the editor keeps the edits + // and its own failure indicator. + if ( + UserDraftDbSyncer.getState(query).failureMessage || + UserDraftDbSyncer.getConflict(query).conflict + ) { + relocating = false + return + } + const target = destination ?? newPath + sendUserToast(`This item was moved to ${target}. You are now editing it there.`) + await goto(`${base}/${seg}/${target}`) + }) + }) {#if enabled && workspace && path} @@ -130,9 +191,14 @@ {#if onLoadLatestDeploy} {/if} /** - * Modal opened on editor mount when the authed user's per-user draft - * is older than the latest deployed version at the same path — i.e. - * someone else deployed a new version while the draft was sitting - * around. The user is asked to either pick up the latest deploy - * (discards the stale draft) or keep editing what they had. - * - * The parent threads `draftSavedAt` and `deployedAt` raw and the - * modal computes staleness internally; this keeps each route from - * re-implementing the comparison and the threshold (we treat a - * draft as stale only when it's strictly older — a deploy at the - * exact same instant is treated as not stale). + * The prompt for a draft that is behind: someone deployed a newer version + * of the item after the draft forked from it. Opened on every load while + * that holds (the parent computes it; see DraftEditorModals), it names the + * two versions and offers two ways out: look at the diff, or drop the draft + * for the latest deploy. Closing it is "keep editing". Taking the latest as + * the new base while keeping the edits lives in the diff drawer, where the + * user can see what they are taking. * * Open-state is bindable so the parent can dismiss programmatically * (e.g. after the load-latest-deploy callback completes). */ import Modal2 from '$lib/components/common/modal/Modal2.svelte' import Button from '$lib/components/common/button/Button.svelte' - import { AlertTriangle } from 'lucide-svelte' + import { AlertTriangle, GitCompare } from 'lucide-svelte' import { sendUserToast } from '$lib/toast' + import type { UserDraftItemKind } from '$lib/gen' type Props = { isOpen: boolean - /** ISO timestamp the authed user's draft was saved. */ + itemKind: UserDraftItemKind + /** ISO timestamp the authed user's draft was saved. Shown only when the + * versions below are unknown (a draft that predates the base). */ draftSavedAt: string | undefined /** ISO timestamp the latest deploy at this path landed. */ deployedAt: string | undefined + /** The version the draft forked from and the deployed head, as text. */ + draftBaseVersion?: string | undefined + deployedHeadVersion?: string | undefined + /** Who deployed the head. */ + deployedBy?: string | undefined /** Discards the draft and reloads the deployed payload — the route * already has this callback for the AutosaveIndicator's "Reset to * deployed" button; pass the same function in. */ onLoadLatestDeploy: () => void | Promise + /** Opens the editor's own Deployed↔Current diff. Without it the user is + * asked to choose between keeping and discarding their draft with no way + * to see what actually differs — and after a rename the difference is + * often only the path. Omitted where the editor has no diff drawer. */ + onViewDiff?: () => void | Promise } - let { isOpen = $bindable(), draftSavedAt, deployedAt, onLoadLatestDeploy }: Props = $props() + let { + isOpen = $bindable(), + itemKind, + draftSavedAt, + deployedAt, + draftBaseVersion = undefined, + deployedHeadVersion = undefined, + deployedBy = undefined, + onLoadLatestDeploy, + onViewDiff + }: Props = $props() let loading = $state(false) + // Scripts are versioned by hash, the other kinds by a numeric version id; + // the diff picker renders them the same way. + function formatVersion(v: string): string { + return itemKind === 'script' ? v.slice(0, 8) : v + } + async function loadLatestDeploy() { if (loading) return loading = true @@ -49,6 +74,13 @@ } } + // Dismisses on the way out: the diff drawer opens behind this modal, so + // leaving it up would cover the thing the user asked to see. + async function viewDiff() { + isOpen = false + await onViewDiff?.() + } + function formatTs(ts: string | undefined): string { if (!ts) return '' try { @@ -64,22 +96,38 @@
-

- A newer version was deployed after you started editing. Your draft is based on the older - deploy. -

-

- Draft saved {formatTs(draftSavedAt)} · Deployed {formatTs(deployedAt)} -

+

A newer version was deployed after you started editing.

+ {#if draftBaseVersion && deployedHeadVersion} +

+ Your draft is based on {formatVersion(draftBaseVersion)} + · latest is {formatVersion(deployedHeadVersion)} + {#if deployedBy}by {deployedBy}{/if}{#if deployedAt}, {formatTs(deployedAt)}{/if} +

+ {:else} +

+ Draft saved {formatTs(draftSavedAt)} · Deployed {formatTs(deployedAt)} +

+ {/if}
-
- - + {:else} +
+ {/if} + +
diff --git a/frontend/src/lib/components/common/drawer/Disposable.svelte b/frontend/src/lib/components/common/drawer/Disposable.svelte index 642d1366f6..886c14042b 100644 --- a/frontend/src/lib/components/common/drawer/Disposable.svelte +++ b/frontend/src/lib/components/common/drawer/Disposable.svelte @@ -71,6 +71,16 @@ } } + // The entry follows the prop for as long as the overlay is open: a dialog opened before + // the AI chat still has to rise above it when the chat is opened underneath it. + $effect(() => { + const wanted = open && minZIndex > 0 ? minZIndex : undefined + untrack(() => { + if (wanted !== undefined) minZIndexEntries[id] = wanted + else delete minZIndexEntries[id] + }) + }) + // A disposable can be unmounted while still open, by an ancestor that tears its whole // subtree down. Its id would then sit on the stack forever, and since the topmost entry // arbitrates Escape, every overlay opened afterwards would stop answering it. @@ -84,10 +94,10 @@ offset = initialOffset if (stack.val.includes(id)) { stack.val = stack.val.filter((drawer) => drawer !== id) - if (minZIndex > 0) { - delete minZIndexEntries[id] - } } + // Unconditionally: `minZIndex` may have fallen to 0 since the entry was written (the + // chat closed), and a leftover entry keeps every later overlay elevated for good. + delete minZIndexEntries[id] } export function isOpen() { diff --git a/frontend/src/lib/components/common/drawer/DrawerContent.svelte b/frontend/src/lib/components/common/drawer/DrawerContent.svelte index ed2ed68519..9664f3a27e 100644 --- a/frontend/src/lib/components/common/drawer/DrawerContent.svelte +++ b/frontend/src/lib/components/common/drawer/DrawerContent.svelte @@ -30,6 +30,8 @@ * the content hug it with tight top padding; new entities keep normal padding. */ bannerReserved?: boolean + /** For content rendered in place of a drawer, which has nothing to close. */ + hideClose?: boolean children?: import('svelte').Snippet } @@ -50,6 +52,7 @@ titleExtra, banner, bannerReserved = false, + hideClose = false, children }: Props = $props() @@ -69,19 +72,26 @@ )} {id} > -
+
-
{ - dispatch('close') - } - }} - > - -
+ {#if !hideClose} +
{ + dispatch('close') + } + }} + > + +
+ {/if} {title ?? ''} {#if tooltip != '' || documentationLink} diff --git a/frontend/src/lib/components/common/fileDownload/FileDownload.svelte b/frontend/src/lib/components/common/fileDownload/FileDownload.svelte index 8d6529764b..c1a29fae70 100644 --- a/frontend/src/lib/components/common/fileDownload/FileDownload.svelte +++ b/frontend/src/lib/components/common/fileDownload/FileDownload.svelte @@ -1,8 +1,10 @@ - + { + // A keyboard-activated control emits a click with no pointer event before it (`detail` + // 0), so the pointerdown snapshot would be whatever a previous interaction left. + if (e.detail === 0) snapshotTopmost() + }} +/> -{#if isOpen} - -
-
+ + {#snippet children({ zIndex })} + {#if isOpen} +
closeOnOutsideClick && close() - }} + class={'fixed top-0 bottom-0 left-0 right-0 transition-all overflow-auto bg-black bg-opacity-60 w-full h-full'} + style="z-index: {zIndex}" + transition:fadeFast|local > - -
- -

{title}

-
+
+
+ closeOnOutsideClick && !preventDismiss && topmostAtPress && close(), + // A dropdown opened from inside the dialog portals its menu out of it, so a + // click on one of its items lands outside this node and would close the + // dialog under the menu. + exclude: async () => + Array.from(document.querySelectorAll('[data-menu]')) as HTMLElement[] + }} + > + +
-
- {@render headerLeft?.()} -
-
- - {@render headerRight?.()} -
- +

{title}

+
+ +
+ {@render headerLeft?.()} +
+
+ + {@render headerRight?.()} +
+ +
+
+ + + +
{})} + > + {@render body?.()} +
- - - -
{})} - > - {@render children?.()} -
-
+
-
-
- -{/if} + + {/if} + {/snippet} + diff --git a/frontend/src/lib/components/common/stepper/Stepper.svelte b/frontend/src/lib/components/common/stepper/Stepper.svelte index de6303cedc..5b13d9ddfc 100644 --- a/frontend/src/lib/components/common/stepper/Stepper.svelte +++ b/frontend/src/lib/components/common/stepper/Stepper.svelte @@ -12,6 +12,9 @@ allowStepNavigation?: boolean /** Compact variant, for steering a dialog rather than a full page. */ small?: boolean + /** Steps this run does not go through, greyed out and not clickable — a step the + * chosen path skips rather than one not reached yet. */ + disabledIndices?: number[] } let { @@ -21,7 +24,8 @@ statusByStep = [], hasValidations = false, allowStepNavigation = false, - small = false + small = false, + disabledIndices = [] }: Props = $props() const dispatch = createEventDispatcher() @@ -75,15 +79,20 @@ {#each tabs ?? [] as step, index} + {@const disabled = disabledIndices.includes(index)}
  • { - dispatch('click', { index }) + if (!disabled) dispatch('click', { index }) }} > {#if statusByStep[index] === 'pending'} @@ -94,7 +103,9 @@ small ? 'h-4 w-4 rounded-full flex items-center justify-center text-2xs' : 'h-6 w-6 rounded-full flex items-center justify-center text-xs', - getStepColor(index, selectedIndex, statusByStep, maxReachedIndex) + disabled + ? 'bg-gray-200' + : getStepColor(index, selectedIndex, statusByStep, maxReachedIndex) )} class:font-bold={selectedIndex === index} > @@ -105,7 +116,11 @@ {step} diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index 1059a986ce..bd881a31b7 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -11,7 +11,7 @@ import { createEventDispatcher } from 'svelte' import Button from '../button/Button.svelte' import Row from './Row.svelte' - import type { RowSelection } from './rowSelection' + import { selectMenuItems, type RowSelection } from './rowSelection' import InheritedLabels from '$lib/components/InheritedLabels.svelte' import Badge from '../badge/Badge.svelte' import { @@ -188,6 +188,21 @@ const canEdit = canWrite && showEditButton if (draft_only) { return [ + ...selectMenuItems(rowSelection), + { + displayName: 'Move/Rename', + icon: FolderOpen, + action: () => { + // Addressed by the generated path its draft row sits at, but + // named by the path typed in the editor. + moveDrawer.openDrawer((app as any).draft_path ?? path, summary, 'app', { + storagePath: path, + rawApp: !!app.raw_app + }) + }, + disabled: !showEditButton, + hide: $userStore?.operator + }, { displayName: 'Delete', icon: Trash, @@ -221,6 +236,7 @@ ] } return [ + ...selectMenuItems(rowSelection), { displayName: 'Duplicate/Fork', icon: GitFork, diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index 1eb0d35878..f9af803694 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -14,7 +14,7 @@ import Badge from '../badge/Badge.svelte' import Button from '../button/Button.svelte' import Row from './Row.svelte' - import type { RowSelection } from './rowSelection' + import { selectMenuItems, type RowSelection } from './rowSelection' import { sendUserToast } from '$lib/toast' import { copyToClipboard, isOwner } from '$lib/utils' import { isDeployable } from '$lib/utils_deployable' @@ -219,6 +219,20 @@ const canEdit = flow.canWrite && showEditButton if (draft_only) { return [ + ...selectMenuItems(rowSelection), + { + displayName: 'Move/Rename', + icon: FolderOpen, + action: () => { + // Addressed by the generated path its draft row sits at, but + // named by the path typed in the editor. + moveDrawer.openDrawer((flow as any).draft_path ?? path, flow.summary, 'flow', { + storagePath: path + }) + }, + disabled: !showEditButton, + hide: $userStore?.operator + }, { displayName: 'Delete', icon: Trash, @@ -242,6 +256,7 @@ ] } return [ + ...selectMenuItems(rowSelection), { displayName: 'View runs', icon: List, diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index ab5a460a5e..938086a0e8 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -23,7 +23,7 @@ import Badge from '../badge/Badge.svelte' import Button from '../button/Button.svelte' import Row from './Row.svelte' - import type { RowSelection } from './rowSelection' + import { selectMenuItems, type RowSelection } from './rowSelection' import { sendUserToast } from '$lib/toast' import { capitalize, copyToClipboard, isOwner } from '$lib/utils' import { isDeployable } from '$lib/utils_deployable' @@ -281,6 +281,7 @@ const canEdit = script.canWrite && showEditButton if (script.draft_only) { return [ + ...selectMenuItems(rowSelection), { displayName: 'View code', icon: Code, @@ -288,6 +289,19 @@ showCode(script.path, script.summary) } }, + { + displayName: 'Move/Rename', + icon: FolderOpen, + action: () => { + // Addressed by the generated path its draft row sits at, but + // named by the path typed in the editor. + moveDrawer.openDrawer(script.draft_path ?? script.path, script.summary, 'script', { + storagePath: script.path + }) + }, + disabled: !showEditButton, + hide: $userStore?.operator + }, { displayName: 'Delete', icon: Trash, @@ -311,6 +325,7 @@ ] } return [ + ...selectMenuItems(rowSelection), { displayName: 'View code', icon: Code, diff --git a/frontend/src/lib/components/common/table/rowSelection.ts b/frontend/src/lib/components/common/table/rowSelection.ts index 081f29addc..a78fe0aa1d 100644 --- a/frontend/src/lib/components/common/table/rowSelection.ts +++ b/frontend/src/lib/components/common/table/rowSelection.ts @@ -1,3 +1,6 @@ +import { SquareCheckBig } from 'lucide-svelte' +import type { Item } from '$lib/utils' + /** * Wiring for a row whose kind icon doubles as a selection control: the icon * swaps to a checkbox on hover, and stays one while a selection is active. @@ -14,3 +17,19 @@ export type RowSelection = { active: boolean onToggle: (e: MouseEvent | KeyboardEvent) => void } + +/** + * The row menu's way into a selection, for the rows that offer one. The icon + * checkbox is the fast path but only appears on hover; this is the one a user + * can find by looking. + */ +export function selectMenuItems(rowSelection: RowSelection | undefined): Item[] { + if (!rowSelection) return [] + return [ + { + displayName: rowSelection.selected ? 'Deselect' : 'Select', + icon: SquareCheckBig, + action: (e) => rowSelection.onToggle(e) + } + ] +} diff --git a/frontend/src/lib/components/copilot/StepGenQuick.svelte b/frontend/src/lib/components/copilot/StepGenQuick.svelte index 0be2fecb4c..3890cacd58 100644 --- a/frontend/src/lib/components/copilot/StepGenQuick.svelte +++ b/frontend/src/lib/components/copilot/StepGenQuick.svelte @@ -1,5 +1,4 @@ + {#if stepName} -
    - {stepName} +
    + + {stepName}
    {/if} @@ -125,7 +155,7 @@ expanded={reasoningExpanded} onToggle={() => (reasoningToggled = !reasoningExpanded)} shimmer={reasoningStreaming} - class="mb-2" + class={message.content ? 'mb-1' : ''} labelClass="truncate" contentClass="font-main text-secondary {markdownProse.xs}" > @@ -134,9 +164,36 @@ {/if} {#if s3Object} - +
    + +
    {:else if message.content} -
    +
    {/if} + +{#if message.content && showActions} + +
    + + {#if timestamp} + {timestamp} + {/if} + {#if runHref} + + job {truncateRev(jobId ?? '', 8)} + + + {/if} +
    +{/if} diff --git a/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte b/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte index 7694be3827..8e299a4d3e 100644 --- a/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte +++ b/frontend/src/lib/components/copilot/chat/AssistantSettingsModal.svelte @@ -19,7 +19,6 @@ callers that already know which section they mean, such as the "+" menu's Manage import Modal2 from '$lib/components/common/modal/Modal2.svelte' import SidebarNavigation from '$lib/components/common/sidebar/SidebarNavigation.svelte' import Tooltip from '$lib/components/meltComponents/Tooltip.svelte' - import { workspaceStore } from '$lib/stores' import { logFeatureUsage } from '$lib/utils/featureUsage' import { getAiChatManager } from './aiChatManagerContext' import { summarizeTools } from './agentContext' @@ -28,6 +27,9 @@ callers that already know which section they mean, such as the "+" menu's Manage import AssistantInstructionsSection from './AssistantInstructionsSection.svelte' import AssistantMcpSection from './AssistantMcpSection.svelte' import AssistantFilesSection from './AssistantFilesSection.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() const aiChatManager = getAiChatManager() @@ -88,13 +90,13 @@ callers that already know which section they mean, such as the "+" menu's Manage // unconditionally rather than behind `??`: short-circuiting it would leave this // derived with no dependency at all, frozen on the workspace it first saw. let ws = $derived.by(() => { - const active = $workspaceStore + const active = $operatingWorkspace return aiChatManager.operatingWorkspace ?? active ?? '' }) - // Exactly what the chat loop sends — plan mode's transition tool is registered - // alongside `tools` there, so listing only `tools` would under-report. - let tools = $derived(summarizeTools([...aiChatManager.tools, ...aiChatManager.planMode.tools])) + // The session's capability surface, not this turn's: the heading answers what the + // assistant can call here, so it must not shift when the autonomy picker does. + let tools = $derived(summarizeTools(aiChatManager.availableTools)) // `SidebarNavigation`'s item shape, which is what the instance and workspace settings // navs are built from too. @@ -147,6 +149,7 @@ callers that already know which section they mean, such as the "+" menu's Manage if (!aiChatManager.loading && !aiChatManager.sendInFlight) { void aiChatManager.refreshGlobalSkills() void aiChatManager.refreshMcpServers() + void aiChatManager.refreshSessionAccess() } } diff --git a/frontend/src/lib/components/copilot/chat/ChatContextPicker.svelte b/frontend/src/lib/components/copilot/chat/ChatContextPicker.svelte index d2cf1dfee6..68a65d75fc 100644 --- a/frontend/src/lib/components/copilot/chat/ChatContextPicker.svelte +++ b/frontend/src/lib/components/copilot/chat/ChatContextPicker.svelte @@ -19,7 +19,6 @@ On a workspace-leaf pick, emits a reference-only `WorkspaceScriptElement` / Content is materialized at message-prep time by `AIChatManager` — see PR #9216. -->
    -
    -
    + -
    -
    + {#if expanded}
    { - const out = new Set() - for (const m of text.matchAll(MENTION_RE)) out.add(mentionTitle(m[0])) - return out - } - // Titles currently appearing as `@title` mentions in the textarea. Compared // against the previous snapshot in a $effect (NOT inside handleInput — // the picker mutates `value` programmatically via `updateInstructionsWithContext`, // which doesn't fire `oninput`, so a handleInput-only diff goes stale). - const mentionedTitles = $derived(extractMentions(value)) + const mentionedTitles = $derived(mentionTitlesInText(value)) let prevMentionedTitles = $state>(new Set()) let showContextTooltip = $state(false) @@ -268,7 +268,11 @@ if (!att) return match return `${match}` }) - html = html.replace(MENTION_RE, (match) => { + html = html.replace(MENTION_RE, (match, ...args) => { + const offset = args[args.length - 2] + if (typeof offset !== 'number' || !hasMentionLeadingBoundary(html, offset)) { + return match + } const title = unescapeHtml(mentionTitle(match)) const inContext = availableContext.find((c) => c.title === title) || @@ -566,6 +570,23 @@ showContextTooltip = false } + export async function insertMention(title: string) { + const token = formatMention(title) + const selectionStart = textarea?.selectionStart ?? value.length + const selectionEnd = textarea?.selectionEnd ?? selectionStart + const { from, to, ids } = tokensOverlapping(selectionStart, selectionEnd) + const before = value.slice(0, from) + const after = value.slice(to) + const prefix = before.length === 0 || /\s$/.test(before) ? '' : ' ' + const suffix = after.length === 0 ? ' ' : /^\s/.test(after) ? '' : ' ' + const inserted = `${prefix}${token}${suffix}` + replacePasteRange(from, to, ids, inserted) + await tick() + const pos = from + inserted.length + textarea?.setSelectionRange(pos, pos) + textarea?.focus() + } + function refreshCommandSkills() { if (commandSkillsRefreshInFlight) return commandSkillsRefreshInFlight = true diff --git a/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte b/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte index cd6c58b2a3..b49bc582ef 100644 --- a/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte +++ b/frontend/src/lib/components/copilot/chat/ContextUsageIndicator.svelte @@ -1,6 +1,6 @@ @@ -285,7 +411,9 @@ that number is still moving. `font-medium` because the row is a button and the base layer sets those semibold, which would leave this the one bold word in the header. --> {#snippet status()} - {#if !pending} + + {#if !pending && !inspected} {statusTime} @@ -297,7 +425,7 @@ tab it already opened. The row's only control, as on every other tool call. --> {#snippet previewChip()} These inputs are open in the preview panel.
    - {:else if pending} + {:else if pending && runForm} {:else} -
    +
    +
    + {#if fetchFailed === message.tool_call_id} + This run could not be read. It may have been deleted, or be in another workspace. + Its result is on the JSON toggle. + {:else} + + + Loading this run... + + {/if} +
    {:else} {:else if activeTab === 'logs'} @@ -433,15 +580,25 @@ >{logs} {:else} -

    No logs yet.

    +

    + {inspectedJob?.logsFailed + ? 'Logs could not be read.' + : running + ? 'No logs yet.' + : 'No logs.'} +

    {/if} - {#if running} + {#if running && !inspected}
    streaming
    {/if} - {:else if failed} + + {:else if !inspected && failed}
    {message.error}
    @@ -465,8 +622,8 @@ which the row already owns. --> diff --git a/frontend/src/lib/components/copilot/chat/ToolCodeDiffView.svelte b/frontend/src/lib/components/copilot/chat/ToolCodeDiffView.svelte new file mode 100644 index 0000000000..6f74ff7a80 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ToolCodeDiffView.svelte @@ -0,0 +1,170 @@ + + + + +
    + {#each visibleRows as row, index ('key' in row ? `${row.kind}:${row.key}` : `line:${row.oldLine}:${row.newLine}:${index}`)} + {#if row.kind === 'collapsed' || row.kind === 'omitted'} + + {:else} +
    + {row.oldLine ?? ''} + + {row.newLine ?? ''}{row.kind === 'added' ? '+' : row.kind === 'removed' ? '-' : ''} + + + {#each row.changedRanges ?? [] as range} + + {/each} + {@html highlightedLine(row) || ' '} + +
    + {/if} + {/each} +
    + + diff --git a/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte b/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte new file mode 100644 index 0000000000..a2ab9d341c --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/ToolDiffCard.svelte @@ -0,0 +1,103 @@ + + +{#snippet headerRight()} +
    + {#if counts && (counts.added > 0 || counts.removed > 0)} + + +{counts.added} + −{counts.removed} + + {/if} + {#if showPreviewChip && message.previewCard} + + {/if} +
    +{/snippet} + + (toggled = { id: toggleKey, open: !expanded })} + toggleable={hasBody} + shimmer={isRunning} + class={message.isQueued && !message.error + ? 'opacity-60 hover:opacity-100 transition-opacity' + : ''} + labelClass="truncate" + contentClass="p-0 overflow-hidden space-y-0" + {headerRight} +> + {#if diff && hasDiff && expanded} + + {/if} + {#if message.error} +
    + {message.error} +
    + {/if} +
    diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index a0d16043a4..aa8dc47cec 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -9,8 +9,12 @@ CircleMinus, FileText, PanelRight, - Lock + Lock, + ExternalLink, + BookOpen } from 'lucide-svelte' + import { base } from '$lib/base' + import { truncateRev } from '$lib/utils' import { EXIT_PLAN_MODE_TOOL, isPlanCardTool, @@ -24,7 +28,7 @@ import { getChatViewHost } from './chatViewHost' const chatHost = getChatViewHost() - import { isActiveUserQuestion, type ToolDisplayMessage } from './shared' + import { isActiveUserQuestion, webSearchResultOf, type ToolDisplayMessage } from './shared' import ChatCollapsibleCard from './ChatCollapsibleCard.svelte' import { twMerge } from 'tailwind-merge' import { slide } from 'svelte/transition' @@ -38,6 +42,8 @@ import ToolPreviewCard from './ToolPreviewCard.svelte' import AskUserQuestionDisplay from './AskUserQuestionDisplay.svelte' import RunScriptCard from './RunScriptCard.svelte' + import ToolDiffCard from './ToolDiffCard.svelte' + import { hasToolCodeDiff } from './toolCodeDiff' import WebSearchSourcesDisplay from './WebSearchSourcesDisplay.svelte' import ExpandableImage from '$lib/components/common/image/ExpandableImage.svelte' import McpServerIcon from '$lib/components/mcp/McpServerIcon.svelte' @@ -127,7 +133,10 @@ // The run card owns this call from the form to whatever settled it, cancelling included: // the card is the call, and a run the user stopped is not a different kind of thing. - const isRunCard = $derived(Boolean(message.runForm)) + // A call that inspected a run rather than starting one gets the same card, bound to + // the job it named — what happened in a run reads the same either way. + const isRunCard = $derived(Boolean(message.runForm || message.inspectedRun)) + const isDiffCard = $derived(Boolean(message.codeDiff) || hasToolCodeDiff(message.toolName)) // The preview chip sits on the header row (to the right of the tool-call text); // shown once the tool settled, never while loading/erroring/awaiting confirmation. @@ -136,6 +145,18 @@ message.previewCard && !message.isLoading && !message.error && !message.needsConfirmation ) ) + + // A provider-side search sets `webSearchSources` and words its own header; any other tool + // gets the same card by returning the web search result shape. + const searchResult = $derived( + message.webSearchSources || message.error ? undefined : webSearchResultOf(message.result) + ) + const sources = $derived(message.webSearchSources ?? searchResult?.sources) + const label = $derived( + searchResult?.query !== undefined + ? `${message.content} · "${searchResult.query}"` + : message.content + ) {#if activeUserQuestion} @@ -152,8 +173,22 @@ {message.toolName} {/if}
    +{:else if message.heldForFolderInstructions} + +
    + + + {message.content} + + {#if message.toolName} + {message.toolName} held until read + {/if} +
    {:else if isRunCard} +{:else if isDiffCard} + {:else if planState} @@ -249,6 +284,19 @@ {/if} {/snippet} + {#snippet jobLink()} + + job {truncateRev(message.jobId ?? '', 8)} + + + {/snippet} + @@ -264,7 +312,7 @@ weight alone: queued calls (waiting their turn behind the executing tool) are faded, the running one sweeps, a settled one is plain. --> (isExpanded = !isExpanded)} toggleable={detailsAvailable || message.isStreamingArguments === true} @@ -275,7 +323,7 @@ headerClass={message.needsConfirmation ? 'opacity-80' : ''} labelClass={showPreviewChip ? 'truncate' : ''} contentClass="space-y-3" - headerRight={showPreviewChip ? previewChip : undefined} + headerRight={showPreviewChip ? previewChip : message.jobId ? jobLink : undefined} headerLeft={mcpServer?.workspace ? serverMark : undefined} > @@ -326,8 +374,8 @@ {#if visibleActions.length > 0} - {:else if message.webSearchSources?.length && !message.error} - + {:else if sources?.length && !message.error} + {:else} {card.title}
    {card.subtitle}
    - + {#if hasToolDisplayActionHandler(action.type)}
  • \` means the runtime writes the node's output table FOR you — write the body as a single SELECT and the runtime wraps it in the create/replace, so do NOT also write your own CREATE TABLE / INSERT. The \`dbt://\` target below is the opposite: the node writes its own DDL and none of the write strategies apply to it. IMPORTANT: a MANAGED \`// materialize\` is **DuckDB-only** and its target MUST be a DuckLake table (\`ducklake:///
    \`) — deploy rejects a \`ducklake://\` target on any other language. For a \`python3\`/\`bun\`/\`postgresql\` node writing the lake, do NOT use \`// materialize\`; write the output via the SDK instead (e.g. \`wmill.writeS3File(...)\`, a \`CREATE TABLE\` in postgresql, or \`wmill.databaseUrlFromResource\`/ducklake helpers) and let the output be inferred. Reach for \`duckdb\` when a node should materialize a DuckLake table. The one target any language BUT DBT'S OWN may declare (a dbt project's writes come from its manifest, so \`// materialize\` on a dbt script is rejected at deploy) is a WAREHOUSE RELATION: \`// materialize manual dbt:////\`, with \`\` a warehouse the workspace configures under Settings → dbt. \`manual\` is its only mode — nothing generates warehouse DDL, so the node issues its own write and the annotation records the outcome. Use it on an ingestion node a dbt project reads as a \`source\`: the declared relation and the dbt model become ONE graph node, and a downstream \`// on dbt:////\` fires when that node completes. Write strategy: with no option it REPLACES the whole table each run (full refresh; the only mode whose output columns may change); \`// materialize append\` INSERT-appends rows (incremental); \`// materialize key=\` merges/upserts on \`\`. \`// materialize manual \` opts OUT of managed writes — the script writes its own DDL and the annotation only records the output asset for lineage. \`materialize\` is paired with partitioning for incremental pipelines: a \`// partitioned \` node runs once per partition (append/merge into a fixed-schema table), and the \`{partition}\` token — usable in any asset URI AND in the body SQL — is substituted with the current partition's IDENTITY string at run time. To filter the source to the active slice on a time grain, use the runtime-injected macro: \`WHERE wm_partition() = {partition}\`. \`wm_partition(ts)\` buckets a timestamp with the exact identity format the runtime used (daily/hourly/weekly/monthly), so it always matches and you never hand-write a \`strftime\` format. Do NOT write \`= TIMESTAMP {partition}\`: the identity string is not a valid timestamp literal for hourly/weekly/monthly and errors at runtime. For \`dynamic\` partitioning the identity is your caller-supplied key (not a timestamp, no macro), so filter on it directly: \`WHERE = {partition}\`. \`materialize\` is an output DECLARATION on the node — it is not a command; there is no "materialize run". - \`measure\` / \`dimension\` (declared metrics): on a node that materializes a DuckLake table, \`// measure = [where ]\` names the canonical way to aggregate that table (e.g. \`// measure revenue = sum(amount) where not is_refund\`), and \`// dimension = \` names a way to slice it (e.g. \`// dimension region = region\`, \`// dimension month = date_trunc('month', ordered_at)\`). They execute nothing: they are catalogued at deploy so the editor and other agents can reuse the definition instead of re-deriving it and silently disagreeing. Keep the predicate in the \`where\` clause rather than folding it into the aggregate: it is rendered as \` FILTER (WHERE )\`, which is what lets two measures with different predicates sit under one GROUP BY. DuckLake-only, and only meaningful next to \`// materialize\`. Declare one when a number carries a judgement call someone else would get wrong (refunds excluded, test rows dropped, which column is the amount); do NOT blanket every table with measures, an obvious \`count(*)\` earns nothing. To USE a metric another node declares, read that node with read_pipeline_node and reuse its exact expression rather than guessing it. - Use get_pipeline_graph to see the current nodes/assets/triggers, and read_pipeline_node before editing one. -- Every node of this pipeline lives at \`f/${ctx.folder}/\` — \`${ctx.folder}\` is the folder name and \`f/\` is the owner prefix every workspace path carries, so write it exactly once (never \`f/f/…\`, and never a bare \`\`). +- Every node of this pipeline lives at \`f/${ctx.folder}/\` — \`${ctx.folder}\` is the folder name and \`f/\` is the owner prefix every workspace path carries, so write it exactly once (never \`f/f/…\`, and never a bare \`\`).${ + canWriteDraft + ? ` - Build new nodes with build_pipeline_node and edit existing ones with edit_pipeline_node. These apply directly as unsaved drafts on the canvas (like the flow/script editor applies AI edits) — they DO NOT deploy. There is no separate Accept/Reject step. Prefer these over the generic write_script/edit_script draft tools while a pipeline is open. - Reuse existing asset paths from the graph when wiring a downstream node to an upstream one (read the upstream's write asset, then \`// on\` that same URI). - Only deploy when the user explicitly asks; the user deploys drafts from the canvas.` + : '' + }` } diff --git a/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts b/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts index ab80dc3e88..01e5040040 100644 --- a/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts @@ -1,6 +1,7 @@ import type { ChatCompletionSystemMessageParam } from 'openai/resources/chat/completions.mjs' import type { ArtifactVersionTarget } from '$lib/components/sessions/previewRouter' -import { createToolDef, type Tool, type ToolCallbacks } from './shared' +import { createToolDef, type ToolCallbacks } from './shared' +import { NONE, type SessionTool } from './sessionCapabilities' import { appendPlanModeInstructions, derivePlanTitle, @@ -100,14 +101,24 @@ export class PlanModeController { /** Only the transition the current posture allows; auto-accepting exposes neither, since * entering is the user's choice. */ - get tools(): Tool[] { + get tools(): SessionTool[] { if (!this.#host.available) return [] if (this.#host.autoAccepting) return [] return this.#host.active ? [this.exitTool] : [this.enterTool] } + /** Both transitions, whichever posture is selected: what plan mode contributes to this + * chat's capabilities, rather than to the turn it is about to send. */ + get availableTools(): SessionTool[] { + return this.#host.available ? [this.enterTool, this.exitTool] : [] + } + // This safety tag is what keeps plan mode escapable through its handoff tool. - exitTool: Tool = { + exitTool: SessionTool = { + // Ungated even for a user who can change nothing: the posture is the USER's choice, + // and its instructions order this call to hand the plan over — withholding it + // strands the model in a round nothing else can end. + requires: NONE, def: createToolDef(exitPlanModeArgs, EXIT_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL_DESCRIPTION), planModeSafe: true, requiresConfirmation: true, @@ -156,7 +167,8 @@ export class PlanModeController { } } - enterTool: Tool = { + enterTool: SessionTool = { + requires: NONE, def: createToolDef(enterPlanModeArgs, ENTER_PLAN_MODE_TOOL, ENTER_PLAN_MODE_TOOL_DESCRIPTION), planModeSafe: true, requiresConfirmation: true, diff --git a/frontend/src/lib/components/copilot/chat/script/core.test.ts b/frontend/src/lib/components/copilot/chat/script/core.test.ts new file mode 100644 index 0000000000..f235ae3796 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/script/core.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('monaco-editor', () => ({ + editor: {}, + languages: {}, + KeyCode: {}, + Uri: { parse: (value: string) => ({ toString: () => value }) }, + MarkerSeverity: { Error: 8, Warning: 4, Info: 2, Hint: 1 } +})) +vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () => ({ + getTypeScriptWorker: async () => async () => ({}), + typescriptVersion: 'test' +})) +vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ default: () => ({}) })) +vi.mock('$lib/components/vscode', () => ({})) + +import { editCodeToolWithDiff } from './core' + +describe('editCodeToolWithDiff', () => { + it('records the complete script diff after applying replacements', async () => { + const statuses: unknown[] = [] + const applyCode = vi.fn(async () => {}) + + await editCodeToolWithDiff.fn({ + args: { diffs: [{ old_string: 'return 1', new_string: 'return 2' }] }, + helpers: { + getScriptOptions: () => ({ + code: 'export function main() {\n\treturn 1\n}', + lang: 'bun', + path: 'f/example', + args: {} + }), + applyCode + }, + toolCallbacks: { + setToolStatus: (_toolId, status) => statuses.push(status) + }, + toolId: 'edit-1' + }) + + expect(applyCode).toHaveBeenCalledTimes(2) + expect(statuses.at(-1)).toMatchObject({ + codeDiff: { + before: 'export function main() {\n\treturn 1\n}', + after: 'export function main() {\n\treturn 2\n}', + lang: 'typescript' + } + }) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/script/core.ts b/frontend/src/lib/components/copilot/chat/script/core.ts index 35e6f5169a..92cb889b24 100644 --- a/frontend/src/lib/components/copilot/chat/script/core.ts +++ b/frontend/src/lib/components/copilot/chat/script/core.ts @@ -9,6 +9,7 @@ import type { } from 'openai/resources/index.mjs' import { type DBSchema, dbSchemas } from '$lib/stores' import type { ContextElement } from '../context' +import { NONE, RUN_PREVIEW, type SessionTool } from '../sessionCapabilities' import { createSearchHubScriptsTool, type Tool, @@ -22,10 +23,12 @@ import { } from '../shared' import { createWorkspaceMutationTools } from '../workspaceTools' import { setupTypeAcquisition, type DepsToGet } from '$lib/ata' -import { getModelContextWindow } from '../../modelConfig' +import { getEffectiveModelContextWindow } from '../../modelConfig' import type { ReviewChangesOpts } from '../monaco-adapter' -import { getCurrentModel } from '$lib/aiStore' +import { copilotInfo, getCurrentModel } from '$lib/aiStore' +import { get } from 'svelte/store' import { getDbSchemas } from '$lib/components/apps/components/display/dbtable/metadata' +import { scriptLangToEditorLang } from '$lib/scripts' import { getScriptPrompt, getWorkflowAsCodePrompt } from '$system_prompts' // Score threshold for npm packages search filtering @@ -460,9 +463,12 @@ export const resourceTypeTool: Tool = { // Generic DB schema tool factory shared by the script, flow and global modes export function createDbSchemaTool( opts: { description?: string; updateEditorCache?: boolean } = {} -): Tool { +): SessionTool { const { description, updateEditorCache = true } = opts return { + // `getDbSchemas` below introspects by running a query job through /jobs/run/preview, + // which jobs.rs refuses operators — so this reads like a lookup but gates like a run. + requires: RUN_PREVIEW, def: description ? { ...DB_SCHEMA_FUNCTION_DEF, @@ -548,7 +554,11 @@ export async function searchExternalIntegrationResources(args: { query: string } ) const model = getCurrentModel() - const modelContextWindow = getModelContextWindow(model.model) + const modelContextWindow = getEffectiveModelContextWindow( + model.provider, + model.model, + get(copilotInfo).contextWindowPerModel + ) const results: PackageSearchResult[] = await Promise.all( filtered.map(async (r: PackageSearchQuery) => { let documentation = '' @@ -604,7 +614,8 @@ const SEARCH_NPM_PACKAGES_TOOL: ChatCompletionFunctionTool = { } // Helpers-agnostic so both script mode and global mode can offer it. -export const searchNpmPackagesTool: Tool<{}> = { +export const searchNpmPackagesTool: SessionTool<{}> = { + requires: NONE, def: SEARCH_NPM_PACKAGES_TOOL, planModeSafe: true, fn: async ({ args, toolId, toolCallbacks }) => { @@ -821,7 +832,12 @@ export const editCodeToolWithDiff: Tool = { toolCallbacks.setToolStatus(toolId, { content: `Code changes applied`, - result: 'Success' + result: 'Success', + codeDiff: { + before: oldCode, + after: updatedCode, + lang: scriptLangToEditorLang(scriptOptions.lang) + } }) return `Applied changes to the script editor.` } catch (error) { @@ -873,7 +889,12 @@ export const editCodeTool: Tool = { toolCallbacks.setToolStatus(toolId, { content: 'Code changes applied', - result: 'Success' + result: 'Success', + codeDiff: { + before: oldCode, + after: args.code, + lang: scriptLangToEditorLang(scriptOptions.lang) + } }) return 'Code has been applied to the script editor.' } catch (error) { diff --git a/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts new file mode 100644 index 0000000000..fc77b3e1c4 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts @@ -0,0 +1,52 @@ +import type { Tool } from './shared' + +// A leaf on purpose: every module defining a session-reachable tool imports these +// constants, so importing anything with side effects here drags it into all of them. + +/** + * What a user may do in ONE workspace, as an AI session's toolset needs to know it. + * Best-effort, not a boundary — the token is the enforcement point, so this narrows + * what the model is offered and guarantees nothing. + */ +export type SessionCapability = + /** Passes drafts.rs `require_can_write_path` — for a draft of any kind, schedules and + * triggers included, though an operator may create those directly. */ + | 'write_draft' + /** Passes the operator refusal jobs.rs `run_preview_*` makes before starting a job. */ + | 'run_preview' + /** Passes `check_deploy_rules`. */ + | 'deploy' + /** Passes the operator refusal the script, flow and app handlers make before creating or + * deleting one. The same condition as `run_preview` today, but another handler family's + * check, so each follows its own gate if the two ever diverge. */ + | 'manage_code' + /** Passes `require_admin`. */ + | 'admin' + +export type SessionAccess = ReadonlySet + +/** + * The capabilities without which a tool's call cannot succeed — usually because the + * backend refuses it, occasionally because only that capability produces the tool's + * input. Never a relevance judgement: a tool the server would accept ships, even when + * it is of little use to the session, because withholding it is a stricter answer than + * the one the user would get by trying. + */ +export type SessionToolPolicy = readonly SessionCapability[] + +export const NONE: SessionToolPolicy = [] +export const WRITE_DRAFT: SessionToolPolicy = ['write_draft'] +export const RUN_PREVIEW: SessionToolPolicy = ['run_preview'] +export const DEPLOY: SessionToolPolicy = ['deploy'] + +/** + * A tool that can reach an AI session's toolset. `requires` is mandatory, and declared + * beside the `fn` whose call is what proves it right. + */ +export type SessionTool = Tool & { + requires: SessionToolPolicy + /** For a tool taking the item kind as its `type` argument, where one verdict for the whole + * tool is either too strict or too loose: what each kind's handler needs. A session is + * offered only the kinds it can land, and the rest never appear in the schema it sees. */ + kindRequires?: Readonly>> +} diff --git a/frontend/src/lib/components/copilot/chat/shared.test.ts b/frontend/src/lib/components/copilot/chat/shared.test.ts index 768b6d1b65..4574df7355 100644 --- a/frontend/src/lib/components/copilot/chat/shared.test.ts +++ b/frontend/src/lib/components/copilot/chat/shared.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import type { DisplayMessage, ToolDisplayMessage } from './shared' -import { openItemPreviewAction } from './shared' +import { openItemPreviewAction, webSearchResultOf } from './shared' vi.mock('monaco-editor', () => ({ editor: {} @@ -54,7 +54,10 @@ vi.mock('$lib/gen', () => ({ AzureTriggerService: { createAzureTrigger: vi.fn() }, AmqpTriggerService: { createAmqpTrigger: vi.fn() }, EmailTriggerService: { createEmailTrigger: vi.fn() }, - SettingService: { getGlobal: vi.fn() } + SettingService: { getGlobal: vi.fn() }, + ResourceService: { + getResourceValue: vi.fn(async ({ path }: { path: string }) => ({ content: `body of ${path}` })) + } })) vi.mock('$lib/utils', () => ({ @@ -205,6 +208,106 @@ describe('buildContextString', () => { }) describe('processToolCall', () => { + it('delivers folder instructions once, holding back a change until they are read', async () => { + const { createToolDef, processToolCall } = await import('./shared') + const read = vi.fn(async () => 'read ok') + const write = vi.fn(async () => 'write ok') + const tools = [ + { def: createToolDef(z.object({}), 'read_item', 'Read'), planModeSafe: true, fn: read }, + { def: createToolDef(z.object({}), 'write_item', 'Write'), fn: write } + ] + const folderInstructions = { + list: () => [ + { path: 'f/billing/AGENTS', scope: 'f/billing/' }, + { path: 'f/billing/eu/AGENTS', scope: 'f/billing/eu/' }, + { path: 'f/other/AGENTS', scope: 'f/other/' }, + { path: 'g/ops/AGENTS', scope: 'g/ops/' } + ], + deliveredBy: new Map() + } + const messages: any[] = [] + const call = async ( + id: string, + name: string, + path: string | object, + workspace = 'test-workspace' + ) => { + const message = await processToolCall({ + tools, + toolCall: { + id, + type: 'function', + function: { + name, + arguments: JSON.stringify(typeof path === 'string' ? { path } : path) + } + }, + helpers: {}, + workspace, + messages, + toolCallbacks: { setToolStatus: vi.fn(), removeToolStatus: vi.fn(), folderInstructions } + }) + messages.push(message) + return message.content as string + } + const turn = () => messages.push({ role: 'assistant', content: '' }) + + turn() + const readResult = await call('c1', 'read_item', 'f/billing/eu/invoice') + expect(read).toHaveBeenCalledTimes(1) + expect(readResult.startsWith('read ok')).toBe(true) + // Outermost first, so the nested folder's instructions read as the specific ones. + expect(readResult.indexOf('body of f/billing/AGENTS')).toBeLessThan( + readResult.indexOf('body of f/billing/eu/AGENTS') + ) + // Same batch: the model has not read c1's result yet, so the write still waits, + // pointed at that result rather than handed the bodies twice. + const sameBatch = await call('c2', 'write_item', 'f/billing/eu/invoice') + expect(write).not.toHaveBeenCalled() + expect(sameBatch).not.toContain('body of f/billing/AGENTS') + expect(sameBatch).toContain('earlier result of this same batch') + + turn() + expect(await call('c3', 'write_item', 'f/billing/eu/invoice')).toBe('write ok') + + const held = await call('c4', 'write_item', 'f/other/x') + expect(write).toHaveBeenCalledTimes(1) + expect(held).toContain('body of f/other/AGENTS') + expect(held).not.toContain('body of f/billing/AGENTS') + + turn() + expect(await call('c5', 'write_item', 'f/other/x')).toBe('write ok') + + expect(await call('g1', 'read_item', 'g/ops/runbook')).toContain('body of g/ops/AGENTS') + + // The same path in another workspace is another resource, delivered afresh. + expect(await call('w1', 'read_item', 'f/other/x', 'other-workspace')).toContain( + 'body of f/other/AGENTS' + ) + + // A delivery lost from the conversation (compaction) is made again. + messages.splice(0) + turn() + expect(await call('c6', 'read_item', 'f/billing/y')).toContain('body of f/billing/AGENTS') + + // A trigger names its target inside its config. + messages.splice(0) + turn() + const nested = await call('t1', 'write_item', { kind: 'http', config: { path: 'f/other/t' } }) + expect(nested).toContain('body of f/other/AGENTS') + expect(write).toHaveBeenCalledTimes(2) + + // A call stopped mid-run is answered with a placeholder under its id: no delivery. + messages.splice(0) + folderInstructions.deliveredBy.set('stopped', { + workspace: 'test-workspace', + paths: ['f/billing/AGENTS'] + }) + messages.push({ role: 'tool', tool_call_id: 'stopped', content: 'Interrupted' }) + turn() + expect(await call('c7', 'read_item', 'f/billing/y')).toContain('body of f/billing/AGENTS') + }) + it('returns pre-confirmation validation errors without asking for confirmation', async () => { const { createToolDef, processToolCall } = await import('./shared') const error = 'the script needs to be deployed before doing this action' @@ -1510,6 +1613,50 @@ describe('pollJobCompletion detach', () => { }) }) +describe('executeTestRun result note', () => { + // A failed run's card lands on the error, with the logs a tab away: the model must stay + // free to quote them, so only a success says the user already sees the run. + it('tells the model not to repeat a successful result, and not a failed one', async () => { + vi.useFakeTimers() + try { + const { executeTestRun } = await import('./shared') + const { JobService } = await import('$lib/gen') + const getJobUpdates = vi.mocked(JobService.getJobUpdates) + getJobUpdates.mockReset() + getJobUpdates.mockResolvedValue({ completed: true, running: false } as any) + const run = async (success: boolean) => { + vi.mocked(JobService.getJob).mockReset() + vi.mocked(JobService.getJob).mockResolvedValue({ + type: 'CompletedJob', + success, + result: [1, 2], + logs: 'ran' + } as any) + const promise = executeTestRun({ + jobStarter: async () => 'job1', + workspace: 'w', + toolId: 'tool1', + startMessage: 'Starting...', + contextName: 'script', + // The job hooks are what mark the global/sessions chat. + toolCallbacks: { + setToolStatus: vi.fn(), + removeToolStatus: vi.fn(), + onJobStatus: vi.fn(), + onJobStarted: vi.fn() + } as any + }) + await vi.advanceTimersByTimeAsync(1000) + return promise + } + expect(await run(true)).toContain('Do not repeat') + expect(await run(false)).not.toContain('Do not repeat') + } finally { + vi.useRealTimers() + } + }) +}) + describe('deriveChatJobStatus', () => { // CompletedJob is discriminated by the presence of a `success` key; the branch // order deliberately mirrors JobStatusIcon so the badge and scalar never drift. @@ -1825,3 +1972,38 @@ describe('processToolCall confirmation hooks', () => { expect(tool.fn).toHaveBeenCalled() }) }) + +// Any tool gets the web search card by returning this shape. The card renders urls and +// titles only, so a result carrying anything more must keep its JSON pane. +describe('webSearchResultOf', () => { + it('reads the shape, from the value or its JSON text', () => { + const result = { sources: [{ url: 'https://a.dev', title: 'A' }], query: 'a' } + expect(webSearchResultOf(result)).toEqual(result) + expect(webSearchResultOf(JSON.stringify(result))).toEqual(result) + }) + + // A Python tool serializes an absent optional as null, and the card has nothing to tell + // its author why an almost-right result fell back to JSON. + it('reads null on an optional field as absent', () => { + expect( + webSearchResultOf({ sources: [{ url: 'https://a.dev', title: null }], query: null }) + ).toEqual({ + sources: [{ url: 'https://a.dev', title: undefined }], + query: undefined + }) + }) + + it.each([ + ['an extra key', { sources: [{ url: 'https://a.dev' }], summary: 'x' }], + ['an extra source field', { sources: [{ url: 'https://a.dev', snippet: 'x' }] }], + ['a source without url', { sources: [{ title: 'A' }] }], + // The card renders no relative or javascript: link, so a result whose urls it would + // drop keeps its own JSON rather than showing an empty source list. + ['a relative url', { sources: [{ url: '/docs/pg17', title: 'PG 17' }] }], + ['a javascript: url', { sources: [{ url: 'javascript:alert(1)' }] }], + ['no sources', { sources: [] }], + ['a bare list of links', [{ url: 'https://a.dev' }]] + ])('rejects %s', (_, result) => { + expect(webSearchResultOf(result)).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 13799cf09f..1e486c7d41 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -7,6 +7,7 @@ import type { UserDraftItemKind } from '$lib/gen' // The gate's two refusals, from a module that holds prose and one size limit: under the // shallow-import rule below, the rest of plan mode is not reachable from here. import { PLAN_MODE_MESSAGES } from './planModeMessages' +import { NONE } from './sessionCapabilities' // Import-free leaf, so it satisfies the shallow-import rule below. import { openItemPreviewAction, @@ -38,6 +39,7 @@ import type { CodePieceElement, ContextElement, FlowModuleCodePieceElement } fro import { workspaceStore } from '$lib/stores' import type { ExtendedOpenFlow } from '$lib/components/flows/types' import { findModuleInFlow, findModuleInModules } from '$lib/components/flows/flowTree' +import { agentTestInputTransforms } from '$lib/components/flows/agentFormFields' import type { FunctionParameters } from 'openai/resources/shared.mjs' import { z } from 'zod' import { @@ -59,6 +61,7 @@ import { forLater } from '$lib/forLater' import { scriptLangToEditorLang } from '$lib/scripts' import { getCurrentModel } from '$lib/aiStore' import { type editor as meditor } from 'monaco-editor' +import { pendingFolderInstructions, type FolderInstructionsContext } from './folderInstructions' // Prettify function for code arguments - extracts and formats code from JSON function prettifyCodeArguments(content: string): string { @@ -610,6 +613,84 @@ export type WebSearchSource = { title?: string } +export type ToolCodeDiff = { + before: string + after: string + /** Monaco language id. */ + lang: string +} + +/** The result shape any tool returns to have it rendered as a web search card. */ +export type WebSearchResult = { + sources: WebSearchSource[] + query?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** A link the card can render: the source list drops everything else, so a result whose + * urls are relative or `javascript:` would show as an empty list in place of its own output. */ +export function isRenderableSourceUrl(url: string): boolean { + try { + return ['http:', 'https:'].includes(new URL(url).protocol) + } catch { + return false + } +} + +/** An absent optional field reaches JSON as `null` from a Python tool and as nothing from a + * TypeScript one, so both read as absent. */ +function isOptionalString(value: unknown): boolean { + return value === null || typeof value === 'string' +} + +function isWebSearchSource(value: unknown): value is { url: string; title?: unknown } { + return ( + isRecord(value) && + typeof value.url === 'string' && + isRenderableSourceUrl(value.url) && + Object.entries(value).every( + ([key, v]) => key === 'url' || (key === 'title' && isOptionalString(v)) + ) + ) +} + +/** + * A tool result as a web search, or undefined when it is anything else. The keys must be + * exactly `sources` and an optional `query`: the card renders only urls and titles, so a + * result carrying more would lose it. Accepts the result as its JSON text too. + */ +export function webSearchResultOf(result: unknown): WebSearchResult | undefined { + if (typeof result === 'string') { + try { + result = JSON.parse(result) + } catch { + return undefined + } + } + if (!isRecord(result) || !Array.isArray(result.sources) || result.sources.length === 0) { + return undefined + } + const sources = result.sources + if ( + !sources.every(isWebSearchSource) || + !Object.entries(result).every( + ([key, v]) => key === 'sources' || (key === 'query' && isOptionalString(v)) + ) + ) { + return undefined + } + return { + sources: sources.map((source) => ({ + url: source.url, + title: typeof source.title === 'string' ? source.title : undefined + })), + query: typeof result.query === 'string' ? result.query : undefined + } +} + export type ToolDisplayMessage = { role: 'tool' tool_call_id: string @@ -634,10 +715,19 @@ export type ToolDisplayMessage = { * workspace rides along: a chat is readable from any workspace, and the same path names * a different server in each. */ mcpServer?: { workspace: string; path: string } + /** The run behind the call. Flow chats only: the card links to it. */ + jobId?: string showFade?: boolean actions?: ToolDisplayAction[] userQuestion?: UserQuestionDisplay runForm?: RunFormDisplay + /** A run this call inspected rather than started, rendered by the same card. The card + * reads its panes from this job, so the user sees its own args and result in full, and its + * logs as a 4000-char tail, while the model keeps the capped envelope the tool returned. + * `runId` and `step` are the address the call was made with, kept so the card can name what + * was inspected the way the tool's own row did: a step job names neither the step nor the + * run it belongs to. */ + inspectedRun?: { jobId: string; workspace: string; runId: string; step?: string } webSearchSources?: WebSearchSource[] /** Data URL of an image the tool produced (e.g. take_screenshot), shown on the card. */ imageUrl?: string @@ -645,6 +735,9 @@ export type ToolDisplayMessage = { * always-visible card that opens (or focuses) the item's preview in the * session side panel. Set only for session chats — the side panel is their surface. */ previewCard?: { kind: PreviewCardKind; path: string } + /** Whole text on both sides of an edit this call saved. Kept in full rather than as a + * reference: drafts are overwritten in place, so the sides cannot be fetched back later. */ + codeDiff?: ToolCodeDiff planArtifactId?: string /** The version this card's proposal wrote, so a card scrolled far up still opens the plan * it proposed rather than what the document became. */ @@ -652,6 +745,8 @@ export type ToolDisplayMessage = { /** Refused by the plan-mode gate. Renders as its own lean row rather than a tool * error, so the transcript says the mode stopped it and not that the call failed. */ blockedByPlanMode?: boolean + /** Held back until the model has read the folder instructions it was handed. */ + heldForFolderInstructions?: boolean /** The user declined: the reject button, a Stop, or a posture switch. Set only there, so * a decision is distinguishable from every other way a call errors. */ declinedByUser?: boolean @@ -677,7 +772,9 @@ export type AssistantDisplayMessage = BaseDisplayMessage & { /** The run behind this answer. Flow chats only: a copilot turn happens in the * browser and has no job. */ jobId?: string - /** When the message was stored, as the server reports it. */ + /** When the answer arrived: the server's time for a flow chat's stored row, the browser's + * for a copilot answer, which is stamped as it lands. Absent on a copilot chat restored + * from history, which predates the stamp. */ createdAt?: string } @@ -773,11 +870,11 @@ export function pendingUserActionDetail( // the sessions page) react to mutating tools — refreshing previews — without // the tool layer knowing about the UI. Single slot; the consumer filters by name // and reads the tool args (e.g. the mutated item's `path`) to scope its refresh. -let toolCompletionListener: ((toolName: string, args: any) => void) | undefined +// `workspace` is the one the tool acted on: a path names an item only within it. +export type ToolCompletionListener = (toolName: string, args: any, workspace: string) => void +let toolCompletionListener: ToolCompletionListener | undefined -export function setToolCompletionListener( - fn: ((toolName: string, args: any) => void) | undefined -): void { +export function setToolCompletionListener(fn: ToolCompletionListener | undefined): void { toolCompletionListener = fn } @@ -805,7 +902,7 @@ async function callTool({ ) } const result = await tool.fn({ args, workspace, helpers, toolCallbacks, toolId }) - toolCompletionListener?.(functionName, args) + toolCompletionListener?.(functionName, args, workspace) return result } @@ -882,20 +979,29 @@ function stringifyErrorBody(body: unknown): string { * (tab closed while a tool polls) logs nothing, so the statuses sum to the calls that * finished, not to the calls made. */ -type ToolCallStatus = 'ok' | 'error' | 'declined' | 'rejected' | 'blocked_plan_mode' +type ToolCallStatus = + | 'ok' + | 'error' + | 'declined' + | 'rejected' + | 'blocked_plan_mode' + | 'held_for_instructions' export async function processToolCall({ tools, toolCall, helpers, toolCallbacks, - workspace + workspace, + messages = [] }: { tools: Tool[] toolCall: ChatCompletionMessageFunctionToolCall helpers: T toolCallbacks: ToolCallbacks workspace?: string + /** The conversation so far, for which folder instructions it already carries. */ + messages?: readonly ChatCompletionMessageParam[] }): Promise { const tool = tools.find((t) => t.def.function.name === toolCall.function.name) const workspaceId = workspace ?? get(workspaceStore) ?? '' @@ -979,6 +1085,43 @@ export async function processToolCall({ } } + // After the gates that refuse the call, so a refused call does not use up the + // delivery. A call that changes something is held back until the model has read + // the instructions: arriving with its result, they would be too late to shape it. + const folderInstructions = tool + ? await pendingFolderInstructions( + toolCallbacks.folderInstructions, + messages, + args, + workspaceId + ) + : undefined + if (folderInstructions?.paths.length) { + toolCallbacks.folderInstructions?.deliveredBy.set(toolCall.id, { + workspace: workspaceId, + paths: folderInstructions.paths + }) + } + if (folderInstructions && tool?.planModeSafe !== true) { + logToolOutcome('held_for_instructions') + toolCallbacks.setToolStatus(toolCall.id, { + content: 'Read folder instructions', + heldForFolderInstructions: true, + parameters: args, + isLoading: false, + isQueued: false, + isStreamingArguments: false, + needsConfirmation: false, + showDetails: tool?.showDetails, + autoCollapseDetails: tool?.autoCollapseDetails + }) + return { + role: 'tool' as const, + tool_call_id: toolCall.id, + content: `Not run: this call touches a folder whose instructions you had not read yet. Follow them, then make the call again (adjusted if they require it).\n\n${folderInstructions.text}` + } + } + const requiresConfirmation = tool?.requiresConfirmation === true // By name: skipping the wait is itself an answer on the user's behalf, and one tool // must not be answered for. @@ -1083,7 +1226,7 @@ export async function processToolCall({ const toAdd = { role: 'tool' as const, tool_call_id: toolCall.id, - content: result + content: folderInstructions ? `${result}\n\n${folderInstructions.text}` : result } return toAdd } catch (err) { @@ -1157,7 +1300,10 @@ export interface Tool { workspace: string helpers: T }) => MaybePromise - setSchema?: (helpers: any) => Promise + /** This chat's definition of the tool, for a schema that depends on the chat (its workspace, + * its open flow). Returns a new def: tools are module singletons shared by every chat, so + * one written back leaks into the others. */ + schemaFor?: (helpers: any) => Promise /** Safe to run while plan mode is active. Absence fails closed. */ planModeSafe?: boolean /** The arguments a plan-mode-safe tool still refuses while the posture holds — for a tool @@ -1358,6 +1504,9 @@ export interface ToolCallbacks { attachToolImage?: (toolId: string, image: AttachedImage) => void /** Drain every image buffered this batch (insertion order), clearing the buffer. */ takePendingToolImages?: () => AttachedImage[] + /** Wired only by the global/sessions chat: delivers the folder instructions + * covering the paths a tool call names, once per conversation. */ + folderInstructions?: FolderInstructionsContext } export function createToolDef( @@ -1457,6 +1606,7 @@ export function isHubPath(path: string): boolean { } export const createSearchHubScriptsTool = (withContent: boolean = false) => ({ + requires: NONE, def: searchHubScriptsToolDef, planModeSafe: true, fn: async ({ args, toolId, toolCallbacks }) => { @@ -1860,6 +2010,15 @@ export async function buildTestRunArgs( return parsedArgs } +// Said with the result rather than in the system prompt: there, models still copied a +// result's table or JSON into the reply, under a card that already renders it. Only for +// a successful run in the generic card completedJobToolStatus fills: a failed run's card +// lands on the error with the logs a tab away, and a formatCompletion card shows its own. +// Never on a background completion: its card can be far up the chat by then, so the +// reply may be the only place the user reads the result. +const RESULT_SHOWN_NOTE = + "The user already sees this run's result in its card. Do not repeat the result or logs in your reply: say what it shows, quoting only the values your conclusion rests on." + // The string handed back to the model when a job is backgrounded. It carries the // job id so the model can pull status/args/result/logs on demand (get_run / list_runs), // and tells it the completion will be reported later (notify-only wake). @@ -2004,7 +2163,12 @@ export async function executeTestRun(config: TestRunConfig): Promise { ...(job.success ? {} : { error: getErrorMessage(job.result) }) }) - const summary = formatResultSummary(job.result, job.logs, job.success) + // detachEnabled marks the global/sessions chat; the in-editor chats read the summary + // alone. The card opening on the result is up to each tool: a run card does, a + // generic one only with `autoCollapseDetails: false` at its registration. + const summary = + (detachEnabled && job.success ? `${RESULT_SHOWN_NOTE}\n` : '') + + formatResultSummary(job.result, job.logs, job.success) // get_run only exists in the global/sessions chat (the same hosts that wire // the job hooks) — don't advertise it to in-editor chats. if (detachEnabled && config.contextName === 'flow' && !job.success) { @@ -2049,6 +2213,13 @@ type FlowStepRunConfig = { toolId: string loadScript?: FlowStepScriptLoader loadSubflow?: FlowStepSubflowLoader + /** The path of the flow the step belongs to. An agent step's preview carries it, as the flow + * editor's own step test does: the run is filed under it, and a managed memory keyed on a + * memory id someone named reads the flow's conversation rather than a stray one. */ + flowPath?: string + /** The flow editor's substitution of a linked agent's unsaved draft, passed in because it + * reaches the draft store, which this module's import list must not. */ + withAgentDrafts?: (value: FlowValue) => Promise } export type FlowStepTestRunConfig = FlowStepRunConfig & { @@ -2073,8 +2244,9 @@ export type ResolvedFlowStepRun = { startMessage: string /** Takes the arguments as submitted. The preprocessor's entrypoint override is added * here rather than by the caller: it is declared by no schema, so anything that - * conforms arguments to one would drop it. */ - startJob: (args: Record) => Promise + * conforms arguments to one would drop it. An agent step also takes the run form's rows: + * each runs from its submitted value, including one the submission leaves out. */ + startJob: (args: Record, formKeys?: readonly string[]) => Promise } function normalizeFlowStepArgs(args: Record | null | undefined): Record { @@ -2118,7 +2290,9 @@ export async function resolveFlowStepRun({ toolCallbacks, toolId, loadScript = loadDeployedScriptForFlowStep, - loadSubflow + loadSubflow, + flowPath, + withAgentDrafts }: FlowStepRunConfig): Promise { const targetModule = findModuleInFlow(flowValue, stepId) ?? undefined @@ -2205,12 +2379,51 @@ export async function resolveFlowStepRun({ } } + if (moduleValue.type === 'aiagent') { + return { + module: targetModule, + // The job started is a flow preview: an agent step has none of its own, and the tool + // calls it makes are that flow's steps. + runnableKind: 'flow', + startMessage: `Starting test run of agent step "${stepId}"...`, + startJob: async (args, formKeys) => { + // The step alone, stripped of the settings around it: a mock would answer for it + // and a suspend would hold the run for an approval nobody is watching for. + const value: FlowValue = { + modules: [ + { + id: targetModule.id, + value: { + ...moduleValue, + input_transforms: agentTestInputTransforms( + moduleValue.input_transforms, + args, + formKeys + ) + } + } + ] + } + return JobService.runFlowPreview({ + workspace, + requestBody: { + // After the inputs are wired, as the flow editor's step test does: a linked agent's + // draft brings its own brain and tools, and the step's run inputs stay on top. + value: withAgentDrafts ? await withAgentDrafts(value) : value, + args, + path: flowPath + } + }) + } + } + } + toolCallbacks.setToolStatus(toolId, { content: `Step type "${moduleValue.type}" not supported for testing`, error: `Cannot test step of type "${moduleValue.type}"` }) throw new Error( - `Cannot test step of type "${moduleValue.type}". Supported types: rawscript, script, flow` + `Cannot test step of type "${moduleValue.type}". Supported types: rawscript, script, flow, aiagent` ) } diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiff.test.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiff.test.ts new file mode 100644 index 0000000000..5077585a4f --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolCodeDiff.test.ts @@ -0,0 +1,411 @@ +import { describe, expect, it } from 'vitest' +import hljs from 'highlight.js/lib/core' +import { + argumentsCarryWholeFile, + diffLineCounts, + hasToolCodeDiff, + toolCodeDiff, + toolDiffLines, + visibleToolDiffRows +} from './toolCodeDiff' +import { highlightedSourceLines } from './toolCodeDiffHighlight' +import { TOOL_CODE_DIFF_LANGUAGES, toolCodeDiffLanguage } from './toolCodeDiffLanguage' +import type { ToolDisplayMessage } from './shared' + +function message(overrides: Partial): ToolDisplayMessage { + return { + role: 'tool', + tool_call_id: 'call-1', + content: 'Updated script "f/example" as a draft', + ...overrides + } +} + +describe('toolCodeDiff', () => { + it('uses the saved full script sides after an edit succeeds', () => { + const codeDiff = { + before: 'const before = true\n', + after: 'const after = true\n', + lang: 'typescript' + } + + expect( + toolCodeDiff( + message({ + toolName: 'edit_script', + parameters: { old_string: 'before', new_string: 'after' }, + codeDiff + }) + ) + ).toBe(codeDiff) + }) + + it('uses edit arguments until the full script sides are available', () => { + expect( + toolCodeDiff( + message({ + toolName: 'edit_script', + parameters: { old_string: 'before', new_string: 'after' } + }) + ) + ).toEqual({ before: 'before', after: 'after', lang: 'plaintext' }) + }) + + it('renders streamed edit arguments before their JSON object is complete', () => { + expect( + toolCodeDiff( + message({ + toolName: 'edit_script', + parameters: '{"old_string":"before\\nline","new_string":"after' + }) + ) + ).toEqual({ before: 'before\nline', after: 'after', lang: 'plaintext' }) + }) + + it('uses each replacement from an in-editor edit while its arguments stream', () => { + expect( + toolCodeDiff( + message({ + toolName: 'edit_code', + parameters: { + diffs: [ + { old_string: 'first old', new_string: 'first new' }, + { old_string: 'second old', new_string: 'second new' } + ] + } + }) + ) + ).toEqual({ + before: 'first old\nsecond old', + after: 'first new\nsecond new', + lang: 'plaintext' + }) + }) + + it('uses each replacement while in-editor edit arguments are incomplete', () => { + expect( + toolCodeDiff( + message({ + toolName: 'edit_code', + parameters: + '{"diffs":[{"old_string":"first old","new_string":"first new"},{"old_string":"second old","new_string":"second new' + }) + ) + ).toEqual({ + before: 'first old\nsecond old', + after: 'first new\nsecond new', + lang: 'plaintext' + }) + }) + + it('shows a streamed full-code editor update without replacements', () => { + expect( + toolCodeDiff(message({ toolName: 'edit_code', parameters: '{"code":"console.log(1)' })) + ).toEqual({ before: '', after: 'console.log(1)', lang: 'plaintext' }) + }) + + it('shows streamed full-script updates before the saved diff is available', () => { + expect( + toolCodeDiff( + message({ toolName: 'write_script', parameters: '{"content":"print(\\\"hello\\\")' }) + ) + ).toEqual({ before: '', after: 'print("hello")', lang: 'plaintext' }) + }) + + it('only marks tools with a defined argument diff as diff-capable', () => { + expect(hasToolCodeDiff('edit_script')).toBe(true) + expect(hasToolCodeDiff('edit_code')).toBe(true) + expect(hasToolCodeDiff('write_script')).toBe(true) + expect(hasToolCodeDiff('__proto__')).toBe(false) + expect(hasToolCodeDiff('toString')).toBe(false) + expect(hasToolCodeDiff(undefined)).toBe(false) + }) + + it('ignores tool names inherited from Object.prototype', () => { + expect(toolCodeDiff(message({ toolName: 'constructor', parameters: {} }))).toBeUndefined() + }) + + it('counts added and removed lines independently', () => { + expect( + diffLineCounts({ + before: 'one\ntwo\nthree\n', + after: 'one\nnew\nthree\nfour\n', + lang: 'plaintext' + }) + ).toEqual({ + added: 2, + removed: 1 + }) + }) + + it('keeps a final newline as a diffable line', () => { + expect(toolDiffLines({ before: 'one', after: 'one\n', lang: 'plaintext' })).toEqual([ + { kind: 'context', content: 'one', oldLine: 1, newLine: 1 }, + { kind: 'added', content: '', newLine: 2 } + ]) + }) + + it('does not add a final-newline row when creating or deleting a file', () => { + expect(toolDiffLines({ before: '', after: 'one\n', lang: 'plaintext' })).toEqual([ + { kind: 'added', content: 'one', newLine: 1, changedRanges: [{ start: 0, length: 3 }] } + ]) + expect(toolDiffLines({ before: 'one\n', after: '', lang: 'plaintext' })).toEqual([ + { kind: 'removed', content: 'one', oldLine: 1, changedRanges: [{ start: 0, length: 3 }] } + ]) + }) + + it('keeps both gutters correct across removed and added lines', () => { + expect( + toolDiffLines({ + before: 'one\ntwo\nthree\n', + after: 'one\nnew\nthree\nfour\n', + lang: 'plaintext' + }) + ).toEqual([ + { kind: 'context', content: 'one', oldLine: 1, newLine: 1 }, + { + kind: 'removed', + content: 'two', + oldLine: 2, + newLine: undefined, + changedRanges: [{ start: 0, length: 3 }] + }, + { + kind: 'added', + content: 'new', + oldLine: undefined, + newLine: 2, + changedRanges: [{ start: 0, length: 3 }] + }, + { kind: 'context', content: 'three', oldLine: 3, newLine: 3 }, + { + kind: 'added', + content: 'four', + oldLine: undefined, + newLine: 4, + changedRanges: [{ start: 0, length: 4 }] + } + ]) + }) + + it('keeps unchanged trailing lines out of the change counts', () => { + const diff = { before: 'one\ntwo\nthree', after: 'one\nnew\nthree', lang: 'plaintext' } + + expect(diffLineCounts(diff)).toEqual({ added: 1, removed: 1 }) + expect(toolDiffLines(diff).filter((line) => line.content === 'three')).toEqual([ + { kind: 'context', content: 'three', oldLine: 3, newLine: 3 } + ]) + }) + + it('does not report changes for identical files', () => { + const diff = { before: 'one\ntwo\nthree', after: 'one\ntwo\nthree', lang: 'plaintext' } + + expect(diffLineCounts(diff)).toEqual({ added: 0, removed: 0 }) + expect(toolDiffLines(diff)).toEqual([ + { kind: 'context', content: 'one', oldLine: 1, newLine: 1 }, + { kind: 'context', content: 'two', oldLine: 2, newLine: 2 }, + { kind: 'context', content: 'three', oldLine: 3, newLine: 3 } + ]) + }) + + it('renders a newly created file', () => { + expect(toolDiffLines({ before: '', after: 'print("hello")', lang: 'python' })).toEqual([ + { + kind: 'added', + content: 'print("hello")', + oldLine: undefined, + newLine: 1, + changedRanges: [{ start: 0, length: 14 }] + } + ]) + }) + + it('renders a deleted file', () => { + expect(toolDiffLines({ before: 'print("goodbye")', after: '', lang: 'python' })).toEqual([ + { + kind: 'removed', + content: 'print("goodbye")', + oldLine: 1, + newLine: undefined, + changedRanges: [{ start: 0, length: 16 }] + } + ]) + }) + + it('uses Monaco line alignment for reordered lines', () => { + expect( + toolDiffLines({ + before: 'const a = 1\nconst b = 2', + after: 'const b = 2\nconst a = 1', + lang: 'typescript' + }) + ).toEqual([ + { + kind: 'added', + content: 'const b = 2', + newLine: 1, + changedRanges: [{ start: 0, length: 11, extendsToEnd: true }] + }, + { kind: 'context', content: 'const a = 1', oldLine: 1, newLine: 2 }, + { + kind: 'removed', + content: 'const b = 2', + oldLine: 2, + changedRanges: [{ start: 0, length: 11 }] + } + ]) + }) + + it('avoids expensive alignment while arguments stream', () => { + const before = Array.from({ length: 1_000 }, (_, index) => `before ${index}`).join('\n') + const after = Array.from({ length: 1_000 }, (_, index) => `after ${index}`).join('\n') + + expect(toolDiffLines({ before, after, lang: 'plaintext' }, true)).toHaveLength(400) + expect(diffLineCounts({ before, after, lang: 'plaintext' }, true)).toEqual({ + added: 1_000, + removed: 1_000 + }) + }) + + it('diffs a small edit in a large file', () => { + const beforeLines = Array.from({ length: 600 }, (_, index) => `line ${index}`) + const afterLines = beforeLines.map((line, index) => (index === 400 ? 'changed' : line)) + const diff = { before: beforeLines.join('\n'), after: afterLines.join('\n'), lang: 'plaintext' } + const lines = toolDiffLines(diff) + + expect(lines.filter((line) => line.kind !== 'context')).toMatchObject([ + { kind: 'removed', content: 'line 400', oldLine: 401 }, + { kind: 'added', content: 'changed', newLine: 401 } + ]) + expect(lines[399]).toEqual({ kind: 'context', content: 'line 399', oldLine: 400, newLine: 400 }) + expect(diffLineCounts(diff)).toEqual({ added: 1, removed: 1 }) + }) + + it('reports a large rewrite as a whole-file change', () => { + const before = Array.from({ length: 1_000 }, (_, index) => `before ${index}`).join('\n') + const after = Array.from({ length: 1_000 }, (_, index) => `after ${index}`).join('\n') + + expect(diffLineCounts({ before, after, lang: 'plaintext' })).toEqual({ + added: 1_000, + removed: 1_000 + }) + }) + + it('keeps an empty streamed replacement paired with its own edit', () => { + const parameters = + '{"diffs":[{"old_string":"a","new_string":""},{"old_string":"b","new_string":"c"}]}' + + expect(toolCodeDiff(message({ toolName: 'edit_code', parameters }))).toEqual({ + before: 'a\nb', + after: '\nc', + lang: 'plaintext' + }) + }) + + it('recognizes calls whose arguments are a whole file', () => { + const call = ( + toolName: string, + parameters: unknown, + codeDiff?: ToolDisplayMessage['codeDiff'] + ) => argumentsCarryWholeFile(message({ toolName, parameters, codeDiff })) + + expect(call('write_script', { path: 'f/a', content: '' })).toBe(true) + expect(call('edit_code', '{"code":"x')).toBe(true) + expect( + call('edit_code', { code: 'x' }, { before: 'old', after: 'x', lang: 'typescript' }) + ).toBe(true) + expect(call('edit_code', { diffs: [{ old_string: 'a', new_string: 'b' }] })).toBe(false) + expect(call('edit_script', { old_string: 'a', new_string: 'b' })).toBe(false) + }) + + it('shows each row of a small edit once', () => { + const lines = toolDiffLines({ before: 'a\nb\nc\n', after: 'a\nB\nc\n', lang: 'plaintext' }) + + expect(visibleToolDiffRows(lines, new Map())).toEqual(lines) + expect(lines.map((line) => line.kind)).toEqual(['context', 'removed', 'added', 'context']) + }) + + it('bounds the rows of a large rewrite', () => { + const before = Array.from({ length: 1_000 }, (_, index) => `before ${index}`).join('\n') + const after = Array.from({ length: 1_000 }, (_, index) => `after ${index}`).join('\n') + const lines = toolDiffLines({ before, after, lang: 'plaintext' }) + const rows = visibleToolDiffRows(lines, new Map()) + + expect(rows).toHaveLength(802) + expect(rows[400]).toEqual({ kind: 'omitted', key: 'removed:0', count: 600 }) + expect(rows[401]).toMatchObject({ kind: 'added', newLine: 1 }) + expect(rows[801]).toEqual({ kind: 'omitted', key: 'added:1000', count: 600 }) + + // One click on a run reveals 400 more rows; the overall cap still holds until `end` is clicked. + const expanded = visibleToolDiffRows(lines, new Map([['removed:0', 1]])) + expect(expanded).toHaveLength(1_001) + expect(expanded[800]).toEqual({ kind: 'omitted', key: 'removed:0', count: 200 }) + expect(expanded[1_000]).toEqual({ kind: 'omitted', key: 'end', count: 801 }) + expect( + visibleToolDiffRows( + lines, + new Map([ + ['removed:0', 1], + ['end', 1] + ]) + ) + ).toHaveLength(1_202) + }) + + it('highlights each line within the scope of the whole source', () => { + const typescript = toolCodeDiffLanguage('typescript') + hljs.registerLanguage(typescript.name, typescript.register) + const code = 'const s = `a\nb`\nconst n = 1\n' + + const lines = highlightedSourceLines(code, typescript.name) + expect(lines).toHaveLength(3) + expect(lines[0]).toBe( + 'const s = `a' + ) + expect(lines[1]).toMatch(/^b`<\/span>/) + expect(highlightedSourceLines(code, typescript.name, 2)).toEqual(lines.slice(0, 2)) + expect(highlightedSourceLines('a\nb', typescript.name)).toEqual(['a', 'b']) + expect(highlightedSourceLines('a\nb\n', typescript.name)).toEqual(['a', 'b']) + expect(highlightedSourceLines('', typescript.name)).toEqual([]) + + const large = Array.from({ length: 2_001 }, () => 'const a = 1 < 2').join('\n') + '\n' + const plain = highlightedSourceLines(large, typescript.name) + expect(plain).toHaveLength(2_001) + expect(plain[0]).toBe('const a = 1 < 2') + + const minified = highlightedSourceLines('x'.repeat(100_001), typescript.name) + expect(minified).toEqual(['x'.repeat(10_000) + '…']) + }) + + it('has a highlighter for every supported editor language', () => { + expect(TOOL_CODE_DIFF_LANGUAGES).toEqual( + expect.arrayContaining([ + 'typescript', + 'javascript', + 'python', + 'json', + 'yaml', + 'sql', + 'shell', + 'powershell', + 'php', + 'rust', + 'graphql', + 'csharp', + 'nu', + 'java', + 'r', + 'go', + 'ruby', + 'plaintext' + ]) + ) + for (const language of TOOL_CODE_DIFF_LANGUAGES) { + const highlighter = toolCodeDiffLanguage(language) + hljs.registerLanguage(highlighter.name, highlighter.register) + expect(() => + hljs.highlight('const changed = true', { language: highlighter.name }) + ).not.toThrow() + } + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts new file mode 100644 index 0000000000..2e5d8bf13c --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolCodeDiff.ts @@ -0,0 +1,381 @@ +// This is Monaco's advanced diff engine; it supplies the same multi-line inner ranges. +import { DefaultLinesDiffComputer } from '@codingame/monaco-vscode-api/vscode/vs/editor/common/diff/defaultLinesDiffComputer/defaultLinesDiffComputer' +import type { ToolCodeDiff, ToolDisplayMessage } from './shared' + +// Builds a diff from a call's own arguments, for the moments the saved `codeDiff` does not +// exist: while the arguments stream, when the call failed, and for transcripts saved before +// calls recorded one. Argument diffs have no language or surrounding lines. +const ARGS_DIFF_BY_TOOL: Record ToolCodeDiff | undefined> = { + edit_script: (params) => argumentDiff(streamingEditArguments(params)), + write_script: (params) => fullContentArgumentDiff(params, 'content'), + edit_code: (params) => { + if ( + params && + typeof params === 'object' && + Array.isArray((params as { diffs?: unknown }).diffs) + ) { + const diffs = (params as { diffs: unknown[] }).diffs + return argumentDiffs(diffs.map(streamingEditArguments)) + } + if (typeof params === 'string') { + return ( + argumentDiffs(streamingEditArgumentsArray(params)) ?? + fullContentArgumentDiff(params, 'code') + ) + } + return argumentDiff(streamingEditArguments(params)) ?? fullContentArgumentDiff(params, 'code') + } +} + +function fullContentArgumentDiff(params: unknown, key: string): ToolCodeDiff | undefined { + const content = + params && typeof params === 'object' + ? (params as Record)[key] + : typeof params === 'string' + ? partialJsonString(params, key) + : undefined + return typeof content === 'string' ? { before: '', after: content, lang: 'plaintext' } : undefined +} + +function argumentDiff( + edit: { old_string?: string; new_string?: string } | undefined +): ToolCodeDiff | undefined { + return typeof edit?.old_string === 'string' || typeof edit?.new_string === 'string' + ? { + before: typeof edit.old_string === 'string' ? edit.old_string : '', + after: typeof edit.new_string === 'string' ? edit.new_string : '', + lang: 'plaintext' + } + : undefined +} + +function argumentDiffs( + edits: ({ old_string?: string; new_string?: string } | undefined)[] +): ToolCodeDiff | undefined { + const diffs = edits.map(argumentDiff).filter((diff): diff is ToolCodeDiff => diff !== undefined) + if (diffs.length === 0) return undefined + return { + before: diffs.map((diff) => diff.before).join('\n'), + after: diffs.map((diff) => diff.after).join('\n'), + lang: 'plaintext' + } +} + +function streamingEditArguments( + params: unknown +): { old_string?: string; new_string?: string } | undefined { + if (params && typeof params === 'object') + return params as { old_string?: string; new_string?: string } + if (typeof params !== 'string') return undefined + + return { + old_string: partialJsonString(params, 'old_string'), + new_string: partialJsonString(params, 'new_string') + } +} + +function streamingEditArgumentsArray( + partialJson: string +): { old_string?: string; new_string?: string }[] { + const oldStrings = partialJsonStrings(partialJson, 'old_string') + const newStrings = partialJsonStrings(partialJson, 'new_string') + return Array.from({ length: Math.max(oldStrings.length, newStrings.length) }, (_, index) => ({ + old_string: oldStrings[index], + new_string: newStrings[index] + })) +} + +function partialJsonString(partialJson: string, key: string): string | undefined { + return partialJsonStrings(partialJson, key)[0] +} + +function partialJsonStrings(partialJson: string, key: string): string[] { + const matches = partialJson.matchAll(new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)`, 'g')) + const result: string[] = [] + // An empty string is a real value (a deletion): skipping it would pair the next edit's + // `new_string` with this edit's `old_string`. + for (const match of matches) { + try { + result.push(JSON.parse(`"${match[1]}"`)) + } catch { + continue + } + } + return result +} + +export function hasToolCodeDiff(toolName: string | undefined): boolean { + return toolName !== undefined && Object.hasOwn(ARGS_DIFF_BY_TOOL, toolName) +} + +export function toolCodeDiff(message: ToolDisplayMessage): ToolCodeDiff | undefined { + if (message.codeDiff) return message.codeDiff + if (!message.toolName) return undefined + return Object.hasOwn(ARGS_DIFF_BY_TOOL, message.toolName) + ? ARGS_DIFF_BY_TOOL[message.toolName](message.parameters) + : undefined +} + +// Decided by tool and argument shape, never by the saved `codeDiff`: an overwrite's saved diff +// has a before side, and clearing a script leaves both argument sides empty, yet both calls sent +// a whole file. +export function argumentsCarryWholeFile(message: ToolDisplayMessage): boolean { + if (message.toolName === 'write_script') return true + if (message.toolName !== 'edit_code') return false + const params = message.parameters + if (typeof params === 'string') return /"code"\s*:/.test(params) + return Boolean(params && typeof params === 'object' && 'code' in params) +} + +export function diffLineCounts( + diff: ToolCodeDiff, + streaming = false +): { added: number; removed: number } { + if (streaming) { + return { added: lines(diff.after).length, removed: lines(diff.before).length } + } + + return toolDiffLineCounts(toolDiffLines(diff)) +} + +export function toolDiffLineCounts(lines: ToolDiffLine[]): { added: number; removed: number } { + let added = 0 + let removed = 0 + for (const line of lines) { + if (line.kind === 'added') added++ + else if (line.kind === 'removed') removed++ + } + return { added, removed } +} + +export type ToolDiffLine = { + kind: 'context' | 'added' | 'removed' + content: string + oldLine?: number + newLine?: number + changedRanges?: CharacterRange[] +} + +export type CharacterRange = { start: number; length: number; extendsToEnd?: boolean } + +const STREAMING_PREVIEW_LINES = 200 + +function lines(value: string): string[] { + const result = value.split('\n') + if (result.at(-1) === '') result.pop() + return result +} + +function hasFinalNewline(value: string): boolean { + return value.endsWith('\n') +} + +export function toolDiffLines(diff: ToolCodeDiff, streaming = false): ToolDiffLine[] { + const before = lines(diff.before) + const after = lines(diff.after) + if (streaming) return streamingDiffLines(before, after) + + const result: ToolDiffLine[] = [] + const monacoBefore = monacoLines(diff.before) + const monacoAfter = monacoLines(diff.after) + // Runs on the UI thread. Cost follows the amount changed, not file size: only a large + // rewrite reaches the cap, and Monaco then reports the whole file as one change. + const changes = new DefaultLinesDiffComputer().computeDiff(monacoBefore, monacoAfter, { + ignoreTrimWhitespace: false, + maxComputationTimeMs: 200, + computeMoves: false, + extendToSubwords: false + }).changes + const removedRanges = new Map() + const addedRanges = new Map() + for (const change of changes) { + for (const innerChange of change.innerChanges ?? []) { + addCharacterRanges(removedRanges, monacoBefore, innerChange.originalRange) + addCharacterRanges(addedRanges, monacoAfter, innerChange.modifiedRange) + } + } + + let oldIndex = 0 + let newIndex = 0 + for (const change of changes) { + const oldStart = change.original.startLineNumber - 1 + const newStart = change.modified.startLineNumber - 1 + appendContextLines( + result, + before, + after, + oldIndex, + newIndex, + Math.min(oldStart - oldIndex, newStart - newIndex) + ) + appendChangedLines( + result, + 'removed', + before, + oldStart, + change.original.endLineNumberExclusive - 1, + removedRanges + ) + appendChangedLines( + result, + 'added', + after, + newStart, + change.modified.endLineNumberExclusive - 1, + addedRanges + ) + oldIndex = Math.min(change.original.endLineNumberExclusive - 1, before.length) + newIndex = Math.min(change.modified.endLineNumberExclusive - 1, after.length) + } + const trailingContext = Math.max(0, Math.min(before.length - oldIndex, after.length - newIndex)) + appendContextLines(result, before, after, oldIndex, newIndex, trailingContext) + oldIndex += trailingContext + newIndex += trailingContext + appendChangedLines(result, 'removed', before, oldIndex, before.length, removedRanges) + appendChangedLines(result, 'added', after, newIndex, after.length, addedRanges) + if ( + diff.before !== '' && + diff.after !== '' && + hasFinalNewline(diff.before) !== hasFinalNewline(diff.after) + ) { + result.push( + hasFinalNewline(diff.before) + ? { kind: 'removed', content: '', oldLine: before.length + 1 } + : { kind: 'added', content: '', newLine: after.length + 1 } + ) + } + + return result +} + +export type VisibleToolDiffRow = + | ToolDiffLine + | { kind: 'collapsed'; key: string; count: number } + | { kind: 'omitted'; key: string; count: number } + +const CONTEXT_LINES = 3 +// Every visible row is a DOM row with no virtualization, and only context collapses: a whole-file +// rewrite would otherwise render every line of both sides. Two capped runs fit under the overall +// cap, so a rewrite still shows its added side. Each expansion of an omitted row reveals one more +// cap's worth, so no single click renders an unbounded number of rows. +const MAX_CHANGED_RUN_ROWS = 400 +const MAX_VISIBLE_ROWS = 1_000 + +// `expansions` counts the clicks on each collapsed or omitted row, by its key. +export function visibleToolDiffRows( + lines: ToolDiffLine[], + expansions: ReadonlyMap +): VisibleToolDiffRow[] { + const result: VisibleToolDiffRow[] = [] + for (let index = 0; index < lines.length; ) { + const start = index + const kind = lines[start].kind + while (index < lines.length && lines[index].kind === kind) index++ + const count = index - start + + if (kind !== 'context') { + const key = `${kind}:${start}` + const shown = MAX_CHANGED_RUN_ROWS * (1 + (expansions.get(key) ?? 0)) + result.push(...lines.slice(start, Math.min(index, start + shown))) + if (count > shown) result.push({ kind: 'omitted', key, count: count - shown }) + continue + } + + const key = `${lines[start].oldLine}:${lines[start].newLine}:${count}` + if (count <= CONTEXT_LINES * 2 + 1 || expansions.has(key)) { + result.push(...lines.slice(start, index)) + continue + } + result.push(...lines.slice(start, start + CONTEXT_LINES)) + result.push({ kind: 'collapsed', key, count: count - CONTEXT_LINES * 2 }) + result.push(...lines.slice(index - CONTEXT_LINES, index)) + } + + const maxRows = MAX_VISIBLE_ROWS * (1 + (expansions.get('end') ?? 0)) + if (result.length <= maxRows) return result + const hidden = result + .slice(maxRows) + .reduce((total, row) => total + ('count' in row ? row.count : 1), 0) + return [...result.slice(0, maxRows), { kind: 'omitted', key: 'end', count: hidden }] +} + +function streamingDiffLines(before: string[], after: string[]): ToolDiffLine[] { + return [ + ...before + .slice(0, STREAMING_PREVIEW_LINES) + .map((content, index) => ({ kind: 'removed' as const, content, oldLine: index + 1 })), + ...after + .slice(0, STREAMING_PREVIEW_LINES) + .map((content, index) => ({ kind: 'added' as const, content, newLine: index + 1 })) + ] +} + +function monacoLines(value: string): string[] { + const result = lines(value) + return result.length ? result : [''] +} + +function appendContextLines( + result: ToolDiffLine[], + before: string[], + after: string[], + oldStart: number, + newStart: number, + count: number +): void { + for (let index = 0; index < count; index++) { + result.push({ + kind: 'context', + content: before[oldStart + index], + oldLine: oldStart + index + 1, + newLine: newStart + index + 1 + }) + } +} + +function appendChangedLines( + result: ToolDiffLine[], + kind: 'added' | 'removed', + source: string[], + start: number, + end: number, + ranges: Map +): void { + for (let index = start; index < Math.min(end, source.length); index++) { + const changedRanges = ranges.get(index + 1) + result.push({ + kind, + content: source[index], + ...(kind === 'added' ? { newLine: index + 1 } : { oldLine: index + 1 }), + ...(changedRanges?.length ? { changedRanges } : {}) + }) + } +} + +function addCharacterRanges( + ranges: Map, + lines: string[], + range: { + startLineNumber: number + startColumn: number + endLineNumber: number + endColumn: number + } +): void { + for ( + let lineNumber = range.startLineNumber; + lineNumber <= Math.min(range.endLineNumber, lines.length); + lineNumber++ + ) { + const content = lines[lineNumber - 1] + const start = lineNumber === range.startLineNumber ? range.startColumn - 1 : 0 + const extendsToEnd = lineNumber < range.endLineNumber + const end = extendsToEnd ? content.length : range.endColumn - 1 + if (end > start) { + ranges.set(lineNumber, [ + ...(ranges.get(lineNumber) ?? []), + { start, length: end - start, ...(extendsToEnd ? { extendsToEnd } : {}) } + ]) + } + } +} diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiffHighlight.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiffHighlight.ts new file mode 100644 index 0000000000..b63d002e9e --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolCodeDiffHighlight.ts @@ -0,0 +1,57 @@ +import hljs from 'highlight.js/lib/core' +import { escapeHtml } from '$lib/utils' + +// highlight.js runs on the UI thread at roughly 15 ms per 1,000 lines (50 KB), and must see the +// whole source for multi-line strings and comments to scope correctly. Larger sides, including +// minified one-line sources, render as plain text with each line cut at MAX_PLAIN_LINE_CHARS. +const MAX_HIGHLIGHTED_LINES = 2_000 +const MAX_HIGHLIGHTED_CHARS = 100_000 +const MAX_PLAIN_LINE_CHARS = 10_000 + +// Highlights the whole source before splitting it, so a line inside a multi-line string or +// comment keeps that scope. Only the first `lineLimit` lines are highlighted: a streamed source +// grows on every delta while the preview shows a bounded prefix. +export function highlightedSourceLines( + code: string, + languageName: string, + lineLimit = Infinity +): string[] { + if (code === '' || lineLimit <= 0) return [] + const sourceLines = code.split('\n') + const truncated = sourceLines.length > lineLimit + const shownLines = truncated ? sourceLines.slice(0, lineLimit) : sourceLines + const source = truncated ? shownLines.join('\n') : code + if (shownLines.length > MAX_HIGHLIGHTED_LINES || source.length > MAX_HIGHLIGHTED_CHARS) { + const plain = shownLines.map((line) => + escapeHtml( + line.length > MAX_PLAIN_LINE_CHARS ? line.slice(0, MAX_PLAIN_LINE_CHARS) + '…' : line + ) + ) + if (!truncated && code.endsWith('\n')) plain.pop() + return plain + } + const highlighted = hljs.highlight(source, { language: languageName }).value + return splitHighlightedLines(highlighted, !truncated && code.endsWith('\n')) +} + +// Returns one entry per source line, closing the spans still open at each line end and +// reopening them on the next line so every entry is balanced HTML. +export function splitHighlightedLines(highlighted: string, hasFinalNewline: boolean): string[] { + const result: string[] = [] + const openTags: string[] = [] + let line = '' + + for (const token of highlighted.split(/(<[^>]+>|\n)/)) { + if (token === '\n') { + result.push(line + ''.repeat(openTags.length)) + line = openTags.join('') + } else { + line += token + if (token.startsWith('') openTags.pop() + } + } + + if (!hasFinalNewline) result.push(line) + return result +} diff --git a/frontend/src/lib/components/copilot/chat/toolCodeDiffLanguage.ts b/frontend/src/lib/components/copilot/chat/toolCodeDiffLanguage.ts new file mode 100644 index 0000000000..ddf2464136 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/toolCodeDiffLanguage.ts @@ -0,0 +1,43 @@ +import { csharp, go, java, json, plaintext, type LanguageType } from 'svelte-highlight/languages' +import graphql from 'svelte-highlight/languages/graphql' +import javascript from 'svelte-highlight/languages/javascript' +import php from 'svelte-highlight/languages/php' +import python from 'svelte-highlight/languages/python' +import powershell from 'svelte-highlight/languages/powershell' +import r from 'svelte-highlight/languages/r' +import ruby from 'svelte-highlight/languages/ruby' +import rust from 'svelte-highlight/languages/rust' +import shell from 'svelte-highlight/languages/shell' +import sql from 'svelte-highlight/languages/sql' +import typescript from 'svelte-highlight/languages/typescript' +import yaml from 'svelte-highlight/languages/yaml' + +// Keyed by the editor language `scriptLangToEditorLang` returns, not the script language. +// for related places search: ADD_NEW_LANG +const LANGUAGE_BY_MONACO: Record> = { + typescript, + javascript, + python, + json, + yaml, + sql, + shell, + powershell, + php, + rust, + graphql, + csharp, + nu: python, + java, + r, + go, + ruby, + text: plaintext, + plaintext +} + +export const TOOL_CODE_DIFF_LANGUAGES = Object.keys(LANGUAGE_BY_MONACO) + +export function toolCodeDiffLanguage(language: string): LanguageType { + return LANGUAGE_BY_MONACO[language] ?? plaintext +} diff --git a/frontend/src/lib/components/copilot/chat/typewriterReveal.ts b/frontend/src/lib/components/copilot/chat/typewriterReveal.ts index 0cc1d75ac3..93b33962ee 100644 --- a/frontend/src/lib/components/copilot/chat/typewriterReveal.ts +++ b/frontend/src/lib/components/copilot/chat/typewriterReveal.ts @@ -9,6 +9,13 @@ // state is the `onReveal` callback — so the pacing is unit-testable with an // injected clock and scheduler. +import { BROWSER } from 'esm-env' + +/** SSR and readers who prefer reduced motion get no pacing: text lands as it arrives. */ +export function prefersInstantReveal(): boolean { + return !BROWSER || (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false) +} + type Schedule = (cb: () => void) => unknown type Cancel = (handle: unknown) => void diff --git a/frontend/src/lib/components/copilot/lib.test.ts b/frontend/src/lib/components/copilot/lib.test.ts index 964bf6e703..8e06e0e2b9 100644 --- a/frontend/src/lib/components/copilot/lib.test.ts +++ b/frontend/src/lib/components/copilot/lib.test.ts @@ -13,7 +13,7 @@ import { import { parseFimCompletionChoice } from './fim' import { getKnownModelContextWindow, - getModelContextWindow, + getModelContextWindowFromTable, modelSupportsVision, requiresMaxCompletionTokens } from './modelConfig' @@ -263,6 +263,7 @@ describe('model context windows', () => { expect(getKnownModelContextWindow('gemini-3-flash')).toBe(1000000) expect(getKnownModelContextWindow('gemini-2.5-flash')).toBe(1000000) expect(getKnownModelContextWindow('deepseek-v4-pro')).toBe(1000000) + expect(getKnownModelContextWindow('deepseek-flash')).toBe(1000000) expect(getKnownModelContextWindow('deepseek-chat')).toBe(1000000) expect(getKnownModelContextWindow('deepseek-reasoner')).toBe(1000000) }) @@ -289,12 +290,12 @@ describe('model context windows', () => { // a version between "qwen3" and "-max" must not claim the 256K entry, and // there is deliberately no qwen family entry (variant windows range 8K–1M) expect(getKnownModelContextWindow('qwen3.8-max')).toBeUndefined() - expect(getModelContextWindow('qwen3.8-max')).toBe(128000) + expect(getModelContextWindowFromTable('qwen3.8-max')).toBe(128000) }) it('returns undefined for unrecognized models, 128K via the defaulting wrapper', () => { expect(getKnownModelContextWindow('some-custom-model')).toBeUndefined() - expect(getModelContextWindow('some-custom-model')).toBe(128000) + expect(getModelContextWindowFromTable('some-custom-model')).toBe(128000) }) }) diff --git a/frontend/src/lib/components/copilot/lib.toolCalls.test.ts b/frontend/src/lib/components/copilot/lib.toolCalls.test.ts index c665cc964e..6966e018d1 100644 --- a/frontend/src/lib/components/copilot/lib.toolCalls.test.ts +++ b/frontend/src/lib/components/copilot/lib.toolCalls.test.ts @@ -285,3 +285,65 @@ describe('parseOpenAICompletion tool call arguments', () => { expect(toolResult.content).toBe('tool ok') }) }) + +describe('output token limit', () => { + it('follows the model through every host that serves it', async () => { + const { getModelMaxTokens } = await import('./lib') + expect(getModelMaxTokens('deepseek', 'deepseek-flash')).toBe(131072) + expect(getModelMaxTokens('azure_foundry', 'DeepSeek-V4-Pro')).toBe(131072) + expect(getModelMaxTokens('openrouter', 'deepseek/deepseek-v4-pro')).toBe(131072) + // gpt-oss reasons whether or not the chat sends an effort. + expect(getModelMaxTokens('groq', 'openai/gpt-oss-120b')).toBe(16000) + expect(getModelMaxTokens('openrouter', 'openai/o3')).toBe(100000) + // A legacy `/thinking` selection is its base model, not the `thinking` row. + expect(getModelMaxTokens('anthropic', 'claude-sonnet-4-5/thinking')).toBe(64000) + }) + + it('gives a model outside the table room to think only when the request asks it to', async () => { + const { getModelMaxTokens } = await import('./lib') + expect(getModelMaxTokens('openrouter', 'x-ai/grok-4.3', 'high')).toBe(32768) + expect(getModelMaxTokens('openrouter', 'x-ai/grok-4.3', 'none')).toBe(8192) + expect(getModelMaxTokens('openrouter', 'x-ai/grok-4.3')).toBe(8192) + // The registry does not know Custom AI models, so an effort says nothing about them. + expect(getModelMaxTokens('customai', 'local-model', 'high')).toBe(8192) + // A self-hosted open-weight model runs at its operator's context length, not the + // vendor's, while a Custom AI proxy in front of a hosted model keeps its row. + expect(getModelMaxTokens('customai', 'deepseek-v4-pro', 'high')).toBe(8192) + expect(getModelMaxTokens('customai', 'gpt-5')).toBe(128000) + }) + + it('sizes the budget from the effort the same request carries', async () => { + const { getCompletion } = await import('./lib') + const create = vi.fn() + + await getCompletion([], new AbortController(), undefined, { + forceModelProvider: { provider: 'mistral', model: 'mistral-medium-latest' }, + openaiClient: { chat: { completions: { create } } } as any, + reasoningEffort: 'high' + }) + + expect(create.mock.calls[0][0]).toMatchObject({ max_tokens: 32768, reasoning_effort: 'high' }) + }) + + it('fails a response cut off at the output token limit instead of ending the turn', async () => { + const { parseOpenAICompletion } = await import('./lib') + const { OutputTokenLimitError } = await import('./chat/outputTokenLimit') + + // Thinking counts toward max_tokens, so the cap can land before any answer. + const parsed = parseOpenAICompletion( + streamOf([ + { choices: [{ delta: { reasoning_content: 'I need to plan the flow so each' } }] }, + { choices: [{ delta: {}, finish_reason: 'length' }] } + ]), + createCallbacks(), + [], + [], + [], + {}, + undefined, + { workspace: 'test' } + ) + + await expect(parsed).rejects.toBeInstanceOf(OutputTokenLimitError) + }) +}) diff --git a/frontend/src/lib/components/copilot/lib.ts b/frontend/src/lib/components/copilot/lib.ts index 19f350a665..655a872f96 100644 --- a/frontend/src/lib/components/copilot/lib.ts +++ b/frontend/src/lib/components/copilot/lib.ts @@ -15,11 +15,17 @@ import { get, type Writable } from 'svelte/store' import { OpenAPI, ResourceService, type Script } from '../../gen' import { EDIT_CONFIG, FIX_CONFIG, GEN_CONFIG } from './prompts' import { + getKnownModelMaxOutputTokens, requiresMaxCompletionTokens, usesAnthropicMessagesApi, usesOpenRouterPromptCaching } from './modelConfig' -import { applyReasoningToConfig } from './reasoningRegistry' +import { + applyReasoningToConfig, + requestsReasoning, + stripLegacyThinkingSuffix, + type ReasoningEffort +} from './reasoningRegistry' import { formatResourceTypes } from './utils' import { appendPendingToolImages, @@ -28,6 +34,7 @@ import { type Tool, type ToolCallbacks } from './chat/shared' +import { OutputTokenLimitError } from './chat/outputTokenLimit' import { hasValidToolCallArguments } from './chat/toolCallArguments' import { getNonStreamingOpenAIResponsesCompletion, @@ -116,7 +123,7 @@ export const AI_PROVIDERS: Record = { }, deepseek: { label: 'DeepSeek', - defaultModels: ['deepseek-v4-pro', 'deepseek-v4-flash'] + defaultModels: ['deepseek-v4-pro', 'deepseek-flash'] }, groq: { label: 'Groq', @@ -309,40 +316,31 @@ export async function fetchAvailableModels( return data?.data.map((m) => m.id) ?? [] } -export function getModelMaxTokens(provider: AIProvider, model: string) { - if (model.includes('gpt-5')) { - return 128000 - } else if ( - (provider === 'azure_openai' || provider === 'openai' || provider === 'azure_foundry') && - model.startsWith('o') - ) { - return 100000 - } else if ( - // Raising this further would also raise the worst case of the - // non-streaming completion path, which the Anthropic SDK refuses once - // the request could run past ~10 minutes. - model.includes('claude-sonnet') || - model.includes('claude-haiku') || - model.includes('claude-fable') || - model.includes('claude-mythos') || - // Opus only from 4.5 on. Opus 4.1 and older cap at 32K and fall through - // to the row below. Dots are normalized because OpenRouter writes - // `anthropic/claude-opus-4.5` where Anthropic writes `claude-opus-4-5`. - /claude-opus-(4-(5|6|7|8)|5)(?!\d)/.test(model.replace(/\./g, '-')) || - model.includes('gemini-2.5') || - model.includes('gemini-3') - ) { - return 64000 - } else if (model.includes('gpt-4.1')) { - return 32768 - } else if (model.includes('claude-opus')) { - return 32000 - } else if (model.includes('gpt-4o') || model.includes('codestral')) { - return 16384 - } else if (model.includes('gpt-4-turbo') || model.includes('gpt-3.5')) { - return 4096 - } - return 8192 +// Thinking counts toward max_tokens, so a model outside the table that is asked to +// reason gets more room than the plain fallback. A host rejects a budget above the +// model's cap, so the reasoning fallback must stay within the cap of every model it +// reaches (true of every reasoning model OpenRouter serves outside the table). Custom +// AI never gets it: the registry does not know its models, and the per-model override +// is its way to a larger budget. +const FALLBACK_MAX_TOKENS = 8192 +const REASONING_FALLBACK_MAX_TOKENS = 32768 + +/** + * The output budget a request sends when the workspace sets no override. `reasoningEffort` + * is the effort the request carries, as `resolveRequestReasoning` resolves it. + */ +export function getModelMaxTokens( + provider: AIProvider, + model: string, + reasoningEffort?: ReasoningEffort +): number { + const bareModel = stripLegacyThinkingSuffix(model) + return ( + getKnownModelMaxOutputTokens(provider, bareModel) ?? + (requestsReasoning(provider, bareModel, reasoningEffort) + ? REASONING_FALLBACK_MAX_TOKENS + : FALLBACK_MAX_TOKENS) + ) } // Resolves the completion token cap for a model: the workspace's per-model @@ -350,8 +348,16 @@ export function getModelMaxTokens(provider: AIProvider, model: string) { // Anthropic request paths so both honor the same limit. `cap` bounds the result // (used by short metadata completions, see METADATA_MAX_TOKENS) — a hard ceiling // that wins over both the workspace override and the default. -function resolveMaxTokens(modelProvider: AIProviderModel, cap?: number): number { - const defaultMaxTokens = getModelMaxTokens(modelProvider.provider, modelProvider.model) +function resolveMaxTokens( + modelProvider: AIProviderModel, + cap?: number, + reasoningEffort?: ReasoningEffort +): number { + const defaultMaxTokens = getModelMaxTokens( + modelProvider.provider, + modelProvider.model, + reasoningEffort + ) const modelKey = `${modelProvider.provider}:${modelProvider.model}` let customMaxTokensStore: Record | undefined try { @@ -373,9 +379,10 @@ export const METADATA_MAX_TOKENS = 4096 function getModelSpecificConfig( modelProvider: AIProviderModel, tools?: OpenAI.Chat.Completions.ChatCompletionTool[], - maxTokensCap?: number + maxTokensCap?: number, + reasoningEffort?: ReasoningEffort ) { - const maxTokens = resolveMaxTokens(modelProvider, maxTokensCap) + const maxTokens = resolveMaxTokens(modelProvider, maxTokensCap, reasoningEffort) if ( (modelProvider.provider === 'openai' || modelProvider.provider === 'azure_openai' || @@ -897,7 +904,8 @@ export function getProviderAndCompletionConfig({ tools, forceModelProvider, maxTokensCap, - promptCaching + promptCaching, + reasoningEffort }: { messages: ChatCompletionMessageParam[] stream: K @@ -907,6 +915,9 @@ export function getProviderAndCompletionConfig({ // Opt-in: a cache write costs more than an uncached read, so it only pays off where // the same prefix is sent again. True for the chat loop, false for one-shot calls. promptCaching?: boolean + // The effort the caller will add with `applyReasoningToConfig`. Only sizes the + // output budget here: a request that reasons needs room for the thinking. + reasoningEffort?: ReasoningEffort }): { provider: AIProvider config: K extends true @@ -923,7 +934,7 @@ export function getProviderAndCompletionConfig({ provider: modelProvider.provider, config: { ...providerConfig, - ...getModelSpecificConfig(modelProvider, tools, maxTokensCap), + ...getModelSpecificConfig(modelProvider, tools, maxTokensCap, reasoningEffort), messages: processedMessages, stream } as any @@ -1129,7 +1140,8 @@ export async function getCompletion( stream: true, tools, forceModelProvider: options?.forceModelProvider, - promptCaching: options?.promptCaching + promptCaching: options?.promptCaching, + reasoningEffort: options?.reasoningEffort }) // Use Responses API for OpenAI and Azure OpenAI @@ -1218,6 +1230,7 @@ export async function parseOpenAICompletion( // to the next call, the previous one is demoted to queued. let streamingToolCallId: string | undefined = undefined let malformedFunctionCallError = false + let hitOutputTokenLimit = false let tokenUsage = emptyChatTokenUsage() let answer = '' @@ -1244,6 +1257,9 @@ export async function parseOpenAICompletion( ) { malformedFunctionCallError = true } + if (finishReason === 'length') { + hitOutputTokenLimit = true + } // Mistral nests reasoning inside structured content parts; split them out // so a content delta never leaks "[object Object]" into the answer. @@ -1417,7 +1433,8 @@ export async function parseOpenAICompletion( toolCall, helpers, toolCallbacks: callbacks, - workspace: options?.workspace + workspace: options?.workspace, + messages }) messages.push(messageToAdd) addedMessages.push(messageToAdd) @@ -1460,6 +1477,8 @@ export async function parseOpenAICompletion( } messages.push(toolResponse) addedMessages.push(toolResponse) + } else if (hitOutputTokenLimit) { + throw new OutputTokenLimitError() } else { return { shouldContinue: false, tokenUsage } } diff --git a/frontend/src/lib/components/copilot/modelConfig.test.ts b/frontend/src/lib/components/copilot/modelConfig.test.ts index 1788d9c365..4f54eb223a 100644 --- a/frontend/src/lib/components/copilot/modelConfig.test.ts +++ b/frontend/src/lib/components/copilot/modelConfig.test.ts @@ -1,5 +1,27 @@ import { describe, expect, it } from 'vitest' -import { usesAnthropicMessagesApi } from './modelConfig' +import { + getConfiguredModelContextWindow, + getEffectiveModelContextWindow, + usesAnthropicMessagesApi +} from './modelConfig' + +describe('workspace context window overrides', () => { + const overrides = { 'customai:qwen-local': 32_768, 'anthropic:claude-sonnet-5': 200_000 } + + it('wins over the built-in table and the assumed window for its exact provider:model', () => { + expect(getEffectiveModelContextWindow('customai', 'qwen-local', overrides)).toBe(32_768) + expect(getConfiguredModelContextWindow('customai', 'qwen-local', overrides)).toBe(32_768) + expect(getEffectiveModelContextWindow('anthropic', 'claude-sonnet-5', overrides)).toBe(200_000) + }) + + it('does not apply to the same model id served by another provider', () => { + expect(getEffectiveModelContextWindow('openrouter', 'claude-sonnet-5', overrides)).toBe( + 1_000_000 + ) + expect(getConfiguredModelContextWindow('openai', 'qwen-local', overrides)).toBeUndefined() + expect(getEffectiveModelContextWindow('openai', 'qwen-local', overrides)).toBe(128_000) + }) +}) describe('usesAnthropicMessagesApi', () => { it('routes the native Anthropic provider through the Messages API', () => { diff --git a/frontend/src/lib/components/copilot/modelConfig.ts b/frontend/src/lib/components/copilot/modelConfig.ts index cc45a23dac..77d0cf942a 100644 --- a/frontend/src/lib/components/copilot/modelConfig.ts +++ b/frontend/src/lib/components/copilot/modelConfig.ts @@ -66,9 +66,9 @@ export function requiresMaxCompletionTokens(model: string) { // ids (anthropic.claude-sonnet-4-6-...-v1:0, gpt-5.2-2026-01-01) still resolve. // Conservative family fallbacks sit below the explicit entries; models not // listed at all resolve to undefined. Consumers that need a number regardless -// (trim/compaction, the usage indicator) go through getModelContextWindow, -// whose conservative 128K fallback keeps a limit enforced and is surfaced to -// the user as an assumed window. +// (trim/compaction, the usage indicator) go through +// getEffectiveModelContextWindow, whose conservative 128K fallback keeps a +// limit enforced and is surfaced to the user as an assumed window. const MODEL_CONTEXT_WINDOWS: [name: string, contextWindow: number][] = [ // Anthropic — Sonnet/Opus 4.6+ ship a 1M window at standard pricing (GA); // Haiku, older Claude models (3.x, 4.0, 4.1, 4.5) and date-suffixed Claude 4 @@ -96,10 +96,12 @@ const MODEL_CONTEXT_WINDOWS: [name: string, contextWindow: number][] = [ ['gemini-3.1', 1_000_000], ['gemini-3', 1_000_000], ['gemini-2.5', 1_000_000], - // DeepSeek — the V4 family (pro / flash) is 1M. The deepseek-chat / - // deepseek-reasoner aliases were retired 2026-07-24 but can still sit in a - // saved selection, so they keep resolving to the window they had. + // DeepSeek — the V4 family (pro / flash) is 1M; V4.1 Flash is served as + // `deepseek-flash`. The deepseek-chat / deepseek-reasoner aliases were retired + // 2026-07-24 but can still sit in a saved selection, so they keep resolving to + // the window they had. ['deepseek-v4', 1_000_000], + ['deepseek-flash', 1_000_000], ['deepseek-chat', 1_000_000], ['deepseek-reasoner', 1_000_000], ['deepseek', 128_000], @@ -115,6 +117,58 @@ const MODEL_CONTEXT_WINDOWS: [name: string, contextWindow: number][] = [ ['codestral', 32_000] ] +// Output token budgets, matched like the context windows above so one row covers +// every host of a model: native API, Azure AI Foundry, OpenRouter, Together, Bedrock. +// A host rejects any request above its own limit, so each value is one every host of +// the model accepts, not the vendor's maximum. Models not listed fall back in +// `getModelMaxTokens`. +const MODEL_MAX_OUTPUT_TOKENS: [name: string, maxOutputTokens: number][] = [ + // Anthropic — the Anthropic SDK refuses a non-streaming request whose max_tokens + // implies more than ~10 minutes, so these stay below the models' own maximum. + // Opus caps at 32K before 4.5. + ['claude-opus-4.5', 64_000], + ['claude-opus-4.6', 64_000], + ['claude-opus-4.7', 64_000], + ['claude-opus-4.8', 64_000], + ['claude-opus-5', 64_000], + ['claude-opus', 32_000], + ['claude-sonnet', 64_000], + ['claude-haiku', 64_000], + ['claude-fable', 64_000], + ['claude-mythos', 64_000], + // OpenAI + ['gpt-5', 128_000], + ['gpt-4.1', 32_768], + ['gpt-4o', 16_384], + ['gpt-4-turbo', 4_096], + ['gpt-3.5', 4_096], + ['o1', 100_000], + ['o3', 100_000], + ['o4-mini', 100_000], + // Google + ['gemini-3', 64_000], + ['gemini-2.5', 64_000] +] + +// Open-weight models, which a self-hosted server (Custom AI) caps at whatever context +// length its operator set, often below these: vLLM rejects a request whose prompt plus +// max_tokens exceeds it. So these rows only apply to the providers that host them. +const OPEN_WEIGHT_MAX_OUTPUT_TOKENS: [name: string, maxOutputTokens: number][] = [ + // gpt-oss reasons on every request. Bedrock documents 16K, where Groq takes 65536 + ['gpt-oss', 16_000], + // DeepSeek — thinks by default and counts the thinking toward max_tokens. 131072 + // is DeepSeek's own default at the highest effort. R1 is capped by its OpenRouter + // host. + ['deepseek-v4', 131_072], + ['deepseek-flash', 131_072], + ['deepseek-r1', 16_000], + // Mistral + ['codestral', 16_384], + // Models named for thinking reason on every request, whether or not the chat sends + // an effort. Every one OpenRouter lists takes at least this much. + ['thinking', 32_768] +] + // Version separators differ by route to the same model: Anthropic writes // `claude-opus-4-8`, OpenRouter writes `anthropic/claude-opus-4.8`. Collapsing // dots to dashes on both sides keeps one table entry covering every route — @@ -162,9 +216,7 @@ export function buildModelMatchers( // separator is normalized. Only a short segment: a date is digits as well // (`-20251101`) and stays a decoration. strictVariants ? '(?!-\\d{1,3}(?:$|-))' : '', - strictVariants - ? `(?!-(?!(?:v\\d|${DECORATIVE_SUFFIXES.join('|')})$)[a-z])` - : '' + strictVariants ? `(?!-(?!(?:v\\d|${DECORATIVE_SUFFIXES.join('|')})$)[a-z])` : '' ].join('') return [new RegExp(pattern + guards), value] }) @@ -172,8 +224,9 @@ export function buildModelMatchers( /** * The `provider:model` key the workspace AI settings use for their per-model maps - * (`max_tokens_per_model`, `model_pricing`). A bare model id is not enough: the - * same id can be served by more than one provider at different rates. + * (`max_tokens_per_model`, `model_pricing`, `context_window_per_model`). A bare + * model id is not enough: the same id can be served by more than one provider at + * different rates. * * Matched exactly, unlike the fuzzy tables above. Those tables generalize across * every route to one model on purpose; a per-model *setting* must not, or an @@ -196,9 +249,54 @@ export function getKnownModelContextWindow(model: string): number | undefined { return matchModel(MODEL_CONTEXT_WINDOW_MATCHERS, model) } -export function getModelContextWindow(model: string) { - // Trim/compaction logic needs a number; assume a conservative window when unknown. - return getKnownModelContextWindow(model) ?? 128000 +const MODEL_MAX_OUTPUT_TOKEN_MATCHERS = buildModelMatchers(MODEL_MAX_OUTPUT_TOKENS) +const OPEN_WEIGHT_MAX_OUTPUT_TOKEN_MATCHERS = buildModelMatchers(OPEN_WEIGHT_MAX_OUTPUT_TOKENS) + +export function getKnownModelMaxOutputTokens( + provider: AIProvider, + model: string +): number | undefined { + return ( + matchModel(MODEL_MAX_OUTPUT_TOKEN_MATCHERS, model) ?? + (provider === 'customai' ? undefined : matchModel(OPEN_WEIGHT_MAX_OUTPUT_TOKEN_MATCHERS, model)) + ) +} + +/** Trim/compaction logic needs a number; assume a conservative window when unknown. */ +export const ASSUMED_CONTEXT_WINDOW = 128000 + +/** + * What the built-in table states for a model, assuming a conservative window for one + * it does not list. Table only, so it ignores the workspace's `context_window_per_model` + * — deciding how much context to send goes through `getEffectiveModelContextWindow` + * instead. Named for the table because a plain `getModelContextWindow` is the obvious + * thing to reach for, and reaching for it here would silently skip the override. + */ +export function getModelContextWindowFromTable(model: string) { + return getKnownModelContextWindow(model) ?? ASSUMED_CONTEXT_WINDOW +} + +/** + * The context window stated for a model: the workspace's override + * (`context_window_per_model`, passed in so this module stays free of stores) + * when one is set, otherwise the built-in table. Undefined only when neither + * knows the model, which is when a consumer falls back to an assumed window. + */ +export function getConfiguredModelContextWindow( + provider: AIProvider, + model: string, + overrides: Record | undefined +): number | undefined { + return overrides?.[modelKey(provider, model)] ?? getKnownModelContextWindow(model) +} + +/** The window to budget against: the override, then the table, then the assumption. */ +export function getEffectiveModelContextWindow( + provider: AIProvider, + model: string, + overrides: Record | undefined +): number { + return getConfiguredModelContextWindow(provider, model, overrides) ?? ASSUMED_CONTEXT_WINDOW } /** diff --git a/frontend/src/lib/components/copilot/reasoningRegistry.ts b/frontend/src/lib/components/copilot/reasoningRegistry.ts index 242f6cb329..9915e9c42f 100644 --- a/frontend/src/lib/components/copilot/reasoningRegistry.ts +++ b/frontend/src/lib/components/copilot/reasoningRegistry.ts @@ -374,7 +374,8 @@ const ANTHROPIC_ALWAYS_THINKING = /fable|mythos/ /** * Disable token to forward when the user explicitly turns reasoning off on a * model that reasons *by default* — omitting the field would silently keep - * the default-on behavior. Undefined means omission is the correct off. + * the default-on behavior. Undefined means omission is the correct off. Every + * token is `'none'`: `requestsReasoning` reads that value as off. */ export function explicitOffToken(provider: AIProvider, model: string): ReasoningEffort | undefined { switch (reasoningProviderFamily(provider, model)) { @@ -434,6 +435,20 @@ export function resolveRequestReasoning( return undefined } +/** + * Whether a request carrying `effort`, as `resolveRequestReasoning` resolves it, + * has the model reason. An effort sent to a model the registry does not mark as + * reasoning-capable answers no: an explicit choice is always sent, but that does + * not make the model think. + */ +export function requestsReasoning( + provider: AIProvider, + model: string, + effort: ReasoningEffort | undefined +): boolean { + return !!effort && effort !== 'none' && supportsReasoning(provider, model) +} + export type ReasoningApiKind = 'anthropic' | 'responses' | 'completions' | 'deepseek' | 'mistral' /** diff --git a/frontend/src/lib/components/datatableAcl/PgAclEditor.svelte b/frontend/src/lib/components/datatableAcl/PgAclEditor.svelte new file mode 100644 index 0000000000..a3a765014a --- /dev/null +++ b/frontend/src/lib/components/datatableAcl/PgAclEditor.svelte @@ -0,0 +1,288 @@ + + +{#if acl.error} + {errorText(acl.error)} +{:else if !info} + Loading… +{:else} +
    + {#if info.clone} + + Read only: this data table is a clone, and its owners and grants stay as they were copied + from the data table it was cloned from. + + {:else if !info.editable} + + Read only: access is changed by the admins of the workspace that governs this data table, on + Windmill Enterprise Edition. + + {/if} + + {#if target.kind !== 'database'} +
    +
    + Owner + + {target.kind === 'schema' + ? 'The role that owns the schema and everything already in it, except what belongs to an extension, which stays with the extension. Changing it also keeps the new owner in reach of what the current roles create here from then on; a role added afterwards is not covered.' + : 'The role that owns the table. Its owner may always read and write it, and is who ALTER and DROP answer to.'} + +
    + {#if info.editable} +
    + Role + Privileges + On + + + + + {#each grantRows as grant (grantKey(grant))} + {@const revokeScope = revokeScopeOf(grant)} + {@const revocable = revocablePrivileges(grant, target)} + {@const blocked = blockingSources(grant, revocable)} + {@const uncovered = uncoveredCreators(grant, info.roles)} + + {grant.grantee} + {grant.privileges.join(', ')} + + {grantScopeLabel(grant)} + {#if blocked.length > 0} + + from {blocked.join(', ')} + + {/if} + {#if uncovered.length > 0} + + · not for what {uncovered.join(', ')} + {uncovered.length === 1 ? 'creates' : 'create'} + + {/if} + + + + {#if info.editable && revokeScope && revocable.length > 0 && blocked.length === 0 && info.roles.includes(grant.grantee) && grant.grantee !== ADMIN_ROLE} + + + {/if} + + +{/if} + + (pending = undefined)} +> +
    + {#if pendingCoversObjects} + + The same privileges on several objects read as one row, and are revoked together. + + {/if} + {#each pending?.warnings ?? [] as warning (warning)} + {warning} + {/each} + + Runs against {datatable} in a single transaction: + +
    {(pending?.statements ?? []).join(';\n')};
    +
    +
    diff --git a/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte b/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte new file mode 100644 index 0000000000..5e5119abb6 --- /dev/null +++ b/frontend/src/lib/components/datatableAcl/PgGrantBuilder.svelte @@ -0,0 +1,95 @@ + + +
    +
    + GRANT + ({ value: p, label: p }))} + placeholder="privileges" + {disabled} + size="sm" + class="min-w-48" + /> + ON + ({ value: r, label: r }))} + placeholder="role" + {disabled} + size="sm" + class="w-40" + /> + +
    + {#if statement} +
    {statement}
    + {/if} +
    diff --git a/frontend/src/lib/components/datatableAcl/aclScopes.test.ts b/frontend/src/lib/components/datatableAcl/aclScopes.test.ts new file mode 100644 index 0000000000..7c01836e89 --- /dev/null +++ b/frontend/src/lib/components/datatableAcl/aclScopes.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import type { AclGrant } from '$lib/gen' +import { + blockingSources, + grantKey, + groupGrants, + revocablePrivileges, + revokeScopeOf, + uncoveredCreators +} from './aclScopes' + +const table = (name: string) => ({ name, kind: 'TABLE' }) +const by = (role: string, privileges: string[], reachable = true) => ({ + role, + privileges, + reachable +}) +const byAdmin = (grant: Omit): AclGrant => ({ + ...grant, + sources: [by('admin', grant.privileges)] +}) + +describe('grantKey', () => { + it('tells apart a table and a function of the same name', () => { + const row = (object: { name: string; kind: string; args?: string }) => ({ + grantee: 'analytics', + privileges: ['SELECT'], + objects: [object], + sources: [by('admin', ['SELECT'])] + }) + expect(grantKey(row(table('orders')))).not.toBe( + grantKey(row({ name: 'orders', kind: 'FUNCTION', args: '' })) + ) + }) +}) + +describe('groupGrants', () => { + // A row's revoke names every object in it, so a row must only hold what one revoke may take. + it('folds the same privileges on objects of one kind, and nothing else', () => { + const grants: AclGrant[] = [ + { grantee: 'analytics', privileges: ['SELECT'], object: table('orders') }, + { grantee: 'analytics', privileges: ['SELECT'], object: table('salaries') }, + { grantee: 'operator', privileges: ['SELECT'], object: table('orders') }, + { grantee: 'analytics', privileges: ['INSERT', 'SELECT'], object: table('events') }, + { grantee: 'analytics', privileges: ['SELECT'], object: { name: 's', kind: 'SEQUENCE' } }, + { grantee: 'analytics', privileges: ['SELECT'], future: 'TABLES' }, + { grantee: 'analytics', privileges: ['USAGE'] } + ].map(byAdmin) + const rows = groupGrants(grants) + expect(rows.map((r) => [r.grantee, r.privileges, r.objects, r.future])).toEqual([ + ['analytics', ['SELECT'], [table('orders'), table('salaries')], undefined], + ['operator', ['SELECT'], [table('orders')], undefined], + ['analytics', ['INSERT', 'SELECT'], [table('events')], undefined], + ['analytics', ['SELECT'], [{ name: 's', kind: 'SEQUENCE' }], undefined], + ['analytics', ['SELECT'], [], 'TABLES'], + ['analytics', ['USAGE'], [], undefined] + ]) + }) + + // A revoke takes the row back from every source, so the row must name them all, with what each + // gave. + it('keeps every source of the grants it folds', () => { + const grants: AclGrant[] = [ + { + grantee: 'analytics', + privileges: ['SELECT'], + object: table('orders'), + sources: [by('admin', ['SELECT'])] + }, + { + grantee: 'analytics', + privileges: ['SELECT'], + object: table('salaries'), + sources: [by('admin', ['SELECT']), by('operator', ['SELECT'])] + } + ] + expect(groupGrants(grants)[0].sources).toEqual([ + by('admin', ['SELECT']), + by('operator', ['SELECT']) + ]) + }) +}) + +describe('revoke of a row', () => { + const row = (future?: string) => ({ + grantee: 'analytics', + privileges: ['SELECT'], + objects: [], + future, + sources: [by('admin', ['SELECT'])] + }) + + it('takes back only what the editor may revoke on the database', () => { + const database = { ...row(), privileges: ['CONNECT', 'CREATE'] } + expect(revocablePrivileges(database, { kind: 'database' })).toEqual(['CREATE']) + expect(revocablePrivileges(database, { kind: 'schema', schema: 'public' })).toEqual([ + 'CONNECT', + 'CREATE' + ]) + // Set database-wide, so not one a schema's revoke could take back either. + const schemasLater = { ...row('SCHEMAS'), privileges: ['CREATE'] } + expect(revocablePrivileges(schemasLater, { kind: 'database' })).toEqual([]) + }) + + it('maps default privileges to their scope, and refuses the ones it has none for', () => { + expect(revokeScopeOf(row())).toBe('target') + expect(revokeScopeOf(row('TABLES'))).toBe('future_tables') + expect(revokeScopeOf(row('TYPES'))).toBeUndefined() + expect(revokeScopeOf({ ...row(), objects: [{ name: 'mood', kind: 'TYPE' }] })).toBeUndefined() + }) + + // Postgres takes a grant back only through its source: offering the revoke would promise what + // the plan then refuses. But only the sources of what is revoked count: the catalog's CONNECT + // on the database comes from its owner, out of reach, and must not hold back a CREATE the + // editor granted. + it('is held back only by a source out of reach for what it takes', () => { + const database = { + ...row(), + privileges: ['CONNECT', 'CREATE'], + sources: [by('postgres', ['CONNECT'], false), by('admin', ['CREATE'])] + } + const revocable = revocablePrivileges(database, { kind: 'database' }) + expect(blockingSources(database, revocable)).toEqual([]) + expect(blockingSources(database, ['CONNECT'])).toEqual(['postgres']) + const partly = { + ...row('TABLES'), + sources: [by('admin', ['SELECT']), by('postgres', ['SELECT'], false)] + } + expect(blockingSources(partly, ['SELECT'])).toEqual(['postgres']) + }) + + // Whether a grant can be taken back depends on its object, so a row folding several objects is + // only revocable if each of its grants is. + it('is held back by a source out of reach on any of the objects it folds', () => { + const grants: AclGrant[] = [ + { + grantee: 'analytics', + privileges: ['SELECT'], + object: table('orders'), + sources: [by('admin', ['SELECT'])] + }, + { + grantee: 'analytics', + privileges: ['SELECT'], + object: table('salaries'), + sources: [by('admin', ['SELECT'], false)] + } + ] + const [folded] = groupGrants(grants) + expect(folded.objects).toHaveLength(2) + expect(blockingSources(folded, ['SELECT'])).toEqual(['admin']) + // Folding reads the grants, never rewrites them. + expect(grants[0].sources[0].reachable).toBe(true) + }) +}) + +describe('uncoveredCreators', () => { + // A default privilege binds only the creating roles it was granted for: a role added since is + // left out until the grant is made again. + it('names the roles a created-later row leaves out', () => { + const future = { + grantee: 'analytics', + privileges: ['SELECT'], + objects: [], + future: 'TABLES', + sources: [by('admin', ['SELECT']), by('analytics', ['SELECT'])] + } + expect(uncoveredCreators(future, ['admin', 'analytics', 'late'])).toEqual(['late']) + expect(uncoveredCreators({ ...future, future: undefined }, ['late'])).toEqual([]) + // Set by a role outside the catalog, it was never meant to cover the catalog's roles. + expect( + uncoveredCreators({ ...future, sources: [by('postgres', ['SELECT'], false)] }, [ + 'admin', + 'late' + ]) + ).toEqual([]) + }) +}) diff --git a/frontend/src/lib/components/datatableAcl/aclScopes.ts b/frontend/src/lib/components/datatableAcl/aclScopes.ts new file mode 100644 index 0000000000..d5b8ad9d20 --- /dev/null +++ b/frontend/src/lib/components/datatableAcl/aclScopes.ts @@ -0,0 +1,211 @@ +import type { AclGrant, AclSource, AclTarget } from '$lib/gen' + +/** The role a data table connects as without roles — `custom_instance_user` in Postgres. */ +export const ADMIN_ROLE = 'admin' + +/** Privileges Postgres accepts per kind of object. Mirrors the whitelist the backend validates + * against — a privilege missing here just cannot be built. */ +/** `CREATE` on a database is the right to create schemas in it, and the only database privilege + * handed out here: `CONNECT` is managed with the instance's role catalog. */ +export const DATABASE_PRIVILEGES = ['CREATE'] +export const SCHEMA_PRIVILEGES = ['USAGE', 'CREATE'] +export const TABLE_PRIVILEGES = [ + 'SELECT', + 'INSERT', + 'UPDATE', + 'DELETE', + 'TRUNCATE', + 'REFERENCES', + 'TRIGGER' +] +/** Postgres 17 and later only, so it is offered from what the server reports. */ +export const MAINTAIN_PRIVILEGE = 'MAINTAIN' +export const SEQUENCE_PRIVILEGES = ['USAGE', 'SELECT', 'UPDATE'] +export const FUNCTION_PRIVILEGES = ['EXECUTE'] + +export type AclScope = + | 'target' + | 'all_tables' + | 'all_sequences' + | 'all_functions' + | 'future_tables' + | 'future_sequences' + | 'future_functions' + +export type AclTargetKind = AclTarget['kind'] + +/** The scopes a target can grant on, in the order the builder offers them. */ +export function scopesOf(kind: AclTargetKind): { value: AclScope; label: string }[] { + if (kind === 'database') return [{ value: 'target', label: 'the database itself' }] + if (kind === 'table') return [{ value: 'target', label: 'this table' }] + return [ + { value: 'target', label: 'the schema itself' }, + { value: 'all_tables', label: 'all tables in it' }, + { value: 'all_sequences', label: 'all sequences in it' }, + { value: 'all_functions', label: 'all functions in it' }, + { value: 'future_tables', label: 'tables created later' }, + { value: 'future_sequences', label: 'sequences created later' }, + { value: 'future_functions', label: 'functions created later' } + ] +} + +export function privilegesOf( + scope: AclScope, + kind: AclTargetKind, + supportsMaintain = false +): string[] { + const tablePrivileges = supportsMaintain + ? [...TABLE_PRIVILEGES, MAINTAIN_PRIVILEGE] + : TABLE_PRIVILEGES + switch (scope) { + case 'target': + if (kind === 'database') return DATABASE_PRIVILEGES + return kind === 'schema' ? SCHEMA_PRIVILEGES : tablePrivileges + case 'all_tables': + case 'future_tables': + return tablePrivileges + case 'all_sequences': + case 'future_sequences': + return SEQUENCE_PRIVILEGES + case 'all_functions': + case 'future_functions': + return FUNCTION_PRIVILEGES + } +} + +/** What a statement built at this scope reads as, for the builder's own preview. */ +export function scopeSql(scope: AclScope, target: AclTarget, dbname?: string): string { + if (target.kind === 'database') return `DATABASE ${dbname ?? ''}`.trim() + const schema = target.schema + switch (scope) { + case 'target': + return target.kind === 'schema' ? `SCHEMA ${schema}` : `TABLE ${schema}.${target.table}` + case 'all_tables': + return `ALL TABLES IN SCHEMA ${schema}` + case 'all_sequences': + return `ALL SEQUENCES IN SCHEMA ${schema}` + case 'all_functions': + return `ALL FUNCTIONS IN SCHEMA ${schema}` + case 'future_tables': + return `TABLES (default privileges in ${schema})` + case 'future_sequences': + return `SEQUENCES (default privileges in ${schema})` + case 'future_functions': + return `FUNCTIONS (default privileges in ${schema})` + } +} + +/** One row of the grants table: the same privileges on several objects read as one line, since + * granting them per object is what `ON ALL TABLES` does. */ +export type GroupedGrant = { + grantee: string + privileges: string[] + objects: NonNullable[] + future?: string + /** Every role the row's grants come from, each once, with what it gave. */ + sources: AclSource[] +} + +export function groupGrants(grants: AclGrant[]): GroupedGrant[] { + const rows: GroupedGrant[] = [] + for (const grant of grants) { + const existing = grant.object + ? rows.find( + (r) => + r.grantee === grant.grantee && + r.future === grant.future && + r.objects[0]?.kind === grant.object?.kind && + r.privileges.join() === grant.privileges.join() + ) + : undefined + if (existing) { + existing.objects.push(grant.object!) + for (const source of grant.sources) { + const known = existing.sources.find((s) => s.role === source.role) + // Whether a role's grant can be taken back depends on the object it is on, so a row + // holds a source as reachable only if it is on every object the row folds. + if (known) { + known.reachable &&= source.reachable + known.privileges = [...new Set([...known.privileges, ...source.privileges])].sort() + } else { + existing.sources.push({ ...source, privileges: [...source.privileges] }) + } + } + } else { + rows.push({ + grantee: grant.grantee, + privileges: grant.privileges, + objects: grant.object ? [grant.object] : [], + future: grant.future, + sources: grant.sources.map((s) => ({ ...s, privileges: [...s.privileges] })) + }) + } + } + return rows +} + +/** The roles that gave some of `privileges` and that this data table's connection cannot act for. + * Only they can take those grants back, so a revoke of `privileges` is not offered. */ +export function blockingSources(grant: GroupedGrant, privileges: string[]): string[] { + return grant.sources + .filter((s) => !s.reachable && s.privileges.some((p) => privileges.includes(p))) + .map((s) => s.role) +} + +/** Which of `roles` a "created later" row granted for some of them does not cover. A default + * privilege binds only the creating roles it was granted for, so what the others create stays out + * of it. A row none of `roles` set — the instance's own, say — was never meant to cover them, and + * names none. */ +export function uncoveredCreators(grant: GroupedGrant, roles: string[]): string[] { + if (!grant.future || !grant.sources.some((s) => roles.includes(s.role))) return [] + return roles.filter((r) => !grant.sources.some((s) => s.role === r)) +} + +/** A row's identity. Two rows may share a grantee and an object name — a table `orders` and a + * function `orders()` — so the kind and the privileges are part of it too. */ +export function grantKey(grant: GroupedGrant): string { + return [ + grant.grantee, + grant.future ?? '', + grant.privileges.join(','), + ...grant.objects.map((o) => `${o.kind}:${o.name}(${o.args ?? ''})`) + ].join('|') +} + +/** The scope a revoke of this row takes, or `undefined` when the builder cannot express it — + * Postgres also records privileges on types, present and default, which nothing here grants and + * the API has no scope for. */ +export function revokeScopeOf(grant: GroupedGrant): AclScope | undefined { + if (!grant.future) return grant.objects.some((o) => o.kind === 'TYPE') ? undefined : 'target' + const scope = `future_${grant.future.toLowerCase()}` + return (['future_tables', 'future_sequences', 'future_functions'] as const).find( + (s) => s === scope + ) +} + +/** The privileges of a row a revoke may take back. On the database that is `CREATE` alone: + * `CONNECT` belongs to the role catalog, which would grant it again, and `TEMPORARY` is not one + * the editor hands out — a row holding only those has nothing to revoke here. A database's rows + * "created later" are default privileges set database-wide, which nothing here revokes. */ +export function revocablePrivileges(grant: GroupedGrant, target: AclTarget): string[] { + if (target.kind === 'database' && grant.objects.length === 0) { + if (grant.future) return [] + return grant.privileges.filter((p) => DATABASE_PRIVILEGES.includes(p)) + } + return grant.privileges +} + +/** How a row reads back: what it covers, in one phrase. */ +export function grantScopeLabel(grant: GroupedGrant): string { + if (grant.future) return `${grant.future.toLowerCase()} created later` + if (grant.objects.length === 1) { + const object = grant.objects[0] + // A routine's arguments are part of what it is, so two of the same name would otherwise + // read as one row twice. + const args = object.args !== undefined ? `(${object.args})` : '' + return `${object.kind.toLowerCase()} ${object.name}${args}` + } + if (grant.objects.length > 1) + return `${grant.objects.length} ${grant.objects[0].kind.toLowerCase()}s` + return 'itself' +} diff --git a/frontend/src/lib/components/datatableMigrationRole.test.ts b/frontend/src/lib/components/datatableMigrationRole.test.ts new file mode 100644 index 0000000000..6d136b8371 --- /dev/null +++ b/frontend/src/lib/components/datatableMigrationRole.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect } from 'vitest' +import { parseMigrationRole, withMigrationRole } from './datatableMigrationRole' + +describe('parseMigrationRole', () => { + test('reads every spelling the server accepts from the leading comment block', () => { + for (const line of [ + '-- role analyst', + '-- Role: analyst', + '-- role=analyst', + '-- role analyst;' + ]) { + expect(parseMigrationRole(`\n${line}\nBEGIN;\nEND;`)).toEqual({ + kind: 'role', + role: 'analyst' + }) + } + }) + + test('an annotation below BEGIN is not one', () => { + expect(parseMigrationRole('BEGIN;\n-- role analyst\nEND;')).toEqual({ kind: 'none' }) + }) + + test('a malformed attempt is an error, not the default', () => { + for (const line of [ + '-- role based access below', + '-- role', + '-- role:', + '-- role an;alytics' + ]) { + expect(parseMigrationRole(`${line}\nBEGIN;`)).toEqual({ kind: 'malformed', line }) + } + }) + + test('comments that do not start with the word role are ignored', () => { + expect(parseMigrationRole('-- roles analyst\n-- rolex\nBEGIN;')).toEqual({ kind: 'none' }) + }) +}) + +describe('withMigrationRole', () => { + test('leads above BEGIN, so the server reads it', () => { + const out = withMigrationRole('BEGIN;\nSELECT 1;\nEND;', 'analyst') + expect(out).toBe('-- role analyst\nBEGIN;\nSELECT 1;\nEND;') + }) + + test('replaces any attempt rather than stacking, malformed ones included', () => { + const out = withMigrationRole( + '-- Role: auditor\n-- role oops no\n-- keep me\nBEGIN;', + 'analyst' + ) + expect(out).toBe('-- role analyst\n-- keep me\nBEGIN;') + }) + + test('undefined strips the annotation, so it runs as admin', () => { + expect(withMigrationRole('-- role analyst\n\nBEGIN;\nEND;', undefined)).toBe('BEGIN;\nEND;') + }) + + test('refuses a name the server would refuse', () => { + expect(() => withMigrationRole('BEGIN;', 'bad;name')).toThrow() + }) +}) diff --git a/frontend/src/lib/components/datatableMigrationRole.ts b/frontend/src/lib/components/datatableMigrationRole.ts new file mode 100644 index 0000000000..5475bf2fd7 --- /dev/null +++ b/frontend/src/lib/components/datatableMigrationRole.ts @@ -0,0 +1,70 @@ +import { isDatatableRoleName } from './dbTypes' + +/** + * A migration carries the data table role it runs as in its own SQL, as a `-- role ` + * annotation. There is no separate field: the annotation is what the server reads, and keeping + * it in the SQL is what lets it survive a `wmill sync` round-trip. + * + * Mirrors `SqlAnnotations::datatable_role` on the backend. It is only read from the leading + * comment block, so an annotation below `BEGIN;` is ignored and the migration runs as admin. A + * leading comment whose first word is `role` is an annotation attempt, and a malformed one is an + * error there, so it is one here too. + */ + +export type MigrationRole = + | { kind: 'none' } + | { kind: 'role'; role: string } + | { kind: 'malformed'; line: string } + +/** The body of a leading comment line that attempts a role annotation, or undefined. */ +function roleAttempt(line: string): string | undefined { + if (!line.startsWith('--')) return undefined + const body = line.slice(2).trimStart() + if (body.slice(0, 4).toLowerCase() !== 'role') return undefined + const after = body.slice(4) + if (after !== '' && !/^[\s:=]/.test(after)) return undefined + return after +} + +function parseAttempt(after: string): string | undefined { + let rest = after.trimStart() + if (rest.startsWith(':') || rest.startsWith('=')) rest = rest.slice(1) + const tokens = rest.split(/\s+/).filter((t) => t !== '') + if (tokens.length !== 1) return undefined + const role = tokens[0].endsWith(';') ? tokens[0].slice(0, -1) : tokens[0] + return isDatatableRoleName(role) ? role : undefined +} + +export function parseMigrationRole(sql: string): MigrationRole { + for (const raw of sql.split('\n')) { + const line = raw.trim() + if (line === '') continue + if (!line.startsWith('--')) break + const after = roleAttempt(line) + if (after === undefined) continue + const role = parseAttempt(after) + return role === undefined ? { kind: 'malformed', line } : { kind: 'role', role } + } + return { kind: 'none' } +} + +/** + * `sql` declaring `role`: any role annotation attempt in the leading comment block is removed, + * and `-- role ` is prepended above everything, or nothing when `role` is undefined. + */ +export function withMigrationRole(sql: string, role: string | undefined): string { + if (role !== undefined && !isDatatableRoleName(role)) { + throw new Error(`Invalid data table role '${role}'`) + } + const lines = sql.split('\n') + const kept: string[] = [] + let i = 0 + for (; i < lines.length; i++) { + const line = lines[i].trim() + if (line !== '' && !line.startsWith('--')) break + if (roleAttempt(line) === undefined) kept.push(lines[i]) + } + const rest = [...kept, ...lines.slice(i)] + while (rest.length > 0 && rest[0].trim() === '') rest.shift() + return role === undefined ? rest.join('\n') : [`-- role ${role}`, ...rest].join('\n') +} diff --git a/frontend/src/lib/components/datatableUsableRoles.test.ts b/frontend/src/lib/components/datatableUsableRoles.test.ts new file mode 100644 index 0000000000..cca4ab7607 --- /dev/null +++ b/frontend/src/lib/components/datatableUsableRoles.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from 'vitest' + +const request = vi.fn() +vi.mock('$lib/gen/core/request', () => ({ request: (...args: unknown[]) => request(...args) })) +vi.mock('$lib/gen', () => ({ OpenAPI: {} })) +vi.mock('$lib/cloud', () => ({ isCloudHosted: () => false })) + +import { listUsableDatatableRoles } from './datatableUsableRoles' + +const refuse = (body: string) => () => + Promise.reject(Object.assign(new Error('Bad Request'), { body })) + +describe('listUsableDatatableRoles', () => { + // The refusal is recognised by its sentence, so rewording it on one side alone would turn every + // data table on a build without the Enterprise Edition into a failed lookup rather than one + // that is not under roles. + it('reads the enterprise refusal as not under roles, and rethrows anything else', async () => { + request.mockImplementation(refuse('Data table roles are a Windmill Enterprise Edition feature')) + expect(await listUsableDatatableRoles('ws', 'main')).toEqual({ + permissioned: false, + roles: [], + default_role: 'admin' + }) + + request.mockImplementation(refuse('Data table not found')) + const rethrown = await listUsableDatatableRoles('ws', 'main').catch((e) => e) + expect(rethrown).toBeInstanceOf(Error) + }) +}) diff --git a/frontend/src/lib/components/datatableUsableRoles.ts b/frontend/src/lib/components/datatableUsableRoles.ts new file mode 100644 index 0000000000..84e52f29a8 --- /dev/null +++ b/frontend/src/lib/components/datatableUsableRoles.ts @@ -0,0 +1,44 @@ +import { OpenAPI, type ListUsableDatatableRolesResponse } from '$lib/gen' +import { request } from '$lib/gen/core/request' +import { isCloudHosted } from '$lib/cloud' +import { ADMIN_DATATABLE_ROLE } from './dbTypes' + +// `datatable_roles_unavailable` on the server, which is a plain 400: rewording it there without +// here makes every role picker on a non-Enterprise build fail instead of reading "not under roles". +const ROLES_UNAVAILABLE = 'Data table roles are a Windmill Enterprise Edition feature' + +const NOT_UNDER_ROLES: ListUsableDatatableRolesResponse = { + permissioned: false, + roles: [], + default_role: ADMIN_DATATABLE_ROLE +} + +/** + * The roles the caller may connect as on a data table. Cloud has no instance database, so no data + * table there is under roles, and none of the role pickers show. Without the Enterprise Edition + * every roles route refuses, which reads the same way: the data table is then used the way it was + * before roles, and one that is under roles is refused when something connects to it. + */ +export async function listUsableDatatableRoles( + workspace: string, + datatableName: string +): Promise { + if (isCloudHosted()) return NOT_UNDER_ROLES + try { + // The generated client encodes path params with `encodeURI`, which leaves a '?' in a + // data table name created before names were restricted to cut the path short. + return await request( + { ...OpenAPI, ENCODE_PATH: encodeURIComponent }, + { + method: 'GET', + url: '/w/{workspace}/workspaces/datatable_usable_roles/{datatable_name}', + path: { workspace, datatable_name: datatableName } + } + ) + } catch (e) { + const body = (e as { body?: unknown })?.body + const detail = `${typeof body === 'string' ? body : JSON.stringify(body ?? '')} ${(e as Error)?.message ?? e}` + if (detail.includes(ROLES_UNAVAILABLE)) return NOT_UNDER_ROLES + throw e + } +} diff --git a/frontend/src/lib/components/dbManagerDrawerModel.svelte.ts b/frontend/src/lib/components/dbManagerDrawerModel.svelte.ts index 59345c6ae7..890635dcf8 100644 --- a/frontend/src/lib/components/dbManagerDrawerModel.svelte.ts +++ b/frontend/src/lib/components/dbManagerDrawerModel.svelte.ts @@ -5,7 +5,7 @@ import { isDbType } from './dbTypes' /** * Single URL param `dbm` encodes the full DB manager state: - * firstSegment~path~schema.table + * firstSegment~path~schema.table~role=name * * firstSegment: * datatable – database with datatable:// resource (resourceType always postgresql) @@ -26,28 +26,46 @@ import { isDbType } from './dbTypes' * datatable~main~.customers (schema "public" implied) * ducklake~main~.orders (schema "main" implied) * postgresql~$res:u/user/my_pg~public.customers + * datatable~main~.customers~role=analyst + * datatable~main~role=analyst (no schema/table selected) + * + * role=name (last segment, optional, data tables only): the data table role to connect as. + * Omitted means the data table's default role. A trailing segment starting with `role=` is always + * the role, whatever follows. The name is kept as written, even when invalid (a `.` included), so + * the connection refuses it visibly instead of falling back to the default. */ const dbManagerSchema = z.object({ dbm: z.string().nullable() }) -interface ParsedDbm { +export interface ParsedDbm { type: 'database' | 'datatable' | 'ducklake' path: string resType?: string schema?: string table?: string + role?: string } -function parseDbm(raw: unknown): ParsedDbm | null { +const ROLE_SEGMENT_PREFIX = 'role=' + +function isRoleSegment(segment: string | undefined): segment is string { + return !!segment && segment.startsWith(ROLE_SEGMENT_PREFIX) +} + +export function parseDbm(raw: unknown): ParsedDbm | null { if (!raw || typeof raw !== 'string') return null const parts = raw.split('~') if (parts.length < 2 || !parts[1]) return null const firstSeg = parts[0] const path = parts[1] - const schemaTable = parts[2] ?? '' + const rest = parts.slice(2) + const role = isRoleSegment(rest.at(-1)) + ? rest.pop()!.slice(ROLE_SEGMENT_PREFIX.length) + : undefined + const schemaTable = rest[0] ?? '' let type: ParsedDbm['type'] let resType: string | undefined @@ -81,12 +99,12 @@ function parseDbm(raw: unknown): ParsedDbm | null { schema = defaultSchemas[type] } - return { type, path, resType, schema, table } + return { type, path, resType, schema, table, role: type === 'datatable' ? role : undefined } } const defaultSchemas: Record = { datatable: 'public', ducklake: 'main' } -function buildDbm(p: ParsedDbm): string { +export function buildDbm(p: ParsedDbm): string { const firstSeg = p.type === 'database' ? p.resType! : p.type const schema = p.schema === defaultSchemas[p.type] ? undefined : p.schema let schemaTable = '' @@ -97,7 +115,12 @@ function buildDbm(p: ParsedDbm): string { } else if (schema) { schemaTable = `${schema}.` } - return schemaTable ? `${firstSeg}~${p.path}~${schemaTable}` : `${firstSeg}~${p.path}` + const segments = [firstSeg, p.path] + if (schemaTable) segments.push(schemaTable) + if (p.type === 'datatable' && p.role !== undefined) { + segments.push(`${ROLE_SEGMENT_PREFIX}${p.role}`) + } + return segments.join('~') } export interface DbManagerUriState { @@ -105,6 +128,8 @@ export interface DbManagerUriState { readonly effectiveInput: DbInput | undefined readonly isDatatableInput: boolean selectedDatatable: string | undefined + /** The data table role the drawer connects as; undefined means its default. */ + selectedRole: string | undefined selectedSchema: string | undefined selectedTable: string | undefined readonly open: boolean @@ -137,6 +162,7 @@ export function useDbManagerUriState(): DbManagerUriState { type: 'database' as const, resourceType: resType as DbType, resourcePath: parsed.type === 'datatable' ? `datatable://${parsed.path}` : parsed.path, + role: parsed.role, specificSchema: parsed.schema, specificTable: parsed.table } @@ -163,6 +189,7 @@ export function useDbManagerUriState(): DbManagerUriState { type: isDatatable ? 'datatable' : 'database', path: isDatatable ? nInput.resourcePath.slice('datatable://'.length) : nInput.resourcePath, resType: isDatatable ? undefined : nInput.resourceType, + role: isDatatable ? nInput.role : undefined, schema: nInput.specificSchema, table: nInput.specificTable }) @@ -194,7 +221,14 @@ export function useDbManagerUriState(): DbManagerUriState { return parsed?.type === 'datatable' ? parsed.path : undefined }, set selectedDatatable(v: string | undefined) { - if (v) updateField({ path: v }) + // A role belongs to one data table, so it cannot carry over to another. + if (v) updateField({ path: v, role: undefined }) + }, + get selectedRole() { + return parsed?.role + }, + set selectedRole(v: string | undefined) { + updateField({ role: v }) }, get selectedSchema() { return parsed?.schema diff --git a/frontend/src/lib/components/dbManagerRole.test.ts b/frontend/src/lib/components/dbManagerRole.test.ts new file mode 100644 index 0000000000..6c45d0ed30 --- /dev/null +++ b/frontend/src/lib/components/dbManagerRole.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest' +import { buildDbm, parseDbm } from './dbManagerDrawerModel.svelte' +import { schemaCacheKey } from './dbSchemaCache' +import { datatableReference, type DbInput } from './dbTypes' + +describe('dbm role segment', () => { + it('round-trips a role, with and without a table', () => { + for (const dbm of ['datatable~main~.orders~role=p4_analytics', 'datatable~main~role=p4-op']) { + expect(buildDbm(parseDbm(dbm)!)).toBe(dbm) + } + expect(parseDbm('datatable~main~sales.orders~role=analyst')).toMatchObject({ + path: 'main', + schema: 'sales', + table: 'orders', + role: 'analyst' + }) + }) + + it('reads a link without a role as the default role', () => { + const parsed = parseDbm('datatable~main~.orders')! + expect(parsed.role).toBeUndefined() + expect(parsed).toMatchObject({ schema: 'public', table: 'orders' }) + expect(buildDbm(parsed)).toBe('datatable~main~.orders') + }) + + it('keeps an invalid role as written, so the connection refuses it', () => { + expect(parseDbm('datatable~main~role=a;b')?.role).toBe('a;b') + // A dot does not turn it into a schema.table selection read as the default role. + expect(parseDbm('datatable~main~role=bad.name')).toMatchObject({ + role: 'bad.name', + schema: undefined, + table: undefined + }) + }) +}) + +describe('connecting as a role', () => { + const input = (role?: string): DbInput => ({ + type: 'database', + resourceType: 'postgresql', + resourcePath: 'datatable://main', + role + }) + + // What `getDatabaseArg` builds every DB manager connection from. + it('appends the role to the data table reference', () => { + expect(datatableReference('main', 'p4_analytics')).toBe('datatable://main?role=p4_analytics') + expect(datatableReference('main', undefined)).toBe('datatable://main') + }) + + it('never appends a role to a name containing ?', () => { + // The server reads such a whole reference as the stored name first, so `?role=` would + // be taken as part of the name or refused instead of picking the role. + expect(datatableReference('legacy?x', undefined)).toBe('datatable://legacy?x') + expect(() => datatableReference('sales?role=analytics', 'admin')).toThrow(/'\?' in its name/) + }) + + it('refuses a role name the server would not accept', () => { + expect(() => datatableReference('main', 'a&role=admin')).toThrow(/Invalid data table role/) + expect(() => datatableReference('main', '')).toThrow(/Invalid data table role/) + }) + + it('keys the schema cache by role', () => { + expect(schemaCacheKey('ws', input('a'))).not.toBe(schemaCacheKey('ws', input('b'))) + expect(schemaCacheKey('ws', input('a'))).not.toBe(schemaCacheKey('ws', input())) + }) +}) diff --git a/frontend/src/lib/components/dbOps.ts b/frontend/src/lib/components/dbOps.ts index 5005cc931c..dbe507d13f 100644 --- a/frontend/src/lib/components/dbOps.ts +++ b/frontend/src/lib/components/dbOps.ts @@ -8,7 +8,8 @@ import { runScriptAndPollResult } from './jobs/utils' import { writingJobOptions } from './jobs/writingJob' import type { DBSchema, SQLSchema } from '$lib/stores' import { stringifySchema } from './copilot/lib' -import type { DbInput, DbType } from './dbTypes' +import { datatableReference, type DbInput, type DbType } from './dbTypes' +import { withMigrationRole } from './datatableMigrationRole' import { assert } from '$lib/utils' import { WorkspaceService } from '$lib/gen' import { pendingMigrations } from './workspaceSettings/datatableMigrationUtils' @@ -23,6 +24,7 @@ import { transformSnowflakeForeignKeys, type RawForeignKey } from './apps/components/display/dbtable/queries/relationalKeys' +import { groupForeignKeyRows, type DbRelation, type RawAllForeignKeyRow } from './dbRelations' export type IDbTableOps = { dbType: DbType @@ -70,7 +72,9 @@ export function dbTableOpsWithPreviewScripts({ }): IDbTableOps { const dbType = getDbType(input) const language = getLanguageByResourceType(dbType) - const dbArg = getDatabaseArg(input) + // Built per call: an invalid role throws there, as that operation's error, rather than while + // the manager renders. + const dbArg = () => getDatabaseArg(input) const ducklake = input.type === 'ducklake' ? input.ducklake : undefined function makeMarker(op: string, payload: Record): string { @@ -91,7 +95,7 @@ export function dbTableOpsWithPreviewScripts({ }) const result = await runScriptAndPollResult({ workspace, - requestBody: { args: { ...dbArg, quicksearch }, language, content, tag } + requestBody: { args: { ...dbArg(), quicksearch }, language, content, tag } }) const count = result?.[0].count as number return count @@ -106,7 +110,7 @@ export function dbTableOpsWithPreviewScripts({ }) let items = (await runScriptAndPollResult({ workspace, - requestBody: { args: { ...dbArg, ...params }, language, content, tag } + requestBody: { args: { ...dbArg(), ...params }, language, content, tag } })) as unknown[] if (!items || !Array.isArray(items)) { throw 'items is not an array' @@ -123,7 +127,7 @@ export function dbTableOpsWithPreviewScripts({ { workspace, requestBody: { - args: { ...dbArg, value_to_update: newValue, ...values }, + args: { ...dbArg(), value_to_update: newValue, ...values }, language, content, tag @@ -135,14 +139,14 @@ export function dbTableOpsWithPreviewScripts({ onDelete: async ({ values }) => { const content = makeMarker('DELETE', { table: tableKey, columns: colDefs }) await runScriptAndPollResult( - { workspace, requestBody: { args: { ...dbArg, ...values }, language, content, tag } }, + { workspace, requestBody: { args: { ...dbArg(), ...values }, language, content, tag } }, writingJobOptions ) }, onInsert: async ({ values }) => { const content = makeMarker('INSERT', { table: tableKey, columns: colDefs }) await runScriptAndPollResult( - { workspace, requestBody: { args: { ...dbArg, ...values }, language, content, tag } }, + { workspace, requestBody: { args: { ...dbArg(), ...values }, language, content, tag } }, writingJobOptions ) } @@ -246,6 +250,7 @@ export type IDbSchemaOps = { previewAlterSql: (params: { values: AlterTableValues; schema?: string }) => Promise onCreateSchema: (params: { schema: string }) => Promise onDeleteSchema: (params: { schema: string }) => Promise + onRenameSchema: (params: { schema: string; newSchema: string }) => Promise onFetchTableEditorDefinition: (params: { table: string schema?: string @@ -255,6 +260,10 @@ export type IDbSchemaOps = { table: string schema?: string }) => Promise + /** Every foreign key of the database in one query, for the schema diagram. + * PostgreSQL only — the other databases either don't expose foreign keys or + * can only be asked one table at a time. */ + onFetchAllForeignKeys: () => Promise } /** Thrown by a schema op when the user declines the out-of-order run warning. @@ -283,7 +292,8 @@ export function dbSchemaOpsWithPreviewScripts({ tag?: string }): IDbSchemaOps { const dbType = getDbType(input) - const dbArg = getDatabaseArg(input) + // Built per call, for the same reason as in the table ops above. + const dbArg = () => getDatabaseArg(input) const language = getLanguageByResourceType(dbType) const ducklake = input.type === 'ducklake' ? input.ducklake : undefined @@ -293,6 +303,8 @@ export function dbSchemaOpsWithPreviewScripts({ input.type === 'database' && input.resourcePath.startsWith('datatable://') ? input.resourcePath.slice('datatable://'.length) : undefined + // A migration declaring no role runs as admin, whatever role the manager connects as. + const migrationRole = input.type === 'database' ? (input.role ?? input.migrationRole) : undefined function makeMarker(op: string, payload: Record): string { if (ducklake) payload.ducklake = ducklake @@ -359,7 +371,7 @@ export function dbSchemaOpsWithPreviewScripts({ : undefined if (!datatableName || !status?.enabled) { await runScriptAndPollResult( - { workspace, requestBody: { args: dbArg, content, language, tag } }, + { workspace, requestBody: { args: dbArg(), content, language, tag } }, writingJobOptions ) return @@ -373,12 +385,16 @@ export function dbSchemaOpsWithPreviewScripts({ throw new MigrationRunCancelled() } } - const codeUp = wrapMigration(await expandMarker(workspace, language, content)) + // Wrapped before annotating: the annotation must lead, above `BEGIN;`. + const codeUp = withMigrationRole( + wrapMigration(await expandMarker(workspace, language, content)), + migrationRole + ) // Down migrations are only generated for Postgres for now. let codeDown: string | undefined if (downContent && dbType === 'postgresql') { const downSql = (await expandMarker(workspace, language, downContent)).trim() - if (downSql) codeDown = wrapMigration(downSql) + if (downSql) codeDown = withMigrationRole(wrapMigration(downSql), migrationRole) } const created = await WorkspaceService.createDatatableMigration({ workspace, @@ -415,7 +431,7 @@ export function dbSchemaOpsWithPreviewScripts({ const fkContent = makeMarker('FOREIGN_KEYS', { table, schema }) const fkResult = await runScriptAndPollResult({ workspace, - requestBody: { args: dbArg, content: fkContent, language, tag } + requestBody: { args: dbArg(), content: fkContent, language, tag } }) let rawForeignKeys: RawForeignKey[] @@ -443,6 +459,16 @@ export function dbSchemaOpsWithPreviewScripts({ return [] } + async function fetchAllForeignKeys(): Promise { + if (dbType !== 'postgresql') return [] + const content = makeMarker('ALL_FOREIGN_KEYS', {}) + const rows = (await runScriptAndPollResult({ + workspace, + requestBody: { args: dbArg(), content, language, tag } + })) as RawAllForeignKeyRow[] + return Array.isArray(rows) ? groupForeignKeyRows(rows) : [] + } + return { onDelete: async ({ tableKey, schema }) => { const content = makeMarker('DROP_TABLE', { table: tableKey, schema }) @@ -501,7 +527,13 @@ export function dbSchemaOpsWithPreviewScripts({ const downContent = makeMarker('CREATE_SCHEMA', { schema }) await applyDdl(migrationName('drop_schema', schema), content, downContent) }, + onRenameSchema: async ({ schema, newSchema }) => { + const content = makeMarker('RENAME_SCHEMA', { schema, new_schema: newSchema }) + const downContent = makeMarker('RENAME_SCHEMA', { schema: newSchema, new_schema: schema }) + await applyDdl(migrationName('rename_schema', schema), content, downContent) + }, onFetchForeignKeys: fetchForeignKeys, + onFetchAllForeignKeys: fetchAllForeignKeys, onFetchTableEditorDefinition: async ({ table, schema, colDefs }) => { const foreignKeys = await fetchForeignKeys({ table, schema }) let pk_constraint_name: string | undefined @@ -512,7 +544,7 @@ export function dbSchemaOpsWithPreviewScripts({ const pkContent = makeMarker('PRIMARY_KEY_CONSTRAINT', { table, schema }) const pkResult = (await runScriptAndPollResult({ workspace, - requestBody: { args: dbArg, content: pkContent, language, tag } + requestBody: { args: dbArg(), content: pkContent, language, tag } })) as { constraint_name?: string; CONSTRAINT_NAME?: string }[] if (pkResult && Array.isArray(pkResult) && pkResult.length > 0) { @@ -611,7 +643,9 @@ export function getDefaultDbTag(input: DbInput): string { export function getDatabaseArg(input: DbInput | undefined) { if (input?.type === 'database') { if (input.resourcePath.startsWith('datatable://')) { - return { database: input.resourcePath } + return { + database: datatableReference(input.resourcePath.slice('datatable://'.length), input.role) + } } else { return { database: '$res:' + input.resourcePath } } diff --git a/frontend/src/lib/components/dbRelations.test.ts b/frontend/src/lib/components/dbRelations.test.ts new file mode 100644 index 0000000000..ad5d63a43e --- /dev/null +++ b/frontend/src/lib/components/dbRelations.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import { buildRelationIndex, groupForeignKeyRows, type RawAllForeignKeyRow } from './dbRelations' + +function row(over: Partial): RawAllForeignKeyRow { + return { + fk_constraint_name: 'fk', + source_schema: 'shop', + source_table: 'orders', + source_column: 'customer_id', + target_schema: 'shop', + target_table: 'customers', + target_column: 'id', + ordinal: 1, + ...over + } +} + +describe('groupForeignKeyRows', () => { + it('pairs a composite key by ordinal, whatever order the rows arrive in', () => { + const relations = groupForeignKeyRows([ + row({ + fk_constraint_name: 'returns_order_item_fkey', + source_table: 'returns', + source_column: 'sku', + target_table: 'order_items', + target_column: 'sku', + ordinal: 2 + }), + row({ + fk_constraint_name: 'returns_order_item_fkey', + source_table: 'returns', + source_column: 'order_id', + target_table: 'order_items', + target_column: 'order_id', + ordinal: 1 + }) + ]) + + expect(relations).toHaveLength(1) + expect(relations[0].from.columns).toEqual(['order_id', 'sku']) + expect(relations[0].to.columns).toEqual(['order_id', 'sku']) + }) + + it('keeps same-named constraints on different tables apart', () => { + const relations = groupForeignKeyRows([ + row({ fk_constraint_name: 'owner_fkey', source_table: 'orders' }), + row({ fk_constraint_name: 'owner_fkey', source_table: 'addresses' }) + ]) + expect(relations.map((r) => r.from.table).sort()).toEqual(['addresses', 'orders']) + }) +}) + +describe('buildRelationIndex', () => { + const index = buildRelationIndex( + groupForeignKeyRows([ + row({ source_table: 'orders', source_column: 'customer_id', target_column: 'id' }), + row({ + fk_constraint_name: 'items_order_fkey', + source_table: 'order_items', + source_column: 'order_id', + target_table: 'orders', + target_column: 'id' + }) + ]) + ) + + it('links a column only to the column it is actually paired with', () => { + // `order_items.order_id` points at `orders.id`, not at the same-named + // column of every other table taking part in a relation. + expect([...(index.linkedColumns.get('shop.order_items.order_id') ?? [])]).toEqual([ + 'shop.orders.id' + ]) + }) + + it('relates tables in both directions', () => { + expect([...(index.relatedTables.get('shop.orders') ?? [])].sort()).toEqual([ + 'shop.customers', + 'shop.order_items' + ]) + }) +}) diff --git a/frontend/src/lib/components/dbRelations.ts b/frontend/src/lib/components/dbRelations.ts new file mode 100644 index 0000000000..eb6451057e --- /dev/null +++ b/frontend/src/lib/components/dbRelations.ts @@ -0,0 +1,137 @@ +/** One row of the `ALL_FOREIGN_KEYS` marker: a single referencing column. */ +export type RawAllForeignKeyRow = { + fk_constraint_name: string + source_schema: string + source_table: string + source_column: string + target_schema: string + target_table: string + target_column: string + ordinal: number | string +} + +export type DbTableRef = { schema: string; table: string } + +/** A foreign key, with its columns paired to the ones they point at by index. */ +export type DbRelation = { + constraint: string + from: DbTableRef & { columns: string[] } + to: DbTableRef & { columns: string[] } +} + +export function tableKeyOf(ref: DbTableRef): string { + return `${ref.schema}.${ref.table}` +} + +export function columnKeyOf(ref: DbTableRef, column: string): string { + return columnKeyIn(tableKeyOf(ref), column) +} + +export function columnKeyIn(tableKey: string, column: string): string { + return `${tableKey}.${column}` +} + +/** Groups the flat rows into one relation per constraint, keeping the column + * pairs in the constraint's declaration order (the query's `ordinal`). */ +export function groupForeignKeyRows(rows: RawAllForeignKeyRow[]): DbRelation[] { + const byConstraint = new Map() + + for (const raw of rows) { + const row = lowercaseKeys(raw) + if (!row.source_table || !row.target_table) continue + const key = `${row.source_schema}.${row.source_table}.${row.fk_constraint_name}` + let entry = byConstraint.get(key) + if (!entry) { + entry = { + relation: { + constraint: row.fk_constraint_name, + from: { schema: row.source_schema, table: row.source_table, columns: [] }, + to: { schema: row.target_schema, table: row.target_table, columns: [] } + }, + pairs: [] + } + byConstraint.set(key, entry) + } + entry.pairs.push(row) + } + + return Array.from(byConstraint.values()).map(({ relation, pairs }) => { + pairs.sort((a, b) => Number(a.ordinal) - Number(b.ordinal)) + relation.from.columns = pairs.map((p) => p.source_column) + relation.to.columns = pairs.map((p) => p.target_column) + return relation + }) +} + +function lowercaseKeys(row: RawAllForeignKeyRow): RawAllForeignKeyRow { + const out: any = {} + for (const key of Object.keys(row)) out[key.toLowerCase()] = (row as any)[key] + return out as RawAllForeignKeyRow +} + +/** Everything the diagram needs to answer "what lights up when this is hovered". + * Built once per fetched relation set, then read on every hover. */ +export type RelationIndex = { + /** Tables reachable from a table by one foreign key, either direction. */ + relatedTables: Map> + /** Columns a column is paired with by a foreign key, either direction. */ + linkedColumns: Map> + /** Every column taking part in a relation of a table, keyed by table. Both + * the table's own referencing/referenced columns and the far ends. */ + relatedColumns: Map> + /** Columns that reference another table — rendered with a link marker. */ + referencingColumns: Set + /** Columns another table points at. */ + referencedColumns: Set + /** The table a column key belongs to. Both parts are dot-joined names, so + * splitting a column key back apart is not safe. */ + columnTable: Map +} + +export function buildRelationIndex(relations: DbRelation[]): RelationIndex { + const index: RelationIndex = { + relatedTables: new Map(), + linkedColumns: new Map(), + relatedColumns: new Map(), + referencingColumns: new Set(), + referencedColumns: new Set(), + columnTable: new Map() + } + + for (const relation of relations) { + const fromTable = tableKeyOf(relation.from) + const toTable = tableKeyOf(relation.to) + + add(index.relatedTables, fromTable, toTable) + add(index.relatedTables, toTable, fromTable) + + const pairCount = Math.min(relation.from.columns.length, relation.to.columns.length) + for (let i = 0; i < pairCount; i++) { + const fromColumn = columnKeyOf(relation.from, relation.from.columns[i]) + const toColumn = columnKeyOf(relation.to, relation.to.columns[i]) + + index.referencingColumns.add(fromColumn) + index.referencedColumns.add(toColumn) + index.columnTable.set(fromColumn, fromTable) + index.columnTable.set(toColumn, toTable) + + add(index.linkedColumns, fromColumn, toColumn) + add(index.linkedColumns, toColumn, fromColumn) + + // Both ends of the pair under both tables: hovering either table has to + // light its own column and the one it is paired with. + for (const table of [fromTable, toTable]) { + add(index.relatedColumns, table, fromColumn) + add(index.relatedColumns, table, toColumn) + } + } + } + + return index +} + +function add(map: Map>, key: string, value: string) { + let set = map.get(key) + if (!set) map.set(key, (set = new Set())) + set.add(value) +} diff --git a/frontend/src/lib/components/dbSchemaCache.ts b/frontend/src/lib/components/dbSchemaCache.ts new file mode 100644 index 0000000000..0421c0c033 --- /dev/null +++ b/frontend/src/lib/components/dbSchemaCache.ts @@ -0,0 +1,21 @@ +import type { DbInput } from './dbTypes' + +/** What identifies a database's schema, role included: two roles on one data table may reach + * different schemas, so they cannot share a cache entry. Never throws, since it keys derived + * state; the connection itself is what refuses an invalid role. */ +export function getDbSchemasPath(input: DbInput): string { + switch (input.type) { + case 'database': + return input.role !== undefined && input.resourcePath.startsWith('datatable://') + ? `${input.resourcePath}?role=${input.role}` + : input.resourcePath + case 'ducklake': + return 'ducklake://' + input.ducklake + } +} + +/** Scoped by the acting workspace: a data table of the same name can exist in both the nav and + * the acting workspace, and one's schema must not be reused for the other. */ +export function schemaCacheKey(workspace: string | undefined, input: DbInput): string { + return `${workspace}:${getDbSchemasPath(input)}` +} diff --git a/frontend/src/lib/components/dbTypes.ts b/frontend/src/lib/components/dbTypes.ts index 6a85617110..e6d3af12e8 100644 --- a/frontend/src/lib/components/dbTypes.ts +++ b/frontend/src/lib/components/dbTypes.ts @@ -3,6 +3,12 @@ export type DbInput = type: 'database' resourceType: DbType resourcePath: string + /** The data table role to connect as; the data table's default when unset. Only + * meaningful for a `datatable://` path. */ + role?: string + /** The role migrations written through this input declare when `role` is unset. A + * migration declaring none runs as admin, not as the role the manager connects as. */ + migrationRole?: string specificSchema?: string specificTable?: string } @@ -23,3 +29,47 @@ export const dbTypes = [ 'duckdb' ] as const export const isDbType = (str?: string): str is DbType => !!str && dbTypes.includes(str as DbType) + +/** The role every data table has: the one it connects as when it is not under roles. */ +export const ADMIN_DATATABLE_ROLE = 'admin' + +/** What the server accepts in `-- role ` and `?role=`. */ +export function isDatatableRoleName(name: string): boolean { + return /^[A-Za-z0-9_-]{1,63}$/.test(name) +} + +/** Whether a role can be named in a reference to this data table. A name stored before names were + * restricted may contain `?`, and the server reads such a whole reference as that name first, so + * `?role=` after it would be taken as part of the name or refused. */ +export function datatableNameTakesRole(name: string): boolean { + return !name.includes('?') +} + +/** The `migrationRole` of a data table that cannot name a role in its reference: it connects as + * its default role, which its migrations must then declare. */ +export function defaultMigrationRole( + name: string, + permissioned: boolean | undefined, + defaultRole: string | undefined +): string | undefined { + return permissioned && !datatableNameTakesRole(name) ? defaultRole : undefined +} + +/** `datatable://`, with `?role=` when a role is named. Throws rather than build a + * reference the executor would refuse, or one that would silently mean another role. */ +export function datatableReference(name: string, role: string | undefined): string { + if (role === undefined) return `datatable://${name}` + if (!isDatatableRoleName(role)) { + throw new Error( + `Invalid data table role '${role}': only letters, digits, '_' and '-' are allowed` + ) + } + if (!datatableNameTakesRole(name)) { + throw new Error( + `Data table '${name}' has a '?' in its name, so it can only be used here as its default role. Rename it to connect as role '${role}'.` + ) + } + return `datatable://${name}?role=${role}` +} + +export type DatatableRowAction = 'migrations' | 'roles' | 'export' | 'import' diff --git a/frontend/src/lib/components/dbdiagram/DbSchemaDiagram.svelte b/frontend/src/lib/components/dbdiagram/DbSchemaDiagram.svelte new file mode 100644 index 0000000000..d9b2752374 --- /dev/null +++ b/frontend/src/lib/components/dbdiagram/DbSchemaDiagram.svelte @@ -0,0 +1,171 @@ + + +
    + {#if error} +
    + {error} +
    + {/if} + {#if tables.length === 0} +
    + Check tables in the sidebar to draw them here +
    + {:else} + + highlight.clear()} + > +
    + + + + +
    +
    + {/if} + {#if loading} +
    + + Loading relationships +
    + {/if} +
    + + diff --git a/frontend/src/lib/components/dbdiagram/DbTableNode.svelte b/frontend/src/lib/components/dbdiagram/DbTableNode.svelte new file mode 100644 index 0000000000..d17faaa006 --- /dev/null +++ b/frontend/src/lib/components/dbdiagram/DbTableNode.svelte @@ -0,0 +1,118 @@ + + + +
    highlight.hover({ table: table.key })} + onmouseleave={() => highlight.hover(undefined)} + onclick={() => highlight.togglePin({ table: table.key })} + role="button" + tabindex="-1" +> + + +
    + + {table.table} + {table.schema} +
    + + {#each columns as column (column.name)} + {@const columnKey = columnKeyIn(table.key, column.name)} + {@const isLit = highlight.columns?.has(columnKey)} + +
    highlight.hover({ table: table.key, column: column.name })} + onmouseleave={() => highlight.hover({ table: table.key })} + onclick={(e) => { + e.stopPropagation() + highlight.togglePin({ table: table.key, column: column.name }) + }} + > + {#if column.isPrimaryKey} + + {:else if data.index.referencingColumns.has(columnKey)} + + {:else} + + {/if} + {column.name} + {column.datatype} +
    + {/each} + + {#if hasExpandToggle(table)} +
    + +
    + {/if} +
    diff --git a/frontend/src/lib/components/dbdiagram/DiagramAutoFit.svelte b/frontend/src/lib/components/dbdiagram/DiagramAutoFit.svelte new file mode 100644 index 0000000000..b271886abd --- /dev/null +++ b/frontend/src/lib/components/dbdiagram/DiagramAutoFit.svelte @@ -0,0 +1,20 @@ + diff --git a/frontend/src/lib/components/dbdiagram/dbDiagramHighlight.svelte.ts b/frontend/src/lib/components/dbdiagram/dbDiagramHighlight.svelte.ts new file mode 100644 index 0000000000..54f52b85ca --- /dev/null +++ b/frontend/src/lib/components/dbdiagram/dbDiagramHighlight.svelte.ts @@ -0,0 +1,80 @@ +import { getContext, setContext } from 'svelte' +import { columnKeyIn, type RelationIndex } from '../dbRelations' + +/** What the pointer is on: a whole table, or one of its columns. */ +export type HighlightTarget = { table: string; column?: string } + +const EMPTY: ReadonlySet = new Set() + +/** + * The diagram draws no edges, so the relationships only exist while something is + * highlighted. This owns that: what is hovered (or pinned by a click), and which + * tables and columns it lights up. + */ +export class DbDiagramHighlight { + #getIndex: () => RelationIndex + + hovered = $state(undefined) + /** Set by clicking, so the relations can be read without holding the pointer + * still. A hover takes precedence while it lasts. */ + pinned = $state(undefined) + + target = $derived(this.hovered ?? this.pinned) + + /** Tables that stay lit. Undefined means nothing is highlighted and every + * table renders at full strength. */ + tables = $derived.by | undefined>(() => { + const target = this.target + if (!target) return undefined + if (target.column) { + const columnTable = this.#getIndex().columnTable + const tables = new Set([target.table]) + for (const column of this.columns ?? EMPTY) { + const table = columnTable.get(column) + if (table) tables.add(table) + } + return tables + } + return new Set([target.table, ...(this.#getIndex().relatedTables.get(target.table) ?? EMPTY)]) + }) + + /** Columns that stay lit inside the highlighted tables. */ + columns = $derived.by | undefined>(() => { + const target = this.target + if (!target) return undefined + const index = this.#getIndex() + if (target.column) { + const key = columnKeyIn(target.table, target.column) + return new Set([key, ...(index.linkedColumns.get(key) ?? EMPTY)]) + } + return index.relatedColumns.get(target.table) ?? EMPTY + }) + + constructor(getIndex: () => RelationIndex) { + this.#getIndex = getIndex + } + + hover(target: HighlightTarget | undefined) { + this.hovered = target + } + + /** Clicking the already-pinned target unpins it. */ + togglePin(target: HighlightTarget) { + const current = this.pinned + this.pinned = + current && current.table === target.table && current.column === target.column + ? undefined + : target + } + + clear() { + this.hovered = undefined + this.pinned = undefined + } +} + +const contextKey = 'DbDiagramHighlight' + +export const setDbDiagramHighlight = (highlight: DbDiagramHighlight) => + setContext(contextKey, highlight) +export const getDbDiagramHighlight = () => getContext(contextKey) diff --git a/frontend/src/lib/components/dbdiagram/dbDiagramModel.test.ts b/frontend/src/lib/components/dbdiagram/dbDiagramModel.test.ts new file mode 100644 index 0000000000..8b6b584860 --- /dev/null +++ b/frontend/src/lib/components/dbdiagram/dbDiagramModel.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { CARD_WIDTH, cardHeight, layoutTables, type DiagramTable } from './dbDiagramModel' + +function table(name: string, columnCount: number): DiagramTable { + return { + key: `shop.${name}`, + schema: 'shop', + table: name, + columns: Array.from({ length: columnCount }, (_, i) => ({ + name: `c${i}`, + datatype: 'text', + isPrimaryKey: i === 0 + })) + } +} + +describe('layoutTables', () => { + // Runs again on every checkbox click, so cards must not land on top of each + // other for any mix of card heights. + it('never overlaps two cards', () => { + const tables = Array.from({ length: 40 }, (_, i) => table(`t${i}`, 1 + (i % 17))) + const expanded = new Set(['shop.t3', 'shop.t20']) + const positions = layoutTables(tables, expanded) + + const boxes = tables.map((t) => ({ + x: positions[t.key].x, + y: positions[t.key].y, + h: cardHeight(t, expanded.has(t.key)) + })) + for (let i = 0; i < boxes.length; i++) { + for (let j = i + 1; j < boxes.length; j++) { + const a = boxes[i] + const b = boxes[j] + const apart = + a.x + CARD_WIDTH <= b.x || b.x + CARD_WIDTH <= a.x || a.y + a.h <= b.y || b.y + b.h <= a.y + expect(apart, `${tables[i].key} overlaps ${tables[j].key}`).toBe(true) + } + } + }) + + it('places the same tables in the same spots every time', () => { + const tables = Array.from({ length: 12 }, (_, i) => table(`t${i}`, 3 + i)) + expect(layoutTables(tables, new Set())).toEqual(layoutTables(tables, new Set())) + }) +}) diff --git a/frontend/src/lib/components/dbdiagram/dbDiagramModel.ts b/frontend/src/lib/components/dbdiagram/dbDiagramModel.ts new file mode 100644 index 0000000000..ccd32cdb22 --- /dev/null +++ b/frontend/src/lib/components/dbdiagram/dbDiagramModel.ts @@ -0,0 +1,131 @@ +import type { DBSchema } from '$lib/stores' +import type { ColumnDef } from '../apps/components/display/dbtable/utils' +import type { SelectedTable } from '../DBManager.svelte' +import { tableKeyOf } from '../dbRelations' + +export type DiagramColumn = { + name: string + datatype: string + isPrimaryKey: boolean +} + +export type DiagramTable = { + /** `schema.table`, the key relations and highlights are addressed by. */ + key: string + schema: string + table: string + columns: DiagramColumn[] +} + +/** Card geometry. The node component must render exactly the rows the layout + * budgeted for, or cards overlap. */ +export const CARD_WIDTH = 248 +export const HEADER_HEIGHT = 34 +export const ROW_HEIGHT = 22 +const CARD_PADDING = 6 +const GAP_X = 32 +const GAP_Y = 20 +/** Rows shown before a card collapses into a "+N more" row. */ +export const COLLAPSED_ROW_LIMIT = 12 +/** Cards are packed towards this width/height ratio, so the canvas opens on + * something screen-shaped rather than one very long column. */ +const TARGET_ASPECT = 1.7 + +export function visibleColumns(table: DiagramTable, expanded: boolean): DiagramColumn[] { + return expanded ? table.columns : table.columns.slice(0, COLLAPSED_ROW_LIMIT) +} + +export function hiddenColumnCount(table: DiagramTable, expanded: boolean): number { + return table.columns.length - visibleColumns(table, expanded).length +} + +/** Whether the card carries a row to expand or collapse its column list. */ +export function hasExpandToggle(table: DiagramTable): boolean { + return table.columns.length > COLLAPSED_ROW_LIMIT +} + +export function cardHeight(table: DiagramTable, expanded: boolean): number { + const rows = visibleColumns(table, expanded).length + (hasExpandToggle(table) ? 1 : 0) + return HEADER_HEIGHT + rows * ROW_HEIGHT + CARD_PADDING +} + +/** The tables to draw, in the order they are laid out. */ +export function buildDiagramTables( + dbSchema: DBSchema, + colDefs: Record | undefined, + selected: SelectedTable[] +): DiagramTable[] { + if (dbSchema.lang === 'graphql') return [] + const tables: DiagramTable[] = [] + + for (const { schema, table } of selected) { + const key = tableKeyOf({ schema, table }) + const fromColDefs = colDefs?.[key] + let columns: DiagramColumn[] + if (fromColDefs?.length) { + columns = fromColDefs.map((col) => ({ + name: col.field ?? '', + datatype: col.datatype ?? '', + isPrimaryKey: !!col.isprimarykey + })) + } else { + // The cached schema has no primary-key flag; it only stands in until the + // column metadata query lands. + const schemaColumns = dbSchema.schema[schema]?.[table] + if (!schemaColumns) continue + columns = Object.entries(schemaColumns).map(([name, col]) => ({ + name, + datatype: col.type, + isPrimaryKey: false + })) + } + tables.push({ key, schema, table, columns }) + } + + tables.sort((a, b) => a.schema.localeCompare(b.schema) || a.table.localeCompare(b.table)) + return tables +} + +/** Packs the cards into balanced columns, in reading order. Runs again on every + * change to the selection, so it must be deterministic: the same tables always + * land in the same place. */ +export function layoutTables( + tables: DiagramTable[], + expanded: Set +): Record { + const heights = tables.map((t) => cardHeight(t, expanded.has(t.key))) + const stacked = heights.reduce((a, b) => a + b, 0) + GAP_Y * Math.max(0, tables.length - 1) + + let columnCount = 1 + let bestScore = Infinity + for (let c = 1; c <= tables.length; c++) { + const width = c * CARD_WIDTH + (c - 1) * GAP_X + const height = stacked / c + // Compared in log space so twice-too-wide and twice-too-tall score alike. + const score = Math.abs(Math.log(width / height / TARGET_ASPECT)) + if (score < bestScore) { + bestScore = score + columnCount = c + } + } + + const target = stacked / columnCount + const positions: Record = {} + let column = 0 + let columnHeight = 0 + + tables.forEach((table, i) => { + const height = heights[i] + // Overshooting the target by more than stopping short of it costs less in + // the next column. + const overshoot = columnHeight + height - target + if (column < columnCount - 1 && columnHeight > 0 && overshoot > target - columnHeight) { + column++ + columnHeight = 0 + } + positions[table.key] = { x: column * (CARD_WIDTH + GAP_X), y: columnHeight } + columnHeight += height + GAP_Y + }) + + return positions +} diff --git a/frontend/src/lib/components/dbt/DbtEditor.svelte b/frontend/src/lib/components/dbt/DbtEditor.svelte index 4b30f8fa28..51a029c05a 100644 --- a/frontend/src/lib/components/dbt/DbtEditor.svelte +++ b/frontend/src/lib/components/dbt/DbtEditor.svelte @@ -11,7 +11,6 @@ import { createEventDispatcher, onDestroy, onMount } from 'svelte' import type { Schema, SupportedLanguage } from '$lib/common' import type { Preview, ScriptModule } from '$lib/gen' - import { workspaceStore } from '$lib/stores' import { emptySchema } from '$lib/utils' import { inferArgs } from '$lib/infer' import { Pane, Splitpanes } from 'svelte-splitpanes' @@ -54,6 +53,9 @@ import { ChevronDown, CornerDownLeft, Play, Plus } from 'lucide-svelte' import type { ScriptEditorWhitelabelCustomUi } from '../custom_ui' import { processSecretArgs } from '../secretArgUtils' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() let { schema = $bindable(), @@ -86,7 +88,7 @@ } = $props() const dispatch = createEventDispatcher() - let opWs = $derived(workspaceOverride ?? $workspaceStore) + let opWs = $derived(workspaceOverride ?? $operatingWorkspace) /** The open file, or `null` for the descriptor at the project root. */ let openFile = $state(null) diff --git a/frontend/src/lib/components/details/ErrorHandlerToggleButtonV2.svelte b/frontend/src/lib/components/details/ErrorHandlerToggleButtonV2.svelte index 51cb389616..2aec483e32 100644 --- a/frontend/src/lib/components/details/ErrorHandlerToggleButtonV2.svelte +++ b/frontend/src/lib/components/details/ErrorHandlerToggleButtonV2.svelte @@ -3,7 +3,9 @@ import { FlowService, ScriptService } from '$lib/gen' import { sendUserToast } from '$lib/toast' - import { workspaceStore } from '$lib/stores' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { kind: 'script' | 'flow' @@ -25,11 +27,11 @@ async function toggleErrorHandler(): Promise { toggleState = !toggleState - if ($workspaceStore !== undefined) { + if ($operatingWorkspace !== undefined) { try { if (kind === 'flow') { await FlowService.toggleWorkspaceErrorHandlerForFlow({ - workspace: $workspaceStore, + workspace: $operatingWorkspace, path: scriptOrFlowPath, requestBody: { muted: !errorHandlerMuted @@ -37,7 +39,7 @@ }) } else { await ScriptService.toggleWorkspaceErrorHandlerForScript({ - workspace: $workspaceStore, + workspace: $operatingWorkspace, path: scriptOrFlowPath, requestBody: { muted: !errorHandlerMuted diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index cedf4298f6..0dc6536641 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -71,9 +71,8 @@ import FlowAssetsHandler, { initFlowGraphAssetsCtx } from '$lib/components/flows/FlowAssetsHandler.svelte' - import { setDetailWorkspace } from '$lib/components/details/detailWorkspace' import { interceptNav } from '$lib/components/details/interceptNav' - import { setTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace' + import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' import FlowChat from '$lib/components/flows/conversations/FlowChat.svelte' import { slide } from 'svelte/transition' import { twMerge } from 'tailwind-merge' @@ -117,8 +116,7 @@ // Every workspace-scoped call below and in the drawers this page opens targets // `workspace`, not the nav store: a session views a flow in its own workspace. - setDetailWorkspace(() => workspace) - setTriggerWorkspace(() => workspace) + setOperatingWorkspace(() => workspace) let flow: Flow | undefined = $state() let can_write = $state(false) @@ -684,67 +682,78 @@
    - {#if flow?.path} - - {/if} + + +
    *:first-child]:mt-3' : 'contents'} + > + {#if flow?.path} + + {/if} - {#if flow?.archived} - This flow was archived -
    - {/if} + {#if flow?.archived} + This flow was archived +
    + {/if} - {#if pinnedVersion !== undefined} - - This is a historical version of the flow, not the latest. - - View latest - - -
    - {/if} + {#if pinnedVersion !== undefined} + + This is a historical version of the flow, not the latest. + + View latest + + +
    + {/if} - {#if !emptyString(flow?.description)} -
    - -
    -
    - {/if} + + {#if !chatInputEnabled && !emptyString(flow?.description)} +
    + +
    +
    + {/if} - {#if deploymentInProgress} -
    - - - Deployment in progress - {#if deploymentJobId} - view job - {/if} - -
    - {/if} - {#if flow.lock_error_logs && flow.lock_error_logs != ''} - -

    - This flow has not been deployed successfully because of the following errors: -

    - -
    -
    - {/if} + {#if deploymentInProgress} +
    + + + Deployment in progress + {#if deploymentJobId} + view job + {/if} + +
    + {/if} + {#if flow.lock_error_logs && flow.lock_error_logs != ''} + +

    + This flow has not been deployed successfully because of the following errors: +

    + +
    +
    + {/if} +
    {#if chatInputEnabled} @@ -756,6 +765,7 @@ inputSchema={flow?.schema} flowModules={flow?.value?.modules} wideLayout + frame="none" /> {:else} {@const hasSchema = diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index 7880ba52ef..caa5ab3874 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -98,9 +98,8 @@ import TriggersBadge from '$lib/components/graph/renderers/triggers/TriggersBadge.svelte' import TriggersEditor from '$lib/components/triggers/TriggersEditor.svelte' import { Triggers } from '$lib/components/triggers/triggers.svelte' - import { setDetailWorkspace } from '$lib/components/details/detailWorkspace' import { interceptNav } from '$lib/components/details/interceptNav' - import { setTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace' + import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' import { buildForkEditUrl, editInForkAllowed, @@ -145,8 +144,7 @@ // Every workspace-scoped call below and in the drawers this page opens targets // `workspace`, not the nav store: a session views an item in its own workspace. - setDetailWorkspace(() => workspace) - setTriggerWorkspace(() => workspace) + setOperatingWorkspace(() => workspace) let script: Script | undefined = $state() let topHash: string | undefined = $state() diff --git a/frontend/src/lib/components/details/detailWorkspace.test.ts b/frontend/src/lib/components/details/detailWorkspace.test.ts deleted file mode 100644 index 5c18ecd1c0..0000000000 --- a/frontend/src/lib/components/details/detailWorkspace.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { readFileSync } from 'node:fs' -import { dirname, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' - -function read(relPath: string): string { - return readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), relPath), 'utf-8') -} - -function navStoreReads(source: string): string[] { - return source - .split('\n') - .map((text, i) => `${i + 1}:${text.trim()}`) - .filter((l) => l.includes('$workspaceStore')) -} - -// The one line allowed to read the nav store: the resolver's fallback, which is what every -// call site outside a detail page still takes. -const FALLBACK = - /^\d+:let ws = \$derived\((?:workspace \?\? )?detailWs\?\.\(\) \?\? \$workspaceStore\)$/ - -// A detail component operates on its `workspace` prop, which in an AI session is the session's -// workspace and not the one the top nav points at. Any `$workspaceStore` reintroduced here aims -// that call at the nav workspace, which still looks right on the workspace pages — the only -// place the two coincide — and silently reads the wrong workspace everywhere else. -describe.each(['./ScriptDetail.svelte', './FlowDetail.svelte'])('%s', (relPath) => { - const source = read(relPath) - - it('never reads the nav workspace store', () => { - expect(navStoreReads(source)).toEqual([]) - }) - - it('registers the resolver for the drawers it opens', () => { - expect(source).toContain('setDetailWorkspace(() => workspace)') - expect(source).toContain('setTriggerWorkspace(() => workspace)') - }) -}) - -// The shared components a detail page opens. Each is mounted from many other places too, so it -// resolves its workspace rather than taking it as a prop: the resolver when a detail page set -// one, the nav store otherwise. -describe.each([ - '../ShareModal.svelte', - '../MoveDrawer.svelte', - '../PersistentScriptDrawer.svelte', - '../ScriptVersionHistory.svelte', - '../DeployWorkspace.svelte', - '../SavedInputsPicker.svelte', - '../HistoricInputs.svelte', - '../SaveInputsButton.svelte' -])('%s', (relPath) => { - const source = read(relPath) - - it('reads the nav workspace only as the resolver fallback', () => { - expect(navStoreReads(source).filter((l) => !FALLBACK.test(l))).toEqual([]) - }) - - it('consults the resolver', () => { - expect(source).toContain('const detailWs = getDetailWorkspace()') - }) -}) diff --git a/frontend/src/lib/components/details/detailWorkspace.ts b/frontend/src/lib/components/details/detailWorkspace.ts deleted file mode 100644 index 7cbdcfacfd..0000000000 --- a/frontend/src/lib/components/details/detailWorkspace.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { getContext, setContext } from 'svelte' - -const DETAIL_WORKSPACE_KEY = 'detailWorkspace' - -/** - * Context seam letting the script/flow detail pages and the drawers they open - * operate on a workspace other than the globally-active `$workspaceStore`. An AI - * session views an item in its own (possibly forked) workspace WITHOUT switching - * `$workspaceStore`, which stays on whatever the top nav points at, so - * `ScriptDetail`/`FlowDetail` register a resolver here from their `workspace` prop. - * - * Every workspace-scoped backend call inside the detail subtree — ShareModal, - * DeployWorkspace, MoveDrawer, PersistentScriptDrawer, ScriptVersionHistory and the - * saved-input components — reads its workspace via {@link getDetailWorkspace} and - * falls back to `$workspaceStore` when no resolver is set. No resolver is set - * anywhere outside a detail component, so every other call site of those shared - * components is unchanged. Mirrors `triggerWorkspace` and `rawAppWorkspace`. - * - * A getter (not a value) so a live `$derived` is read reactively at each call site. - */ -export function setDetailWorkspace(resolver: () => string | undefined): void { - setContext(DETAIL_WORKSPACE_KEY, resolver) -} - -/** - * The detail-workspace resolver set by an embedding detail component, or `undefined` - * when none is set (fall back to `$workspaceStore`). Read once during component init; - * use in a `$derived`: `const ws = $derived(detailWs?.() ?? $workspaceStore)`. - */ -export function getDetailWorkspace(): (() => string | undefined) | undefined { - return getContext<(() => string | undefined) | undefined>(DETAIL_WORKSPACE_KEY) -} diff --git a/frontend/src/lib/components/diff_drawer.ts b/frontend/src/lib/components/diff_drawer.ts index c6e9b5cdf8..c1e7e8980a 100644 --- a/frontend/src/lib/components/diff_drawer.ts +++ b/frontend/src/lib/components/diff_drawer.ts @@ -1,9 +1,53 @@ import type { Value } from '$lib/utils' +export type DiffVersionOption = { + /** Opaque to the drawer — a script hash, a flow version id, an app version id. */ + id: string + /** Identifies the version: its number and hash. Carries the weight in the list. */ + label: string + /** Who deployed it, and when — rendered under the label in secondary text, so the + * version reads first and the attribution second. */ + subtitle?: string + /** The version currently deployed. */ + isHead?: boolean +} + +/** How many deployed versions the diff picker asks for at a time. The history endpoints + * still answer whole when nobody asks — the panels and the CLI read them that way — so + * this is the picker's own appetite, not their default. */ +export const VERSION_PAGE_SIZE = 20 + export type DiffDrawerDiff = | { mode: 'normal' deployed: Value + /** Names the deployed side, e.g. `Deployed d2154d55 by bob`. The version + * identity is stripped from the displayed metadata, so without this the + * reader cannot tell what their draft is being compared against. */ + deployedLabel?: string + /** Deployed versions the reader can compare against, newest first. The + * drawer only renders the picker; each editor supplies its own list and + * fetcher because scripts, flows and apps identify versions differently. */ + versions?: DiffVersionOption[] + /** Loads one version's payload. Returning `undefined` leaves the current + * comparison in place rather than blanking the diff. */ + loadVersion?: (id: string) => Promise + /** Fetches the next, older page of `versions`. `versions` holds one page so the + * drawer opens without waiting on a path a pipeline has deployed thousands of + * times; the reader asks for the rest. Returning nothing ends the list. */ + loadMoreVersions?: () => Promise + /** Moves the draft's base to the head and keeps its content, rendered as a + * header action so the user takes the latest with the diff in front of them. + * Called with the version the drawer is showing as head (from `versions`), so + * the base adopted is the one the reader just looked at. */ + onTakeLatest?: (head?: string) => void | Promise + /** The version the draft forked from. The drawer offers `onTakeLatest` only + * while it differs from the head, and passes that head to it. */ + draftBase?: string + /** The version the deployed payload passed here came from, used as the head when + * no `versions` list loaded. Without either, the drawer cannot tell whether the + * draft is behind and offers nothing. */ + deployedHead?: string draft?: Value | undefined current: Value defaultDiffType?: 'deployed' | 'draft' @@ -18,7 +62,19 @@ export type DiffDrawerDiff = } export interface DiffDrawerI { - openDrawer: () => void + /** Pass the token from `beginOpening` to continue that opening; called without one, + * the drawer claims a fresh opening, so any reuse invalidates a fetch still in + * flight rather than being overwritten by it. */ + openDrawer: (token?: number) => void closeDrawer: () => void - setDiff: (diff: DiffDrawerDiff) => void + /** Takes the opening's token on the same terms as `openDrawer`. */ + setDiff: (diff: DiffDrawerDiff, token?: number) => void + /** Claim the drawer for one opening. Filling it takes awaited fetches, and a path + * change remounts the editor while this drawer stays, so the token lives here: an + * editor checks `ownsOpening` before every write and drops the opening it started + * when it goes away. */ + beginOpening: () => number + ownsOpening: (token: number) => boolean + /** Drop that opening and, when it still owns the drawer, what it put on screen. */ + abandonOpening: (token: number) => void } diff --git a/frontend/src/lib/components/flow_builder.ts b/frontend/src/lib/components/flow_builder.ts index ce848c6f83..f763e92faa 100644 --- a/frontend/src/lib/components/flow_builder.ts +++ b/frontend/src/lib/components/flow_builder.ts @@ -10,6 +10,14 @@ import type { WorkspaceItem } from './workspacePicker' export type FlowBuilderProps = { initialPath?: string + /** The draft row's own path (the URL path). `initialPath` is re-seeded from the + * draft's `draft_path` so the topbar shows the pending name, so it can't be used + * to resolve what is actually deployed. */ + userDraftPath?: string + /** Moves the draft's base to the head and keeps its content; offered in the diff + * drawer while the draft is behind, and called with the version the drawer shows + * as head. */ + onTakeLatest?: (head?: string) => void | Promise pathStoreInit?: string | undefined newFlow: boolean selectedId: string | undefined @@ -24,9 +32,10 @@ export type FlowBuilderProps = { disabledFlowInputs?: boolean savedPrimarySchedule?: ScheduleTrigger | undefined // used to set the primary schedule in the legacy primaryScheduleStore version?: number | undefined - /** flow_version the draft was forked from; when set, the deploy-time staleness - * check compares it (not the load-time head `version`) against the latest. */ - draftBaseVersion?: number | undefined + /** flow_version the draft was forked from (as text, like `draft_base`); when + * set, the deploy-time staleness check compares it (not the load-time head + * `version`) against the latest. */ + draftBaseVersion?: string | undefined draftTriggersFromUrl?: Trigger[] | undefined selectedTriggerIndexFromUrl?: number | undefined children?: import('svelte').Snippet diff --git a/frontend/src/lib/components/flows/FlowAssetsHandler.svelte b/frontend/src/lib/components/flows/FlowAssetsHandler.svelte index 947eca1a67..86fff85437 100644 --- a/frontend/src/lib/components/flows/FlowAssetsHandler.svelte +++ b/frontend/src/lib/components/flows/FlowAssetsHandler.svelte @@ -43,11 +43,13 @@ type RawScript } from '$lib/gen' import { deepEqual } from 'fast-equals' - import { workspaceStore } from '$lib/stores' import S3FilePicker from '../S3FilePicker.svelte' import ResourceEditorDrawer from '../ResourceEditorDrawer.svelte' import { watch } from 'runed' import { sendUserToast } from '$lib/toast' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() let { modules, @@ -63,7 +65,7 @@ const flowGraphAssetsCtx = getContext('FlowGraphAssetContext') const { selectionManager, opWorkspace } = getContext('FlowEditorContext') || {} - let opWs = $derived(opWorkspace?.() ?? $workspaceStore) + let opWs = $derived(opWorkspace?.() ?? $operatingWorkspace) // Expose the acting workspace to the asset explore controls (ExploreAssetButton // reads it from this context; the DB manager / S3 picker act on it). $effect(() => { diff --git a/frontend/src/lib/components/flows/FlowEditor.svelte b/frontend/src/lib/components/flows/FlowEditor.svelte index bf25a1dcda..a9c3a40e8a 100644 --- a/frontend/src/lib/components/flows/FlowEditor.svelte +++ b/frontend/src/lib/components/flows/FlowEditor.svelte @@ -40,14 +40,21 @@ import FlowPanelPlacementPicker from './common/FlowPanelPlacementPicker.svelte' import { prefersSessionHandoff } from '../copilot/chat/global/gate' import { openSourceInSession } from '$lib/components/sessions/sessionSwitch.svelte' - import { userStore, workspaceStore } from '$lib/stores' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) const { flowStore, selectionManager, pathStore, opWorkspace } = getContext('FlowEditorContext') // Flow paths repeat across workspaces, and a session keeps every tab it has visited alive, so two // editors can hold the same path at once. Both halves are needed to tell them apart. - let editorWorkspace = $derived(opWorkspace?.() ?? $workspaceStore) + let editorWorkspace = $derived(opWorkspace?.() ?? $operatingWorkspace) function targetWorkspace(t: AgentEditorTarget): string | undefined { - return t.workspace ?? $workspaceStore + return t.workspace ?? $operatingWorkspace } const sessionScopedManager = getContext('aiChatManager') @@ -404,7 +411,7 @@ if ( !sessionScopedManager && sessionOpen && - prefersSessionHandoff($userStore?.operator) + prefersSessionHandoff(actingUser?.operator) ) { void openSourceInSession(sessionOpen, { previewParams: { selected: detail.moduleId }, diff --git a/frontend/src/lib/components/flows/FlowHistoryInner.svelte b/frontend/src/lib/components/flows/FlowHistoryInner.svelte index 1f906c1e45..87c2306339 100644 --- a/frontend/src/lib/components/flows/FlowHistoryInner.svelte +++ b/frontend/src/lib/components/flows/FlowHistoryInner.svelte @@ -5,12 +5,14 @@ import { Pane, Splitpanes } from 'svelte-splitpanes' import { classNames, displayDate, emptyString, sendUserToast } from '$lib/utils' import { type Flow, FlowService, type FlowVersion } from '$lib/gen' - import { workspaceStore } from '$lib/stores' import { Skeleton } from '$lib/components/common' import Button from '../common/button/Button.svelte' import { ArrowRight, Loader2, Pencil, X } from 'lucide-svelte' import { getContext } from 'svelte' import type { FlowEditorContext } from './types' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { path: string @@ -21,7 +23,7 @@ let { path, allowFork = false, onHistoryRestore }: Props = $props() const flowEditorContext = getContext('FlowEditorContext') - let opWs = $derived(flowEditorContext?.opWorkspace?.() ?? $workspaceStore) + let opWs = $derived(flowEditorContext?.opWorkspace?.() ?? $operatingWorkspace) let loading: boolean = $state(false) diff --git a/frontend/src/lib/components/flows/FlowProgressBar.svelte b/frontend/src/lib/components/flows/FlowProgressBar.svelte index d31c1cae97..5edf55b5de 100644 --- a/frontend/src/lib/components/flows/FlowProgressBar.svelte +++ b/frontend/src/lib/components/flows/FlowProgressBar.svelte @@ -2,6 +2,7 @@ import { type Job } from '$lib/gen' import ProgressBar from '../progressBar/ProgressBar.svelte' import { forLater } from '$lib/forLater' + import type { Snippet } from 'svelte' interface Props { job?: Job | undefined @@ -11,6 +12,7 @@ textPosition?: 'top' | 'bottom' // Prefer step ID over step index when both are available showStepId?: boolean + errorAction?: Snippet } let { @@ -19,7 +21,8 @@ class: className, slim = false, textPosition = 'top', - showStepId = false + showStepId = false, + errorAction }: Props = $props() let error: number | undefined = $state(undefined) @@ -183,4 +186,5 @@ {isCanceled} {isScheduled} {isSkipped} + {errorAction} /> diff --git a/frontend/src/lib/components/flows/agentFormFields.test.ts b/frontend/src/lib/components/flows/agentFormFields.test.ts index 311b48ade1..48b4c94d92 100644 --- a/frontend/src/lib/components/flows/agentFormFields.test.ts +++ b/frontend/src/lib/components/flows/agentFormFields.test.ts @@ -6,6 +6,7 @@ import { agentMemoryMode, historyInputApplies, agentFieldIsSet, + agentTestInputTransforms, initialVisibleAgentFields } from './agentFormFields' @@ -147,3 +148,23 @@ describe('memoryPropertyFor', () => { ).toBeUndefined() }) }) + +describe('agentTestInputTransforms', () => { + it('unsets a blank history input and feeds every other form key from flow_input', () => { + const authored = { + memory_id: { type: 'static' as const, value: 'thread-1' }, + previous_messages: { type: 'static' as const, value: [{ role: 'user', content: 'hi' }] }, + system_prompt: { type: 'javascript' as const, expr: 'results.a.prompt' } + } + expect( + agentTestInputTransforms(authored, { memory_id: '', previous_messages: [{ role: 'user' }] }, [ + 'memory_id', + 'previous_messages', + 'system_prompt' + ]) + ).toEqual({ + previous_messages: { type: 'javascript', expr: 'flow_input.previous_messages' }, + system_prompt: { type: 'javascript', expr: 'flow_input.system_prompt' } + }) + }) +}) diff --git a/frontend/src/lib/components/flows/agentFormFields.ts b/frontend/src/lib/components/flows/agentFormFields.ts index 3d62497ba3..7f09afdf7e 100644 --- a/frontend/src/lib/components/flows/agentFormFields.ts +++ b/frontend/src/lib/components/flows/agentFormFields.ts @@ -30,6 +30,28 @@ export const AGENT_TOOLS_ROW = 'tools' export const AGENT_HISTORY_KEYS = ['memory_id', 'previous_messages'] as const export type AgentHistoryKey = (typeof AGENT_HISTORY_KEYS)[number] +/** The input transforms a one-off test of an agent step runs with: each form key is fed from + * `flow_input`, over the step's own transforms. A blank history input is unset, as on the step: + * an expression evaluating to nothing reads as an empty memory id, and the step's transform is + * stale. */ +export function agentTestInputTransforms( + stepTransforms: Record | undefined, + args: Record, + formKeys: readonly string[] = Object.keys(args) +): Record { + const isBlank = (v: unknown) => v == undefined || v === '' || (Array.isArray(v) && !v.length) + const kept = (key: string) => + !(AGENT_HISTORY_KEYS as readonly string[]).includes(key) || !isBlank(args[key]) + return { + ...Object.fromEntries(Object.entries(stepTransforms ?? {}).filter(([key]) => kept(key))), + ...Object.fromEntries( + formKeys + .filter(kept) + .map((key) => [key, { type: 'javascript' as const, expr: `flow_input.${key}` }]) + ) + } +} + /** What turning managed memory on writes. */ export const DEFAULT_AGENT_MEMORY: MemoryConfig = { kind: 'window', context_length: 10 } diff --git a/frontend/src/lib/components/flows/agentToolUtils.ts b/frontend/src/lib/components/flows/agentToolUtils.ts index e9838f60e3..87c15bc66a 100644 --- a/frontend/src/lib/components/flows/agentToolUtils.ts +++ b/frontend/src/lib/components/flows/agentToolUtils.ts @@ -126,6 +126,11 @@ export function toolEnabledName(tool: AgentTool): string | undefined { return toolDisplayName(tool) } +/** The names `enabled_tools` can pick from: every tool in the roster that has one. */ +export function enabledToolNames(tools: AgentTool[]): string[] { + return tools.map((tool) => toolEnabledName(tool)).filter((name): name is string => !!name) +} + /** * Create an AI Agent tool (nested agent) */ diff --git a/frontend/src/lib/components/flows/common/FlowCardHeader.svelte b/frontend/src/lib/components/flows/common/FlowCardHeader.svelte index 063367cc85..5776c88025 100644 --- a/frontend/src/lib/components/flows/common/FlowCardHeader.svelte +++ b/frontend/src/lib/components/flows/common/FlowCardHeader.svelte @@ -19,7 +19,7 @@ import FlowPanelChrome from './FlowPanelChrome.svelte' import type { FlowBuilderWhitelabelCustomUi } from '$lib/components/custom_ui' import DropdownV2 from '$lib/components/DropdownV2.svelte' - import { hubBaseUrlStore, workspaceStore } from '$lib/stores' + import { hubBaseUrlStore } from '$lib/stores' import { DEFAULT_HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION } from '$lib/hub' import { getLatestHashForScript } from '$lib/scripts' import { sendUserToast, type Item } from '$lib/utils' @@ -27,6 +27,9 @@ import { getToolNameError } from '$lib/components/flows/agentToolUtils' import { logFeatureUsage } from '$lib/utils/featureUsage' import autosize from '$lib/autosize' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { flowModuleValue?: FlowModuleValue | undefined @@ -65,7 +68,7 @@ const flowEditorContext = getContext('FlowEditorContext') const { scriptEditorDrawer, workspaceScriptSettingsDrawer } = flowEditorContext - let opWs = $derived(flowEditorContext?.opWorkspace?.() ?? $workspaceStore) + let opWs = $derived(flowEditorContext?.opWorkspace?.() ?? $operatingWorkspace) const scriptPath = $derived(flowModuleValue?.type === 'script' ? flowModuleValue.path : undefined) const pinnedHash = $derived(flowModuleValue?.type === 'script' ? flowModuleValue.hash : undefined) const isHub = $derived(scriptPath?.startsWith('hub/') ?? false) diff --git a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte index b6a98160be..4f69d3a1a1 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorHost.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorHost.svelte @@ -1,7 +1,16 @@ {#if draft.refusal} @@ -359,7 +434,7 @@ - +
    - - - - -
    - + +
    + +
    + + +
    + +
    +
    + + + +
    +
    + {#if chatMounted} +
    + {#if chatGap?.memory} +
    + +

    Chat needs managed memory

    +

    + {chatGap.memoryCanTurnOn + ? 'Without it, every message would be answered without the ones before it.' + : 'This agent replays a fixed list of messages. Switch its memory to managed to chat with it.'} +

    + {#if chatGap.memoryCanTurnOn && !readOnly} + + {/if} +
    + {:else if chatGap?.noStream} +
    + + + {chatGap.noStream === 'image' + ? 'Image answers do not stream: each one shows once its run ends.' + : 'Streaming is off: each answer shows once its run ends.'} + + {#if chatGap.noStream === 'off' && !readOnly} + + {/if} +
    + {/if} + +
    + +
    - - - - - + {/if} +
    diff --git a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte index 11e59769fc..ea45761bf9 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte @@ -1,5 +1,7 @@ -
    +
    {#if chat && chatState} {#if !hideSidebar} {/if} - - {#key chat} - - {/key} + +
    + + {#key chat} + + {/key} +
    {/if}
    diff --git a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte index f4cdc64912..cad5688d89 100644 --- a/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowChatInterface.svelte @@ -10,6 +10,7 @@ import { emptyString, type DynamicInput } from '$lib/utils' import { onDestroy, tick, untrack } from 'svelte' import type { Chat } from 'windmill-chat' + import { chatFlowKey } from './flowChatProps' import type { FlowModule } from '$lib/gen' import { useWorkspaceStorageConfigured } from '$lib/components/inputTransformEnv.svelte' import { @@ -35,12 +36,16 @@ * and the attachments input. */ flowModules?: FlowModule[] path: string + /** What the stored inputs are filed under when the path is not steady (see FlowChat). */ + identity?: string workspace?: string /** The flow's description, shown under the empty transcript's prompt. */ description?: string wideLayout?: boolean /** What this surface's runs create: previews in the editor, deployed runs on the flow page. */ conversationKind?: 'test' | 'deployed' + /** What a message runs, as the composer names it. */ + subject?: 'flow' | 'agent' } let { @@ -49,10 +54,12 @@ additionalInputsSchema, flowModules, path, + identity = undefined, workspace = undefined, description = undefined, wideLayout = false, - conversationKind = 'deployed' + conversationKind = 'deployed', + subject = 'flow' }: Props = $props() // Derive helperScript for dynamic inputs from schema @@ -87,7 +94,7 @@ const modelWiring = $derived(resolveAgentModelWiring(flowModules)) // An agent with nothing to call cannot answer, and the composer cannot fix it, so the // chat says what to go and do instead of offering controls that write nowhere. - const modelGap = $derived(agentModelGap(modelWiring)) + const modelGap = $derived(agentModelGap(modelWiring, subject)) const showModelButton = $derived(showsModelButton(modelWiring)) // LocalStorage helpers @@ -122,7 +129,7 @@ const runInputs = $derived(withoutRejectedEffort(modelWiring, effectiveInputs)) function getStorageKey(): string { - return `${STORAGE_KEY_PREFIX}${path}` + return `${STORAGE_KEY_PREFIX}${chatFlowKey({ path, identity })}` } function loadInputsFromStorage(): Record | null { @@ -181,7 +188,10 @@ ? undefined : 'This workspace has no object storage, so files cannot be attached.', workspace: () => workspace, - sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason + sendDisabled: () => deploymentInProgress || !!modelGap || !!wrongKindReason, + // The model controls only: a retry changes model when the reader did, but replays + // the run's own attachments rather than whatever the composer holds now. + inputsShownInComposer: () => composerOwnedInputs(modelWiring, undefined) } ) setChatViewHost(chatHost) @@ -266,7 +276,7 @@ {:else}

    Start a conversation

    -

    Send a message to run the flow and see the results

    +

    Send a message to run the {subject} and see the results

    {#if !emptyString(description)}
    @@ -329,7 +339,7 @@ {wideLayout} {emptyHint} footerSettings={modalSchema || showModelButton ? footerSettings : undefined} - placeholder="Send a message to run the flow" + placeholder="Send a message to run the {subject}" disabled={deploymentInProgress || !!modelGap || !!wrongKindReason} disabledMessage={deploymentInProgress ? 'Deployment in progress' diff --git a/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte b/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte index 49efbbc1fa..d2ea5c5061 100644 --- a/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte +++ b/frontend/src/lib/components/flows/conversations/FlowConversationsSidebar.svelte @@ -185,7 +185,7 @@ : 'w-[44px]'}" > -
    +
    +
    + + {/if} + + {#if previewResult && !previewError} +
    +

    Changes to push

    + + {#if previewResult.changes?.length > 0} + + {:else} +
    + The repository already matches this workspace: there is nothing to push. +
    + {/if} +
    + {/if} + + {#if applyError} + + {applyError} + + {/if} + +
    + {#each [{ id: previewJobId, status: previewJobStatus, label: 'Changes job', running: isPreviewLoading }, { id: applyJobId, status: applyJobStatus, label: 'Push job', running: isApplying }] as job (job.label)} + + {#if job.running || (job.id && job.status !== 'success')} +
    + {#if job.status === 'running'} + + {:else if job.status === 'failure'} + + {/if} + {#if job.id} + {job.label}: + + {job.id} + + {/if} +
    + {/if} + {/each} + + +
    +
    + +
    + + {#if showCliInstructions} +
    + +
    {cliInstructions}
    +
    + {/if} +
    +
    +
    diff --git a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts index 075c6b1f24..97b79b7c1b 100644 --- a/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts +++ b/frontend/src/lib/components/git_sync/GitSyncContext.svelte.ts @@ -1,5 +1,5 @@ import { getContext, setContext } from 'svelte' -import { enterpriseLicense, userStore } from '$lib/stores' +import { enterpriseLicense, userStore, userWorkspaces, workspaceStore } from '$lib/stores' import { get } from 'svelte/store' import { sendUserToast } from '$lib/toast' import { apiErrorMessage } from '$lib/utils' @@ -46,7 +46,15 @@ export type GitSyncSettings = { export type ModalState = { push: { idx: number; repo: GitSyncRepository; open: boolean } | null pull: { idx: number; repo: GitSyncRepository; open: boolean; settingsOnly?: boolean } | null - success: { open: boolean; savedWithoutInit?: boolean; autoPullOn?: boolean } | null + success: { + open: boolean + savedWithoutInit?: boolean + autoPullOn?: boolean + /** Why pulling fell back to polling, when it did. */ + webhookError?: string + /** Whether this workspace is the one that would turn pulling on for this repository. */ + ownsAutoPull?: boolean + } | null } export type ValidationState = { @@ -202,6 +210,11 @@ export function createGitSyncContext(workspace: string) { 'flow', 'app', 'folder', + 'resource', + 'resourcetype', + 'variable', + 'schedule', + 'trigger', 'workspacedependencies', 'datatablemigration' ] @@ -290,8 +303,23 @@ export function createGitSyncContext(workspace: string) { closeModal('pull') } - function showSuccessModal(savedWithoutInit?: boolean, autoPullOn?: boolean) { - activeModals.success = { open: true, savedWithoutInit, autoPullOn } + /** Only a sync repository in a non-fork workspace has a pull toggle of its own: promotion + * repositories have none, and a fork's pulling is its parent's to set. */ + function ownsAutoPull(repo: GitSyncRepository): boolean { + const ws = get(workspaceStore) + const isFork = + (ws?.startsWith('wm-fork-') ?? false) || + !!get(userWorkspaces)?.find((w) => w.id === ws)?.parent_workspace_id + return !repo.use_individual_branch && !isFork + } + + function showSuccessModal( + savedWithoutInit?: boolean, + autoPullOn?: boolean, + ownsAutoPull?: boolean, + webhookError?: string + ) { + activeModals.success = { open: true, savedWithoutInit, autoPullOn, ownsAutoPull, webhookError } } function closeSuccessModal() { @@ -314,8 +342,11 @@ export function createGitSyncContext(workspace: string) { repo.detectionJobId = undefined repo.detectionJobStatus = undefined - // Track the detection timestamp to avoid race conditions from old jobs + // Track the detection timestamp to avoid race conditions from old jobs. Stamped before + // the request, or a failure to even start the job reads as superseded and leaves the + // repository loading for good. const detectionTimestamp = Date.now() + repo._detectionTimestamp = detectionTimestamp try { const jobId = await JobService.runScriptByPath({ @@ -335,7 +366,6 @@ export function createGitSyncContext(workspace: string) { repo.detectionJobId = jobId repo.detectionJobStatus = 'running' - repo._detectionTimestamp = detectionTimestamp // Use JobManager for polling - result will be the actual job response await jobManager.runWithProgress(() => Promise.resolve(jobId), { @@ -506,7 +536,9 @@ export function createGitSyncContext(workspace: string) { } } - async function saveRepository(idx: number, savedWithoutInit = false) { + /** `announce` off for a caller that reports the save itself — the setup dialog ends on a + * step saying the same thing, and a modal over it would say it twice. */ + async function saveRepository(idx: number, savedWithoutInit = false, announce = true) { const repo = repositories[idx] if (!repo || !validateRepository(repo, idx)) { throw new Error('Cannot save invalid repository') @@ -539,7 +571,8 @@ export function createGitSyncContext(workspace: string) { if (repoToSave.auto_pull) { repoToSave.auto_pull = { ...repoToSave.auto_pull, - enabled_by: repoToSave.auto_pull.enabled ? get(userStore)?.email : undefined + enabled_by: repoToSave.auto_pull.enabled ? get(userStore)?.email : undefined, + webhook_error: await readWebhookError(repoToSave) } } @@ -553,7 +586,31 @@ export function createGitSyncContext(workspace: string) { repoToSave.detectionState = undefined repoToSave.extractedSettings = undefined // Show success modal for new connections - showSuccessModal(savedWithoutInit, repoToSave.auto_pull?.enabled === true) + if (announce) { + showSuccessModal( + savedWithoutInit, + repoToSave.auto_pull?.enabled === true, + ownsAutoPull(repoToSave), + repoToSave.auto_pull?.webhook_error + ) + } + } + } + + /** The webhook is registered after the settings commit, best-effort, and a failure is + * reported only through the stored settings — so it stays invisible until they are read + * back. Without this, pulling reads as working when it has silently fallen back to + * polling (an app missing the webhook permission, an instance GitHub cannot reach). */ + async function readWebhookError(repo: GitSyncRepository): Promise { + if (!repo.auto_pull?.enabled) return undefined + try { + const settings = await WorkspaceService.getSettings({ workspace }) + return settings.git_sync?.repositories?.find( + (r) => r.git_repo_resource_path.replace('$res:', '') === repo.git_repo_resource_path + )?.auto_pull?.webhook_error + } catch { + // Only costs the warning; the card shows it on the next load. + return undefined } } @@ -704,6 +761,11 @@ export function createGitSyncContext(workspace: string) { 'flow', 'app', 'folder', + 'resource', + 'resourcetype', + 'variable', + 'schedule', + 'trigger', 'workspacedependencies', 'datatablemigration' ] @@ -712,9 +774,10 @@ export function createGitSyncContext(workspace: string) { legacyImported: false, isUnsavedConnection: true, collapsed: false - // Pull-from-Git defaults are applied by the repository card once the - // selected resource resolves: only app-backed repos (instant webhook - // delivery) default to auto-pull on; polling is opt-in for token repos. + // Pull-from-Git defaults are applied once the selected resource resolves, by + // whoever is configuring the connection (see applyNewConnectionDefaults): only + // app-backed repos (instant webhook delivery) default to auto-pull on; polling is + // opt-in for token repos. }) gitSyncTestJobs.push({ jobId: '', @@ -741,6 +804,11 @@ export function createGitSyncContext(workspace: string) { 'flow', 'app', 'folder', + 'resource', + 'resourcetype', + 'variable', + 'schedule', + 'trigger', 'workspacedependencies', 'datatablemigration' ] diff --git a/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte b/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte index 610dafb562..cb8236cafc 100644 --- a/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncModalManager.svelte @@ -111,5 +111,7 @@ bind:open={gitSyncContext.activeModals.success.open} savedWithoutInit={gitSyncContext.activeModals.success.savedWithoutInit} autoPullOn={gitSyncContext.activeModals.success.autoPullOn} + explainAutoPullOff={gitSyncContext.activeModals.success.ownsAutoPull ?? false} + webhookError={gitSyncContext.activeModals.success.webhookError} /> {/if} diff --git a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte index 291c89c606..8c3af1c54d 100644 --- a/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncRepositoryCard.svelte @@ -1,7 +1,6 @@ {#if !gitSyncContext} @@ -132,7 +228,7 @@ {#snippet actions()} {#if (gitSyncAllowed || gitSyncStatus.user_count != null) && gitSyncContext?.repositories != undefined} +
    + {:else} +
    + {#each syncRows as { repo, idx } (idx)} + {@render repoRow(repo, idx)} + {/each} + {#if promotionRows.length > 0} + +
    Promotion repositories
    + {#each promotionRows as { repo, idx } (idx)} + {@render repoRow(repo, idx)} + {/each} + {/if} +
    - {#if $enterpriseLicense} - - {#if primarySync && !primarySync.repo?.isUnsavedConnection && !devSingleRepo} - {#if secondarySync.length > 0 || secondarySyncExpanded} -
    - - - {#if secondarySyncExpanded} -
    - {#if secondarySync.length === 0} -
    - No secondary sync repositories configured -
    - {:else} - {#each secondarySync as { repo, idx } (repo.git_repo_resource_path)} -
    - -
    - {/each} - {/if} - - {#if !hasUnsavedSecondary} -
    - -
    - {/if} -
    - {/if} -
    - {:else} - - {#if !hasUnsavedSecondary} -
    - + {addSyncLabel} + + {#if !$enterpriseLicense}{/if}
    {/if} - {/if} - {/if} - - - {#if showPromotion && !devSingleRepo} -
    - gitSyncContext.addPromotionRepository()} - isCollapsible={false} - showEmptyState={primaryPromotion?.repo === null} - /> - - - {#if primaryPromotion && !primaryPromotion.repo?.isUnsavedConnection} - {#if secondaryPromotion.length > 0 || secondaryPromotionExpanded} -
    - - - {#if secondaryPromotionExpanded} -
    - {#if secondaryPromotion.length === 0} -
    - No secondary promotion repositories configured -
    - {:else} - {#each secondaryPromotion as { repo, idx } (repo.git_repo_resource_path)} -
    - -
    - {/each} - {/if} - - {#if !hasUnsavedSecondaryPromotion} -
    - -
    - {/if} -
    - {/if} -
    - {:else} - - {#if !hasUnsavedSecondaryPromotion} -
    - -
    - {/if} - {/if} + {#if showAddPromotion} +
    + + + Each deploy in this workspace pushes its changes to a dedicated + wm_deploy/** + branch of the repository instead of committing to its tracked branch directly. Merging + that branch promotes the change: the workspace that syncs the tracked branch deploys + it on merge, so set up Git Sync there. Windmill can open the pull request for each + deploy branch, or you can use the open-pr-on-commit workflow. + + {#if !$enterpriseLicense}{/if} +
    {/if}
    - {:else if !showPromotion} + {/if} + + {#if $enterpriseLicense && !showPromotion}
    + (deleting = undefined)} + > + + Deploys of this workspace will stop being committed to + {deleting?.path}. The repository itself and its resource are + left untouched. + + + + + + (settingsPath = undefined)} + > + settingsDrawer?.closeDrawer()} + > + {#if settingsIdx !== -1} + {#key settingsIdx} + + {/key} + {/if} + + + {/if} {/if} + +{#snippet repoRow(repo: GitSyncRepository, idx: number)} + {@const validation = gitSyncContext?.getValidation(idx)} +
    +
    +
    + + {repo.git_repo_resource_path || 'No resource selected'} + + {#if repo.isUnsavedConnection} + Not saved + {:else if validation?.hasChanges} + Unsaved changes + {/if} + {#if repo.legacyImported} + Legacy configuration + {/if} +
    + {rowLabel(idx)} +
    +
    + + (deleting = { idx, path: repo.git_repo_resource_path }) + } + ]} + /> +
    +
    +{/snippet} diff --git a/frontend/src/lib/components/git_sync/GitSyncSetupModal.svelte b/frontend/src/lib/components/git_sync/GitSyncSetupModal.svelte new file mode 100644 index 0000000000..c89ed45d76 --- /dev/null +++ b/frontend/src/lib/components/git_sync/GitSyncSetupModal.svelte @@ -0,0 +1,600 @@ + + + +
    + { + if (e.detail.index === 0 && step > 1) back() + }} + /> + +
    +
    + {#if step === 1} + {#snippet githubIcon()} + + {/snippet} + {#snippet gitlabIcon()} + + {/snippet} + {#snippet resourceIcon()} + + {/snippet} +
    + {@render providerCard( + 'github_app', + githubIcon, + 'GitHub App (recommended)', + 'Connect with the GitHub UI.', + !$enterpriseLicense + )} + {@render providerCard( + 'github_pat', + githubIcon, + 'GitHub personal access token', + 'Generate a token from GitHub to allow Windmill to access your account.' + )} + {@render providerCard( + 'gitlab', + gitlabIcon, + 'GitLab', + 'Generate a token from GitLab to allow Windmill to access your account.' + )} + {#if hasResource || provider === 'existing'} + {@render providerCard( + 'existing', + resourceIcon, + 'Use an existing git_repository resource', + 'Pick a resource that already holds the repository URL and its credentials.' + )} + {/if} +
    + {#if provider === 'existing'} + + {/if} + {:else if step === 5} + {#if done} + + {/if} + {:else if step === 4} + {#if draft} + (applying = v)} + onSuccess={() => { + initialized = true + void saveDraft() + }} + /> + {/if} + {#if saveError} + + {saveError} + + {/if} + {:else if step === 3} + {#if draftIdx !== -1} + + {#key configureAttempt} + + {/key} + {/if} + {#if saveError} + + {saveError} + + {/if} + {:else if $workspaceStore} +
    + {#if provider === 'github_app' && githubApp} + + {:else if gitlabHeld} + + {:else if provider === 'gitlab'} + + {:else} + + {/if} + + {#if connection} +
    + + +
    + Save as resource + + Windmill keeps the repository and its access as a git_repository resource + at this path. + + {#key connection.url} + + {/key} +
    +
    + {/if} + + {#if connectError} + + {connectError} + + {/if} +
    + {/if} +
    + +
    +
    + {#if step > 1 && step < 5} + + {:else if step === 1 && !hasResource && provider !== 'existing'} + + {/if} +
    + {#if step === 5} + + {:else if step === 4} +
    + {#if initialized} + + {:else} + + + {/if} +
    + {:else if step === 3} + {#if draft?.detectionState === 'no-wmill'} + + {:else} + + {/if} + {:else} + + {/if} +
    +
    +
    +
    + +{#snippet providerCard( + key: Provider, + icon: Snippet, + title: string, + subtitle: string, + eeOnly: boolean = false +)} + {#snippet description()} + + {subtitle} + {#if eeOnly}{/if} + + {/snippet} + selectProvider(key)} + /> +{/snippet} diff --git a/frontend/src/lib/components/git_sync/GitSyncSuccessContent.svelte b/frontend/src/lib/components/git_sync/GitSyncSuccessContent.svelte new file mode 100644 index 0000000000..047f55ef41 --- /dev/null +++ b/frontend/src/lib/components/git_sync/GitSyncSuccessContent.svelte @@ -0,0 +1,72 @@ + + + + +
    + {#if savedWithoutInit} + + Only new changes will be pushed to this repository. What this workspace holds today has not + been pushed to it. + + {/if} + + {#if autoPullOn && webhookError} + + {webhookError} + + {:else if autoPullOn} + + New commits to the tracked branch deploy into this workspace automatically. You can adjust + this anytime on the repository's settings. + + {:else if explainAutoPullOff} + + Turn on "Automatically deploy changes from Git" on the repository to have Windmill pull new + commits into this workspace for you. Prefer to control deployment from your own pipeline + (tests, custom gating, deploy on PR merge)? Set up GitHub Actions or a similar CI/CD workflow + instead. + + {/if} + + +
    + +
    +

    Git sync connection saved successfully!

    +

    + Your repository is now configured to receive changes from Windmill. +

    +
    +
    +
    diff --git a/frontend/src/lib/components/git_sync/GitSyncSuccessModal.svelte b/frontend/src/lib/components/git_sync/GitSyncSuccessModal.svelte index 32dc68a0f4..00ffa285b2 100644 --- a/frontend/src/lib/components/git_sync/GitSyncSuccessModal.svelte +++ b/frontend/src/lib/components/git_sync/GitSyncSuccessModal.svelte @@ -1,81 +1,24 @@ -
    - -
    -
    - -
    -
    -

    Git sync connection saved successfully!

    -

    Your repository is now configured to receive changes from Windmill.

    -
    -
    - - - {#if savedWithoutInit} -
    -

    Repository saved without initialization

    -

    - Only new changes will be pushed to this repository. Existing content in Windmill has not - been initialized to the repository. -

    -
    - {/if} - - - {#if autoPullOn} -
    -

    - - Pull from Git is on -

    -

    - New commits to the tracked branch deploy into this workspace automatically. You can - adjust this anytime on the repository card. -

    -
    - {:else} -
    -

    - - Deploy changes from Git back to Windmill -

    -

    - Turn on "Automatically deploy changes from Git" on the repository to have Windmill pull - new commits into this workspace for you. -

    -
    -

    - Prefer to control deployment from your own pipeline (tests, custom gating, deploy on PR - merge)? Set up GitHub Actions or similar CI/CD workflows instead. -

    - -
    -
    - {/if} -
    +
    diff --git a/frontend/src/lib/components/git_sync/PushWorkspaceModal.svelte b/frontend/src/lib/components/git_sync/PushWorkspaceModal.svelte index 87c6ecb807..dea331bea5 100644 --- a/frontend/src/lib/components/git_sync/PushWorkspaceModal.svelte +++ b/frontend/src/lib/components/git_sync/PushWorkspaceModal.svelte @@ -1,13 +1,9 @@ -
    - -

    Push your current workspace content to the connected Git repository based on the configured - filters.

    -

    Note: This will not update git sync settings in wmill.yaml. Settings can only be pulled from - the repository as it is the source of truth.

    - - - {#if isNewConnection} -
    -

    Settings that will be pushed to repository

    -
    -
    Include paths: {uiState.include_path?.join(', ') || 'None'}
    -
    Exclude paths: {uiState.exclude_path?.join(', ') || 'None'}
    - {#if uiState.extra_include_path?.length > 0} -
    Extra include paths: {uiState.extra_include_path.join(', ')}
    - {/if} -
    Include types: {uiState.include_type?.join(', ') || 'None'}
    -
    -

    To modify these settings, cancel and configure them in the workspace settings.

    -
    + +
    +
    Include paths: {uiState.include_path?.join(', ') || 'None'}
    +
    Exclude paths: {uiState.exclude_path?.join(', ') || 'None'}
    + {#if uiState.extra_include_path?.length} +
    Extra include paths: {uiState.extra_include_path.join(', ')}
    {/if} - - - {#if !previewResult} -
    - -
    - {/if} - - - {#if previewJobId} -
    - {#if previewJobStatus === 'running'} - - {:else if previewJobStatus === 'success'} - - {:else if previewJobStatus === 'failure'} - - {/if} - Preview job: - - {previewJobId} - -
    - {/if} - - - {#if previewError} - - {previewError} - - {/if} - - - {#if previewResult && !previewError} -
    -

    Changes to Push

    - - {#if previewResult.changes?.length > 0} - - {:else} -
    -
    No changes to push to the repository.
    -
    - {/if} -
    - {/if} - - - {#if previewResult && !previewError} - {@const hasChanges = previewResult.changes?.length > 0} - {#if hasChanges} -
    -
    - - {#if isNewConnection && onSaveWithoutInit} - - {/if} -
    -
    - {/if} - {/if} - - - {#if applyJobId} -
    - {#if applyJobStatus === 'running'} - - {:else if applyJobStatus === 'success'} - - {:else if applyJobStatus === 'failure'} - - {/if} - Push job: - - {applyJobId} - -
    - {/if} - - - {#if applyError} - - {applyError} - - {/if} - - -
    - - - {#if showCliInstructions} -
    -
    -# Setup (only needed if local folder not initialized yet)
    -npm install -g windmill-cli
    -wmill workspace add {$workspaceStore} {$workspaceStore} {window.location.origin}
    -wmill init --workspace {$workspaceStore} --repository {gitRepoResourcePath}
    -
    -# Pull workspace content to git repository
    -wmill sync pull --workspace {$workspaceStore} --repository {gitRepoResourcePath}
    -
    - {/if} -
    +
    Included types: {uiState.include_type?.join(', ') || 'None'}
    +

    + This does not update the git sync settings in wmill.yaml: they + can only be pulled from the repository, which is their source of truth. To change what is + pushed, cancel and edit the filters in the workspace settings. +

    + + {#if open} + {#key gitRepoResourcePath} + + {/key} + {/if} + + + {#snippet actions()} + + {#if isNewConnection && onSaveWithoutInit} + + {/if} + {/snippet} diff --git a/frontend/src/lib/components/git_sync/setup/ConfigureSyncStep.svelte b/frontend/src/lib/components/git_sync/setup/ConfigureSyncStep.svelte new file mode 100644 index 0000000000..c1ff4e1f0d --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/ConfigureSyncStep.svelte @@ -0,0 +1,314 @@ + + +{#if repo} +
    + {#if factsError} + +
    + {factsError} + +
    +
    + {/if} + {#if !repo.detectionState || repo.detectionState === 'idle' || repo.detectionState === 'loading'} +
    + + Checking the repository... +
    + {:else if repo.detectionState === 'error'} + +
    + {repo.detectionError || 'The check failed.'} + Check the URL and the token, or go back and connect the repository again. + +
    +
    + {:else if repo.detectionState === 'no-wmill'} + + The repository has no Windmill configuration yet. Choose what to sync, then initialize it + with the current content of this workspace. + + {:else if repo.detectionState === 'has-wmill'} + + The repository already holds a Windmill configuration. Its sync settings are loaded below. + + {/if} + + {#if repo.detectionState === 'no-wmill' || repo.detectionState === 'has-wmill'} + + {#snippet subtitle()} + + {/snippet} + + + {#if mode === 'promotion'} + + {#if managedCredential} + setPromotionOpenPrs(e.detail)} + /> + {:else} + + To open a pull request for each deploy branch, connect the repository through the GitHub + App or give a GitLab repository a project access token — otherwise set up the + open-pr-on-commit workflow in the repository. + + {/if} + {:else if !isFork} +
    + setAutoPull(e.detail)} + > + {#snippet right()} + {#if !$enterpriseLicense}{/if} + {/snippet} + + {#if repo.auto_pull?.enabled} + + {managedCredential + ? 'New commits are delivered instantly by webhook, with polling as a fallback.' + : 'The branch is checked for new commits about every minute.'} + + {:else} + + Leave it off to control deployment from your own pipeline (tests, custom gating, + deploy on PR merge) with + GitHub Actions or a similar CI/CD workflow. + + {/if} + setSyncForks(e.detail)} + /> + {#if managedCredential} + setForkOpenPrs(e.detail)} + > + {#snippet right()} + {#if !$enterpriseLicense}{/if} + {/snippet} + + {/if} +
    + {/if} + {/if} + + {#if repo.detectionState !== 'no-wmill' && repo.detectionState !== 'has-wmill'} + +
    + {#if repo.detectionJobId} + {#if repo.detectionJobStatus === 'success'} + + {:else if repo.detectionJobStatus === 'failure'} + + {/if} + Check job: + + {repo.detectionJobId} + + {/if} +
    + {/if} +
    +{/if} diff --git a/frontend/src/lib/components/git_sync/setup/GithubAppConnectForm.svelte b/frontend/src/lib/components/git_sync/setup/GithubAppConnectForm.svelte new file mode 100644 index 0000000000..fdea233345 --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/GithubAppConnectForm.svelte @@ -0,0 +1,232 @@ + + +
    + {#if (!setup.loaded || gh.loadingGithubInstallations) && gh.workspaceGithubInstallations.length === 0} +
    + Loading GitHub App installations... +
    + {:else} + {#if usable.length === 0} + + {#if gh.isCheckingInstallation} + Finish installing the Windmill GitHub App in the tab that opened. This dialog picks up the + installation on its own. + {:else} + Install the Windmill GitHub App on the GitHub account or organization that owns the + repository, then come back here. + {/if} + + {:else} + + {/if} +
    diff --git a/frontend/src/lib/components/git_sync/setup/TokenUrlConnectForm.svelte b/frontend/src/lib/components/git_sync/setup/TokenUrlConnectForm.svelte new file mode 100644 index 0000000000..2d8c7f2f48 --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/TokenUrlConnectForm.svelte @@ -0,0 +1,95 @@ + + +
    + + +
    diff --git a/frontend/src/lib/components/git_sync/setup/connectionDefaults.ts b/frontend/src/lib/components/git_sync/setup/connectionDefaults.ts new file mode 100644 index 0000000000..4af90decf6 --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/connectionDefaults.ts @@ -0,0 +1,45 @@ +import type { GitSyncRepository } from '../GitSyncContext.svelte' + +/** + * Defaults for a connection that has not been saved yet, once its resource is known. + * Only fills what the user has not set, so it is safe to call again. + */ +export function applyNewConnectionDefaults( + repo: GitSyncRepository, + o: { + mode: 'sync' | 'promotion' + /** Windmill holds a credential for the repository: a GitHub App or a stored token. */ + managedCredential: boolean + isGithubApp: boolean + isFork: boolean + ee: boolean + } +): void { + if (!repo.isUnsavedConnection) return + // Pulling from Git defaults on only where Windmill can receive webhooks. Polling is + // opt-in for token repositories, and forks never get the parent-only defaults (the + // backend rejects them). + if ( + o.mode === 'sync' && + o.managedCredential && + !o.isFork && + o.ee && + repo.auto_pull === undefined + ) { + repo.auto_pull = { enabled: true, mode: 'auto', sync_forks: true } + } + // A promotion deploy pushes a wm_deploy/** branch that exists to be merged; without a + // pull request it is an orphaned branch. Fork PRs stay opt-in everywhere. + if ( + o.mode === 'promotion' && + o.managedCredential && + o.ee && + repo.promotion_open_prs === undefined + ) { + repo.promotion_open_prs = true + } + // Webhook with a polling fallback is the only delivery for app repositories. + if (o.isGithubApp && repo.auto_pull?.mode === 'polling') { + repo.auto_pull = { ...repo.auto_pull, mode: 'auto' } + } +} diff --git a/frontend/src/lib/components/git_sync/setup/githubAppSetup.svelte.ts b/frontend/src/lib/components/git_sync/setup/githubAppSetup.svelte.ts new file mode 100644 index 0000000000..645973720f --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/githubAppSetup.svelte.ts @@ -0,0 +1,50 @@ +import { + createGitHubAppState, + loadGithubInstallations, + startInstallationCheck, + stopInstallationCheck, + type GitHubAppState +} from '$lib/githubApp' + +/** + * GitHub App installations for the setup dialog. Owned by the dialog rather than the + * connect form so the installations are known before the user leaves step 1: the + * install page has to be opened synchronously from that click, or the browser blocks + * the new tab. + */ +export class GithubAppSetup { + gh: GitHubAppState = $state(createGitHubAppState()) + loaded = $state(false) + + constructor(private workspace: string) {} + + get usable() { + return this.gh.workspaceGithubInstallations.filter((i) => !i.error) + } + + async reload(): Promise { + try { + await loadGithubInstallations(this.gh, this.workspace) + } catch { + // loadGithubInstallations toasts its own failure + } + this.loaded = true + const usable = this.usable + if (!usable.some((i) => i.installation_id === this.gh.selectedGHAppInstallationId)) { + this.gh.selectedGHAppInstallationId = usable[0]?.installation_id + this.gh.selectedGHAppRepository = undefined + } + } + + /** Must be called from a click handler, synchronously. */ + openInstall(): void { + const url = this.gh.githubInstallationUrl + if (!url) return + window.open(url, '_blank', 'noopener') + startInstallationCheck(this.gh, this.workspace, () => void this.reload()) + } + + dispose(): void { + stopInstallationCheck(this.gh) + } +} diff --git a/frontend/src/lib/components/git_sync/setup/repoConnection.test.ts b/frontend/src/lib/components/git_sync/setup/repoConnection.test.ts new file mode 100644 index 0000000000..4b9cdbe9cb --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/repoConnection.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'vitest' +import { parseRepoUrl, repoSlug, withToken } from './repoConnection' + +describe('parseRepoUrl', () => { + test('drops credentials, query and fragment from a pasted URL', () => { + const url = parseRepoUrl(' https://someone:secret@github.com/acme/infra.git?utm=1#readme ') + expect(url?.toString()).toBe('https://github.com/acme/infra.git') + }) + + test('refuses what cannot carry a token safely or name a repository', () => { + // http would put the token on the wire, and the rest name no repository at all. + expect(parseRepoUrl('http://github.com/acme/infra')).toBeUndefined() + expect(parseRepoUrl('ssh://git@github.com/acme/infra')).toBeUndefined() + expect(parseRepoUrl('github.com/acme/infra')).toBeUndefined() + expect(parseRepoUrl('https://github.com/acme')).toBeUndefined() + }) +}) + +test('withToken encodes the token so a punctuated one survives the round trip', () => { + const token = 'glpat-a/b@c:d' + const embedded = withToken(parseRepoUrl('https://gitlab.com/acme/infra')!, token, 'gitlab') + expect(embedded).toBe(`https://oauth2:${encodeURIComponent(token)}@gitlab.com/acme/infra`) + expect(new URL(embedded).password).toBe(encodeURIComponent(token)) +}) + +test('repoSlug names the resource after the repository', () => { + expect(repoSlug('https://github.com/acme/My-Repo.git')).toBe('my_repo') + expect(repoSlug('https://gitlab.com/group/sub/project/')).toBe('project') +}) diff --git a/frontend/src/lib/components/git_sync/setup/repoConnection.ts b/frontend/src/lib/components/git_sync/setup/repoConnection.ts new file mode 100644 index 0000000000..81955086fd --- /dev/null +++ b/frontend/src/lib/components/git_sync/setup/repoConnection.ts @@ -0,0 +1,117 @@ +import { GitSyncService, ResourceService, VariableService } from '$lib/gen' + +/** What a provider form hands back once the user has picked a repository. */ +export type RepoConnection = { + /** The repository URL, never carrying a secret. */ + url: string + isGithubApp?: boolean + /** A token for Windmill to store and renew server-side (EE GitLab). */ + heldToken?: string + /** `url` with the token embedded, filed as a secret variable because the + * resource value itself is readable by anyone who can read the resource. */ + tokenUrl?: string +} + +/** Username git sends alongside a token: GitHub ignores it for PATs, GitLab + * requires a non-empty one and documents `oauth2`. */ +const TOKEN_USERNAME = { github: 'x-access-token', gitlab: 'oauth2' } as const + +export type TokenProvider = keyof typeof TOKEN_USERNAME + +/** Parses a pasted repository URL, dropping any credentials it carried. */ +export function parseRepoUrl(raw: string): URL | undefined { + let u: URL + try { + u = new URL(raw.trim()) + } catch { + return undefined + } + // https only: the token is embedded in this URL, and plain http would put it on the wire. + if (u.protocol !== 'https:') return undefined + if (u.pathname.replace(/\/+$/, '').split('/').filter(Boolean).length < 2) return undefined + u.username = '' + u.password = '' + u.search = '' + u.hash = '' + return u +} + +export function withToken(url: URL, token: string, provider: TokenProvider): string { + const u = new URL(url.toString()) + u.username = TOKEN_USERNAME[provider] + u.password = token.trim() + return u.toString() +} + +/** A resource name derived from the repository, e.g. `acme/infra.git` → `infra`. */ +export function repoSlug(url: string): string { + const last = + url + .replace(/\.git\/?$/, '') + .replace(/\/+$/, '') + .split('/') + .pop() ?? '' + return last.replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase() || 'git_repository' +} + +/** + * Writes everything a connection needs and creates the `git_repository` resource at + * `path`. A token-bearing URL goes to a secret variable at the same path, which the + * backend renames along with the resource. + */ +export async function createRepositoryResource( + workspace: string, + path: string, + conn: RepoConnection, + location: { branch?: string; folder?: string } +): Promise { + const branch = location.branch?.trim() + // Relative to the repository root, which is how the sync script joins it. + const folder = location.folder?.trim().replace(/^\/+|\/+$/g, '') + let url = conn.url + let createdVariable = false + if (conn.tokenUrl) { + if (await VariableService.existsVariable({ workspace, path })) { + throw new Error(`A variable already exists at ${path}. Pick another path.`) + } + await VariableService.createVariable({ + workspace, + requestBody: { + path, + value: conn.tokenUrl, + is_secret: true, + description: `Authenticated URL of the git repository ${conn.url}` + } + }) + createdVariable = true + url = `$var:${path}` + } + try { + if (conn.heldToken) { + await GitSyncService.setGitCredential({ + workspace, + requestBody: { repo_url: conn.url, token: conn.heldToken } + }) + } + await ResourceService.createResource({ + workspace, + requestBody: { + path, + resource_type: 'git_repository', + description: `Git repository ${conn.url}`, + value: { + url, + ...(branch ? { branch } : {}), + ...(folder ? { folder } : {}), + is_github_app: !!conn.isGithubApp + } + } + }) + } catch (e) { + // Otherwise a retry at the same path is refused over this run's own secret. + if (createdVariable) { + await VariableService.deleteVariable({ workspace, path }).catch(() => {}) + } + throw e + } +} diff --git a/frontend/src/lib/components/graph/FlowGraphV2.svelte b/frontend/src/lib/components/graph/FlowGraphV2.svelte index 4c004a4f56..3c870832ee 100644 --- a/frontend/src/lib/components/graph/FlowGraphV2.svelte +++ b/frontend/src/lib/components/graph/FlowGraphV2.svelte @@ -55,7 +55,6 @@ import FlowYamlEditor from '../flows/header/FlowYamlEditor.svelte' import BranchOneEndNode from './renderers/nodes/branchOneEndNode.svelte' import type { TriggerContext } from '../triggers' - import { workspaceStore } from '$lib/stores' import SubflowBound from './renderers/nodes/SubflowBound.svelte' import DiffDrawer from '../DiffDrawer.svelte' import ViewportResizer from './ViewportResizer.svelte' @@ -112,6 +111,9 @@ locateModules, areContiguousSiblings } from '../flows/multiSelectUtils' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() let useDataflow: Writable = writable(false) let showAssets: Writable = writable(true) @@ -266,7 +268,7 @@ fullSize = false, disableAi = false, triggerNode = false, - workspace = $workspaceStore ?? 'NO_WORKSPACE', + workspace = $operatingWorkspace ?? 'NO_WORKSPACE', editMode = false, allowSimplifiedPoll = true, expandedSubflows = $bindable({}), diff --git a/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte b/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte index 12cf247c9a..cf2a7339c6 100644 --- a/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte +++ b/frontend/src/lib/components/graph/renderers/nodes/AssetNode.svelte @@ -207,10 +207,13 @@ import type { Edge, Node } from '@xyflow/svelte' import { getNodeColorClasses, NODE } from '../../util' - import { userStore } from '$lib/stores' import { deepEqual } from 'fast-equals' import { slide } from 'svelte/transition' import AssetColumnBadges from '$lib/components/assets/AssetColumnBadges.svelte' + import { useOperatingUser } from '$lib/components/operatingWorkspace.svelte' + + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) interface Props { data: AssetN['data'] @@ -279,7 +282,7 @@ Could not find resource {/snippet} - {:else if isSelected && assetCanBeExplored(data.asset, cachedResourceMetadata) && !$userStore?.operator} + {:else if isSelected && assetCanBeExplored(data.asset, cachedResourceMetadata) && !actingUser?.operator}
    ([]) + $effect(() => { + if (selection.items.length > 0) lastNonEmpty = selection.items + }) + let items = $derived(selection.items.length > 0 ? selection.items : lastNonEmpty) + + let reducedMotion = useReducedMotion() const plural = (n: number) => (n === 1 ? '' : 's') const ACTION_LABEL: Record = { @@ -71,9 +84,6 @@ function actionTitle(action: BulkAction): string { const n = targets(action).length - // Selection mode is entered from the toolbar with nothing picked yet, so this - // is the state the primary entry point lands on — it has no blocked reason. - if (items.length === 0) return `Select items to ${ACTION_LABEL[action].toLowerCase()}` if (n === 0) return `Cannot ${ACTION_LABEL[action].toLowerCase()}: ${blockedSummary(action)}` if (n < items.length) return `${ACTION_LABEL[action]} ${n} of the ${items.length} selected` return `${ACTION_LABEL[action]} ${n} item${plural(n)}` @@ -98,6 +108,11 @@ let discardReverts = $derived(pendingItems.filter((i) => !i.draftOnly)) function open(action: BulkAction) { + // Gate on the LIVE selection, not `items`. That holds the last non-empty + // copy so the bar doesn't blink to zero while it flies out, and the bar stays + // clickable for the whole 200ms outro — so without this a click landing in + // that window would open a dialog acting on rows the user just deselected. + if (selection.items.length === 0) return if (targets(action).length === 0) return pending = action outcomes = undefined @@ -156,92 +171,99 @@ } -
    - - {selection.size} selected - -
    - - - {#if targets('archive').length > 0 || targets('unarchive').length === 0} + + {items.length} selected + +
    - {/if} - {#if targets('unarchive').length > 0} - - {/if} - open('discard'), - disabled: targets('discard').length === 0, - // Only while blocked: a disabled entry can't open its modal, so the reason - // has to live here — and an enabled one would render a pointless ⓘ. - tooltip: targets('discard').length === 0 ? actionTitle('discard') : undefined - }, - { - displayName: `Delete${countSuffix('delete')}`, - icon: Trash, - type: 'delete' as const, - action: () => open('delete'), - disabled: targets('delete').length === 0, - tooltip: targets('delete').length === 0 ? actionTitle('delete') : undefined - } - ]} - > - {#snippet buttonReplacement()} + + {#if targets('archive').length > 0 || targets('unarchive').length === 0} - {/snippet} - -
    - + {/if} + open('discard'), + disabled: targets('discard').length === 0, + // Only while blocked: a disabled entry can't open its modal, so the reason + // has to live here — and an enabled one would render a pointless ⓘ. + tooltip: targets('discard').length === 0 ? actionTitle('discard') : undefined + }, + { + displayName: `Delete${countSuffix('delete')}`, + icon: Trash, + type: 'delete' as const, + action: () => open('delete'), + disabled: targets('delete').length === 0, + tooltip: targets('delete').length === 0 ? actionTitle('delete') : undefined + } + ]} + > + {#snippet buttonReplacement()} + + {/snippet} + +
    +
    `${i.path} → ${movedPath(i, target)}`) + pendingItems.map((i) => `${sourcePath(i)} → ${movedPath(i, target)}`) )} {/if} {:else if pending === 'discard'} diff --git a/frontend/src/lib/components/home/CreateActionsMenu.svelte b/frontend/src/lib/components/home/CreateActionsMenu.svelte index 2cf1f28d54..7c5bb4986e 100644 --- a/frontend/src/lib/components/home/CreateActionsMenu.svelte +++ b/frontend/src/lib/components/home/CreateActionsMenu.svelte @@ -247,7 +247,8 @@ } } - let activeKey = $state(allOptions[0]?.key) + // the doc panel only shows while an option is hovered or focused, so the menu opens compact + let activeKey: string | undefined = $state(undefined) // every option's import action, surfaced together under the bottom "Import" submenu. // The hub project leads and is separated below: the others each paste one artifact the // user already holds, while this one brings a whole project in from somewhere else. @@ -350,6 +351,7 @@ if (isOpen && !wasOpen) { logFeatureUsage('home', 'new_menu_open', { key: source }) } + if (!isOpen) activeKey = undefined wasOpen = isOpen }) @@ -360,8 +362,18 @@ // only persist the non-default (hidden) state, so a cleared key means "shown" storeLocalSetting(SHOW_DOC_SETTING, value ? undefined : 'false') } - let active = $derived(allOptions.find((o) => o.key === activeKey) ?? allOptions[0]) - let activeAc = $derived(accentClasses[active.accent]) + // The pointer crosses the gutter outside the menu on its way into the Workflow-as-Code + // submenu, so leaving the menu keeps the panel while that submenu is open; it clears + // once the submenu closes with neither pointer nor focus left on the menu. + let menuEl: HTMLDivElement | undefined = $state(undefined) + $effect(() => { + if ($wacSubOpen || !menuEl) return + if (!menuEl.matches(':hover') && !menuEl.contains(document.activeElement)) { + activeKey = undefined + } + }) + let active = $derived(allOptions.find((o) => o.key === activeKey)) + let activeAc = $derived(active ? accentClasses[active.accent] : undefined) // shared YAML/JSON import drawer, reused by every "Import …" extra let importDrawer: Drawer | undefined = $state(undefined) @@ -427,179 +439,195 @@ {/if}
    -{#if $open && active} +{#if $open} +
    { + if (!$wacSubOpen) activeKey = undefined + }} > - {#if showDoc} - -
    -
    -
    - -
    -
    -
    -

    {active.label}

    - {#if active.badge} - - {active.badge.label} - - {/if} -
    -

    {active.tagline}

    -
    -
    - -

    {active.description}

    - -
      - {#each active.bullets as bullet (bullet)} -
    • - - {bullet} -
    • - {/each} -
    - - -
    - {/if} - - -
    - {#snippet rowBody(option: Option, ac: (typeof accentClasses)[string])} -
    - -
    - - {option.label} - - {#if option.badge} - - {option.badge.label} - - {/if} - {/snippet} - {#each allOptions as option (option.key)} - {@const ac = accentClasses[option.accent]} - {@const rowClass = - 'w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover'} - {#if option.variants} - - {#if $wacSubOpen} +
    + {#if showDoc && active && activeAc} + +
    +
    - {#each option.variants ?? [] as variant (variant.label)} - {@const VariantIcon = variant.icon} - - {/each} +
    - {/if} - {:else} - - {/if} - {/each} +
    +
    +

    {active.label}

    + {#if active.badge} + + {active.badge.label} + + {/if} +
    +

    {active.tagline}

    +
    +
    - -
    -
    - - Import - - - - {#if $importSubOpen} -
    - {#each importActions as action, i (action.label)} + {/if} + + +
    + {#snippet rowBody(option: Option, ac: (typeof accentClasses)[string])} +
    + +
    + + {option.label} + + {#if option.badge} + + {option.badge.label} + + {/if} + {/snippet} + {#each allOptions as option (option.key)} + {@const ac = accentClasses[option.accent]} + {@const rowClass = + 'w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover'} + {#if option.variants} + + {#if $wacSubOpen} +
    + {#each option.variants ?? [] as variant (variant.label)} + {@const VariantIcon = variant.icon} + + {/each} +
    + {/if} + {:else} - {#if onImportHubProject && i === 0} -
    - {/if} - {/each} -
    - {/if} + {/if} + {/each} - {#if !showDoc} + +
    - {/if} + {#if $importSubOpen} +
    + {#each importActions as action, i (action.label)} + + {#if onImportHubProject && i === 0} +
    + {/if} + {/each} +
    + {/if} + + {#if !showDoc} + + {/if} +
    {/if} diff --git a/frontend/src/lib/components/home/HomeConnectDrawer.svelte b/frontend/src/lib/components/home/HomeConnectDrawer.svelte index e7e4a306d9..e08ac2ddea 100644 --- a/frontend/src/lib/components/home/HomeConnectDrawer.svelte +++ b/frontend/src/lib/components/home/HomeConnectDrawer.svelte @@ -5,10 +5,12 @@ import CopyableCodeBlock from '$lib/components/details/CopyableCodeBlock.svelte' import { Bot, ExternalLink, Terminal } from 'lucide-svelte' import { shell } from 'svelte-highlight/languages' + import { mcpTokenUrlDisabled } from '$lib/mcpAuth' type ConnectTab = 'cli' | 'mcp' let drawer: Drawer | undefined = $state() + let tokenUrlDisabled = $state(false) let selectedTab: ConnectTab = $state('cli') let openVersion = $state(0) @@ -24,6 +26,10 @@ wmill sync pull`) export function openDrawer(tab: ConnectTab = 'cli') { selectedTab = tab openVersion += 1 + // Falls back like CreateToken below, which shows the bare URL when the read fails. + void mcpTokenUrlDisabled() + .then((v) => (tokenUrlDisabled = v)) + .catch(() => (tokenUrlDisabled = true)) drawer?.openDrawer() } @@ -96,8 +102,13 @@ wmill sync pull`)

    MCP URL

    - Generate an MCP server URL for the current workspace and choose which - scripts, flows, and endpoints the client can access. + {#if tokenUrlDisabled} + The MCP server URL for the current workspace. Your client signs in to + Windmill to use it. + {:else} + Generate an MCP server URL for the current workspace and choose which + scripts, flows, and endpoints the client can access. + {/if}

    diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index 7038a316fa..f86ec9d5bf 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -20,7 +20,6 @@ import type uFuzzy from '@leeoniya/ufuzzy' import { ArrowDownUp, - CheckSquare, ChevronsDownUp, ChevronsUpDown, Code2, @@ -1805,20 +1804,10 @@ {/if} {#if !loading && !contentActive && !workspaceEmpty} - +
    - {#if homeSelection.available && !homeSelection.active} -
    diff --git a/frontend/src/lib/components/jobs/MissingWorkerTagAlert.svelte b/frontend/src/lib/components/jobs/MissingWorkerTagAlert.svelte index 6d996e76e4..c6b86b1cc7 100644 --- a/frontend/src/lib/components/jobs/MissingWorkerTagAlert.svelte +++ b/frontend/src/lib/components/jobs/MissingWorkerTagAlert.svelte @@ -2,9 +2,11 @@ import { base } from '$app/paths' import Alert from '$lib/components/common/alert/Alert.svelte' import { WorkerService } from '$lib/gen' - import { workspaceStore } from '$lib/stores' import { resource } from 'runed' import { hasWorkerForTag } from './missingWorker' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { /** Worker tag the feature's jobs run on. */ @@ -17,7 +19,7 @@ let { tag, subject, workspace = undefined, class: className = '' }: Props = $props() - let ws = $derived(workspace ?? $workspaceStore) + let ws = $derived(workspace ?? $operatingWorkspace) // With per-workspace default tags, jobs run on a workspace-suffixed variant of // `tag`, so probing the bare tag would warn about a tag nothing uses. The diff --git a/frontend/src/lib/components/meltComponents/Menu.svelte b/frontend/src/lib/components/meltComponents/Menu.svelte index 4a821ceacc..ab6de64123 100644 --- a/frontend/src/lib/components/meltComponents/Menu.svelte +++ b/frontend/src/lib/components/meltComponents/Menu.svelte @@ -176,6 +176,9 @@ } async function getMenuElements(): Promise { + // Runs on every pointerdown anywhere, for every mounted menu. Skip the + // whole-document query when the outside handler below cannot act anyway. + if (!usePointerDownOutside || !open) return [] // Tooltip content counts as menu territory for the same reason as the // onOutsideClick veto above. return Array.from( diff --git a/frontend/src/lib/components/meltComponents/Popover.svelte b/frontend/src/lib/components/meltComponents/Popover.svelte index 6a6b7d681b..dd69d16917 100644 --- a/frontend/src/lib/components/meltComponents/Popover.svelte +++ b/frontend/src/lib/components/meltComponents/Popover.svelte @@ -194,6 +194,9 @@ } async function getMenuElements(): Promise { + // Runs on every pointerdown anywhere, for every mounted popover. Skip the + // whole-document query when the outside handler below cannot act anyway. + if (!usePointerDownOutside || (!isOpen && !fullScreen)) return [] const selector = excludeSelectors ? `[data-popover], ${excludeSelectors}` : '[data-popover]' return Array.from(document.querySelectorAll(selector)) as HTMLElement[] } diff --git a/frontend/src/lib/components/metrics/MetricsDrawer.svelte b/frontend/src/lib/components/metrics/MetricsDrawer.svelte index cdc640fba8..223b490041 100644 --- a/frontend/src/lib/components/metrics/MetricsDrawer.svelte +++ b/frontend/src/lib/components/metrics/MetricsDrawer.svelte @@ -2,7 +2,6 @@ import { Drawer, DrawerContent, Button, ClearableInput } from '$lib/components/common' import Toggle from '$lib/components/Toggle.svelte' import { DataMetricService, type DataMetric } from '$lib/gen' - import { workspaceStore } from '$lib/stores' import { SvelteSet } from 'svelte/reactivity' import { attachAliasFor, @@ -16,6 +15,9 @@ import { Copy, Play } from 'lucide-svelte' import SqlRepl from '$lib/components/SqlRepl.svelte' import SimpleAgTable from '$lib/components/SimpleAgTable.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() let { getCode, @@ -27,7 +29,7 @@ workspace?: string } = $props() - let ws = $derived(workspace ?? $workspaceStore) + let ws = $derived(workspace ?? $operatingWorkspace) let drawer: Drawer | undefined = $state() let entries = $state([]) diff --git a/frontend/src/lib/components/moveRenameManager.ts b/frontend/src/lib/components/moveRenameManager.ts index 12cc009cb7..1500f8fc69 100644 --- a/frontend/src/lib/components/moveRenameManager.ts +++ b/frontend/src/lib/components/moveRenameManager.ts @@ -21,6 +21,11 @@ export async function checkFlowOnBehalfOf( * * Note: on_behalf_of_email is intentionally omitted from flow updates for security * reasons — the backend will redeploy the flow on behalf of the current user. + * + * `skip_draft_deletion` on every call: this re-deploys the DEPLOYED content at a + * new path, so the caller's draft is unrelated work, not the thing being + * deployed. Without the flag the backend would delete it. The backend carries + * every remaining draft at the old path over to the new one. */ export async function updateItemPathAndSummary(opts: { workspace: string @@ -47,7 +52,8 @@ export async function updateItemPathAndSummary(opts: { dedicated_worker: flow.dedicated_worker, ws_error_handler_muted: flow.ws_error_handler_muted, visible_to_runner_only: flow.visible_to_runner_only, - labels + labels, + skip_draft_deletion: true } }) } else if (kind === 'script') { @@ -61,7 +67,8 @@ export async function updateItemPathAndSummary(opts: { lock: script.lock, parent_hash: script.hash, path: newPath, - labels + labels, + skip_draft_deletion: true } }) } else if (kind === 'app') { @@ -71,7 +78,8 @@ export async function updateItemPathAndSummary(opts: { requestBody: { path: newPath !== initialPath ? newPath : undefined, summary: newSummary, - labels + labels, + skip_draft_deletion: true } }) } diff --git a/frontend/src/lib/components/operatingWorkspace.svelte.ts b/frontend/src/lib/components/operatingWorkspace.svelte.ts new file mode 100644 index 0000000000..93a7f2c0de --- /dev/null +++ b/frontend/src/lib/components/operatingWorkspace.svelte.ts @@ -0,0 +1,48 @@ +import { getContext, setContext } from 'svelte' +import { fromStore, toStore, type Readable } from 'svelte/store' +import { workspaceStore } from '$lib/stores' +import { withWorkspaceParam } from './sessions/sessionMode.svelte' +import { useActingUser } from '$lib/actingUser.svelte' + +// The workspace a subtree acts on. An AI session edits its (possibly forked) workspace while +// `workspaceStore` stays on the navigation workspace, and a component that reads the navigation +// store directly writes to the parent from inside a fork's editor. So the hosts that embed an +// editor for another workspace set it once, and every component under them reads +// `useOperatingWorkspace()` instead of `$workspaceStore` — nothing has to thread it through props. +// Outside such a host it is the navigation store itself. + +const KEY = Symbol('operatingWorkspace') +const navigation = fromStore(workspaceStore) + +/** Declare the workspace this subtree acts on. A getter, read wherever the workspace is used; + * resolving to nothing defers to the enclosing host. Reads context: call during component + * initialisation. */ +export function setOperatingWorkspace(resolve: () => string | undefined): void { + const outer = getContext<(() => string | undefined) | undefined>(KEY) + setContext(KEY, outer ? () => resolve() ?? outer() : resolve) +} + +/** The workspace this component acts on, as a store. Reads context: call during component + * initialisation. Falls back to the navigation workspace where no host set one, or where the + * host's resolves to nothing. */ +export function useOperatingWorkspace(): Readable { + const resolve = getContext<(() => string | undefined) | undefined>(KEY) + return resolve ? toStore(() => resolve() ?? navigation.current) : workspaceStore +} + +/** The user acting in this subtree's operating workspace: `$userStore` describes the navigation + * workspace, so a permission check reading it inside a fork's editor answers about the parent — + * enabling or disabling controls by the wrong roles. Unresolved reads as unknown, which + * `canWrite`/`isOwner` refuse; ask `resolved` before rendering that as a denial. Reads context + * and registers an effect: call during component initialisation. */ +export function useOperatingUser(): ReturnType { + const operating = fromStore(useOperatingWorkspace()) + return useActingUser(() => operating.current) +} + +/** Stamps this component's operating workspace onto an in-app link, which otherwise opens in + * the navigation workspace. Reads context: call during component initialisation. */ +export function useOperatingWorkspaceHref(): (href: string) => string { + const operating = fromStore(useOperatingWorkspace()) + return (href) => withWorkspaceParam(href, operating.current) +} diff --git a/frontend/src/lib/components/operatingWorkspace.test.ts b/frontend/src/lib/components/operatingWorkspace.test.ts new file mode 100644 index 0000000000..1c14aa7262 --- /dev/null +++ b/frontend/src/lib/components/operatingWorkspace.test.ts @@ -0,0 +1,119 @@ +import { existsSync, readFileSync } from 'node:fs' +import { dirname, join, relative, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const components = dirname(fileURLToPath(import.meta.url)) +const lib = dirname(components) + +// The editors an AI session mounts for its own, possibly forked, workspace. Everything they +// render acts on that workspace, so a component reading the navigation store directly writes +// to the parent from inside a fork's editor — and nothing at runtime would say so. +const SESSION_EDITORS = [ + 'sessions/ScriptEditorView.svelte', + 'sessions/FlowEditorView.svelte', + 'sessions/RawAppEditorView.svelte', + 'sessions/PipelineEditorView.svelte', + 'sessions/PageItemEditorView.svelte' +] + +// Components that read the navigation store on purpose, with why. Keep each reason true. +const NAVIGATION_READERS: Record = { + 'ScriptBuilder.svelte': 'keeps its own tag list when it acts off the navigation workspace', + 'WorkerTagSelect.svelte': 'the shared worker tag cache is navigation-scoped', + 'WorkerTagPicker.svelte': 'the shared worker tag cache is navigation-scoped', + 'flows/content/FlowModuleWorkerTagSelect.svelte': + 'the shared worker tag cache is navigation-scoped', + 'FolderPicker.svelte': '`$userStore` describes the navigation workspace', + 'FolderEditor.svelte': '`$userStore` describes the navigation workspace' +} + +// Followed through plain modules too: a barrel like `$lib/components/common` is how most +// components are reached. +function resolveImport(from: string, spec: string): string | undefined { + const target = spec.startsWith('$lib/') + ? join(lib, spec.slice('$lib/'.length)) + : spec.startsWith('.') + ? resolve(dirname(from), spec) + : undefined + if (!target || target.includes('/gen/')) return undefined + const candidates = [target, `${target}.ts`, `${target}.svelte.ts`, join(target, 'index.ts')] + return candidates.find((c) => /\.(svelte|ts|js)$/.test(c) && existsSync(c)) +} + +function reachableComponents(): string[] { + const seen = new Set() + const queue = SESSION_EDITORS.map((f) => join(components, f)) + while (queue.length) { + const file = queue.pop()! + if (seen.has(file)) continue + seen.add(file) + const source = readFileSync(file, 'utf-8') + for (const m of source.matchAll(/(?:from\s+|import\(\s*)['"]([^'"]+)['"]/g)) { + const next = resolveImport(file, m[1]) + if (next) queue.push(next) + } + } + return [...seen].filter((f) => f.endsWith('.svelte')).map((f) => relative(components, f)) +} + +// Module scripts have no component context to read the operating workspace from, so what +// they fetch takes its workspace explicitly; only instance code and markup are checked. +function instanceCode(file: string): string { + return readFileSync(join(components, file), 'utf-8') + .replace(/]*\bmodule\b[^>]*>[\s\S]*?<\/script>/g, '') + .replace(/|\/\*[\s\S]*?\*\/|(^|[^:])\/\/.*$/gm, '$1') +} + +function readsNavigationStore(file: string): boolean { + return /\$workspaceStore\b|\bget\(workspaceStore\)/.test(instanceCode(file)) +} + +// Roles are per workspace, so a permission judged from `$userStore` inside a fork's editor +// answers about the parent: it disables edits the user may make, or offers ones the backend +// then refuses. Identity (username, email) is the same person in either, and stays. +// Following one alias, the shape that slipped past this before: `let user = $derived(… $userStore +// …)` with the permission checks reading `user`. Aliasing the navigation user for identity — +// their username, or which workspace they are on — is fine and stays. +function judgesByAliasedNavigationUser(code: string): boolean { + const aliases = [...code.matchAll(/(?:let|const)\s+(\w+)\s*=\s*\$derived[^\n]*\$userStore/g)].map( + (m) => m[1] + ) + return aliases.some((name) => + new RegExp( + `${name}(?:\\?|!)?\\.(?:is_admin|is_super_admin|operator|folders|groups)\\b|canWrite\\((?:[^()]|\\([^()]*\\))*?\\b${name}\\b|isOwner\\((?:[^()]|\\([^()]*\\))*?\\b${name}\\b` + ).test(code) + ) +} + +const PERMISSION_READ = + /canWrite\((?:[^()]|\([^()]*\))*?\$userStore|isOwner\((?:[^()]|\([^()]*\))*?\$userStore|\$userStore(?:\?|!)?\.(?:is_admin|is_super_admin|operator|folders|groups)\b/ + +// Components that judge by the navigation user on purpose, with why. +const NAVIGATION_JUDGES: Record = { + 'FolderPicker.svelte': + 'resolves the target workspace’s member itself, and asks `$userStore` only for the navigation one', + 'FolderEditor.svelte': + 'resolves the target workspace’s member itself, and asks `$userStore` only for the navigation one' +} + +describe('components under a session editor', () => { + it('read the operating workspace, not the navigation store', () => { + const reachable = reachableComponents() + // A walk that finds nothing would pass vacuously. + expect(reachable).toContain('triggers/http/RouteEditorInner.svelte') + const offenders = reachable.filter((f) => !(f in NAVIGATION_READERS) && readsNavigationStore(f)) + expect(offenders).toEqual([]) + }) + + it('judge permissions by the user acting in that workspace', () => { + const reachable = reachableComponents() + expect(reachable).toContain('triggers/PermissionedAsLine.svelte') + const offenders = reachable.filter((f) => { + if (f in NAVIGATION_JUDGES) return false + const code = instanceCode(f) + return PERMISSION_READ.test(code) || judgesByAliasedNavigationUser(code) + }) + expect(offenders).toEqual([]) + }) +}) diff --git a/frontend/src/lib/components/progressBar/ProgressBar.svelte b/frontend/src/lib/components/progressBar/ProgressBar.svelte index 1bf99d0e91..77e21dc205 100644 --- a/frontend/src/lib/components/progressBar/ProgressBar.svelte +++ b/frontend/src/lib/components/progressBar/ProgressBar.svelte @@ -4,6 +4,7 @@ import { twMerge } from 'tailwind-merge' import { Loader2 } from 'lucide-svelte' import { fade } from 'svelte/transition' + import type { Snippet } from 'svelte' // Remove padding/margin, border radius and titles @@ -36,6 +37,8 @@ isScheduled?: boolean // Whether the job was skipped (early-stop labelled as skipped) isSkipped?: boolean + /** Rendered after "Error occurred" when the bar is in its error state. */ + errorAction?: Snippet } let { @@ -56,7 +59,8 @@ isWaitingForEvents = false, isCanceled = false, isScheduled = false, - isSkipped = false + isSkipped = false, + errorAction }: Props = $props() let duration = 200 @@ -148,7 +152,7 @@
    @@ -184,6 +188,9 @@ {/if} {/key} + {#if status == 'error' && errorAction} + {@render errorAction()} + {/if}
    {closeBracket} {#if getTypeAsString(jsonFiltered) === 's3object'} - {@const s3DownloadApiPath = `/w/${$workspaceStore}/job_helpers/download_s3_file?file_key=${encodeURIComponent(jsonFiltered?.s3 ?? '')}${jsonFiltered?.storage ? `&storage=${jsonFiltered.storage}` : ''}`} + {@const s3DownloadApiPath = `/w/${$operatingWorkspace}/job_helpers/download_s3_file?file_key=${encodeURIComponent(jsonFiltered?.s3 ?? '')}${jsonFiltered?.storage ? `&storage=${jsonFiltered.storage}` : ''}`} {@const s3DownloadName = jsonFiltered?.s3.split('/').pop() ?? 'unnamed_download.file'} {#if shouldDownloadViaClient()} {:else} - download + download {/if}
    diff --git a/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte b/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte index 5f0488638d..7ccf89a275 100644 --- a/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte @@ -1,35 +1,50 @@ + + { + deleteConfirmedCallback = undefined + }} + on:confirmed={() => { + if (deleteConfirmedCallback) { + deleteConfirmedCallback() + } + deleteConfirmedCallback = undefined + }} +> +
    + Are you sure you want to remove {deletePath}? + {#if deleteIsLinked} + + This resource is linked with a variable of the same path. That variable is deleted with it, + unless another resource still references it. + + {/if} + +
    + You can press + SHIFT + while removing a resource to bypass confirmation. +
    +
    +
    +
    + + + + + inferrer?.toggleDrawer?.()}> + + {#snippet actions()} + + {/snippet} + + + + + + {#snippet titleExtra()} + + {/snippet} +
    + {#if resourceTypeViewerObj.description} + + {/if} + {#if resourceTypeViewerObj.isFileset} + + This resource type represents a collection of files. Each file is identified by its + relative path and contains text content. In the CLI, filesets are stored as directories. + + {:else if resourceTypeViewerObj.formatExtension} + + This resource type represents a plain text file with a .{resourceTypeViewerObj.formatExtension} + extension. (e.g. my_file.{resourceTypeViewerObj.formatExtension}) + + {:else} + + {/if} +
    +
    +
    + + + + {#snippet actions()} + + {/snippet} +
    + + +
    + {#if editResourceType.isFileset} + + This resource type represents a collection of files. The schema cannot be edited. + + {:else if editResourceType.formatExtension} + + This resource type represents a plain text file with a .{editResourceType.formatExtension} + extension. (e.g. my_file.{editResourceType.formatExtension}). The schema only + contains a `content` field and thus cannot be edited. + + {:else} +
    Schema
    +
    + +
    + {/if} +
    +
    +
    +
    + + + + {#snippet actions()} + + {/snippet} +
    + + +
    +
    +
    Schema
    +
    + +
    +
    + +
    + +
    +
    +
    +
    +
    + +{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.resources} + +{:else} + + + {#if showCreateButtons} +
    + + +
    + {/if} +
    + (showCreateButtons = v)} /> +
    + { + if (e.detail == 'cache') { + loading.resources = true + loadCache() + } else if (e.detail == 'states') { + loading.resources = true + loadState() + } else if (e.detail == 'theme') { + loading.resources = true + loadTheme() + } + }} + > + + + {#snippet extra()} + + Every resource has a Resource Type attached to it which contains its schema and make + it easy in scripts and flows to accept only resources of a specific resource type. + + {/snippet} + + + {#snippet extra()} + + States are actually resources (but excluded from the Workspace tab for clarity). + States are used by scripts to keep data persistent between runs of the same script by + the same trigger (schedule or user) + + {/snippet} + + + {#snippet extra()} + + Cached results are actually resources (but excluded from the Workspace tab for + clarity). Cache are used by flows's step to cache result to avoid recomputing + unnecessarily + + {/snippet} + + + {#snippet extra()} + + Themes are actually resources (but excluded from the Workspace tab for clarity). They + are CSS for the legacy low-code app editor only, and are added from the CSS panel of + an app rather than from this page. + + {/snippet} + + +
    +
    +
    + + {#if showTable} +
    + {#if loading.resources} + + {#each new Array(6) as _} + + {/each} + {:else if filteredItems?.length == 0} + {#if activeFilters} + + {:else} + appConnect?.open?.(), + aiId: 'resources-empty-add-resource', + aiDescription: 'Add resource' + } + : undefined} + /> + {/if} + {:else} + + + + + Path + Resource type + Description + Status + Actions + + +
    + {#if filteredItems} + {#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed, labels, inherited_labels, ws_specific, draft_only, is_draft }} + {@const hasDraft = + getLocalDraftHint($operatingWorkspace, 'resource', path) ?? is_draft} + + + + + +
    + { + handledHash = `#/resource/${path}` + openResourceEditor(path, resource_type) + }} + >{#if marked}{@html marked}{:else}{path}{/if}{hasDraft ? '*' : ''} + + {#if labels?.length} +
    + {#each labels as label} + { + const arr = (filters.val.label ?? '').split(',').filter(Boolean) + const idx = arr.indexOf(label) + if (idx >= 0) arr.splice(idx, 1) + else arr.push(label) + const newFilters = { ...filters.val } + if (arr.length) newFilters.label = arr.join(',') + else delete newFilters.label + filters.val = newFilters + }}>{label} + {/each} +
    + {/if} + +
    +
    + + { + const linkedRt = resourceTypes?.find((rt) => rt.name === resource_type) + if (linkedRt) { + resourceTypeViewerObj = { + rt: linkedRt.name, + //@ts-ignore + schema: linkedRt.schema, + description: linkedRt.description ?? '', + formatExtension: linkedRt.format_extension, + isFileset: linkedRt.is_fileset ?? false + } + resourceTypeViewer?.openDrawer?.() + } else { + sendUserToast( + `Resource type ${resource_type} not found in workspace.`, + true + ) + } + }} + > + + + + +
    + + {removeMarkdown(truncate(description ?? '', 200))} + +
    +
    + +
    +
    + {#if is_linked} + + + {#snippet text()} +
    + This resource is linked with a variable of the same path. They + are deleted and renamed together. +
    + {/snippet} +
    + {/if} +
    +
    + {#if is_refreshed} + + + {#snippet text()} +
    + The OAuth token will be kept up-to-date in the background by + Windmill using its refresh token +
    + {/snippet} +
    + {/if} +
    + + {#if is_oauth} +
    + {#if refresh_error} + + + {#snippet text()} +
    + Latest exchange of the refresh token did not succeed. Error: {refresh_error} +
    + {/snippet} +
    + {:else if is_expired} + + + + {#snippet text()} +
    + The access_token is expired, it will get renewed the next time + this variable is fetched or you can request is to be refreshed + in the dropdown on the right. +
    + {/snippet} +
    + {:else} + + + {#snippet text()} +
    + The resource was connected through OAuth and the token is not + expired. +
    + {/snippet} +
    + {/if} +
    + {/if} +
    +
    + +
    + {#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !operatingUser.current?.operator} + + {/if} + { + shareModal?.openDrawer?.(path, 'resource') + } + }, + { + displayName: 'Edit', + icon: Pen, + disabled: !canWrite || !showCreateButtons, + action: () => { + openResourceEditor(path, resource_type) + } + }, + // The agent form covers an agent's configuration, not everything a + // resource carries: the workspace-specific toggle in particular is + // only in the generic editor. JSON rather than that editor's form, + // which would render the configuration field by field and write a + // default into every one the agent leaves unset. Both write the same + // draft row, so this is a second view of the same edits. + ...(resource_type === 'ai_agent' + ? [ + { + displayName: 'Edit as JSON', + icon: Braces, + disabled: !canWrite || !showCreateButtons, + action: () => { + // The drawer anchors itself in the hash, which the deep-link + // effect would then read and route back to the agent editor. + // Claim it first, as the row's own link does. + handledHash = `#/resource/${path}` + // A host's item tab opens an agent on JSON already. + if (hosted) hosted.openItem(path) + else resourceEditor?.initEdit?.(path, { json: true }) + } + } + ] + : []), + ...(!ws_specific && isDeployable('resource', path, deployUiSettings) + ? [ + { + displayName: 'Deploy to prod/staging', + icon: FileUp, + action: () => { + deploymentDrawer?.openDrawer(path, 'resource') + } + } + ] + : []), + { + displayName: 'Delete', + disabled: !canWrite || !showCreateButtons, + icon: Trash, + type: 'delete', + action: (event) => { + // TODO + // @ts-ignore + if (event?.shiftKey) { + deleteResource(path, account) + } else { + deleteIsLinked = is_linked ?? false + deletePath = path + deleteConfirmedCallback = () => { + deleteResource(path, account) + } + } + } + }, + ...(account != undefined + ? [ + { + displayName: 'Refresh token', + icon: RotateCw, + action: async () => { + await OauthService.refreshToken({ + workspace: $operatingWorkspace ?? '', + id: account ?? 0, + requestBody: { + path + } + }) + sendUserToast('Token refreshed') + loadResources() + } + } + ] + : []) + ]} + /> +
    +
    + {/each} + {/if} + + + {/if} + + {:else if tab == 'types'} + {#if loading.types} + + {#each new Array(6) as _} + + {/each} + {:else if filteredResourceTypes?.length == 0} +
    + +
    + {:else} +
    + + + + Name + Description + Actions + + +
    + {#if filteredResourceTypes} + {#each filteredResourceTypes as { name, description, schema, canWrite, format_extension, is_fileset }} + + + { + resourceTypeViewerObj = { + rt: name, + //@ts-ignore + schema: schema, + description: description ?? '', + formatExtension: format_extension, + isFileset: is_fileset ?? false + } + + resourceTypeViewer?.openDrawer?.() + }} + > + + + + + +
    + + {removeMarkdown(truncate(description ?? '', 200))} + +
    +
    + + {#if !canWrite} + +
    + + Shared globally + + This resource type is from the 'admins' workspace shared with all + workspaces + + +
    + {:else if operatingUser.current?.is_admin || operatingUser.current?.is_super_admin} +
    + + +
    + {:else} + + Non Editable + + Since resource types are shared with the whole workspace, only admins + can edit/delete them + + + {/if} +
    +
    + {/each} + {/if} + + + + {/if} + {/if} + + +{/if} + + + + + + + { + loadResources() + }} +/> + + + +{#if !hosted && agentEditorTarget()} + {#await import('$lib/components/flows/content/AgentEditorModal.svelte') then { default: AgentEditorModal }} + t.host === undefined} + onRenamed={(from, to) => { + void loadResources() + // Only while the dialog still shows the agent: closed mid-request, it already cleared the + // anchor, and writing it back would reopen the editor on refresh. + if (agentEditorTarget()?.path !== from) return + // Claimed first, as a row click does, so the deep-link effect does not reopen it. + handledHash = `#/resource/${to}` + setPageDrawerAnchor(RESOURCES_PATH, to) + }} + /> + {/await} +{/if} diff --git a/frontend/src/lib/components/resources/resourcesFilter.ts b/frontend/src/lib/components/resources/resourcesFilter.ts index 0526ff87b3..97d1b61c7e 100644 --- a/frontend/src/lib/components/resources/resourcesFilter.ts +++ b/frontend/src/lib/components/resources/resourcesFilter.ts @@ -76,15 +76,14 @@ export function buildResourcesFilterSchema({ icon: Tag, description: 'Filter by label (comma-separated for multiple)' }, - ...(showUserFoldersFilter - ? { - user_folders_only: { - type: 'boolean' as const, - label: userFoldersLabel || 'User folders only', - icon: Users, - description: 'Show only resources in user folders' - } - } - : {}) + // The key stays for every role, hidden where the filter does not apply: the list's URL sync + // reads the key set once, before the acting user that gates it may have loaded. + user_folders_only: { + type: 'boolean' as const, + label: userFoldersLabel || 'User folders only', + icon: Users, + description: 'Show only resources in user folders', + hidden: !showUserFoldersFilter + } } satisfies FilterSchemaRec } diff --git a/frontend/src/lib/components/runs/JobDetailHeader.svelte b/frontend/src/lib/components/runs/JobDetailHeader.svelte index 5015d5ed37..fb7a8890fc 100644 --- a/frontend/src/lib/components/runs/JobDetailHeader.svelte +++ b/frontend/src/lib/components/runs/JobDetailHeader.svelte @@ -13,7 +13,7 @@ import { sendUserToast } from '$lib/toast' import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte' import TimeAgo from '$lib/components/TimeAgo.svelte' - import { enterpriseLicense, userStore, workspaceStore } from '$lib/stores' + import { enterpriseLicense, userStore } from '$lib/stores' import Tooltip from '$lib/components/meltComponents/Tooltip.svelte' import { ExternalLink, ListFilter, ChevronDown, Share2, Link, Copy } from 'lucide-svelte' import JobStatus from '$lib/components/JobStatus.svelte' @@ -31,6 +31,14 @@ import { flowPathToHref } from '$lib/scripts' import { slide } from 'svelte/transition' import { twMerge } from 'tailwind-merge' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) interface Props { job: Job @@ -79,7 +87,7 @@ } togglingResolution = true try { - const workspace = job.workspace_id ?? $workspaceStore ?? '' + const workspace = job.workspace_id ?? $operatingWorkspace ?? '' const affected = resolve ? await JobService.resolveCompletedJobs({ workspace, @@ -296,7 +304,7 @@ Triggered by {/if} {config.label} @@ -496,7 +504,7 @@
    - {#if isJobResolvable(job) && !$userStore?.operator} + {#if isJobResolvable(job) && !actingUser?.operator}
    @@ -686,7 +694,7 @@ {#each expandedFields as config} {@const displayValue = getDisplayValue(config, job)} {@const fullValue = getFullValue(config, job)} - {@const href = config.getHref?.(job, $workspaceStore || '')} + {@const href = config.getHref?.(job, $operatingWorkspace || '')}
    diff --git a/frontend/src/lib/components/runs/NoWorkerWithTagWarning.svelte b/frontend/src/lib/components/runs/NoWorkerWithTagWarning.svelte index 91eee5f1a1..5ff51c9c04 100644 --- a/frontend/src/lib/components/runs/NoWorkerWithTagWarning.svelte +++ b/frontend/src/lib/components/runs/NoWorkerWithTagWarning.svelte @@ -1,9 +1,11 @@ + + + + +{#if operatingUser.current?.operator && $operatingWorkspace && !$userWorkspaces.find((_) => _.id === $operatingWorkspace)?.operator_settings?.schedules} + +{:else} + + + + +
    +
    + + {#snippet children({ item })} + + + + {/snippet} + + +
    + {#if loading} + {#each new Array(6) as _} + + {/each} + {:else if !schedules?.length} + {#if activeFilters} + + {:else} + scheduleEditor?.openNew(false), + aiId: 'schedules-empty-add', + aiDescription: 'Add schedule' + }} + /> + {/if} + {:else if items?.length} +
    + {#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, paused_until, labels, inherited_labels, draft_only, is_draft } (path)} + {@const hasDraft = + getLocalDraftHint($operatingWorkspace, 'trigger_schedule', path) ?? is_draft} + {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} + {@const avg_s = jobs + ? jobs.reduce((acc, x) => acc + x.duration_ms, 0) / jobs.length + : undefined} + +
    +
    + + + { + handledHash = `#${path}` + editSchedule(path, is_flow) + }} + class="min-w-0 grow hover:underline decoration-gray-400" + > +
    + {summary || script_path}{hasDraft ? '*' : ''} +
    +
    + schedule: {path} +
    +
    + {#if labels?.length} + {#each labels as label} + { + const arr = (filters.val.label ?? '').split(',').filter(Boolean) + const idx = arr.indexOf(label) + if (idx >= 0) arr.splice(idx, 1) + else arr.push(label) + const newFilters = { ...filters.val } + if (arr.length) newFilters.label = arr.join(',') + else delete newFilters.label + filters.val = newFilters + }}>{label} + {/each} + {/if} + + + {#if paused_until && new Date(paused_until) > new Date()} +
    + Paused until {new Date(paused_until).toLocaleString()} +
    + {/if} + + + + + +
    + {#if error} + + + + + + {#snippet text()} +
    + The schedule disabled itself because there was an error scheduling the + next job: {error} +
    + {/snippet} +
    + {/if} +
    + +
    + + {#key toggleResetVersions[path] ?? 0} + { + if (canWrite) { + setScheduleEnabled(path, e.detail) + } else { + sendUserToast('not enough permission', true) + // Permission denied — bump the row's reset + // counter so the Toggle remounts back to the + // prop value. Without this, the local + // `bind:checked` flip from the user's click + // stays stuck on. + bumpToggleReset(path) + } + }} + /> + {/key} +
    +
    + + + { + if (hosted) hosted.openLink(`${base}${href}`) + else goto(href) + } + }, + { + displayName: `Duplicate schedule`, + icon: Copy, + action: () => { + scheduleEditor?.openNew(is_flow, script_path, path) + } + }, + { + displayName: 'Delete', + type: 'delete', + icon: Trash, + disabled: !canWrite, + action: async () => { + await ScheduleService.deleteSchedule({ + workspace: $operatingWorkspace ?? '', + path + }) + loadSchedules() + } + }, + { + displayName: canWrite ? 'Edit' : 'View', + icon: canWrite ? Pen : Eye, + action: () => { + editSchedule(path, is_flow) + } + }, + ...(isDeployable('trigger', path, deployUiSettings) + ? [ + { + displayName: 'Deploy to prod/staging', + icon: FileUp, + action: () => { + deploymentDrawer?.openDrawer(path, 'trigger', { + triggers: { + kind: 'schedules' + } + }) + } + } + ] + : []), + { + displayName: 'View runs', + icon: List, + href: `${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true` + }, + { + displayName: 'Audit logs', + icon: Eye, + href: `${base}/audit_logs?resource=${path}` + }, + { + displayName: 'Run now', + icon: Play, + action: () => { + runScheduleNow(script_path, path, is_flow, $operatingWorkspace!) + } + }, + { + displayName: 'Permissions', + icon: Shield, + action: () => { + shareModal?.openDrawer(path, 'schedule') + } + } + ]} + /> +
    +
    +
    + {#if loadingSchedulesWithJobStats} +
    + + Job stats loading... +
    + {:else} +
    + {#if avg_s} +
    Avg: {(avg_s / 1000).toFixed(2)}s
    + {/if} + {#each jobs ?? [] as job} + {@const h = (avg_s ? job.duration_ms / avg_s : 1) * 7 + 3} + + +
    +
    + +
    +
    +
    + {/each} +
    + {/if} +
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'at ' : ''}{displayDate(edited_at)}
    +
    + {/each} +
    + {:else} + + {/if} +
    + {#if items && items?.length > 15 && nbDisplayed < items.length} +
    + {nbDisplayed} items out of {items.length} + +
    + {/if} + +{/if} + + { + loadSchedules() + }} +/> diff --git a/frontend/src/lib/components/schedules/schedulesFilter.ts b/frontend/src/lib/components/schedules/schedulesFilter.ts index 24fb1c90a3..4b887e5aa4 100644 --- a/frontend/src/lib/components/schedules/schedulesFilter.ts +++ b/frontend/src/lib/components/schedules/schedulesFilter.ts @@ -73,15 +73,14 @@ export function buildSchedulesFilterSchema({ icon: Tag, description: 'Filter by label (comma-separated for multiple)' }, - ...(showUserFoldersFilter - ? { - user_folders_only: { - type: 'boolean' as const, - label: userFoldersLabel || 'User folders only', - icon: Users, - description: 'Show only schedules in user folders' - } - } - : {}) + // The key stays for every role, hidden where the filter does not apply: the list's URL sync + // reads the key set once, before the acting user that gates it may have loaded. + user_folders_only: { + type: 'boolean' as const, + label: userFoldersLabel || 'User folders only', + icon: Users, + description: 'Show only schedules in user folders', + hidden: !showUserFoldersFilter + } } satisfies FilterSchemaRec } diff --git a/frontend/src/lib/components/schema/JobSchemaPicker.svelte b/frontend/src/lib/components/schema/JobSchemaPicker.svelte index 47e6220c79..fe132d546c 100644 --- a/frontend/src/lib/components/schema/JobSchemaPicker.svelte +++ b/frontend/src/lib/components/schema/JobSchemaPicker.svelte @@ -1,8 +1,10 @@ + +{#snippet loading()} +
    + +
    +{/snippet} + +
    + {#if eeLocked} +
    This trigger requires an enterprise license.
    + {:else if lookup.loading || !lookup.current} + {@render loading()} + {:else if !lookup.current.exists} +
    +

    + {pageItemKindLabel(item)} {item.path} no longer exists in this + workspace. +

    + +
    + {:else} + {#key `${item.kind}:${triggerKey}:${item.path}:${workspaceId}:${reloadNonce}:${savedNonce}`} + {#if item.kind === 'variable'} + + {:else if item.kind === 'resource'} + { + if (path !== undefined) onSaved(path) + }} + onRestored={() => savedNonce++} + /> + {:else if triggerKey} + {#await TRIGGER_EDITORS[triggerKey]()} + {@render loading()} + {:then Module} + onSaved(path)} + /> + {/await} + {/if} + {/key} + {/if} +
    diff --git a/frontend/src/lib/components/sessions/PageListView.svelte b/frontend/src/lib/components/sessions/PageListView.svelte new file mode 100644 index 0000000000..7dab54c494 --- /dev/null +++ b/frontend/src/lib/components/sessions/PageListView.svelte @@ -0,0 +1,131 @@ + + +{#snippet loading()} +
    + +
    +{/snippet} + + +
    + {#key `${path}:${workspaceId}:${reloadNonce}`} + {#if listed?.kind === 'variable'} + {#await import('$lib/components/variables/VariablesList.svelte')} + {@render loading()} + {:then Module} + + {/await} + {:else if listed?.kind === 'resource'} + {#await import('$lib/components/resources/ResourcesList.svelte')} + {@render loading()} + {:then Module} + + {/await} + {:else if listed?.kind === 'schedule'} + {#await import('$lib/components/schedules/SchedulesList.svelte')} + {@render loading()} + {:then Module} + + {/await} + {:else if listed?.kind === 'trigger'} + {#await import('$lib/components/triggers/TriggerList.svelte')} + {@render loading()} + {:then Module} + + {/await} + {/if} + {/key} +
    diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index e641a98132..e094d1bcbe 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -167,6 +167,10 @@ applyPageIframeTheme(darkMode) }) + // A page item's editor reads its draft only when it loads, so a reload remounts it. + let pageItemReloadNonce = $state(0) + let pageListReloadNonce = $state(0) + export function reload() { // A live editor shares the runtime store the chat mutates, so generic chat // edits are already reflected — no reload needed. Deploys refresh it via @@ -179,6 +183,14 @@ viewer?.refresh() return } + if (slot.kind === 'pageitem') { + pageItemReloadNonce++ + return + } + if (slot.kind === 'pagelist') { + pageListReloadNonce++ + return + } try { const win = frame?.contentWindow if (!win) return @@ -416,6 +428,43 @@
    {/if}
    +{:else if slot.kind === 'pageitem' && mounted && runtime} +
    + + {#if overlayHostEl} + {#await import('./PageItemEditorView.svelte')} + {@render editorLoading()} + {:then Module} + + {/await} + {/if} +
    +{:else if slot.kind === 'pagelist' && mounted && runtime} +
    + {#if overlayHostEl} + {#await import('./PageListView.svelte')} + {@render editorLoading()} + {:then Module} + + {/await} + {/if} +
    {:else if slot.kind === 'artifact' && mounted}
    c.type !== 'app_dom_selector' || (c.selector === selector && c.appPath === path) ) - if (runtime.manager.loading) { + if (runtime.manager.loading || runtime.manager.sendInFlight || runtime.manager.sendPending) { runtime.manager.queueMessage(prompt, [], snapshot) } else { void runtime.manager.sendRequest({ instructions: prompt, contextOverride: snapshot }) @@ -240,6 +241,9 @@ cell.saved.val), reactive state owned by the SessionRuntime class (via rawAppCell), not by a component ancestor — so Svelte's ownership check flags a false positive here. --> + workspaceId) // This tab's storage path, for the editor below: several tabs are mounted at // once and only this one knows which item each is open on. diff --git a/frontend/src/lib/components/sessions/SessionPicker.svelte b/frontend/src/lib/components/sessions/SessionPicker.svelte index e5b62da0eb..ce1ad89246 100644 --- a/frontend/src/lib/components/sessions/SessionPicker.svelte +++ b/frontend/src/lib/components/sessions/SessionPicker.svelte @@ -33,6 +33,7 @@ reconcileAfterWorkspaceChange, renameSession, selectSession, + sessionPageHref, sessionLastActivityAt, sessionState, setNewSessionWorkspace, @@ -44,8 +45,9 @@ import { unreadCountFor } from './sessionUnread.svelte' import Toggle from '$lib/components/Toggle.svelte' import { - getOrCreateRuntime, + ensureSessionChatPeek, getRuntime, + getSessionChatPeek, getSessionChatStatus, removeSession, resetSessionPreviewTabs @@ -87,7 +89,7 @@ // displayMessages array vs. the localStorage-backed lastSeen map; // both are reactive so the badge updates without polling. function unreadFor(session: Session): number { - return unreadCountFor(session.id, getRuntime(session.id)) + return unreadCountFor(session.id, getRuntime(session.id), getSessionChatPeek(session.id)) } // Whether the composer for a session holds non-whitespace text. We @@ -378,14 +380,12 @@ } }) - // Eagerly create a runtime per VISIBLE session so the status dot reflects - // the persisted chat (last message, pending confirmation, etc.) without - // requiring the user to open the session first. Sessions outside the - // current workspace scope are left cold to avoid opening IDB connections - // for unrelated work. + // Read each VISIBLE session's stored chat so the status dot and unread count + // reflect it without the user opening the session. A runtime per listed + // session would hold every listed chat in memory for as long as the page lives. $effect(() => { for (const session of visibleSessions) { - getOrCreateRuntime(session) + if (!getRuntime(session.id)) void ensureSessionChatPeek(session) } }) @@ -402,7 +402,7 @@ // so opening one must not change the user's active (navigation) workspace. // Open the dedicated sessions page; its preview panel iframes the // session's view (captured page / editor target). - await goto(`/sessions?session_name=${encodeURIComponent(session.name)}`) + await goto(sessionPageHref(session.id)) if (restoreFocus) { // goto() resets focus to — put it back on the active session button // so subsequent arrow keys keep navigating the list. @@ -609,14 +609,14 @@ // After deleting the open session, land somewhere usable: the newest remaining // session, else a fresh one. The page derives the visible session from the - // `session_name` query, so leaving the URL on a deleted session would fall + // `session` query, so leaving the URL on a deleted session would fall // through to recovery and open a blank one rather than their recent work. async function openReplacementSession() { const next = sessionState.sessions[0] if (next) await activate(next) else { const fresh = createSession() - await goto(`/sessions?session_name=${encodeURIComponent(fresh.name)}`) + await goto(sessionPageHref(fresh.id)) } } @@ -768,7 +768,9 @@ {/if} {#each group.sessions as session (session.id)} {@const runtime = getRuntime(session.id)} - {@const status = runtime ? getSessionChatStatus(runtime) : 'idle'} + {@const status = runtime + ? getSessionChatStatus(runtime) + : (getSessionChatPeek(session.id)?.status ?? 'idle')} {@const isSelected = sessionActive && session.id === sessionState.currentSessionId} {@const unread = unreadFor(session)} @@ -986,7 +988,9 @@ {/snippet} {#snippet sessionRow(session, indented, treeDepth)} {@const runtime = getRuntime(session.id)} - {@const status = runtime ? getSessionChatStatus(runtime) : 'idle'} + {@const status = runtime + ? getSessionChatStatus(runtime) + : (getSessionChatPeek(session.id)?.status ?? 'idle')} {@const isSelected = sessionActive && session.id === sessionState.currentSessionId} {@const isEditing = editingId === session.id} {@const unread = unreadFor(session)} @@ -1294,7 +1298,7 @@

    Delete session {pendingDelete?.summary ?? pendingDelete?.name}{pendingDelete?.summary ?? 'Untitled session'}? This cannot be undone.

    {#if pendingDeleteForkId} diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index b56b72c2be..775b1b150f 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -46,6 +46,7 @@ reconcileAfterWorkspaceChange, renameSession, selectSession, + sessionPageHref, sessionState, setSessionArchived, syncWorkspaceTo, @@ -164,10 +165,10 @@ async function resetToNewSession() { const fresh = createSession() selectSession(fresh.id) - // The page derives the visible session from the `session_name` query, not + // The page derives the visible session from the `session` query, not // currentSessionId: navigate so the URL leaves the deleted/archived session, // which would otherwise fall through to recovery or stay on the archived one. - await goto(`/sessions?session_name=${encodeURIComponent(fresh.name)}`) + await goto(sessionPageHref(fresh.id)) } // If the session targets a forked workspace that's still accessible, @@ -536,7 +537,7 @@

    Delete session {session?.summary ?? session?.name}{session?.summary ?? 'Untitled session'}? This cannot be undone.

    {#if sessionForkId} @@ -568,7 +569,7 @@

    Archive session {session?.summary ?? session?.name}{session?.summary ?? 'Untitled session'}? You can restore it later from the archived list.

    {#if sessionForkId} diff --git a/frontend/src/lib/components/sessions/appDraftCodec.test.ts b/frontend/src/lib/components/sessions/appDraftCodec.test.ts index ba0d6f5f2d..8a6ac74f86 100644 --- a/frontend/src/lib/components/sessions/appDraftCodec.test.ts +++ b/frontend/src/lib/components/sessions/appDraftCodec.test.ts @@ -78,3 +78,25 @@ describe('appDraftCodec — draft_path round-trip', () => { expect(back.draft_path).toBe('u/admin/renamed') }) }) + +describe('appDraftCodec — fork base round-trip', () => { + it('carries the draft version both ways', () => { + const draft = runtimeRawAppToDraft(runtime({ parent_version: 3 })) + expect(draft.parent_version).toBe(3) + expect(applyDraftToRuntimeRawApp(runtime({ parent_version: 1 }), draft).parent_version).toBe(3) + }) + + it('keeps an unknown base unknown rather than adopting the runtime version', () => { + const dv: RawAppDraft = { + summary: 'app', + files: {}, + runnables: {}, + data: { tables: [] } as any + } + // This result round-trips into the next save: a fallback here would claim the + // content forked from the version the session happens to hold. + expect(applyDraftToRuntimeRawApp(runtime({ parent_version: 2 }), dv).parent_version).toBe( + undefined + ) + }) +}) diff --git a/frontend/src/lib/components/sessions/appDraftCodec.ts b/frontend/src/lib/components/sessions/appDraftCodec.ts index 10752b8d8f..7d9f4b048f 100644 --- a/frontend/src/lib/components/sessions/appDraftCodec.ts +++ b/frontend/src/lib/components/sessions/appDraftCodec.ts @@ -16,6 +16,9 @@ export type RawAppDraft = { // friendly name (they read `value->>'draft_path'`) — and so editing the path // in the editor changes the persisted draft and triggers an autosave. draft_path?: string + // The app_version the draft forked from. The server derives `draft.base` from + // it, which is what tells a draft that is behind the deployed head. + parent_version?: number } // The shape a raw-app cell's store (`RawAppRuntimeValue` in @@ -30,6 +33,7 @@ export type RuntimeRawApp = { policy: any custom_path?: string draft_path?: string + parent_version?: number } // Strip runtime-only metadata (just `path`, the storage key) when persisting @@ -43,7 +47,8 @@ export function runtimeRawAppToDraft(raw: RuntimeRawApp): RawAppDraft { data: raw.data, policy: raw.policy, custom_path: raw.custom_path, - draft_path: raw.draft_path + draft_path: raw.draft_path, + parent_version: raw.parent_version } } @@ -58,6 +63,10 @@ export function applyDraftToRuntimeRawApp(raw: RuntimeRawApp, dv: RawAppDraft): data: dv.data, policy: dv.policy ?? raw.policy, custom_path: dv.custom_path ?? raw.custom_path, - draft_path: dv.draft_path ?? raw.draft_path + draft_path: dv.draft_path ?? raw.draft_path, + // The incoming draft's own fork base, absence included: this round-trips into the + // next save, so falling back to the runtime's version would give that content a + // base it never forked from and hide that it is behind. + parent_version: dv.parent_version } } diff --git a/frontend/src/lib/components/sessions/pageDrawerSession.ts b/frontend/src/lib/components/sessions/pageDrawerSession.ts index ca7379be20..0a3e7435ca 100644 --- a/frontend/src/lib/components/sessions/pageDrawerSession.ts +++ b/frontend/src/lib/components/sessions/pageDrawerSession.ts @@ -11,6 +11,7 @@ import type { UserDraftItemKind } from '$lib/gen' // flow editors, where pulling the filter schemas that module reads views from would make // every trigger's save utils eager. import { + drawerHashFor, pageHref, stripBase, TRIGGER_PAGES, @@ -63,11 +64,6 @@ async function flushOrRefuse(query: Parameters[0 } } -// How each page addresses a row in its hash. Resources route theirs through an extra -// segment; every other page names the path directly. -const drawerHashFor = (pagePath: string, itemPath: string) => - pagePath === RESOURCES_PATH ? `/resource/${itemPath}` : itemPath - /** * Deep-link the row whose drawer just opened, so the location says what is on screen — a * drawer opened from a row's Edit button is as open as one reached by link, and the chat diff --git a/frontend/src/lib/components/sessions/pageItemLookup.ts b/frontend/src/lib/components/sessions/pageItemLookup.ts new file mode 100644 index 0000000000..bee3fc41c1 --- /dev/null +++ b/frontend/src/lib/components/sessions/pageItemLookup.ts @@ -0,0 +1,62 @@ +import { + AmqpTriggerService, + ApiError, + AzureTriggerService, + EmailTriggerService, + GcpTriggerService, + HttpTriggerService, + KafkaTriggerService, + MqttTriggerService, + NatsTriggerService, + PostgresTriggerService, + ResourceService, + ScheduleService, + SqsTriggerService, + VariableService, + WebsocketTriggerService +} from '$lib/gen' +import type { PageItemRef, TriggerKind } from './previewPaths' + +type Get = (args: { workspace: string; path: string; getDraft: boolean }) => Promise + +const TRIGGER_GETS: Record = { + http: (a) => HttpTriggerService.getHttpTrigger(a), + websocket: (a) => WebsocketTriggerService.getWebsocketTrigger(a), + postgres: (a) => PostgresTriggerService.getPostgresTrigger(a), + kafka: (a) => KafkaTriggerService.getKafkaTrigger(a), + nats: (a) => NatsTriggerService.getNatsTrigger(a), + mqtt: (a) => MqttTriggerService.getMqttTrigger(a), + amqp: (a) => AmqpTriggerService.getAmqpTrigger(a), + sqs: (a) => SqsTriggerService.getSqsTrigger(a), + gcp: (a) => GcpTriggerService.getGcpTrigger(a), + azure: (a) => AzureTriggerService.getAzureTrigger(a), + email: (a) => EmailTriggerService.getEmailTrigger(a) +} + +function getFor(ref: PageItemRef): Get { + switch (ref.kind) { + case 'variable': + return (a) => VariableService.getVariable(a) + case 'resource': + return (a) => ResourceService.getResource(a) + case 'schedule': + return (a) => ScheduleService.getSchedule(a) + case 'trigger': + return TRIGGER_GETS[ref.triggerKind] + } +} + +/** A page item as its editor would load it — deployed, or only a draft — or undefined when it + * is neither: deleted, or a draft that was discarded. Any other failure is thrown, for the + * caller to leave to the editor rather than report as a missing item. */ +export async function lookupPageItem( + ref: PageItemRef, + workspace: string +): Promise | undefined> { + try { + return (await getFor(ref)({ workspace, path: ref.path, getDraft: true })) as Record + } catch (e) { + if (e instanceof ApiError && e.status === 404) return undefined + throw e + } +} diff --git a/frontend/src/lib/components/sessions/previewPaths.ts b/frontend/src/lib/components/sessions/previewPaths.ts index 063cf7cc04..bd1ab1f1a3 100644 --- a/frontend/src/lib/components/sessions/previewPaths.ts +++ b/frontend/src/lib/components/sessions/previewPaths.ts @@ -48,6 +48,92 @@ export const TRIGGER_PAGES: Record + pagePath === RESOURCES_PATH ? `/resource/${itemPath}` : itemPath + +/** The full page a page item is edited on: its list page, with the row's drawer open. */ +export function pageItemPageHref(ref: PageItemRef): string { + const listPath = pageItemListPath(ref) + return `${pageHref(listPath)}#${drawerHashFor(listPath, ref.path)}` +} + +/** The list page a page item is edited from. */ +export function pageItemListPath(ref: PageItemRef): string { + switch (ref.kind) { + case 'variable': + return VARIABLES_PATH + case 'resource': + return RESOURCES_PATH + case 'schedule': + return SCHEDULES_PATH + case 'trigger': + return TRIGGER_PAGES[ref.triggerKind].path + } +} + +/** Whether a (base-stripped) path is the list page of a page item kind. */ +export function isPageItemListPath(path: string): boolean { + return pageItemForListPath(path, '') !== undefined +} + +/** The page item a list page's row names, or undefined for a page that lists none. */ +export function pageItemForListPath(pagePath: string, path: string): PageItemRef | undefined { + const clean = stripBase(pagePath) + if (clean === VARIABLES_PATH) return { kind: 'variable', path } + if (clean === RESOURCES_PATH) return { kind: 'resource', path } + if (clean === SCHEDULES_PATH) return { kind: 'schedule', path } + const trigger = Object.entries(TRIGGER_PAGES).find(([, p]) => p.path === clean) + return trigger ? { kind: 'trigger', triggerKind: trigger[0] as TriggerKind, path } : undefined +} + +const PAGE_ITEM_ROUTE = /^pageitem:(variable|resource|schedule|trigger\.([a-z]+))\/([^?#]+)$/ + +// A scheme rather than a path, like artifacts: the tab mounts the item's editor in process, +// so there is no page a frame could load. The path is encoded whole, so its slashes cannot +// be read as part of the scheme. +export function pageItemUrl(ref: PageItemRef): string { + const kind = ref.kind === 'trigger' ? `trigger.${ref.triggerKind}` : ref.kind + return `pageitem:${kind}/${encodeURIComponent(ref.path)}` +} + +export function parsePageItemRoute(url: string): PageItemRef | null { + const m = url.match(PAGE_ITEM_ROUTE) + if (!m) return null + let path: string + try { + path = decodeURIComponent(m[3]) + } catch { + return null + } + if (m[2] !== undefined) { + if (!(m[2] in TRIGGER_PAGES)) return null + return { kind: 'trigger', triggerKind: m[2] as TriggerKind, path } + } + return { kind: m[1] as 'variable' | 'resource' | 'schedule', path } +} + +/** Singular human name of a page item's kind, e.g. "Kafka trigger". */ +export function pageItemKindLabel(ref: PageItemRef): string { + switch (ref.kind) { + case 'variable': + return 'Variable' + case 'resource': + return 'Resource' + case 'schedule': + return 'Schedule' + case 'trigger': + return TRIGGER_PAGES[ref.triggerKind].label.replace(/s$/, '') + } +} + /** Label a trigger list page from its (base-stripped) pathname, or undefined. */ export function triggerLabelForPath(path: string): string | undefined { const clean = stripBase(path) diff --git a/frontend/src/lib/components/sessions/previewReload.test.ts b/frontend/src/lib/components/sessions/previewReload.test.ts index c8da6bc097..e7924a1813 100644 --- a/frontend/src/lib/components/sessions/previewReload.test.ts +++ b/frontend/src/lib/components/sessions/previewReload.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest' import { toolReloadEffect, tabsToReload, viewerTabsToReload } from './previewReload' import type { SessionPreviewTab } from './sessionState.svelte' +import { pageItemUrl } from './previewPaths' describe('toolReloadEffect', () => { it('maps a non-item mutation to its own list page only', () => { @@ -52,6 +53,26 @@ describe('toolReloadEffect', () => { }) }) +describe('page item tabs', () => { + const tab = (url: string): SessionPreviewTab => ({ id: url, url, loc: url }) + const kafkaA = tab('pageitem:trigger.kafka/u%2Fme%2Fa') + const kafkaB = tab('pageitem:trigger.kafka/u%2Fme%2Fb') + const list = tab('/kafka_triggers') + + it('reloads only the trigger a write names, and its list page', () => { + const { pages, items } = toolReloadEffect('write_trigger', { + kind: 'kafka', + config: { path: 'u/me/a' } + }) + const named = new Set(items.map((i) => pageItemUrl(i))) + expect(tabsToReload([kafkaA, kafkaB, list], new Set(pages), named)).toEqual([kafkaA, list]) + }) + + it('reloads every tab of the kind when the tool names no item', () => { + expect(tabsToReload([kafkaA, kafkaB], new Set(['/kafka_triggers']))).toEqual([kafkaA, kafkaB]) + }) +}) + describe('tabsToReload', () => { const scheduleTab: SessionPreviewTab = { id: 's', url: '/schedules', loc: '/schedules' } const resourceTab: SessionPreviewTab = { id: 'r', url: '/resources', loc: '/resources' } @@ -120,31 +141,31 @@ describe('viewerTabsToReload', () => { }) }) -describe('toolReloadEffect items', () => { +describe('toolReloadEffect deployed', () => { // A deploy is invisible to a View tab otherwise: it renders the deployed version over // the API and shares no store with the chat. it('names the item a deploy or delete changed, for the kinds a tab can view', () => { for (const type of ['script', 'flow', 'app']) { - expect(toolReloadEffect('deploy_workspace_item', { type, path: 'u/me/x' }).items).toEqual([ + expect(toolReloadEffect('deploy_workspace_item', { type, path: 'u/me/x' }).deployed).toEqual([ 'u/me/x' ]) - expect(toolReloadEffect('delete_workspace_item', { type, path: 'u/me/x' }).items).toEqual([ + expect(toolReloadEffect('delete_workspace_item', { type, path: 'u/me/x' }).deployed).toEqual([ 'u/me/x' ]) } }) it('names no item for a draft-only tool — nothing deployed changed', () => { - expect(toolReloadEffect('write_script', { path: 'u/me/x' }).items).toEqual([]) + expect(toolReloadEffect('write_script', { path: 'u/me/x' }).deployed).toEqual([]) expect( - toolReloadEffect('discard_local_draft', { type: 'script', path: 'u/me/x' }).items + toolReloadEffect('discard_local_draft', { type: 'script', path: 'u/me/x' }).deployed ).toEqual([]) - expect(toolReloadEffect('rebase_draft', { type: 'flow', path: 'u/me/x' }).items).toEqual([]) + expect(toolReloadEffect('rebase_draft', { type: 'flow', path: 'u/me/x' }).deployed).toEqual([]) }) it('names no item for types that have a list page instead of a view side', () => { expect( - toolReloadEffect('deploy_workspace_item', { type: 'schedule', path: 'u/me/s' }).items + toolReloadEffect('deploy_workspace_item', { type: 'schedule', path: 'u/me/s' }).deployed ).toEqual([]) }) }) diff --git a/frontend/src/lib/components/sessions/previewReload.ts b/frontend/src/lib/components/sessions/previewReload.ts index 5df0715063..d9259c900d 100644 --- a/frontend/src/lib/components/sessions/previewReload.ts +++ b/frontend/src/lib/components/sessions/previewReload.ts @@ -1,6 +1,15 @@ import type { SessionPreviewTab } from './sessionState.svelte' import { whereIs } from './sessionPreviewTabs.svelte' -import { parsePreviewItemRoute, stripBase, TRIGGER_PAGES, type TriggerKind } from './previewPaths' +import { + pageItemListPath, + pageItemUrl, + parsePageItemRoute, + parsePreviewItemRoute, + stripBase, + TRIGGER_PAGES, + type PageItemRef, + type TriggerKind +} from './previewPaths' // Which list pages a completed chat tool can change, as base-stripped paths // (e.g. `/schedules`). This allowlist is the single source of truth for "does @@ -13,40 +22,50 @@ import { parsePreviewItemRoute, stripBase, TRIGGER_PAGES, type TriggerKind } fro // deliberately absent: every editable item is a live in-process editor that // self-syncs from the store the chat mutates, so its tab needs no reload — and // no list page we preview lists open drafts. They fall through to NO_RELOAD. -// This "live editors self-sync, only list pages reload" invariant is the reason -// the callers below and in the sessions page reload nothing for item tabs. // -// `items` is the one exception to it. A tab can sit on the VIEW side of an item, +// Page items (variables, resources, schedules, triggers) are the exception among +// in-process tabs: their editors read a draft only when they open, so a write to +// one reloads its tab too. `items` names it when the tool's args do; without a +// path, every tab of that kind reloads. +// +// `deployed` is the other exception. A tab can sit on the VIEW side of an item, // which renders the DEPLOYED version read over the API — it shares no store with // the chat, so a tool that changes what is deployed at a path has to name it here // or that tab keeps showing the previous deployment. Only tools that change the -// deployed item belong in `items`; a draft write does not. -export type ToolReloadEffect = { pages: string[]; items: string[] } -const NO_RELOAD: ToolReloadEffect = { pages: [], items: [] } +// deployed item belong in `deployed`; a draft write does not. +export type ToolReloadEffect = { pages: string[]; items: PageItemRef[]; deployed: string[] } +const NO_RELOAD: ToolReloadEffect = { pages: [], items: [], deployed: [] } export function toolReloadEffect(name: string, args: any): ToolReloadEffect { switch (name) { case 'write_schedule': - return { pages: ['/schedules'], items: [] } + return withItem(['/schedules'], itemRef('schedule', args)) case 'write_trigger': - return { pages: triggerPages(args?.kind), items: [] } + // Its path sits in the trigger's own config, not beside `kind`. + return withItem(triggerPages(args?.kind), itemRef('trigger', args?.config, args?.kind)) case 'write_resource': - return { pages: ['/resources'], items: [] } + return withItem(['/resources'], itemRef('resource', args)) case 'write_variable': - return { pages: ['/variables'], items: [] } + return withItem(['/variables'], itemRef('variable', args)) case 'create_folder': - return { pages: ['/folders'], items: [] } + return { pages: ['/folders'], items: [], deployed: [] } // These two change what is deployed at a path, so a tab on that item's View side // is showing a version that no longer exists. case 'delete_workspace_item': case 'deploy_workspace_item': - return { pages: pagesForItemType(args?.type, args), items: deployedItems(args) } + return { + ...withItem(pagesForItemType(args?.type, args), itemRef(args?.type, args)), + deployed: deployedItems(args) + } // Generic item tools carry a workspace-item `type`; refresh its list page // when it lives on one (schedule/resource/variable/trigger). script/flow/app // have their own live editor tab and no previewed list page → nothing. case 'discard_local_draft': case 'rebase_draft': - return { pages: pagesForItemType(args?.type, args), items: [] } + return withItem( + pagesForItemType(args?.type, args), + itemRef(args?.type, args, args?.trigger_kind) + ) default: return NO_RELOAD } @@ -60,6 +79,22 @@ function deployedItems(args: any): string[] { return typeof args?.path === 'string' && args.path ? [args.path] : [] } +function withItem(pages: string[], item: PageItemRef | undefined): ToolReloadEffect { + return { pages, items: item && pages.length ? [item] : [], deployed: [] } +} + +function itemRef(type: unknown, args: any, triggerKind?: unknown): PageItemRef | undefined { + const path = args?.path + if (typeof path !== 'string' || !path) return undefined + if (type === 'variable' || type === 'resource' || type === 'schedule') { + return { kind: type, path } + } + if (type === 'trigger' && (triggerKind as string) in TRIGGER_PAGES) { + return { kind: 'trigger', triggerKind: triggerKind as TriggerKind, path } + } + return undefined +} + function pagesForItemType(type: unknown, args: any): string[] { switch (type) { case 'schedule': @@ -80,16 +115,25 @@ function triggerPages(kind: unknown): string[] { return page ? [page.path] : [] } -// The open tabs a page-reload should refresh: those whose observed page path is -// in `pages`. Item-editor and pipeline tab routes are never list pages, so they -// never match (see the self-sync invariant above). Pure over a tab snapshot so -// the sessions page can reload by id and this stays unit-testable. +// The open tabs a reload should refresh: list-page tabs whose observed page path is +// in `pages`, and page item tabs on those pages — only the named ones when a tool +// named its item. Item-editor and pipeline tab routes are never list pages, so they +// never match (see the self-sync invariant above). Pure over a tab snapshot so the +// sessions page can reload by id and this stays unit-testable. export function tabsToReload( tabs: SessionPreviewTab[], - pages: ReadonlySet + pages: ReadonlySet, + items: ReadonlySet = new Set() ): SessionPreviewTab[] { if (pages.size === 0) return [] - return tabs.filter((t) => pages.has(stripBase(whereIs(t)))) + return tabs.filter((t) => { + const pageItem = parsePageItemRoute(t.url) + if (!pageItem) return pages.has(stripBase(whereIs(t))) + const listPath = pageItemListPath(pageItem) + if (!pages.has(listPath)) return false + const named = [...items].some((u) => pageItemListPath(parsePageItemRoute(u)!) === listPath) + return !named || items.has(pageItemUrl(pageItem)) + }) } // The open tabs a deploy/delete of `items` should refresh: those sitting on the VIEW side diff --git a/frontend/src/lib/components/sessions/previewRouter.test.ts b/frontend/src/lib/components/sessions/previewRouter.test.ts index c8a35cbdbc..fe409d94c1 100644 --- a/frontend/src/lib/components/sessions/previewRouter.test.ts +++ b/frontend/src/lib/components/sessions/previewRouter.test.ts @@ -14,8 +14,27 @@ import { previewLocationContext, previewLocationLabel, resolvePreviewTab, - runFormUrl + runFormUrl, + workspacePageHref } from './previewRouter' +import { pageItemUrl } from './previewPaths' + +describe('workspacePageHref', () => { + it('sends a page item tab to its list page with the row open, never to its scheme', () => { + expect(workspacePageHref(pageItemUrl({ kind: 'resource', path: 'u/me/db' }))).toBe( + '/resources#/resource/u/me/db' + ) + expect( + workspacePageHref(pageItemUrl({ kind: 'trigger', triggerKind: 'kafka', path: 'f/a/b' })) + ).toBe('/kafka_triggers#f/a/b') + }) + + it('has no page for a tab that belongs to the chat', () => { + expect(workspacePageHref(artifactUrl('a1', 'Plan'))).toBeUndefined() + expect(workspacePageHref(runFormUrl('call_1', 'Run'))).toBeUndefined() + expect(workspacePageHref('/runs?path=u/me/x')).toBe('/runs?path=u/me/x') + }) +}) describe('drawerAnchorFor', () => { it('reads the anchored row on the pages that deep-link one', () => { @@ -281,6 +300,17 @@ describe('resolvePreviewTab', () => { expect(resolvePreviewTab('/runs')).toEqual({ kind: 'iframe' }) }) + it('mounts the list page of a page item kind in process, whatever its filters', () => { + expect(resolvePreviewTab('/variables?path_start=f%2Fa%2F')).toEqual({ + kind: 'pagelist', + path: '/variables' + }) + expect(resolvePreviewTab('/kafka_triggers')).toEqual({ + kind: 'pagelist', + path: '/kafka_triggers' + }) + }) + it('routes any script item to a live editor', () => { expect(resolvePreviewTab('/scripts/edit/f/foo/bar')).toEqual({ kind: 'editor', diff --git a/frontend/src/lib/components/sessions/previewRouter.ts b/frontend/src/lib/components/sessions/previewRouter.ts index c2abd52cf9..d1de9ff633 100644 --- a/frontend/src/lib/components/sessions/previewRouter.ts +++ b/frontend/src/lib/components/sessions/previewRouter.ts @@ -3,8 +3,14 @@ import { AUDIT_LOGS_PATH, FOLDERS_PATH, GROUPS_PATH, + isPageItemListPath, + pageItemForListPath, + pageItemListPath, + pageItemPageHref, + pageItemUrl, pageKey, pageHref, + parsePageItemRoute, parsePreviewItemRoute, RESOURCES_PATH, RUNS_PATH, @@ -14,6 +20,7 @@ import { WORKSPACE_SETTINGS_PATH, triggerLabelForPath, TRIGGER_PAGES, + type PageItemRef, type PreviewItemMode, type PreviewItemRoute, type TriggerKind @@ -21,11 +28,15 @@ import { // Re-exported so the preview code that already reads locations through this module keeps // one import, while a caller needing only a path can reach for the leaf instead. export { + pageItemListPath, + pageItemUrl, pageKey, pageHref, + parsePageItemRoute, parsePreviewItemRoute, stripBase, TRIGGER_PAGES, + type PageItemRef, type PreviewItemMode, type PreviewItemRoute, type TriggerKind @@ -74,6 +85,7 @@ export type PreviewTarget = | { type: 'item'; item: WorkspaceItem; mode?: PreviewItemMode; version?: string } | { type: 'artifact'; id: string; name: string; version?: ArtifactVersionTarget } | { type: 'runform'; toolCallId: string; label: string } + | { type: 'pageitem'; ref: PageItemRef } export type PreviewPage = { label: string; path: string; icon: DrillIcon } @@ -123,6 +135,26 @@ export function drawerAnchorFor(location: string): string | undefined { return location.slice(hashAt + 1).replace(/^\/resource\//, '') || undefined } +/** The item a list-page location deep-links, as a tab of its own: a session edits these + * in process, so the list page's drawer is never where one belongs. */ +export function pageItemForLocation(location: string): PageItemRef | undefined { + const anchor = drawerAnchorFor(location) + if (!anchor) return undefined + let path: string + try { + path = decodeURIComponent(anchor) + } catch { + return undefined + } + return pageItemForListPath(location, path) +} + +/** A location with a deep-linked row replaced by that row's own tab; any other unchanged. */ +export function pageItemLocation(location: string): string { + const ref = pageItemForLocation(location) + return ref ? pageItemUrl(ref) : location +} + // Query params the preview host injects into an iframe URL (`nomenubar` hides the nav, // `workspace` scopes the page). Never part of what a location means. const INJECTED_PARAMS = ['nomenubar', 'workspace'] as const @@ -132,7 +164,7 @@ const INJECTED_PARAMS = ['nomenubar', 'workspace'] as const export function canonicalizeObservedLoc(loc: string): string { // An artifact or a run form is a scheme, not a path — `new URL` would happily parse it // and hand back a pathname with the scheme gone. - if (parseArtifactRoute(loc) || parseRunFormRoute(loc)) return loc + if (parseArtifactRoute(loc) || parseRunFormRoute(loc) || parsePageItemRoute(loc)) return loc try { const u = new URL(loc, 'http://_') for (const p of INJECTED_PARAMS) u.searchParams.delete(p) @@ -207,6 +239,8 @@ export function describeLocation(loc: string): PreviewLocation { // Identity is the call, never the label: that carries the script's summary, so folding it // in would open a second tab for the same form whenever the summary differed. if (runForm) return { identity: `runform:${runForm.toolCallId}`, view: '', anchor: '' } + const pageItem = parsePageItemRoute(loc) + if (pageItem) return { identity: pageItemUrl(pageItem), view: '', anchor: '' } const canonical = canonicalizeObservedLoc(loc) const path = stripBase(canonical) const bare = canonical.split('#')[0] @@ -328,6 +362,15 @@ export function previewLocationContext(loc: string): { location: string open?: string } { + // Told as its list page with the item open, the shape the model already reads for a row + // whose drawer is open — which is all a page item tab is to it. + const pageItem = parsePageItemRoute(loc) + if (pageItem) { + return { + ...previewLocationContext(pageItemListPath(pageItem)), + open: promptSafe(pageItem.path) + } + } const { identity, anchor } = describeLocation(loc) const bare = canonicalizeObservedLoc(loc).split('#')[0] const query = bare.includes('?') ? bare.slice(bare.indexOf('?') + 1) : '' @@ -376,6 +419,8 @@ export function previewLocationLabel(url: string): string { if (artifact) return artifact.name || 'Artifact' const runForm = parseRunFormRoute(url) if (runForm) return runForm.label || 'Run form' + const pageItem = parsePageItemRoute(url) + if (pageItem) return pageItem.path.split('/').pop() || pageItem.path const page = matchReusablePage(url) if (page) return page.label const trigger = triggerLabelForPath(url) @@ -492,13 +537,16 @@ export const isArtifactKey = (key: string) => key.startsWith('artifact:') // item's view page, or an iframe fallback. An item of a wrappable kind (script, // flow, raw app) mounts its per-(kind,path) cell editor on `/edit/` and its viewer // on `/get/`; a `/pipeline/` route mounts the data-pipeline graph editor -// (single, shared runtime.pipelineEditorState — `path` is the folder); everything +// (single, shared runtime.pipelineEditorState — `path` is the folder); the list page +// of a page item kind mounts that list; everything // else (static pages, regular drag-and-drop apps, any other route) stays an iframe. export type PreviewSlot = | { kind: 'editor'; editorKind: SessionTargetKind | 'pipeline'; path: string } | { kind: 'viewer'; viewerKind: SessionTargetKind; path: string } | { kind: 'artifact'; id: string; version?: number } | { kind: 'runform'; toolCallId: string } + | { kind: 'pageitem'; ref: PageItemRef } + | { kind: 'pagelist'; path: string } | { kind: 'iframe' } /** The item kind a preview slot hosts, for the slots that host one — so callers can @@ -514,21 +562,48 @@ export function resolvePreviewTab(url: string): PreviewSlot { if (artifact) return { kind: 'artifact', id: artifact.id, version: artifact.version } const runForm = parseRunFormRoute(url) if (runForm) return { kind: 'runform', toolCallId: runForm.toolCallId } + const pageItem = parsePageItemRoute(url) + if (pageItem) return { kind: 'pageitem', ref: pageItem } + const route = stripBase(url) + if (isPageItemListPath(route)) return { kind: 'pagelist', path: route } const pipelineFolder = parsePipelineRoute(url) if (pipelineFolder) { return { kind: 'editor', editorKind: 'pipeline', path: pipelineFolder } } - const route = parsePreviewItemRoute(url) - if (!route) return { kind: 'iframe' } + const item = parsePreviewItemRoute(url) + if (!item) return { kind: 'iframe' } const itemKind: SessionTargetKind | undefined = - route.kind === 'script' + item.kind === 'script' ? 'script' - : route.kind === 'flow' + : item.kind === 'flow' ? 'flow' - : route.kind === 'app' && route.raw_app + : item.kind === 'app' && item.raw_app ? 'raw_app' : undefined if (!itemKind) return { kind: 'iframe' } - if (route.mode === 'view') return { kind: 'viewer', viewerKind: itemKind, path: route.itemPath } - return { kind: 'editor', editorKind: itemKind, path: route.itemPath } + if (item.mode === 'view') return { kind: 'viewer', viewerKind: itemKind, path: item.itemPath } + return { kind: 'editor', editorKind: itemKind, path: item.itemPath } +} + +/** The full workspace page showing what a tab shows ("Open in workspace"), or undefined when + * there is none. Every tab kind answers here, so a new one cannot fall through to its url being + * navigated as a path — an artifact or page item url is a scheme, not a route. */ +export function workspacePageHref(location: string): string | undefined { + const slot = resolvePreviewTab(location) + switch (slot.kind) { + case 'artifact': + case 'runform': + return undefined + case 'pageitem': + return pageItemPageHref(slot.ref) + case 'editor': + case 'viewer': + case 'pagelist': + case 'iframe': + return location + default: { + const unhandled: never = slot + return unhandled + } + } } diff --git a/frontend/src/lib/components/sessions/sessionMirror.svelte.ts b/frontend/src/lib/components/sessions/sessionMirror.svelte.ts index bfa1621822..f30d66ca45 100644 --- a/frontend/src/lib/components/sessions/sessionMirror.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionMirror.svelte.ts @@ -1169,7 +1169,6 @@ function unpackBackup( const active = chats.find((c) => c.id === head.chatId) const session: Session = { ...(head as unknown as Session), - name: '', // Everything in the backup has been read here: no unread badge, and the last // activity is the backup's own time. lastSeenCount: active?.displayMessages.length ?? 0, diff --git a/frontend/src/lib/components/sessions/sessionMirror.test.ts b/frontend/src/lib/components/sessions/sessionMirror.test.ts index 7676662d31..55a1acc138 100644 --- a/frontend/src/lib/components/sessions/sessionMirror.test.ts +++ b/frontend/src/lib/components/sessions/sessionMirror.test.ts @@ -1077,7 +1077,7 @@ describe('sessionMirror restore', () => { expect(pullMock).toHaveBeenCalledTimes(2) await vi.waitFor(() => expect(sessionState.sessions.map((s) => s.id)).toEqual(['s9'])) const restored = sessionState.sessions[0] - expect(restored.name).toBe('session-1') + expect(restored.name).toBeUndefined() expect(restored.summary).toBe('remote') expect(restored.lastSeenCount).toBe(1) expect((await readStoredChat('c9', EMAIL))?.displayMessages).toHaveLength(1) diff --git a/frontend/src/lib/components/sessions/sessionMirrorPlan.ts b/frontend/src/lib/components/sessions/sessionMirrorPlan.ts index caea48c792..5d40c35e3b 100644 --- a/frontend/src/lib/components/sessions/sessionMirrorPlan.ts +++ b/frontend/src/lib/components/sessions/sessionMirrorPlan.ts @@ -78,7 +78,7 @@ export function isFallbackStorage(name: string): boolean { /** * The part of a session record the backup keeps. Left out on purpose: `name` (a - * per-browser counter), the unsent-draft fields (`pending_*`, `draftPrompt`, + * per-browser legacy label), the unsent-draft fields (`pending_*`, `draftPrompt`, * `autoSendDraftAt`), `workspace_root_id` (derived on import), `transient`, `restoredAt` * (this browser's clock), and the two fields reading a session bumps (`lastSeenCount`, * `lastActivityAt`) — so opening a session and reading its new messages never costs a push. diff --git a/frontend/src/lib/components/sessions/sessionMode.svelte.ts b/frontend/src/lib/components/sessions/sessionMode.svelte.ts index a4972a9afb..a2c20e08a8 100644 --- a/frontend/src/lib/components/sessions/sessionMode.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionMode.svelte.ts @@ -47,6 +47,17 @@ export function withMenuHidden(url: string, workspaceId?: string): string { } } +// True when this window is a sessions-preview iframe: embedded, with the `nomenubar` flag +// the preview always sets and the logged layout stickies into sessionStorage. +export function isSessionPreviewFrame(): boolean { + if (typeof window === 'undefined' || window.self === window.top) return false + try { + return sessionStorage.getItem('nomenubar_embedded') === 'true' + } catch { + return false + } +} + // Append `?workspace=` to a canonical route so a full-page navigation (e.g. // "Open in workspace") lands on the session's effective workspace instead of // the navigation workspace. Unlike withMenuHidden, the menu is kept visible — diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts index beafda0ace..d1dc5bc189 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.svelte.ts @@ -8,7 +8,10 @@ import { describeLocation, matchPreviewPage, showsView, + pageItemLocation, + pageItemUrl, parseArtifactRoute, + parsePageItemRoute, parsePipelineRoute, previewLocationContext, promptSafe, @@ -24,6 +27,12 @@ import { import type { SessionPreviewTab, SessionTarget } from './sessionState.svelte' import type { Kind } from '$lib/utils_deployable' import { pipelineFolderFromBundlePath } from '$lib/pipelinePaths' +import { + pageItemKindLabel, + TRIGGER_PAGES, + type PageItemRef, + type TriggerKind +} from './previewPaths' // The single live owner of a session's preview tabs. Runs behind a small // interface both the sessions page (renderer) and the `open_preview` tool cross, @@ -79,7 +88,10 @@ function keptVersion( // scheme. `onto` is the tab about to be written, passed wherever one is being re-pointed so // that every such path keeps its pin. function targetUrl(target: PreviewTarget, onto?: SessionPreviewTab): string { - if (target.type === 'page') return target.href + // A list page asked for with a row anchored is that row's own tab: its drawer would only + // open the editor a page item tab already hosts, inside a frame of its own. + if (target.type === 'page') return pageItemLocation(target.href) + if (target.type === 'pageitem') return pageItemUrl(target.ref) if (target.type === 'artifact') { return artifactUrl(target.id, target.name, keptVersion(target, onto)) } @@ -160,10 +172,9 @@ export function previewTargetForSessionTarget( // Adapt a deployable item's layout kind (the session review dock speaks `Kind`, // not SessionTarget) to a preview destination: the three live editors, data -// pipelines, plus legacy drag-and-drop apps, which the panel hosts as an iframe -// over their edit route. Every other kind maps to undefined — not for lack of any -// route (a variable or trigger has a list page the panel can host) but because -// there is no item editor to preview, so their row falls back to the diff. The +// pipelines, page items (variables, resources, schedules, triggers), plus legacy +// drag-and-drop apps, which the panel hosts as an iframe over their edit route. +// Every other kind maps to undefined, and its row falls back to the diff. The // undefined is also the caller's test for "can this row be previewed?". export function previewTargetForDeployKind(kind: Kind, path: string): PreviewTarget | undefined { if (kind === 'app') { @@ -172,6 +183,16 @@ export function previewTargetForDeployKind(kind: Kind, path: string): PreviewTar if (kind === 'script' || kind === 'flow' || kind === 'raw_app') { return previewTargetForSessionTarget(kind, path) } + if (kind === 'variable' || kind === 'resource' || kind === 'schedule') { + return { type: 'pageitem', ref: { kind, path } } + } + const triggerKind = kind.endsWith('_trigger') ? kind.slice(0, -'_trigger'.length) : undefined + if (triggerKind && triggerKind in TRIGGER_PAGES) { + return { + type: 'pageitem', + ref: { kind: 'trigger', triggerKind: triggerKind as TriggerKind, path } + } + } // A pipeline's editor is its folder's graph view, not its bundle path. if (kind === 'data_pipeline') { const folder = pipelineFolderFromBundlePath(path) @@ -197,7 +218,14 @@ export function hydratePreviewTabs(session: { seen.add(t.id) // Rebuilt field-by-field so stray properties on old saved records (e.g. the // retired `pinned` flag) don't survive hydration and get persisted back. - tabs.push({ id: t.id, url: t.url, loc: t.loc || t.url }) + // A list page saved with a row's drawer open comes back as that row's own tab. Read from + // where the frame was, not from what was last commanded: the user may have moved to + // another row since, or closed the drawer, and the observation is what they saw. + const loc = t.loc || t.url + const item = pageItemLocation(t.loc ? loc : t.url) + tabs.push( + parsePageItemRoute(item) ? { id: t.id, url: item, loc: item } : { id: t.id, url: t.url, loc } + ) } if (tabs.length > 0) { const wantActive = session.activePreviewTabId @@ -298,22 +326,14 @@ export class SessionPreviewTabs { // Drift is a change of what the frame *shows*, not of its URL string: a page // writing its own filter defaults back is not the user navigating away. const drifted = !showsView(tab.loc, url) - // Both cases the browser will not act on, decided here because this is where the - // old and new commands are both in hand: re-commanding the URL a drifted frame - // already carries moves nothing, and moving to another fragment resolves within the - // same document — so a list page never re-runs the `#` read that opens a row. - // Dropping the fragment is not one of them: the same-document path applies only to a - // target that has one, so the browser loads the page — closing the drawer by itself — - // and forcing a second load races that one back onto the row. - const fragmentOnly = - !commandUnchanged && url.includes('#') && tab.url.split('#')[0] === url.split('#')[0] + // Decided here because this is where the old and new commands are both in hand: + // re-commanding the URL a drifted frame already carries moves nothing. retargetTab(tab, url) - if ((commandUnchanged && drifted) || fragmentOnly) this.pulseReload(tab.id) + if (commandUnchanged && drifted) this.pulseReload(tab.id) } - // Force the host to reload the iframe. A navigation onto the tab's exact current URL - // changes nothing, so URL-driven behavior — a `#` opening a drawer the user has - // since closed — would never re-fire. + // Force the host to reload the tab. A navigation onto the tab's exact current URL + // changes nothing, so URL-driven behavior would never re-fire. pulseReload(id: string): void { this.#reloadPulse = { id, nonce: this.#reloadPulse.nonce + 1 } } @@ -443,21 +463,21 @@ export class SessionPreviewTabs { // shows this. The tab on this exact view wins over any other on the page — // `new_tab` puts two views side by side, and retargeting whichever sits first // would overwrite the other and leave both on the same row. - const shown = opts?.forceNewTab - ? undefined - : (this.#tabs.find((t) => showsView(t.loc, url)) ?? - this.#tabs.find((t) => describeLocation(t.loc).identity === describeLocation(url).identity)) + // A page item stays one tab whatever the opener asks: two would hold two drafts of it. + const shown = + opts?.forceNewTab && !parsePageItemRoute(url) + ? undefined + : (this.#tabs.find((t) => showsView(t.loc, url)) ?? + this.#tabs.find( + (t) => describeLocation(t.loc).identity === describeLocation(url).identity + )) if (shown) { const same = showsView(shown.loc, url) if (same) { // The frame is already here, but record what was asked for: `url` is what the // tab persists and remounts from, so leaving it on where the frame started - // sends a refresh back to the row the user has since moved off. + // sends a refresh back to the view the user has since moved off. recordCommand(shown, url) - // Nothing to navigate to, so nothing would re-run: the list pages read their - // `#` once per document, and the drawer it opens may since have been - // closed. Only a forced load can bring it back. - if (describeLocation(url).anchor) this.pulseReload(shown.id) } else { this.#retarget(shown, url) } @@ -520,7 +540,17 @@ export class SessionPreviewTabs { return } } - this.#retarget(t, targetUrl(target, t)) + // One tab per page item, as for editors: two would hold two drafts of one item. + const url = targetUrl(target, t) + if (parsePageItemRoute(url)) { + const existing = this.#tabs.find((x) => x.url === url) + if (existing && existing.id !== t.id) { + this.#activeId = existing.id + this.#flush() + return + } + } + this.#retarget(t, url) this.#flush() } @@ -582,6 +612,17 @@ export class SessionPreviewTabs { this.#flush() } + /** Follow a page item its editor saved under a new path, in place. */ + retargetPageItem(from: PageItemRef, to: PageItemRef): void { + const fromUrl = pageItemUrl(from) + const toUrl = pageItemUrl(to) + if (fromUrl === toUrl) return + const tab = this.#tabs.find((t) => t.url === fromUrl) + if (!tab) return + retargetTab(tab, toUrl) + this.#flush() + } + closeArtifact(artifactId: string): void { const tab = this.#tabs.find((t) => parseArtifactRoute(t.url)?.id === artifactId) if (tab) this.close(tab.id) @@ -614,23 +655,14 @@ export class SessionPreviewTabs { } // Feed back the location an iframe reported on load (only the page can read - // contentWindow.location). Updates the observed `loc`; `url` follows only when a - // drawer closed (below), and the host navigates on a command it isn't already at, - // so that write does not move the frame. + // contentWindow.location). Updates the observed `loc` only: the host navigates on a + // command it isn't already at, and an in-frame move is the user browsing. observeLocation(id: string, loc: string): void { const t = this.#tabs.find((x) => x.id === id) if (!t) return const canonical = canonicalizeObservedLoc(loc) if (t.loc === canonical) return t.loc = canonical - // Closing a drawer drops the row from the frame's URL. The command has to follow, or - // the tab reopens it on the next mount — the iframe loads `url`, not `loc`. Only the - // anchor: any other in-frame move is the user browsing, which must not re-command. - const commanded = describeLocation(t.url) - const observed = describeLocation(canonical) - if (commanded.anchor && !observed.anchor && commanded.identity === observed.identity) { - t.url = t.url.split('#')[0] - } this.#flush() } @@ -738,6 +770,7 @@ export function describePreview( const lines = tabs.map((t) => { const where = whereIs(t) const artifact = parseArtifactRoute(where) + const pageItem = parsePageItemRoute(where) const page = matchPreviewPage(where) const pipelineFolder = parsePipelineRoute(where) const route = parsePreviewItemRoute(where) @@ -745,18 +778,24 @@ export function describePreview( ? // A pinned tab is not showing what the assistant last wrote, and nothing else in this // summary would tell it so. `artifact "${artifact.name || 'Artifact'}"${artifact.version ? ` (pinned to v${artifact.version})` : ''}` - : page - ? `page "${page.label}"${previewLocationDetail(where)}` - : pipelineFolder - ? `pipeline "${pipelineFolder}"` - : route - ? `${route.raw_app ? 'raw_app' : route.kind} "${route.itemPath}"` - : // Trigger list pages land here (they're outside PREVIEW_PAGES), and - // their `#` is the trigger the drawer has open. - `${stripBase(where)}${previewLocationDetail(where)}` + : pageItem + ? `${pageItemKindLabel(pageItem).toLowerCase()} "${pageItem.path}"` + : page + ? `page "${page.label}"${previewLocationDetail(where)}` + : pipelineFolder + ? `pipeline "${pipelineFolder}"` + : route + ? `${route.raw_app ? 'raw_app' : route.kind} "${route.itemPath}"` + : // Trigger list pages land here (they're outside PREVIEW_PAGES), and + // their `#` is the trigger the drawer has open. + `${stripBase(where)}${previewLocationDetail(where)}` const slotKind = resolvePreviewTab(t.url).kind const live = - slotKind === 'editor' ? ', live editor' : slotKind === 'viewer' ? ', deployed view' : '' + slotKind === 'editor' || slotKind === 'pageitem' + ? ', live editor' + : slotKind === 'viewer' + ? ', deployed view' + : '' const active = t.id === activeId ? ', active' : '' // One list entry per tab: an artifact's name, a pipeline folder and an item path // all arrive decoded from a URL, so any of them could otherwise write a line here. diff --git a/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts b/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts index b71de62c23..8b1ba27ea0 100644 --- a/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts +++ b/frontend/src/lib/components/sessions/sessionPreviewTabs.test.ts @@ -177,10 +177,88 @@ describe('previewTargetForDeployKind', () => { pipelineTarget ) }) + it('routes variables, resources, schedules and triggers to their own tab', () => { + expect(previewTargetForDeployKind('schedule', 'u/me/s')).toEqual({ + type: 'pageitem', + ref: { kind: 'schedule', path: 'u/me/s' } + }) + expect(previewTargetForDeployKind('http_trigger', 'u/me/t')).toEqual({ + type: 'pageitem', + ref: { kind: 'trigger', triggerKind: 'http', path: 'u/me/t' } + }) + expect(previewTargetForDeployKind('variable', 'u/me/v')).toEqual({ + type: 'pageitem', + ref: { kind: 'variable', path: 'u/me/v' } + }) + }) + it('has no destination for kinds the preview panel cannot host', () => { - expect(previewTargetForDeployKind('schedule', 'u/me/s')).toBeUndefined() - expect(previewTargetForDeployKind('http_trigger', 'u/me/t')).toBeUndefined() - expect(previewTargetForDeployKind('variable', 'u/me/v')).toBeUndefined() + expect(previewTargetForDeployKind('folder', 'f/x')).toBeUndefined() + expect(previewTargetForDeployKind('resource_type', 'x')).toBeUndefined() + }) +}) + +describe('page item tabs', () => { + const variable: PreviewTarget = { + type: 'pageitem', + ref: { kind: 'variable', path: 'u/me/token' } + } + + it('opens a list page anchored at a row as that row’s own tab, beside the list', () => { + const o = owner() + o.open({ type: 'page', href: '/routes', label: 'HTTP routes' }) + o.open({ type: 'page', href: '/routes#u/me/a', label: 'HTTP routes' }) + expect(o.tabs.map((t) => t.url)).toEqual(['/routes', 'pageitem:trigger.http/u%2Fme%2Fa']) + + // Resources address their row through an extra segment. + o.open({ type: 'page', href: '/resources?owner=u#/resource/u/me/db', label: 'Resources' }) + expect(o.tabs.at(-1)!.url).toBe('pageitem:resource/u%2Fme%2Fdb') + }) + + it('keeps one tab per item, whatever the opener asks', () => { + const o = owner() + o.open(variable) + o.open({ type: 'page', href: '/runs', label: 'Runs' }) + expect(o.open({ type: 'page', href: '/variables#u/me/token', label: 'V' }).status).toBe( + 'focused' + ) + expect(o.open(variable, { forceNewTab: true }).status).toBe('focused') + o.navigate(variable) + expect(o.tabs).toHaveLength(2) + expect(o.activeId).toBe(o.tabs[0].id) + }) + + it('follows an item saved under a new path in place', () => { + const o = owner() + o.open(variable) + const id = o.tabs[0].id + o.retargetPageItem( + { kind: 'variable', path: 'u/me/token' }, + { kind: 'variable', path: 'f/x/token' } + ) + expect(o.tabs).toEqual([ + { id, url: 'pageitem:variable/f%2Fx%2Ftoken', loc: 'pageitem:variable/f%2Fx%2Ftoken' } + ]) + }) + + it('restores a tab saved on a row’s drawer as the row it was last on', () => { + const snap = hydratePreviewTabs({ + previewTabs: [ + { id: 'a', url: '/schedules#u/me/daily', loc: '/schedules?path=u#u/me/daily' }, + // A drawer opened inside the frame: the command is still the bare list. + { id: 'b', url: '/variables', loc: '/variables#u/me/token' }, + // The user moved on to another row inside the frame… + { id: 'c', url: '/variables#u/me/token', loc: '/variables#u/me/other' }, + // …or closed the drawer, leaving no row to restore. + { id: 'd', url: '/variables#u/me/token', loc: '/variables' } + ] + }) + expect(snap.tabs).toEqual([ + { id: 'a', url: 'pageitem:schedule/u%2Fme%2Fdaily', loc: 'pageitem:schedule/u%2Fme%2Fdaily' }, + { id: 'b', url: 'pageitem:variable/u%2Fme%2Ftoken', loc: 'pageitem:variable/u%2Fme%2Ftoken' }, + { id: 'c', url: 'pageitem:variable/u%2Fme%2Fother', loc: 'pageitem:variable/u%2Fme%2Fother' }, + { id: 'd', url: '/variables#u/me/token', loc: '/variables' } + ]) }) }) @@ -232,93 +310,34 @@ describe('SessionPreviewTabs.open', () => { expect(o.activeId).toBe(firstId) }) - // A trigger list page is not a `matchReusablePage`, so the runtime's - // navigate-in-place path doesn't cover it: re-pointing the tab has to happen - // here or the panel keeps showing the previously opened row. - it('re-points a page tab whose hash target changed instead of only focusing it', () => { - const o = owner() - const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' }) - o.open(routes('/routes#u/me/a')) - const firstId = o.activeId - - // 'retargeted', not 'opened': the tab count is unchanged, and the caller - // reports that to the model. - const res = o.open(routes('/routes#u/me/b')) - expect(res.status).toBe('retargeted') - expect(o.tabs).toHaveLength(1) - expect(o.activeId).toBe(firstId) - expect(o.tabs[0].url).toBe('/routes#u/me/b') - - // Back to the bare list: still the same tab, no longer anchored at a row. - expect(o.open(routes('/routes')).status).toBe('retargeted') - expect(o.tabs).toHaveLength(1) - expect(o.tabs[0].url).toBe('/routes') - - // ...and asking for the view it already shows is a plain focus. - expect(o.open(routes('/routes')).status).toBe('focused') - }) - // The list pages rewrite their own filter defaults into the URL after mount, // and `loc` follows that rewrite. Matching on anything but the path made a tab // stop recognizing itself, so every later open spawned a duplicate. it('still recognizes a tab after the page rewrote its own filter params', () => { const o = owner() const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' }) - o.open(routes('/routes#u/me/a')) - const id = o.tabs[0].id - o.observeLocation(id, '/routes?filter_path_of=trigger#u/me/a') + o.open(routes('/routes')) + o.observeLocation(o.tabs[0].id, '/routes?filter_path_of=trigger') - const res = o.open(routes('/routes#u/me/b')) - expect(res.status).toBe('retargeted') + expect(o.open(routes('/routes')).status).toBe('focused') expect(o.tabs).toHaveLength(1) - expect(o.tabs[0].url).toBe('/routes#u/me/b') }) // `new_tab` deliberately keeps two views of one page side by side. Reopening one of // them must focus the tab already showing it, not retarget whichever tab happens to - // sit first in the strip — that would overwrite the other view and leave two tabs - // on the same row. + // sit first in the strip — that would overwrite the other view. it('focuses the tab already showing the exact location before retargeting by path', () => { const o = owner() - const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' }) - o.open(routes('/routes#u/me/a')) + const runs = (href: string) => ({ type: 'page' as const, href, label: 'Runs' }) + o.open(runs('/runs?path=u/me/a')) const first = o.tabs[0].id - o.open(routes('/routes#u/me/b'), { forceNewTab: true }) + o.open(runs('/runs?path=u/me/b'), { forceNewTab: true }) const second = o.tabs[1].id - expect(o.open(routes('/routes#u/me/b')).status).toBe('focused') + expect(o.open(runs('/runs?path=u/me/b')).status).toBe('focused') expect(o.activeId).toBe(second) expect(o.tabs).toHaveLength(2) - expect(o.tabs.find((t) => t.id === first)?.url).toBe('/routes#u/me/a') - }) - - // The list pages read their `#` once per document, so a drawer the user closed - // inside the frame only comes back on a forced load — and re-commanding the location - // the tab already shows produces no navigation the host could act on. - it('forces a load when the requested row is the one the tab already shows', () => { - const o = owner() - const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' }) - o.open(routes('/routes#u/me/a')) - const id = o.tabs[0].id - o.observeLocation(id, '/routes?filter_path_of=trigger#u/me/a') - const before = o.reloadPulse.nonce - - expect(o.open(routes('/routes#u/me/a')).status).toBe('focused') - expect(o.reloadPulse).toEqual({ id, nonce: before + 1 }) - }) - - // Dropping the fragment is a load in itself, so the forced one lands on top of a - // navigation still in flight — and reloads the row the command asked to leave. - it('does not force a load when the requested location drops the row', () => { - const o = owner() - const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' }) - o.open(routes('/routes#u/me/a')) - const id = o.tabs[0].id - const before = o.reloadPulse.nonce - - o.navigate(routes('/routes')) - expect(o.tabs.find((t) => t.id === id)?.url).toBe('/routes') - expect(o.reloadPulse.nonce).toBe(before) + expect(o.tabs.find((t) => t.id === first)?.url).toBe('/runs?path=u/me/a') }) // Runs restores the user's "hide schedules" preference into the URL whenever a load @@ -365,29 +384,11 @@ describe('SessionPreviewTabs.open', () => { expect(o.tabs[0].url).toBe('/apps/edit/u/me/dash') }) - // Re-commanding the URL a tab is already pointed at changes nothing the host can - // see, so the frame would stay wherever the user navigated it inside the page. - it('forces a reload when the request matches the command but the frame drifted', () => { - const o = owner() - const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' }) - o.open(routes('/routes#u/me/a')) - const id = o.tabs[0].id - // The user clicked another trigger inside the iframe. - o.observeLocation(id, '/routes#u/me/b') - const before = o.reloadPulse.nonce - - const res = o.open(routes('/routes#u/me/a')) - expect(res.status).toBe('retargeted') - expect(o.tabs).toHaveLength(1) - expect(o.tabs[0].loc).toBe('/routes#u/me/a') - expect(o.reloadPulse.nonce).toBe(before + 1) - }) - it('forceNewTab opts a page out of the location dedupe', () => { const o = owner() const routes = (href: string) => ({ type: 'page' as const, href, label: 'HTTP routes' }) - o.open(routes('/routes#u/me/a')) - const res = o.open(routes('/routes#u/me/b'), { forceNewTab: true }) + o.open(routes('/routes')) + const res = o.open(routes('/routes'), { forceNewTab: true }) expect(res.status).toBe('opened') expect(o.tabs).toHaveLength(2) }) @@ -512,59 +513,6 @@ describe('SessionPreviewTabs.open', () => { }) }) -describe('SessionPreviewTabs.open — commanded url', () => { - it('records the requested row even when the frame is already showing it', () => { - const o = owner() - o.open({ type: 'page', href: '/routes#u/me/a', label: 'R' }) - // The user moves to another row inside the frame. - o.observeLocation(o.tabs[0].id, '/routes#u/me/b') - o.open({ type: 'page', href: '/routes#u/me/b', label: 'R' }) - // `url` is what a refresh and a remount reload from, so it has to follow. - expect(o.tabs[0].url).toBe('/routes#u/me/b') - expect(o.tabs).toHaveLength(1) - }) -}) - -describe('SessionPreviewTabs.observeLocation', () => { - it('drops the row from the command when the frame closes its drawer', () => { - const o = owner() - o.open({ type: 'page', href: '/routes#u/me/a', label: 'R' }) - // The page clears its own hash when the drawer closes. - o.observeLocation(o.tabs[0].id, '/routes?filter_path_of=trigger') - // The iframe mounts from `url`, so a remount would otherwise reopen the drawer. - expect(o.tabs[0].url).toBe('/routes') - }) - - it('leaves the command alone when the user just browses inside the frame', () => { - const o = owner() - o.open({ type: 'page', href: '/routes#u/me/a', label: 'R' }) - o.observeLocation(o.tabs[0].id, '/routes#u/me/b') - expect(o.tabs[0].url).toBe('/routes#u/me/a') - }) -}) - -describe('SessionPreviewTabs.open — forced loads', () => { - it('pulses when only the fragment changes, since the browser would not load', () => { - const o = owner() - o.open({ type: 'page', href: '/routes#u/me/a', label: 'R' }) - const before = o.reloadPulse.nonce - o.open({ type: 'page', href: '/routes#u/me/b', label: 'R' }) - // Same document: the browser resolves the new fragment without a load, so the - // list page never re-runs the `#` read that opens the row. - expect(o.reloadPulse.nonce).toBeGreaterThan(before) - expect(o.tabs).toHaveLength(1) - }) - - it('does not pulse when the document itself changes', () => { - const o = owner() - o.open({ type: 'page', href: '/routes#u/me/a', label: 'R' }) - const before = o.reloadPulse.nonce - o.open({ type: 'page', href: '/schedules#u/me/a', label: 'S' }) - // Different page: src changes, the browser loads it, nothing to force. - expect(o.reloadPulse.nonce).toBe(before) - }) -}) - describe('SessionPreviewTabs.navigate', () => { it('retargets the active tab to an editor item', () => { const o = owner() diff --git a/frontend/src/lib/components/sessions/sessionRecoveryNotice.svelte.ts b/frontend/src/lib/components/sessions/sessionRecoveryNotice.svelte.ts index 635bd68601..443f972334 100644 --- a/frontend/src/lib/components/sessions/sessionRecoveryNotice.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRecoveryNotice.svelte.ts @@ -1,6 +1,6 @@ import { SvelteSet } from 'svelte/reactivity' -// Session ids opened as a stand-in for a `session_name` this browser doesn't +// Session ids opened as a stand-in for a `session` URL this browser doesn't // hold. Kept in memory rather than on the Session record: persisted, the notice // would replay on every reload of a session the user has since made their own. const recovered = new SvelteSet() diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index d1e38b2cd3..0cf42b9233 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -56,7 +56,10 @@ import { selectPreviewTabsToClose, whereIs } from './sessionPreviewTabs.svelte' +import { pageItemKindLabel } from './previewPaths' import { + pageItemForLocation, + pageItemLocation, parsePreviewItemRoute, previewLocationContext, previewLocationLabel, @@ -97,7 +100,13 @@ import type { RawAppDomResult } from '$lib/components/raw_apps/rawAppDom' import { getNonStreamingMetadataCompletion } from '$lib/components/copilot/lib' -import { pendingUserAction, type DisplayMessage } from '$lib/components/copilot/chat/shared' +import { + pendingUserAction, + type ChatJob, + type DisplayMessage +} from '$lib/components/copilot/chat/shared' +import { readStoredChat } from '$lib/components/copilot/chat/HistoryManager.svelte' +import { getCurrentUserEmail } from '$lib/userScopedStorage' import type { ChatCompletionMessageParam } from 'openai/resources/index.mjs' // Per-kind load state for a session's editor target. Pure state container the @@ -129,6 +138,9 @@ export interface RawAppRuntimeValue { path: string custom_path?: string draft_path?: string + /** The app_version this cell's content forked from, carried so the editor's deploy + * guard and diff drawer compare the same pair the full-page editor does. */ + parent_version?: number } // The deployed baseline a raw-app cell diffs against (topbar Diff drawer). export interface RawAppSavedValue { @@ -144,6 +156,9 @@ export interface RawAppSavedValue { /** No deployed counterpart (draft-only); disables the topbar Diff. */ no_deployed?: boolean custom_path?: string + /** The deployed head at load time, which the editor's deploy guard falls back to when + * the draft carries no base of its own. */ + deployed_version?: number } // One editor cell per (kind, path) the session loads: the load slot plus the @@ -399,7 +414,8 @@ function createRuntime(session: Session): SessionRuntime { // What the side panel is showing, stamped on each user message so the chat // knows the page (and the row whose drawer is open) without spending a // get_preview_status round-trip. Live editors are skipped: they register - // themselves as the ACTIVE EDITOR through UserDraft's live-draft registry. + // themselves as the ACTIVE EDITOR through UserDraft's live-draft registry. A page + // item tab is not one of them, and reads as its list page with the item open. manager.activePreviewResolver = () => { const owner = getRuntime(session.id)?.previewTabs // What is on screen, not merely which tab is selected: the rule tells the model @@ -407,7 +423,9 @@ function createRuntime(session: Session): SessionRuntime { // point those at a page the user cannot see. const tab = owner?.displayedTab if (!tab) return undefined - if (resolvePreviewTab(tab.url).kind !== 'iframe') return undefined + const slotKind = resolvePreviewTab(tab.url).kind + if (slotKind !== 'iframe' && slotKind !== 'pageitem' && slotKind !== 'pagelist') + return undefined return previewLocationContext(whereIs(tab)) } // Pre-flight: materialise the (still-transient) session, then commit @@ -609,8 +627,9 @@ function createRuntime(session: Session): SessionRuntime { // when the path has never been deployed. const aiDraft = UserDraft.get('flow', path, { workspace }) - // getDraft=true omits version_id (the plain getFlowByPath has it) — - // stamp it on so the flow doesn't always diff. Best-effort. + // Fallback for the head: the payload fetches below carry `version_id`, and + // this one covers a response that does not. Best-effort, and a request of + // its own, so the same response wins wherever both are available. let deployedVersionId: number | undefined try { deployedVersionId = (await FlowService.getFlowByPath({ workspace, path }))?.version_id @@ -624,12 +643,16 @@ function createRuntime(session: Session): SessionRuntime { // yet on the backend — draft-only flows are a valid state. try { const result = await FlowService.getFlowByPath({ workspace, path, getDraft: true }) - saved.val = result as SavedFlow + // The editor's deploy guard compares against the head, so keep this + // response's own and fall back to the one fetched above. + saved.val = { + ...(result as SavedFlow), + version_id: (result as SavedFlow).version_id ?? deployedVersionId + } } catch { saved.val = undefined } await initFlow(aiDraft, store, stateStore, workspace) - if (deployedVersionId != null && store.val) store.val.version_id = deployedVersionId slot.loadedPath = path slot.loadedWorkspace = workspace return @@ -637,8 +660,12 @@ function createRuntime(session: Session): SessionRuntime { // No local draft yet — seed from `result.draft ?? result`. const result = await FlowService.getFlowByPath({ workspace, path, getDraft: true }) - saved.val = result as SavedFlow - const flow: Flow = ((result as SavedFlow).draft ?? (result as Flow)) as Flow + saved.val = { + ...(result as SavedFlow), + version_id: (result as SavedFlow).version_id ?? deployedVersionId + } + const serverDraft = (result as SavedFlow).draft as Flow | undefined + const flow: Flow = (serverDraft ?? (result as Flow)) as Flow // Seed the per-tab last_sync from the server draft's timestamp so the // seeding save below attaches a matching last_sync and the server can // reject stale writes (see loadRawApp). Without this a server draft — @@ -651,7 +678,13 @@ function createRuntime(session: Session): SessionRuntime { ) UserDraft.save('flow', path, flow, { workspace }) await initFlow(flow, store, stateStore, workspace) - if (deployedVersionId != null && store.val) store.val.version_id = deployedVersionId + // A draft keeps the base it forked from, unknown included (it then falls + // back to the timestamps); only a fresh checkout takes the head, which is + // also what keeps it from always diffing. See loadScript. The head comes + // from the response that supplied the payload, so a deploy landing between + // the two requests cannot label this checkout as forked from the older one. + const head = (result as SavedFlow).version_id ?? deployedVersionId + if (head != null && store.val && !serverDraft) store.val.version_id = head slot.loadedPath = path slot.loadedWorkspace = workspace } catch (err) { @@ -694,11 +727,10 @@ function createRuntime(session: Session): SessionRuntime { } // Clone before layering the AI draft on top, else we'd mutate // `saved.val` in place and lose the pristine diff baseline. + const savedDraft = saved.val?.draft as NewScript | undefined const baseline: NewScript = saved.val ? (structuredClone( - $state.snapshot( - (saved.val.draft as NewScript | undefined) ?? (saved.val as NewScript) - ) + $state.snapshot(savedDraft ?? (saved.val as NewScript)) ) as NewScript) : { // Seed from the draft's own path (a rename lives in `draft_path`, @@ -716,9 +748,8 @@ function createRuntime(session: Session): SessionRuntime { schema: emptySchema(), language: (aiDraft.language ?? 'bun') as any } - if (saved.val?.hash) { - baseline.parent_hash = saved.val.hash - } + // Only a fresh checkout forks from the head; see the branch below. + if (!savedDraft && saved.val?.hash) baseline.parent_hash = saved.val.hash baseline.content = aiDraft.content if (aiDraft.language) baseline.language = aiDraft.language if (aiDraft.summary !== undefined) baseline.summary = aiDraft.summary @@ -733,10 +764,13 @@ function createRuntime(session: Session): SessionRuntime { saved.val = result as SavedScript // Clone before mutating, else `baseline` aliases `result` and // `baseline.parent_hash` corrupts the diff baseline. - const baseline = structuredClone( - ((result as SavedScript).draft as NewScript | undefined) ?? (result as NewScript) - ) - baseline.parent_hash = result.hash + const serverDraft = (result as SavedScript).draft as NewScript | undefined + const baseline = structuredClone(serverDraft ?? (result as NewScript)) + // Only a fresh checkout forks from the head. A draft keeps the base it has, + // unknown included: `draft.base` is derived from `parent_hash` on every + // save, so stamping the head over it would say this draft is up to date + // when it is not. An unknown base falls back to the timestamps. + if (!serverDraft) baseline.parent_hash = result.hash // Seed the per-tab last_sync from the server draft's timestamp so the // seeding save below attaches a matching last_sync and the server can // reject stale writes (see loadRawApp). Without this a server draft — @@ -797,7 +831,10 @@ function createRuntime(session: Session): SessionRuntime { path: result.path, policy: result.policy, custom_path: result.custom_path, - no_deployed: result.no_deployed + no_deployed: result.no_deployed, + deployed_version: Array.isArray(result.versions) + ? result.versions[result.versions.length - 1] + : undefined } } catch { saved.val = undefined @@ -833,7 +870,10 @@ function createRuntime(session: Session): SessionRuntime { path: result.path, policy: result.policy, custom_path: result.custom_path, - no_deployed: result.no_deployed + no_deployed: result.no_deployed, + deployed_version: Array.isArray(result.versions) + ? result.versions[result.versions.length - 1] + : undefined } // Prefer the server draft over the deployed value (mirrors the // flow/script `result.draft ?? result`). A raw-app draft is already @@ -863,7 +903,14 @@ function createRuntime(session: Session): SessionRuntime { summary: draftValue?.summary ?? result.summary ?? '', path: result.path, custom_path: draftValue?.custom_path ?? result.custom_path, - draft_path: draftValue?.draft_path + draft_path: draftValue?.draft_path, + // Only a fresh checkout forks from the head; a draft keeps its own base, + // unknown included, or it would read as up to date. See loadScript. + parent_version: draftValue + ? draftValue.parent_version + : Array.isArray(result.versions) + ? result.versions[result.versions.length - 1] + : undefined } // Seed the per-tab last_sync from the server draft's timestamp so // later saves attach a matching last_sync and the server can reject @@ -970,7 +1017,7 @@ function createRuntime(session: Session): SessionRuntime { async function initRuntime(runtime: SessionRuntime, session: Session) { const { manager } = runtime - await manager.historyManager.init() + await manager.historyManager.init(session) manager.historyManager.setSessionId(session.id) // Restore linked files persisted for this session (live handles re-grant on send; // snapshots restore directly). Non-transient sessions persist immediately. @@ -985,7 +1032,9 @@ async function initRuntime(runtime: SessionRuntime, session: Session) { if (session.chatId) { manager.historyManager.setCurrentChatId(session.chatId) await manager.historyManager.tagChatWithSession(session.chatId, session.id) - await manager.loadPastChat(session.chatId) + // preserveQueue: restoring a session's own chat is not a conversation switch. + // A message queued while the restore ran is unsent user input waiting for it. + await manager.loadPastChat(session.chatId, { preserveQueue: true }) // loadPastChat only seeds the mask when the chat exists in history; a chatId // pointing at a chat not yet persisted (no turn saved) would leave it // undefined, and the Edits surface would then show every workspace draft. @@ -1005,7 +1054,11 @@ export function getOrCreateRuntime(session: Session): SessionRuntime { if (!runtime) { runtime = createRuntime(session) runtimes.set(session.id, runtime) - initRuntime(runtime, session).catch((e) => console.error('Failed to init session runtime', e)) + // The gate opens even when the restore fails: a session that cannot read its + // history is still usable, and a send that waits forever is not. + runtime.manager.setReadyGate( + initRuntime(runtime, session).catch((e) => console.error('Failed to init session runtime', e)) + ) } return runtime } @@ -1016,6 +1069,9 @@ export function disposeRuntime(sessionId: string) { runtime.manager.cancel('runtime disposed') runtime.manager.historyManager.close() runtimes.delete(sessionId) + // The sidebar reads the stored chat again rather than trust a read that + // predates everything this runtime saw. + peeks.delete(sessionId) } export function listRuntimes(): SessionRuntime[] { @@ -1049,7 +1105,13 @@ onRemoteTurnEnd((sessionId, chatId) => { async function applyRemoteTurnEnd(sessionId: string, chatId: string): Promise { const runtime = runtimes.get(sessionId) - if (!runtime) return + if (!runtime) { + // The driving tab may have rotated to a new chat; follow it, or the next + // read and a later runtime would open the old one. + setSessionChatId(sessionId, chatId) + refreshSessionChatPeek(sessionId) + return + } const m = runtime.manager // Two transient states get a short retry rather than a skip, because the // composer unlocks when this promise settles and a skip would unlock it on @@ -1091,7 +1153,9 @@ async function applyRemoteTurnEnd(sessionId: string, chatId: string): Promise { // open_page dispatches here to show a workspace page (Runs/Schedules) as a page // tab in the calling session's preview panel. Returns undefined when there is no // session so open_page can fall back to browser navigation. -setOpenPagePreviewHandler(({ sessionId: callerSessionId, href, label, newTab }) => { +setOpenPagePreviewHandler(({ sessionId: callerSessionId, href, label: pageLabel, newTab }) => { const sessionId = callerSessionId ?? sessionState.currentSessionId if (!sessionId) return undefined const session = sessionState.sessions.find((s) => s.id === sessionId) if (!session) return undefined const owner = getOrCreateRuntime(session).previewTabs + // A page opened on one item is that item's tab, and the report has to name what opened. + const pageItem = pageItemForLocation(href) + const label = pageItem + ? `the ${pageItemKindLabel(pageItem).toLowerCase()} ${promptSafe(pageItem.path)}` + : pageLabel // open() owns the whole decision — which tab already shows this page, whether the // requested view differs from what it shows, and whether a forced load is needed to // re-fire a drawer. Deciding any of that again here means two predicates for one @@ -1383,3 +1452,87 @@ export function getSessionChatStatus(runtime: SessionRuntime): SessionChatStatus if (last && (last.role === 'assistant' || last.role === 'tool')) return 'awaiting-user' return 'idle' } + +// --------------------------------------------------------------------------- +// Sessions without a runtime +// --------------------------------------------------------------------------- + +// A runtime costs a chat manager and its loaded transcript, and lives until its +// session is deleted, so the sidebar does not create one per listed session. It +// reads the session's one stored chat instead, once. Without a runtime nothing +// runs in this tab, so only two facts of that chat can matter: its length (for +// the unread count) and whether its last message failed. Every other status is +// a live one. +export interface SessionChatPeek { + status: 'error' | 'idle' + messageCount: number +} + +const peeks = new SvelteMap() +const peeksInFlight = new Set() +const peeksPendingRefresh = new Set() + +// Not gated on `detached`: a chat saved mid-wait stores its job undetached, and +// loadPastChat resumes polling every unfinished job either way. +function isLiveJob(j: ChatJob): boolean { + return ( + j.status === 'queued' || + j.status === 'running' || + j.status === 'suspended' || + j.status === 'scheduled' + ) +} + +function sessionById(sessionId: string): Session | undefined { + return sessionState.sessions.find((s) => s.id === sessionId) +} + +function refreshSessionChatPeek(sessionId: string): void { + peeks.delete(sessionId) + if (peeksInFlight.has(sessionId)) { + peeksPendingRefresh.add(sessionId) + return + } + const session = sessionById(sessionId) + if (session) void ensureSessionChatPeek(session) +} + +export function getSessionChatPeek(sessionId: string): SessionChatPeek | undefined { + return peeks.get(sessionId) +} + +/** Read the stored chat of a session that has no runtime. A chat with a detached + * job still running gets its runtime instead: only a runtime polls the job and + * resumes the conversation when it finishes. */ +export async function ensureSessionChatPeek(session: Session): Promise { + const id = session.id + const chatId = session.chatId + if (runtimes.has(id) || peeks.has(id) || peeksInFlight.has(id) || !chatId) return + const email = getCurrentUserEmail() + if (!email) return + peeksInFlight.add(id) + try { + const chat = await readStoredChat(chatId, email) + if (sessionById(id)?.chatId !== chatId) { + refreshSessionChatPeek(id) + return + } + if (!chat || runtimes.has(id)) return + if (chat.backgroundJobs?.some(isLiveJob)) { + getOrCreateRuntime(session) + return + } + const last = chat.displayMessages[chat.displayMessages.length - 1] + peeks.set(id, { + status: last?.role === 'user' && last.error ? 'error' : 'idle', + messageCount: chat.displayMessages.length + }) + } catch (e) { + console.error('Failed to read session chat', e) + } finally { + peeksInFlight.delete(id) + if (peeksPendingRefresh.delete(id)) { + refreshSessionChatPeek(id) + } + } +} diff --git a/frontend/src/lib/components/sessions/sessionState.svelte.ts b/frontend/src/lib/components/sessions/sessionState.svelte.ts index 3014e6d5e7..c5a2f28455 100644 --- a/frontend/src/lib/components/sessions/sessionState.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionState.svelte.ts @@ -82,7 +82,9 @@ export type SessionSummarySource = 'placeholder' | 'generated' | 'manual' export type Session = { id: string - name: string + // Per-browser `session-N` label that older sessions-page URLs carried; only read + // to resolve those links. + name?: string // Committed strictly at first user-message send. Undefined for drafts // that have never been sent — those scope by `pending_workspace_id` // instead and don't show the fork bar. @@ -1005,8 +1007,8 @@ if (BROWSER) { }) } -export function findSessionByName(name: string): Session | undefined { - return sessionState.sessions.find((s) => s.name === name) +export function sessionPageHref(id: string): string { + return `/sessions?session=${encodeURIComponent(id)}` } // Bumped to ask the active session's composer to re-focus even when @@ -1045,15 +1047,6 @@ export function findEmptyLandingSession(): Session | undefined { ) } -// Session names are a per-browser counter (`session-N`) that the sessions page puts in -// its URL, so a new or restored record takes the number after the highest in use. -function nextSessionNumber(sessions: Session[]): number { - const numbers = sessions - .map((s) => /^session-(\d+)$/.exec(s.name)?.[1]) - .map((n) => (n ? parseInt(n, 10) : 0)) - return (numbers.length ? Math.max(...numbers) : 0) + 1 -} - export function createSession(): Session { // Reuse an existing untouched draft from the active family rather than pile a // blank entry on every `+`, so several pending sessions can still be built up @@ -1074,7 +1067,6 @@ export function createSession(): Session { return reusable } sessionState.sessions = sessionState.sessions.filter((s) => !isDiscardableDraft(s)) - const next = nextSessionNumber(sessionState.sessions) // Start in the workspace you're in. The one exception: a root you can't // deploy to (locked, no bypass) steers to its dev, since a session there // couldn't edit anything. The picker lets you switch. @@ -1100,7 +1092,6 @@ export function createSession(): Session { const summary = `${adj.charAt(0).toUpperCase() + adj.slice(1)} session` const session: Session = { id: createLongHash(), - name: `session-${next}`, summary, summarySource: 'placeholder', pending_workspace_id: pending && pending.length > 0 ? pending : undefined, @@ -1459,11 +1450,10 @@ export async function importSessions(records: Session[], email: string): Promise try { const tx = db.transaction('sessions', 'readwrite') const existing = new Set((await tx.store.getAllKeys()).map(String)) - let next = nextSessionNumber([...(await tx.store.getAll()), ...sessionState.sessions]) const restoredAt = Date.now() for (const r of records) { if (existing.has(r.id) || deletedSessionIds.has(r.id)) continue - const record: Session = { ...r, name: `session-${next++}`, restoredAt } + const record: Session = { ...r, restoredAt } delete record.transient delete record.workspace_root_id ensureSessionRootId(record) diff --git a/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts b/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts index dddfe795f7..fe0ad84d78 100644 --- a/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts +++ b/frontend/src/lib/components/sessions/sessionStateIndexedDb.test.ts @@ -814,33 +814,28 @@ describe('sessionState IndexedDB persistence', () => { await vi.waitFor(() => expect(sessionState.sessions).toEqual([])) }) - // A restored backup must never replace what this browser has, come back after the - // user deleted it here, or take a name the sessions page already routes by. - it('importSessions adds only unknown, undeleted records under fresh names', async () => { + // A restored backup must never replace what this browser has, or come back after the + // user deleted it here. + it('importSessions adds only unknown, undeleted records', async () => { const user = freshUser() await login(user) - const local = session({ id: 'local', name: 'session-3', createdAt: 1, summary: 'mine' }) + const local = session({ id: 'local', createdAt: 1, summary: 'mine' }) await putSession(local) sessionState.sessions.push(local) deleteSession('local') await flush() - await putSession(session({ id: 'kept', name: 'session-5', createdAt: 2, summary: 'kept' })) + await putSession(session({ id: 'kept', createdAt: 2, summary: 'kept' })) const imported = await importSessions( [ - session({ id: 'local', name: 'session-1', createdAt: 1, summary: 'remote copy' }), - session({ id: 'kept', name: 'session-1', createdAt: 2, summary: 'remote copy' }), - session({ id: 'new', name: 'session-1', createdAt: 3, workspace_id: 'ws' }) + session({ id: 'local', createdAt: 1, summary: 'remote copy' }), + session({ id: 'kept', createdAt: 2, summary: 'remote copy' }), + session({ id: 'new', createdAt: 3, workspace_id: 'ws' }) ], user.email ) expect(imported).toEqual(['new']) - await vi.waitFor(() => - expect(sessionState.sessions.map((s) => [s.id, s.name])).toEqual([ - ['new', 'session-6'], - ['kept', 'session-5'] - ]) - ) + await vi.waitFor(() => expect(sessionState.sessions.map((s) => s.id)).toEqual(['new', 'kept'])) expect(sessionState.sessions.find((s) => s.id === 'kept')?.summary).toBe('kept') // The wrong user's name gets nothing written. diff --git a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts index a7a492b874..36a91ee453 100644 --- a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts @@ -4,6 +4,7 @@ import { workspaceStore } from '$lib/stores' import { createSession, selectSession, + sessionPageHref, sessionInCurrentFamily, sessionLastActivityAt, sessionState, @@ -81,7 +82,7 @@ function resumableSession(): Session | undefined { export async function enterSessionMode(opts?: { replace?: boolean }): Promise { const target = resumableSession() ?? createSession() selectSession(target.id) - await goto(`/sessions?session_name=${encodeURIComponent(target.name)}`, { + await goto(sessionPageHref(target.id), { replaceState: opts?.replace ?? false }) } @@ -197,7 +198,7 @@ async function openInSession( navRouteOffered = true } selectSession(session.id) - await goto(`/sessions?session_name=${encodeURIComponent(session.name)}`) + await goto(sessionPageHref(session.id)) } // Open an editor's own hand-off, running its `beforeOpen` (which persists the @@ -243,5 +244,5 @@ export async function startSessionWithPrompt( // An empty prompt has nothing to send; leave the composer focused instead. if (opts?.autoSend && prompt.trim()) setSessionAutoSend(session.id) selectSession(session.id) - await goto(`/sessions?session_name=${encodeURIComponent(session.name)}`) + await goto(sessionPageHref(session.id)) } diff --git a/frontend/src/lib/components/sessions/sessionSwitch.test.ts b/frontend/src/lib/components/sessions/sessionSwitch.test.ts index f03ba418b6..4eae32da6b 100644 --- a/frontend/src/lib/components/sessions/sessionSwitch.test.ts +++ b/frontend/src/lib/components/sessions/sessionSwitch.test.ts @@ -62,7 +62,7 @@ describe('enterSessionMode — restore is scoped to the active family', () => { try { await enterSessionMode() expect(sessionState.currentSessionId).toBe('sw-in-family') - expect(goto).toHaveBeenCalledWith('/sessions?session_name=session-911', { + expect(goto).toHaveBeenCalledWith('/sessions?session=sw-in-family', { replaceState: false }) } finally { @@ -82,7 +82,7 @@ describe('enterSessionMode — restore is scoped to the active family', () => { try { await enterSessionMode() expect(sessionState.currentSessionId).toBe('sw-local') - expect(goto).toHaveBeenCalledWith('/sessions?session_name=session-913', { + expect(goto).toHaveBeenCalledWith('/sessions?session=sw-local', { replaceState: false }) } finally { diff --git a/frontend/src/lib/components/sessions/sessionUnread.svelte.ts b/frontend/src/lib/components/sessions/sessionUnread.svelte.ts index 281c6571bc..26ce3f205b 100644 --- a/frontend/src/lib/components/sessions/sessionUnread.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionUnread.svelte.ts @@ -1,4 +1,4 @@ -import type { SessionRuntime } from './sessionRuntime.svelte' +import type { SessionChatPeek, SessionRuntime } from './sessionRuntime.svelte' import { sessionState, putSession } from './sessionState.svelte' // Per-session "last seen" watermark — the displayMessages count the last time @@ -20,11 +20,16 @@ export function markSessionSeen(sessionId: string, count: number) { void putSession(s) } -// Number of unread messages for a session. Undefined / unloaded runtime -// returns 0 — until messages are hydrated we don't know what's new. -export function unreadCountFor(sessionId: string, runtime: SessionRuntime | undefined): number { - if (!runtime) return 0 +// Number of unread messages for a session, from its runtime when it has one, else +// from the sidebar's read of its stored chat. Neither loaded returns 0 — until +// messages are read we don't know what's new. +export function unreadCountFor( + sessionId: string, + runtime: SessionRuntime | undefined, + peek: SessionChatPeek | undefined +): number { + const total = runtime ? runtime.manager.displayMessages.length : peek?.messageCount + if (total === undefined) return 0 const seen = sessionState.sessions.find((x) => x.id === sessionId)?.lastSeenCount ?? 0 - const total = runtime.manager.displayMessages.length return Math.max(0, total - seen) } diff --git a/frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts b/frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts index 7e89d3b945..64f7f7e6c2 100644 --- a/frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts +++ b/frontend/src/lib/components/sessions/useUserDraftSync.svelte.ts @@ -94,7 +94,10 @@ export function useUserDraftSync(opts: UserDraftSyncOptions): void // re-apply only advances the sig, never reverts an edit or fires a save. if (sig === lastInboundSig) return lastInboundSig = sig - codec.applyDraftToStore(incoming) + // Copies both ways: a store sharing proxies with the draft makes every local + // edit re-fire this effect as if a draft came in, reassigning the store on + // each keystroke. + codec.applyDraftToStore($state.snapshot(incoming) as Draft) }) }) @@ -125,7 +128,9 @@ export function useUserDraftSync(opts: UserDraftSyncOptions): void untrack(() => { const current = UserDraft.get(codec.itemKind, path, { workspace }) if (current && codec.sig(current) === sig) return - const toSave = codec.storeToDraft(current) ?? draft + const toSave = $state.snapshot(codec.storeToDraft(current) ?? draft) as Draft + // Our own write comes back through the handle; the inbound side must skip it. + lastInboundSig = codec.sig(toSave) UserDraft.save(codec.itemKind, path, toSave, { workspace }) }) } diff --git a/frontend/src/lib/components/settings/CreateToken.svelte b/frontend/src/lib/components/settings/CreateToken.svelte index 20e4891189..8ee5391443 100644 --- a/frontend/src/lib/components/settings/CreateToken.svelte +++ b/frontend/src/lib/components/settings/CreateToken.svelte @@ -1,15 +1,22 @@ diff --git a/frontend/src/lib/components/triggers/AddTriggersButton.svelte b/frontend/src/lib/components/triggers/AddTriggersButton.svelte index 4027bf9081..17387d13e0 100644 --- a/frontend/src/lib/components/triggers/AddTriggersButton.svelte +++ b/frontend/src/lib/components/triggers/AddTriggersButton.svelte @@ -8,7 +8,9 @@ import { isCloudHosted } from '$lib/cloud' import { CloudOff } from 'lucide-svelte' import { isServiceAvailable } from './native/utils' - import { workspaceStore } from '$lib/stores' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { setDropdownWidthToButtonWidth?: boolean @@ -41,15 +43,15 @@ let githubAvailable = $state(false) async function setNextcloudState() { - nextcloudAvailable = await isServiceAvailable('nextcloud', $workspaceStore!) + nextcloudAvailable = await isServiceAvailable('nextcloud', $operatingWorkspace!) } async function setGoogleState() { - googleAvailable = await isServiceAvailable('google', $workspaceStore!) + googleAvailable = await isServiceAvailable('google', $operatingWorkspace!) } async function setGithubState() { - githubAvailable = await isServiceAvailable('github', $workspaceStore!) + githubAvailable = await isServiceAvailable('github', $operatingWorkspace!) } setNextcloudState() diff --git a/frontend/src/lib/components/triggers/CaptureSection.svelte b/frontend/src/lib/components/triggers/CaptureSection.svelte index eebfe3c1a7..5b1980e1b4 100644 --- a/frontend/src/lib/components/triggers/CaptureSection.svelte +++ b/frontend/src/lib/components/triggers/CaptureSection.svelte @@ -26,7 +26,6 @@ import { Pane, Splitpanes } from 'svelte-splitpanes' import { Popover } from '$lib/components/meltComponents' import { CaptureService } from '$lib/gen' - import { workspaceStore } from '$lib/stores' import { isObject, sendUserToast } from '$lib/utils' import { triggerIconMap } from './utils' import { formatDateShort } from '$lib/utils' @@ -37,6 +36,9 @@ import { twMerge } from 'tailwind-merge' import { FlaskConical } from 'lucide-svelte' import Alert from '../common/alert/Alert.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { disabled?: boolean | undefined @@ -139,7 +141,7 @@ if (!captureInfo.path) return const captures = await CaptureService.listCaptures({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, runnableKind: captureInfo.isFlow ? 'flow' : 'script', path: captureInfo.path, triggerKind: captureType, @@ -167,7 +169,7 @@ try { const captures = await CaptureService.listCaptures({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, runnableKind: captureInfo.isFlow ? 'flow' : 'script', path: captureInfo.path, triggerKind: captureType, @@ -213,7 +215,7 @@ try { isLoadingBigPayload = true const fullCapture = await CaptureService.getCapture({ - workspace: $workspaceStore!, + workspace: $operatingWorkspace!, id: capture.id }) diff --git a/frontend/src/lib/components/triggers/CaptureTable.svelte b/frontend/src/lib/components/triggers/CaptureTable.svelte index 2d20ac02e1..ea0cf5e256 100644 --- a/frontend/src/lib/components/triggers/CaptureTable.svelte +++ b/frontend/src/lib/components/triggers/CaptureTable.svelte @@ -10,7 +10,6 @@ import { createEventDispatcher, onDestroy, untrack } from 'svelte' import { type TriggerKind } from '../triggers' import { CaptureService } from '$lib/gen' - import { workspaceStore } from '$lib/stores' import { type CaptureTriggerKind } from '$lib/gen' import CaptureButton from '$lib/components/triggers/CaptureButton.svelte' import InfiniteList from '../InfiniteList.svelte' @@ -19,6 +18,9 @@ import type { Capture } from '$lib/gen' import { AwsIcon, MqttIcon, AmqpIcon } from '../icons' import GoogleCloudIcon from '../icons/GoogleCloudIcon.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + + const operatingWorkspace = useOperatingWorkspace() interface Props { path: string @@ -33,9 +35,8 @@ limitPayloadSize?: boolean noBorder?: boolean captureActiveIndicator?: boolean | undefined - // Workspace to scope capture list/get/delete calls to. Defaults to the nav - // `$workspaceStore`; an AI-session live editor passes the session's acting - // workspace (a fork) so captures hit the right workspace. + // Workspace to scope capture list/get/delete calls to. Defaults to the operating + // workspace (see `useOperatingWorkspace`). workspace?: string } @@ -55,7 +56,7 @@ workspace = undefined }: Props = $props() - let ws = $derived(workspace ?? $workspaceStore) + let ws = $derived(workspace ?? $operatingWorkspace) let selected: number | undefined = $state(undefined) let testKind: 'preprocessor' | 'main' = $state('main') diff --git a/frontend/src/lib/components/triggers/CaptureWrapper.svelte b/frontend/src/lib/components/triggers/CaptureWrapper.svelte index bfffbfc56b..9b616454fd 100644 --- a/frontend/src/lib/components/triggers/CaptureWrapper.svelte +++ b/frontend/src/lib/components/triggers/CaptureWrapper.svelte @@ -1,5 +1,4 @@ + +{#snippet rowTitle(row: TriggerW & { marked?: any }, hasDraft: boolean | undefined)} + {@const marked = ['http', 'websocket', 'kafka', 'nats', 'email'].includes(triggerKind) + ? row.marked + : undefined} +
    + {#if marked} + + {@html marked} + + {:else if triggerKind === 'http'} + {#if row.summary} + {row.summary} + {:else} + {row.http_method!.toUpperCase()} + /{isCloudHosted() || row.workspaced_route || globalHttpWorkspacedRoute + ? row.workspace_id + '/' + row.route_path + : row.route_path} + {/if} + {:else if triggerKind === 'websocket'} + {websocketUrl(row.url!)} + {:else if triggerKind === 'kafka'} + {row.kafka_resource_path} - {row.topics!.join(', ')} + {:else if triggerKind === 'nats'} + {row.nats_resource_path} - {row.subjects!.join(', ')} + {:else if triggerKind === 'email'} + {emailAddress(row)} + {:else if triggerKind === 'gcp'} + {row.path} - {row.topic_id} + {:else if triggerKind === 'azure'} + {row.path} - {row.topic_name + ? row.topic_name + : (row.scope_resource_id?.split('/')?.pop() ?? '')} ({row.azure_mode}, {row.subscription_name}) + {:else} + {row.path} + {/if}{hasDraft ? '*' : ''} +
    + {#if ['http', 'websocket', 'kafka', 'nats', 'email'].includes(triggerKind)} +
    {row.path}
    + {:else if triggerKind === 'postgres'} +
    + {row.postgres_resource_path} +
    + {/if} +
    + {#if triggerKind === 'http' && row.static_asset_config} + file: {row.static_asset_config.s3} + {:else} + runnable: {row.script_path} + {/if} +
    +{/snippet} + +{#snippet statusDot(row: TriggerW)} + {@const status = config.status} + {#if status} + {@const ping = status.pings + ? row.last_server_ping + ? new Date(row.last_server_ping) + : undefined + : new Date()} + {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} + {@const effectiveMode = row.draft_only ? 'disabled' : row.mode} + {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} +
    + {#if (enabled && (!pinging || row.error)) || (!enabled && row.error) || (enabled && !row.server_id)} + + + + + + {#snippet text()} +
    + {#if enabled} + {#if !row.server_id} + {status.starting} + {:else} + {status.notConnected}{row.error ? ': ' + row.error : ''} + {/if} + {:else} + {status.disabled}: {row.error} + {/if} +
    + {/snippet} +
    + {:else if enabled} + + + + + {#snippet text()} +
    + {status.connected}{status.shuttingDown && !row.server_id ? ' (shutting down...)' : ''} +
    + {/snippet} +
    + {/if} +
    + {/if} +{/snippet} + + { + isDeleting = false + pendingDelete = undefined + }} + on:confirmed={confirmPendingDelete} +> +
    + Are you sure you want to remove this trigger? + {#if pendingDelete && triggerKind === 'postgres'} + + + {:else if pendingDelete && triggerKind === 'gcp'} + + {:else if pendingDelete?.subscription_name && triggerKind === 'azure'} + + {/if} +
    +
    + + +{#await EDITORS[triggerKind]() then Editor} + +{/await} +{#if triggerKind === 'http'} + {#await import('./http/RoutesGenerator.svelte') then Generator} + + {/await} + {#await import('./http/OpenAPISpecGenerator.svelte') then Generator} + + {/await} +{/if} + + (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} +/> + +{#if operatorBlocked} + +{:else} + + +
    + {#if triggerKind === 'http'} + + + {/if} + {#if canCreate} + + {/if} +
    +
    + + {#if config.cloudDisabled && isCloudHosted()} + + {config.title} are disabled in the multi-tenant cloud. + +
    + {/if} +
    +
    + +
    +
    Filter by path of
    + + {#snippet children({ item })} + + + {/snippet} + +
    + + +
    + {#if operatingUser.current?.is_super_admin && operatingUser.current.username.includes('@')} + + {:else if isAdmin} + + {/if} +
    +
    + {#if loading} + {#each new Array(6) as _} + + {/each} + {:else if !triggers?.length} + editor?.openNew(false), + aiId: config.empty.aiId, + aiDescription: config.empty.aiDescription + } + : undefined} + /> + {:else if items?.length} +
    + {#each items.slice(0, nbDisplayed) as row (row.path)} + {@const { + path, + edited_by, + edited_at, + script_path, + is_flow, + extra_perms, + canWrite, + mode, + retry, + error_handler_path, + error_handler_args, + labels, + draft_only, + is_draft + } = row} + {@const hasDraft = + getLocalDraftHint($operatingWorkspace, config.draftKind, path) ?? is_draft} + {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} + {@const effectiveMode = draft_only ? 'disabled' : mode} + {@const live = isLive(row)} + {@const copy = copyUrl(row)} + +
    +
    + + + { + if (hosted) e.preventDefault() + openEdit(path, is_flow) + }} + class="min-w-0 grow hover:underline decoration-gray-400" + > + {@render rowTitle(row, hasDraft)} + + + + + {#if live} + {@render statusDot(row)} + {/if} + +
    + + {#if live} + onToggleMode(path, newMode)} + triggerMode={effectiveMode} + includeModalConfig={{ + triggerPath: path, + triggerKind, + runnableConfig: { + path: script_path, + kind: is_flow ? 'flow' : 'script', + retry, + errorHandlerPath: error_handler_path, + errorHandlerArgs: error_handler_args + } + }} + {canWrite} + hideToggleLabels + hideDropdown + /> + {/if} +
    + +
    + {#if copy} + + {/if} + + openLink(href) + }, + ...(canWrite && !draft_only && mode !== 'suspended' + ? [ + { + displayName: 'Suspend job execution', + icon: Pause, + action: () => { + onToggleMode(path, 'suspended') + } + } + ] + : []), + { + displayName: canWrite ? 'Edit' : 'View', + icon: canWrite ? Pen : Eye, + action: () => openEdit(path, is_flow) + }, + ...(isDeployable('trigger', path, deployUiSettings) + ? [ + { + displayName: 'Deploy to prod/staging', + icon: FileUp, + action: () => { + deploymentDrawer?.openDrawer(path, 'trigger', { + triggers: { kind: config.deployKind } + }) + } + } + ] + : []), + { + displayName: 'Audit logs', + icon: Eye, + href: `${base}/audit_logs?resource=${path}` + }, + { + displayName: 'Permissions', + icon: Shield, + action: () => { + shareModal?.openDrawer(path, config.shareKind as any) + } + }, + { + displayName: 'Delete', + type: 'delete', + icon: Trash, + disabled: !canWrite || (config.adminOnly && !isAdmin), + action: () => deleteTrigger(row) + } + ]} + /> +
    +
    +
    +
    + {#if edited_by}
    edited by {edited_by}
    {/if} +
    {edited_by ? 'at ' : ''}{displayDate(edited_at)}
    +
    +
    +
    + {/each} +
    + {:else} + + {/if} +
    + {#if items && items?.length > 15 && nbDisplayed < items.length} + {nbDisplayed} items out of {items.length} + + {/if} +
    +{/if} + + { + loadTriggers() + }} +/> diff --git a/frontend/src/lib/components/triggers/TriggerSuspendedJobsModal.svelte b/frontend/src/lib/components/triggers/TriggerSuspendedJobsModal.svelte index 98bff3c722..cc00e398ec 100644 --- a/frontend/src/lib/components/triggers/TriggerSuspendedJobsModal.svelte +++ b/frontend/src/lib/components/triggers/TriggerSuspendedJobsModal.svelte @@ -19,8 +19,6 @@ TriggerMode } from '$lib/gen/types.gen' import Button from '../common/button/Button.svelte' - import { workspaceStore } from '$lib/stores' - import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace' import { JobService, TriggerService } from '$lib/gen' import { sendUserToast } from '$lib/toast' import Cell from '$lib/components/table/Cell.svelte' @@ -39,6 +37,7 @@ errorHandlerArgs, slackErrorHandlerHubPathEnding } from '../ErrorOrRecoveryHandler.svelte' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' type Props = { triggerPath: string @@ -49,8 +48,8 @@ } let { triggerKind, triggerPath, onToggleMode, hasChanged, runnableConfig }: Props = $props() - const triggerWs = getTriggerWorkspace() - const wsId = $derived(triggerWs?.() ?? $workspaceStore) + const operatingWorkspace = useOperatingWorkspace() + const wsId = $derived($operatingWorkspace) let shouldShowModal = $state(false) let queuedJobs = $state([]) diff --git a/frontend/src/lib/components/triggers/TriggerTokens.svelte b/frontend/src/lib/components/triggers/TriggerTokens.svelte index 044b89022a..e480480131 100644 --- a/frontend/src/lib/components/triggers/TriggerTokens.svelte +++ b/frontend/src/lib/components/triggers/TriggerTokens.svelte @@ -2,13 +2,13 @@ import { run } from 'svelte/legacy' import { FlowService, ScriptService, UserService, type TruncatedToken } from '$lib/gen' - import { userStore, workspaceStore } from '$lib/stores' - import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace' + import { userStore } from '$lib/stores' import { getContext } from 'svelte' import { Skeleton } from '../common' import Label from '../Label.svelte' import type { TriggerContext } from '../triggers' import { capitalize } from '$lib/utils' + import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' interface Props { isFlow: boolean @@ -17,8 +17,8 @@ } let { isFlow, path, labelPrefix }: Props = $props() - const triggerWs = getTriggerWorkspace() - const wsId = $derived(triggerWs?.() ?? $workspaceStore) + const operatingWorkspace = useOperatingWorkspace() + const wsId = $derived($operatingWorkspace) const { triggersCount } = getContext('TriggerContext') diff --git a/frontend/src/lib/components/triggers/TriggersEditor.svelte b/frontend/src/lib/components/triggers/TriggersEditor.svelte index 4b7733ac33..ae32345907 100644 --- a/frontend/src/lib/components/triggers/TriggersEditor.svelte +++ b/frontend/src/lib/components/triggers/TriggersEditor.svelte @@ -1,5 +1,5 @@ {#if captureInfo} diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorConfigSection.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorConfigSection.svelte index 3a60d3be71..4a44e01b30 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorConfigSection.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorConfigSection.svelte @@ -2,8 +2,6 @@ import { Alert } from '$lib/components/common' import Required from '$lib/components/Required.svelte' import Section from '$lib/components/Section.svelte' - import { userStore, workspaceStore } from '$lib/stores' - import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace' // import { page } from '$app/state' import { getEmailAddress, getEmailDomain } from './utils' import { isCloudHosted } from '$lib/cloud' @@ -12,6 +10,10 @@ import { untrack } from 'svelte' import { EmailTriggerService } from '$lib/gen' import ClipboardPanel from '$lib/components/details/ClipboardPanel.svelte' + import { + useOperatingUser, + useOperatingWorkspace + } from '$lib/components/operatingWorkspace.svelte' interface Props { initialTriggerPath?: string | undefined dirtyLocalPart?: boolean @@ -35,8 +37,10 @@ isDraftOnly = true, showTestingBadge = false }: Props = $props() - const triggerWs = getTriggerWorkspace() - const wsId = $derived(triggerWs?.() ?? $workspaceStore) + const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) + const wsId = $derived($operatingWorkspace) let validateTimeout: number | undefined = undefined @@ -95,7 +99,7 @@ local_part === undefined && (local_part = '') }) - let userIsAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin) + let userIsAdmin = $derived(actingUser?.is_admin || actingUser?.is_super_admin) let userCanEditConfig = $derived(userIsAdmin || isDraftOnly) // User can edit config if they are admin or if the trigger is a draft which will not be saved diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte index d2133cf865..fe472cb8c4 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerEditorInner.svelte @@ -17,8 +17,7 @@ type Retry, type TriggerMode } from '$lib/gen' - import { usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' - import { getTriggerWorkspace } from '$lib/components/triggers/triggerWorkspace' + import { usedTriggerKinds } from '$lib/stores' import { canWrite, capitalize, emptyString, sendUserToast } from '$lib/utils' import Section from '$lib/components/Section.svelte' import { Loader2 } from 'lucide-svelte' @@ -38,9 +37,16 @@ import LocalDraftBanner from '$lib/components/LocalDraftBanner.svelte' import TriggerSuspendedJobsAlert from '../TriggerSuspendedJobsAlert.svelte' import TriggerSuspendedJobsModal from '../TriggerSuspendedJobsModal.svelte' + import { + useOperatingUser, + useOperatingWorkspace, + useOperatingWorkspaceHref + } from '$lib/components/operatingWorkspace.svelte' let { useDrawer = true, + inline = false, + onClose = undefined, hideTarget = false, description = undefined, isEditor = false, @@ -55,8 +61,11 @@ trigger = undefined, customSaveBehavior = undefined } = $props() - const triggerWs = getTriggerWorkspace() - const wsId = $derived(triggerWs?.() ?? $workspaceStore) + const operatingWorkspace = useOperatingWorkspace() + const operatingUser = useOperatingUser() + const actingUser = $derived(operatingUser.current) + const operatingHref = useOperatingWorkspaceHref() + const wsId = $derived($operatingWorkspace) // Form data state let initialPath = $state('') @@ -75,7 +84,13 @@ let workspaced_local_part = $state(false) let drawerLoading = $state(true) let showLoader = $state(false) - let can_write = $state(true) + let permsPath = $state(undefined) + let permsForWrite = $state | undefined>(undefined) + // The acting user in the operating workspace arrives asynchronously, and an unknown user + // refuses — so the editor stays read-only until the lookup lands, which is the safe answer. + const can_write = $derived( + permsPath === undefined ? true : canWrite(permsPath, permsForWrite ?? {}, actingUser) + ) let extraPerms = $state | undefined>(undefined) let error_handler_path: string | undefined = $state() let error_handler_args: Record = $state({}) @@ -95,7 +110,7 @@ let originalConfig = $state(undefined) let hasChanged = $derived(!deepEqual(getEmailTriggerConfig(), originalConfig ?? {})) - const isAdmin = $derived($userStore?.is_admin || $userStore?.is_super_admin) + const isAdmin = $derived(actingUser?.is_admin || actingUser?.is_super_admin) const emailConfig = $derived.by(getEmailTriggerConfig) const draftSync = useTriggerDraftSync({ @@ -127,6 +142,8 @@ defaultConfig?: Partial, fixedScriptPath_?: string ) { + // A `whoami` that failed earlier would otherwise pin this workspace to "unknown user". + operatingUser.forgetFailures() drawerLoading = true let loader = setTimeout(() => { showLoader = true @@ -206,6 +223,8 @@ } function loadTriggerConfig(cfg?: Partial): void { + // The loaded trigger says what it runs; an opener's `isFlow` is only its guess. + if (cfg?.is_flow !== undefined) itemKind = cfg.is_flow ? 'flow' : 'script' script_path = cfg?.script_path ?? '' initialScriptPath = cfg?.script_path ?? '' is_flow = cfg?.is_flow ?? false @@ -213,7 +232,8 @@ local_part = cfg?.local_part ?? '' workspaced_local_part = cfg?.workspaced_local_part ?? false extraPerms = cfg?.extra_perms ?? undefined - can_write = canWrite(path, cfg?.extra_perms ?? {}, $userStore) + permsPath = path + permsForWrite = cfg?.extra_perms ?? {} error_handler_path = cfg?.error_handler_path error_handler_args = cfg?.error_handler_args ?? {} retry = cfg?.retry @@ -265,7 +285,7 @@ saveCfg, edit, wsId!, - !!$userStore?.is_admin || !!$userStore?.is_super_admin, + !!actingUser?.is_admin || !!actingUser?.is_super_admin, usedTriggerKinds ) if (isSaved) { @@ -405,7 +425,7 @@ bind:scriptPath={script_path} {initialScriptPath} canWrite={can_write} - isOperator={!!$userStore?.operator} + isOperator={!!actingUser?.operator} promptClass="text-xs mt-3 mb-1 text-primary" > {#snippet createButton()} @@ -414,7 +434,9 @@ btnClasses="ml-4" variant="accent" size="xs" - href={itemKind === 'flow' ? '/flows/add?hub=72' : '/scripts/add?hub=hub%2F19813'} + href={operatingHref( + itemKind === 'flow' ? '/flows/add?hub=72' : '/scripts/add?hub=hub%2F19813' + )} target="_blank">Create from template {/if} @@ -486,36 +508,44 @@ {/if} {/snippet} -{#if useDrawer} +{#snippet drawerBody()} + (inline ? onClose?.() : drawer?.closeDrawer())} + > + {#snippet actions()} + {@render saveButton()} + {/snippet} + {#snippet banner()} + draftSync.deployed} + reserveSpace={draftSync.hasBaseline} + getCurrent={() => draftSync.current} + onDiscard={() => draftSync.resetToDeployed(initialPath)} + disabled={!can_write} + /> + {/snippet} + {@render config()} + +{/snippet} + +{#if useDrawer && inline} + {@render drawerBody()} +{:else if useDrawer} clearPageDrawerAnchor(TRIGGER_PAGES.email.path)} > - drawer?.closeDrawer()} - > - {#snippet actions()} - {@render saveButton()} - {/snippet} - {#snippet banner()} - draftSync.deployed} - reserveSpace={draftSync.hasBaseline} - getCurrent={() => draftSync.current} - onDiscard={() => draftSync.resetToDeployed(initialPath)} - disabled={!can_write} - /> - {/snippet} - {@render config()} - + {@render drawerBody()} {:else}
    diff --git a/frontend/src/lib/components/triggers/email/EmailTriggerPanel.svelte b/frontend/src/lib/components/triggers/email/EmailTriggerPanel.svelte index 12ec403aeb..7eac6a8971 100644 --- a/frontend/src/lib/components/triggers/email/EmailTriggerPanel.svelte +++ b/frontend/src/lib/components/triggers/email/EmailTriggerPanel.svelte @@ -1,11 +1,15 @@
    {#if resourceId} -
    +
    Selected: {resourceName || resourceId}
    {:else} -
    +
    No file selected
    @@ -198,21 +196,27 @@
    + {:else} + + {/if} +
    + {/if} + + (showCreateButtons = v)} /> + + + + { + loadVariables() + }} + /> + +
    + + + + {#snippet extra()} + + Contextual variables are passed as environment variables when running a script and + depends on the execution context. + + {/snippet} + + + {#if tab == 'workspace'} + + {/if} +
    + + {#if tab == 'workspace'} +
    + {#if !filteredItems} + + {#each new Array(3) as _} + + {/each} + {:else if filteredItems.length == 0} + {#if activeFilters} + + {:else} + variableEditor?.initNew(), + aiId: 'variables-empty-add-variable', + aiDescription: 'Add variable' + } + : undefined} + /> + {/if} + {:else} + + +
    + + Path + Value + Description + Status + Actions + + + + {#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft }} + {@const hasDraft = + getLocalDraftHint($operatingWorkspace, 'variable', path) ?? is_draft} + + + + + +
    + { + handledHash = `#${path}` + editVariable(path) + }} + href="#{path}" + > + {path}{hasDraft ? '*' : ''} + + + {#if labels?.length} + {#each labels as label} + { + const arr = (filters.val.label ?? '').split(',').filter(Boolean) + const idx = arr.indexOf(label) + if (idx >= 0) arr.splice(idx, 1) + else arr.push(label) + const newFilters = { ...filters.val } + if (arr.length) newFilters.label = arr.join(',') + else delete newFilters.label + filters.val = newFilters + }}>{label} + {/each} + {/if} + +
    +
    + + +
    + {#if value} + {truncate(value, 20)} + {:else} + ∗∗∗∗ + {/if} +
    + {#if is_secret} + + + {#snippet text()} + This item is secret + {/snippet} + + {/if} +
    +
    + + {truncate(description ?? '', 50)} + + + +
    + {#if is_linked} + + + {#snippet text()} +
    + This variable is linked with a resource of the same path. They are + deleted and renamed together. +
    + {/snippet} +
    + {/if} + {#if account} + + + {#snippet text()} +
    + This OAuth token will be kept up-to-date in the background by + Windmill using its refresh token +
    + {/snippet} +
    + {/if} + + {#if is_refreshed} +
    + {#if refresh_error} + + +
    + + +
    + + {#snippet text()} +
    + Latest exchange of the refresh token did not succeed. Error: {refresh_error} +
    + {/snippet} +
    + {:else if is_expired} + + + {#snippet text()} +
    + The access_token is expired, it will get renewed the next time + this variable is fetched or you can request is to be refreshed + in the dropdown on the right. +
    + {/snippet} +
    + {:else} + + + {#snippet text()} +
    + The variable was connected through OAuth and the token is not + expired. +
    + {/snippet} +
    + {/if} +
    + {/if} +
    +
    + + { + let owner = isOwner(path, operatingUser.current, $operatingWorkspace) + return [ + { + displayName: 'Edit', + icon: Pen, + action: () => editVariable(path), + disabled: !canWrite || !showCreateButtons + }, + { + displayName: 'Delete', + icon: Trash, + type: 'delete', + action: (event) => { + if (event['shiftKey']) { + deleteVariable(path, account) + } else { + deleteIsLinked = is_linked ?? false + deleteConfirmedCallback = () => { + deleteVariable(path, account) + } + } + }, + disabled: !owner || !showCreateButtons + }, + ...(!ws_specific && + isDeployable(is_secret ? 'secret' : 'variable', path, deployUiSettings) + ? [ + { + displayName: 'Deploy to prod/staging', + icon: FileUp, + action: () => { + deploymentDrawer?.openDrawer(path, 'variable') + } + } + ] + : []), + { + displayName: 'Permissions', + action: () => { + shareModal?.openDrawer(path, 'variable') + }, + icon: Shield + }, + ...(account != undefined + ? [ + { + displayName: 'Refresh token', + icon: RefreshCw, + action: async () => { + await OauthService.refreshToken({ + workspace: $operatingWorkspace ?? '', + id: account ?? 0, + requestBody: { + path + } + }) + sendUserToast('Token refreshed') + loadVariables() + } + } + ] + : []) + ] + }} + /> + +
    + {/each} + + + {/if} + + {:else if tab == 'contextual'} +
    + {#if loading.contextual} + + {#each new Array(8) as _} + + {/each} + {:else} + + {#if contextualVariables.filter((x) => x.is_custom).length === 0} + contextualVariableEditor?.initNew(), + aiId: 'variables-empty-add-contextual-variable', + aiDescription: 'Add contextual variable' + } + : undefined} + /> + {:else} + x.is_custom)} + keys={['name', 'value']} + getRowActions={operatingUser.current?.is_admin || + operatingUser.current?.is_super_admin + ? (row) => { + return [ + { + displayName: 'Edit', + action: () => contextualVariableEditor?.editVariable(row.name, row.value) + }, + { + displayName: 'Delete', + type: 'delete', + action: () => { + deleteContextualVariable(row) + } + } + ] + } + : undefined} + /> + {/if} + + !x.is_custom)} + keys={['name', 'value', 'description']} + /> + {/if} +
    + {/if} + + +{/if} + + { + deleteConfirmedCallback = undefined + }} + on:confirmed={() => { + if (deleteConfirmedCallback) { + deleteConfirmedCallback() + } + deleteConfirmedCallback = undefined + }} +> +
    + Are you sure you want to remove this variable? + {#if deleteIsLinked} + + This variable is linked with a resource of the same path. The linked resource will also be + deleted. + + {/if} + +
    + You can press + SHIFT + while removing a variable to bypass confirmation. +
    +
    +
    +
    diff --git a/frontend/src/lib/components/variables/variablesFilter.ts b/frontend/src/lib/components/variables/variablesFilter.ts index 5f7ce1e51a..d609182da0 100644 --- a/frontend/src/lib/components/variables/variablesFilter.ts +++ b/frontend/src/lib/components/variables/variablesFilter.ts @@ -64,15 +64,14 @@ export function buildVariablesFilterSchema({ icon: Tag, description: 'Filter by label (comma-separated for multiple)' }, - ...(showUserFoldersFilter - ? { - user_folders_only: { - type: 'boolean' as const, - label: userFoldersLabel || 'User folders only', - icon: Users, - description: 'Show only variables in user folders' - } - } - : {}) + // The key stays for every role, hidden where the filter does not apply: the list's URL sync + // reads the key set once, before the acting user that gates it may have loaded. + user_folders_only: { + type: 'boolean' as const, + label: userFoldersLabel || 'User folders only', + icon: Users, + description: 'Show only variables in user folders', + hidden: !showUserFoldersFilter + } } satisfies FilterSchemaRec } diff --git a/frontend/src/lib/components/wizards/AppPicker.svelte b/frontend/src/lib/components/wizards/AppPicker.svelte index e6ee5b3f25..17f384c71d 100644 --- a/frontend/src/lib/components/wizards/AppPicker.svelte +++ b/frontend/src/lib/components/wizards/AppPicker.svelte @@ -1,18 +1,25 @@ + +{#if !hideTrigger} + + + Role + + admin is the connection the data table used before roles, so it owns every + existing object and cannot be removed. Every other role is a login defined for + the whole instance, with only the privileges granted to it under Access. + + + + Tenants + + Users, groups and folders allowed to connect as this role. Workspace admins can + use every role. + + + + Default + + The role a job gets when it names none — no `-- role` annotation, no `?role=` in + the reference. Callers still have to be one of its tenants. + + + + + + + {#each roles as role (roleKey(role))} + {@const isAdmin = role.id === ADMIN_DATATABLE_ROLE} + + +
    + {role.name ?? role.id} + {#if !role.name} + + no longer defined on this instance + + {:else if role.id === undefined} + + {#if $superadmin} +
    + Create it on the instance to use it here. + +
    + {:else} + Only a superadmin can create it on the instance. + {/if} +
    + {/if} +
    +
    + + item.group} + disabled={!editable} + placeholder="Nobody — add users, groups or folders" + /> + + +
    + { + if (role.id !== undefined) defaultRoleId = role.id + }} + /> +
    +
    + + {#if editable && !isAdmin} + removeRole(role)} /> + {/if} + +
    + {/each} + {#if editable} + + +
    +
    + + {/if} + + + {#if info?.supported && !hasUnsavedChanges} +
    + +
    + {/if} + {/if} + + {#snippet actions()} + {#if editable} + + {/if} + {/snippet} + + + +{#if $superadmin} + +{/if} diff --git a/frontend/src/lib/components/workspaceSettings/DataTableRolesSection.svelte b/frontend/src/lib/components/workspaceSettings/DataTableRolesSection.svelte new file mode 100644 index 0000000000..b863dac06b --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/DataTableRolesSection.svelte @@ -0,0 +1,211 @@ + + + + +
    + {#if loadError} + {loadError} + {:else} + + +
    + Name + Login + + + + + {#if loading} + + Loading… + + {:else if roles.length === 0} + + + No data table role yet. Every job connects as + admin. + + + {/if} + {#each roles as role (role.id)} + + + {#if renaming?.id === role.id} +
    + + + (renaming = undefined)} /> +
    + {:else} +
    + {role.name} +
    + {/if} +
    + +
    + + run( + () => + SettingService.updateInstanceDatatableRole({ + id: role.id, + requestBody: { enabled: e.detail } + }), + e.detail ? `Enabled ${role.name}` : `Disabled ${role.name}` + )} + /> + {#if !role.enabled} + Cannot log in + {/if} +
    +
    + + remove(role)} /> + +
    + {/each} + + +
    + + +
    +
    +
    + + + {/if} + diff --git a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte index bb97e434f6..ebc4bd8a7f 100644 --- a/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/DataTableSettings.svelte @@ -12,6 +12,10 @@ resource_type: 'postgresql' | 'instance' resource_path?: string | undefined } + /** Set on a fork's entry: it names the workspace whose data table governs this one, and + * owns no database of its own. Read-only here — only forking writes it, and the server + * carries it across a save rather than taking it from this form. */ + reference?: { workspace_id: string; datatable: string } }[] } @@ -24,7 +28,10 @@ s.dataTables.push({ id: randomUUID(), name, - ...rest + ...rest, + // A pointer entry owns no database. The row renders read-only in that case, so this + // placeholder is never shown or sent. + database: rest.database ?? { resource_type: 'instance' } }) } } @@ -38,6 +45,12 @@ const database = dataTable.database if (dataTable.name in s.datatables) throw 'Settings contain duplicate dataTable name: ' + dataTable.name + // A pointer owns no database, so it has nothing to validate and nothing to send: the + // server keeps the stored reference whatever this payload says. + if (dataTable.reference) { + s.datatables[dataTable.name] = {} + continue + } if (!database.resource_path) throw 'No resource selected for ' + dataTable.name if (database.resource_type === 'instance' && database.resource_path === 'windmill') throw dataTable.name + ' database cannot be called "windmill"' @@ -51,12 +64,11 @@ @@ -33,34 +26,14 @@ {/if} -
    - {#each items as item, idx (item)} - - {item} - - - {/each} - - (newItem = e.currentTarget.value)} - onkeydown={(e) => e.key === 'Enter' && (addItem(), e.preventDefault())} - /> - -
    + diff --git a/frontend/src/lib/components/workspaceSettings/ForkDatatableSection.svelte b/frontend/src/lib/components/workspaceSettings/ForkDatatableSection.svelte index c060c1d1ed..9627e4a026 100644 --- a/frontend/src/lib/components/workspaceSettings/ForkDatatableSection.svelte +++ b/frontend/src/lib/components/workspaceSettings/ForkDatatableSection.svelte @@ -1,33 +1,21 @@ -
    - -
    +
    +
    - - Git Sync filter settings +

    {title}

    {#if isLegacyRepo} This repository uses legacy configuration format and inherits settings from @@ -142,22 +136,14 @@ {/if}
    -
    - {#if !collapsed} {#if isEditable} -
    -
    +
    +
    @@ -337,14 +323,9 @@
    -
    -
    - {isInitialSetup ? 'Configure initial sync settings' : 'Review migration settings'} -
    -
    {:else} -
    +
    @@ -454,5 +435,4 @@ git push
    {/if} - {/if}
    diff --git a/frontend/src/lib/components/workspaceSettings/InstanceRolesButton.svelte b/frontend/src/lib/components/workspaceSettings/InstanceRolesButton.svelte new file mode 100644 index 0000000000..e4e2726935 --- /dev/null +++ b/frontend/src/lib/components/workspaceSettings/InstanceRolesButton.svelte @@ -0,0 +1,48 @@ + + +{#if !hideTrigger} + +{/if} + + + drawer?.closeDrawer()} + tooltip="A data table role is a real Postgres login on this instance, shared by every instance database. A job that names one connects as it, and Postgres decides what it may touch. Which people may use a role on a given data table, and what it may do there, is set per data table, in its roles drawer." + > + {#snippet titleExtra()} + Beta + {/snippet} + {#key openCount} + + {/key} + + diff --git a/frontend/src/lib/components/workspaceSettings/ModelPricing.svelte b/frontend/src/lib/components/workspaceSettings/ModelPricing.svelte index 85824c1c95..5adc268474 100644 --- a/frontend/src/lib/components/workspaceSettings/ModelPricing.svelte +++ b/frontend/src/lib/components/workspaceSettings/ModelPricing.svelte @@ -4,7 +4,7 @@ import TextInput from '../text_input/TextInput.svelte' import { getKnownModelPrice, inheritedCacheRates } from '../copilot/modelPricing' import { modelKey } from '../copilot/modelConfig' - import { stripLegacyThinkingSuffix } from '../copilot/reasoningRegistry' + import { chatModelsByProvider } from './perModelSettings' import { ChevronDown, ChevronUp } from 'lucide-svelte' import { slide } from 'svelte/transition' import SettingCard from '../instanceSettings/SettingCard.svelte' @@ -25,28 +25,7 @@ let errors = $state>({}) let collapsedProviders = $state>({}) - // Rates are keyed by the id the chat reports usage under, and `setCopilotInfo` - // strips the deprecated `/thinking` suffix before the chat ever sees a model. A - // row built from the raw config would save an override under a key nothing - // reports, so it would sit in settings looking applied and never price anything. - // Stripping can collapse two configured slots onto one model, hence the dedupe. - const modelsByProvider = $derived( - Object.entries(aiProviders).reduce( - (acc, [provider, config]) => { - const seen = new Set() - acc[provider] = config.models.flatMap((configured) => { - const model = stripLegacyThinkingSuffix(configured) - if (seen.has(model)) { - return [] - } - seen.add(model) - return [{ provider: provider as AIProvider, model }] - }) - return acc - }, - {} as Record> - ) - ) + const modelsByProvider = $derived(chatModelsByProvider(aiProviders)) type Field = 'input' | 'output' | 'cache_read' | 'cache_write' type CacheField = Extract @@ -228,13 +207,13 @@ {#if overridden}
    Overriding the built-in price - +
    {/if} {#if errors[key]} diff --git a/frontend/src/lib/components/workspaceSettings/ModelTokenLimits.svelte b/frontend/src/lib/components/workspaceSettings/ModelTokenLimits.svelte index 7ad9034e3b..5b76430148 100644 --- a/frontend/src/lib/components/workspaceSettings/ModelTokenLimits.svelte +++ b/frontend/src/lib/components/workspaceSettings/ModelTokenLimits.svelte @@ -1,176 +1,158 @@ {#if Object.keys(aiProviders).length > 0} - +
    - {#each Object.entries(modelsByProvider).filter(([provider, models]) => models.length > 0) as [provider, models]} + {#each Object.entries(modelsByProvider).filter(([_, models]) => models.length > 0) as [provider, models] (provider)} {@const isExpanded = !collapsedProviders[provider]} - {@const hasCustom = hasCustomSettings(provider, models)}
    - + {#if isExpanded}
    - {#each models as { model }} - {@const currentTokens = getCurrentTokensForModel(provider as AIProvider, model)} - {@const defaultTokens = getDefaultTokensForModel(provider as AIProvider, model)} - {@const isAtDefault = isModelAtDefault(provider as AIProvider, model)} + {#each models as { model } (model)} + {@const key = modelKey(provider as AIProvider, model)} + {@const fallback = getDefault(provider as AIProvider, model)} + {@const overridden = isOverridden(provider as AIProvider, model)}
    {model}
    - { - const value = parseInt(e.currentTarget.value) - if (!isNaN(value)) { - updateTokensForModel(provider as AIProvider, model, value) - } - }} - class="w-20 px-2 py-1 text-xs text-center border border-gray-200 dark:border-gray-700 rounded bg-surface focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> +
    + { + if (e.currentTarget.value === '') { + resetModel(provider as AIProvider, model) + return + } + const value = Number(e.currentTarget.value) + if (!isNaN(value)) { + updateTokens(provider as AIProvider, model, value) + } + }, + onblur: (e: Event & { currentTarget: HTMLInputElement }) => { + // Resync a value the state refused, so what is shown is what is stored. + e.currentTarget.value = String(limits[key] ?? '') + errors[key] = '' + } + }} + /> +
    tokens
    - {#if !isAtDefault} -
    - Default: {defaultTokens} tokens + {#if overridden} +
    + + {fallback.assumed ? 'Assumed' : 'Default'}: {fallback.tokens.toLocaleString()} + tokens +
    - {#if errors[getModelKey(provider as AIProvider, model)]} -
    {errors[getModelKey(provider as AIProvider, model)]}
    - {/if} + {/if} + {#if errors[key]} +
    {errors[key]}
    {/if}
    {/each} diff --git a/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte b/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte index d16f948b88..04cf841b28 100644 --- a/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte +++ b/frontend/src/lib/components/workspaceSettings/NewDataTableMigrationModal.svelte @@ -8,12 +8,17 @@ import TextInput from '../text_input/TextInput.svelte' import SimpleEditor from '../SimpleEditor.svelte' import { WorkspaceService, type DatatableMigration } from '$lib/gen' + import { listUsableDatatableRoles } from '../datatableUsableRoles' import { sendUserToast } from '$lib/toast' import { tick } from 'svelte' import ConfirmationModal from '../common/confirmationModal/ConfirmationModal.svelte' import { createAsyncConfirmationModal } from '../common/confirmationModal/asyncConfirmationModal.svelte' import Portal from '$lib/components/Portal.svelte' import { fetchPendingMigrations, outOfOrderRunMessage } from './datatableMigrationUtils' + import Select from '../select/Select.svelte' + import { parseMigrationRole, withMigrationRole } from '../datatableMigrationRole' + import { ADMIN_DATATABLE_ROLE } from '../dbTypes' + import { resource } from 'runed' let { workspace, @@ -50,6 +55,8 @@ let tab = $state('up') let name = $state('') let nameInput = $state() + let upEditor = $state() + let downEditor = $state() // A valid migration name is non-empty and limited to letters, digits, '_' and '-'. const MIGRATION_NAME_RE = /^[a-zA-Z0-9_-]+$/ let nameInvalid = $derived(!MIGRATION_NAME_RE.test(name.trim())) @@ -60,6 +67,96 @@ const confirmationModal = createAsyncConfirmationModal() + // The role lives in the SQL as its `-- role ` annotation, so the code is the single + // source of truth and the Select is a view onto it: reading parses, writing rewrites the + // annotation. + const usableRoles = resource( + () => [workspace, datatable] as const, + async ([ws, dt]) => { + try { + return await listUsableDatatableRoles(ws, dt) + } catch (e) { + console.error('Failed to load data table roles:', e) + return null + } + } + ) + // Not a valid role name, so it cannot collide with one. + const NO_ROLE = '(no role)' + let declaredUp = $derived(parseMigrationRole(codeUp)) + let declaredDown = $derived(enableDown ? parseMigrationRole(codeDown) : undefined) + let malformedLine = $derived( + declaredUp.kind === 'malformed' + ? declaredUp.line + : declaredDown?.kind === 'malformed' + ? declaredDown.line + : undefined + ) + const roleOf = (d: typeof declaredUp) => (d.kind === 'role' ? d.role : undefined) + // A rollback runs as the role its own SQL names: under another role than the up migration it + // typically cannot touch what the up created. + let sqlProblem = $derived( + malformedLine !== undefined + ? malformedMessage(malformedLine) + : declaredDown !== undefined && roleOf(declaredDown) !== roleOf(declaredUp) + ? `The down migration runs as ${roleOf(declaredDown) ?? 'admin (no role)'} but the up migration as ${roleOf(declaredUp) ?? 'admin (no role)'}: make their role annotations match` + : undefined + ) + let selectedRole = $derived(declaredUp.kind === 'role' ? declaredUp.role : NO_ROLE) + let permissioned = $derived(!!usableRoles.current?.permissioned) + // No annotation runs as admin, which the server allows exactly to those who may use `admin`. + let adminUsable = $derived(!!usableRoles.current?.roles.includes(ADMIN_DATATABLE_ROLE)) + let roleItems = $derived.by(() => { + const usable = usableRoles.current + if (!usable?.permissioned) return [] + const names = usable.roles.filter((r) => r !== ADMIN_DATATABLE_ROLE) + // A role the SQL names but the caller cannot use is still shown, or the picker would + // misreport what the migration runs as. + if (declaredUp.kind === 'role' && !names.includes(declaredUp.role)) { + names.push(declaredUp.role) + } + const items = names.map((r) => ({ + value: r, + label: r === usable.default_role ? `${r} (default)` : r + })) + if (adminUsable || declaredUp.kind === 'none') { + items.push({ + value: NO_ROLE, + label: 'No role — runs as admin with full access' + }) + } + return items + }) + + function setRole(value: string | undefined) { + const role = value === NO_ROLE ? undefined : value + codeUp = withMigrationRole(codeUp, role) + // Up and down agree: a rollback run as another role could fail on objects it does not own. + if (enableDown) codeDown = withMigrationRole(codeDown, role) + // Assigning the bound value does not repaint the editor, and its next keystroke would write + // the stale text back. + upEditor?.setCode(codeUp) + if (enableDown) downEditor?.setCode(codeDown) + } + + // Set by `open` when the SQL names no role yet: the data table's default is written once its + // roles are known. + let applyDefaultRole = $state(false) + $effect(() => { + // `undefined` until the first answer lands; `null` when it failed. + const usable = usableRoles.current + if (!applyDefaultRole || !isOpen || usableRoles.loading || usable === undefined) return + applyDefaultRole = false + if (!usable?.permissioned || declaredUp.kind !== 'none') return + const role = + usable.default_role !== ADMIN_DATATABLE_ROLE && usable.roles.includes(usable.default_role) + ? usable.default_role + : usable.default_role === ADMIN_DATATABLE_ROLE && adminUsable + ? undefined + : usable.roles.find((r) => r !== ADMIN_DATATABLE_ROLE) + if (role !== undefined) setRole(role) + }) + // Frame the migration body in an explicit transaction so it applies atomically. function wrapInTransaction(body: string): string { return `BEGIN;\n\n${body}\n\nEND;` @@ -72,15 +169,35 @@ } const PLACEHOLDER = wrapInTransaction('-- Add your migration here') + function malformedMessage(line: string): string { + return `Malformed role annotation \`${line}\`: write it as \`-- role \`, or pick the role above` + } + export function open(prefill?: { name?: string; codeUp?: string; codeDown?: string }) { + // Roles and the default can have changed since the last open (the roles drawer sits next + // to this modal), and the default role is written from this answer. + usableRoles.refetch() name = prefill?.name ?? '' // Start from the transaction template; when prefilled from detected DDL, - // wrap that DDL in the same BEGIN; ... END; frame. - codeUp = prefill?.codeUp - ? wrapInTransaction(ensureTrailingSemicolon(prefill.codeUp)) - : PLACEHOLDER + // wrap that DDL in the same BEGIN; ... END; frame. A role the prefill declares is taken + // out first and put back on top: below `BEGIN;` it would not be read. + const prefillRole = prefill?.codeUp ? parseMigrationRole(prefill.codeUp) : undefined + if (prefill?.codeUp) { + const wrapped = wrapInTransaction( + ensureTrailingSemicolon(withMigrationRole(prefill.codeUp, undefined)) + ) + codeUp = + prefillRole?.kind === 'role' + ? withMigrationRole(wrapped, prefillRole.role) + : prefillRole?.kind === 'malformed' + ? `${prefillRole.line}\n${wrapped}` + : wrapped + } else { + codeUp = PLACEHOLDER + } codeDown = prefill?.codeDown ?? PLACEHOLDER enableDown = (prefill?.codeDown ?? '') !== '' + applyDefaultRole = prefillRole === undefined || prefillRole.kind === 'none' tab = 'up' isOpen = true // Focus the name field once the modal content has rendered. @@ -96,6 +213,10 @@ sendUserToast("Invalid migration name: use only letters, digits, '_' and '-'", true) return } + if (sqlProblem !== undefined) { + sendUserToast(sqlProblem, true) + return + } if (run) { // A new migration gets the highest timestamp, so any still-pending // migration is earlier: running only this one applies it out of order. @@ -176,29 +297,65 @@ closeOnOutsideClick={false} >
    - +
    + + {#if permissioned} + -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - amqpTriggerEditor?.openNew(false), - aiId: 'amqp-triggers-empty-add', - aiDescription: 'Add AMQP trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, extra_perms, canWrite, error, last_server_ping, server_id, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_amqp', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = last_server_ping ? new Date(last_server_ping) : undefined} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - amqpTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {path}{hasDraft ? '*' : ''} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - AMQP trigger is starting... - {:else} - AMQP trigger is not connected{error ? ': ' + error : ''} - {/if} - {:else} - AMQP trigger was disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    - AMQP trigger is connected{!server_id ? ' (shutting down...)' : ''} -
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'amqp', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - amqpTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'amqp' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'amqp_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - await AmqpTriggerService.deleteAmqpTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} - - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte index 4490f70f39..e81dfb9e90 100644 --- a/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps/edit/[...path]/+page.svelte @@ -51,8 +51,9 @@ let deployedAt = $state(undefined) // The app_version the draft was forked from (pinned), + the deployed head, for // the precise staleness check in DraftEditorModals (vs the drifting timestamp). - let draftBaseVersion = $state(undefined) - let deployedHeadVersion = $state(undefined) + let draftBaseVersion = $state(undefined) + let deployedHeadVersion = $state(undefined) + let deployedBy = $state(undefined) /** Increments per `loadApp` call. Stale loads (e.g. when picker * navigation races a draft-discard reload) bail at the next checkpoint @@ -90,6 +91,7 @@ // reused route and falsely trip the stale-draft modal. draftBaseVersion = undefined deployedHeadVersion = undefined + deployedBy = undefined // Brand-new app: no deployed baseline, so never discard-on-equal. deployedBaseline = undefined const templatePath = page.url.searchParams.get('template') @@ -274,13 +276,15 @@ // `no_deployed` — no baseline to be older than. draftSavedAt = backendApp.draft_saved_at as string | undefined deployedAt = backendApp.no_deployed ? undefined : (backendApp.created_at as string | undefined) - // `parent_version` rides on the persisted draft (pinned at fork); undefined - // for a pre-feature draft. Head = the last entry of the deployed `versions`. - draftBaseVersion = savedDraftApp?.parent_version - deployedHeadVersion = + deployedBy = backendApp.no_deployed ? undefined : (backendApp.created_by as string | undefined) + // The app_version the draft forked from; undefined for a draft never forked + // from a deploy. Head = the last entry of the deployed `versions`. + draftBaseVersion = backendApp.draft_base + const headVersion = backendApp.no_deployed || !backendApp.versions ? undefined : backendApp.versions[backendApp.versions.length - 1] + deployedHeadVersion = headVersion != null ? String(headVersion) : undefined const backendApp_ = structuredClone(stateSnapshot(backendApp)) savedApp = { summary: backendApp_.summary, @@ -303,6 +307,11 @@ } if (pendingLoad) { backendApp = { ...backendApp, value: pendingLoad.value as App } as typeof backendApp + // Their draft's base, not ours: the prompt and the deploy guard read it, and + // deploying their content on our base would claim a version it never forked + // from. See /scripts/edit. + const theirs = (pendingLoad.value as App)?.parent_version + draftBaseVersion = theirs != null ? String(theirs) : undefined if (hasOwnDraft) { // AppEditor `migrateApp`s the value in place on mount (see its // `migratedDeployedBaseline`), so the draft cell settles to the @@ -330,7 +339,8 @@ // draft value. An existing own draft already carries it (preserved by the // value swap above). `parent_version` is in DRAFT_COMPARE_IGNORED_FIELDS, so it // never trips the autosave no-op / "unsaved changes" comparison. - if (!hasOwnDraft && !backendApp.no_deployed && backendApp.value) { + // Not after loading a teammate's draft either: that value carries their base. + if (!hasOwnDraft && !pendingLoad && !backendApp.no_deployed && backendApp.value) { const versions = (backendApp as { versions?: number[] }).versions const head = Array.isArray(versions) ? versions[versions.length - 1] : undefined if (head != null) (backendApp.value as App).parent_version = head @@ -444,6 +454,7 @@ {deployedAt} {draftBaseVersion} {deployedHeadVersion} + {deployedBy} onLoadLatestDeploy={async () => { if (!$workspaceStore) return await runResetToDeployed({ @@ -484,6 +495,26 @@ {loadedFromDraft} othersDraftsCount={otherDraftsUsers.length} onOpenOthersDrafts={() => (othersModalOpen = true)} + onDeploy={({ version, head, headBy, headAt }) => { + // The editor stays open across a deploy and pins what it wrote onto the value + // itself. Keep that as the prompt's base while this deploy is the head, so + // the pair still describes the draft the next edit starts; a raced deploy + // leaves it unknown, or the pair would differ and open the prompt on the + // draft this deploy consumed. + draftBaseVersion = version != null && version === head ? String(version) : undefined + draftSavedAt = undefined + if (head != null) { + // Named by whoever deployed the head, not by the page load's author. + deployedHeadVersion = String(head) + deployedBy = headBy + deployedAt = headAt + } + // Another deploy landed on top of this one: there is no draft for the prompt + // to talk about, so say what happened instead. + if (version != null && head != null && version !== head) { + sendUserToast(`Version ${head} was deployed on top of yours (${version})`) + } + }} />
    {/if} diff --git a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte index 78483c327a..769cfcb14f 100644 --- a/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte @@ -47,6 +47,9 @@ /** User-typed path the home list renders, set only when it differs * from the deployed/seeded `savedApp.path`. */ draft_path?: string + /** The app_version the draft forked from; the server derives `draft.base` + * from it. */ + parent_version?: number } let files: Record | undefined = $state(undefined) @@ -122,6 +125,7 @@ summary, policy, custom_path: savedApp?.custom_path, + parent_version: parentVersion, // Persist the typed path as `draft_path` only when it actually differs // from the current path — a `draft_path` equal to the baseline is a // no-op that would block the draft from deduping against the deployed @@ -165,6 +169,14 @@ let othersModalOpen = $state(false) let draftSavedAt = $state(undefined) let deployedAt = $state(undefined) + // The app_version the draft forked from, and the deployed head, for the exact + // staleness check (vs the drifting timestamps). `parentVersion` is what the + // bundle carries: an own draft keeps the version it forked from; a fresh + // checkout forks from the head. + let parentVersion = $state(undefined) + let draftBaseVersion = $state(undefined) + let deployedHeadVersion = $state(undefined) + let deployedBy = $state(undefined) async function loadApp(opts: { getDraft?: boolean } = {}): Promise { const getDraft = opts.getDraft ?? true const tok = ++loadAppToken @@ -185,6 +197,10 @@ loadedFromDraft = false draftSavedAt = undefined deployedAt = undefined + parentVersion = undefined + draftBaseVersion = undefined + deployedHeadVersion = undefined + deployedBy = undefined // `labels` is route-level state; reset it too so a fresh draft doesn't // inherit (and then deploy) the previously-opened app's labels. The // import branch re-seeds it via extractRawApp below. @@ -301,6 +317,21 @@ // See /apps/edit's loader. draftSavedAt = backendApp.draft_saved_at as string | undefined deployedAt = backendApp.no_deployed ? undefined : (backendApp.created_at as string | undefined) + deployedBy = backendApp.no_deployed ? undefined : (backendApp.created_by as string | undefined) + // Head = the last entry of the deployed `versions`. The base the bundle + // carries is the draft's own; a draft that predates the base keeps none + // (stamping the head would hide whatever it is behind), and only a fresh + // checkout forks from the head. + const versions = backendApp.versions as number[] | undefined + const headVersion = + backendApp.no_deployed || !Array.isArray(versions) ? undefined : versions[versions.length - 1] + deployedHeadVersion = headVersion != null ? String(headVersion) : undefined + draftBaseVersion = backendApp.draft_base as string | undefined + parentVersion = hasOwnDraft + ? draftBaseVersion != null + ? Number(draftBaseVersion) + : undefined + : headVersion // Deployed baseline for the autosave `discardIf`, captured BEFORE the swap // below mutates `backendApp`. Mirrors the bundle `$effect`'s shape (minus // the edit-only `draft_path`) so an unedited draft compares equal. @@ -314,7 +345,8 @@ data: extractDataConfig(backendApp.value) ?? { ...DEFAULT_DATA }, summary: backendApp.summary ?? '', policy: backendApp.policy, - custom_path: backendApp.custom_path + custom_path: backendApp.custom_path, + parent_version: parentVersion }) ) as RawAppDraft) // The raw-app autosave stores a flat `RawAppDraft`, but this loader (and @@ -393,6 +425,11 @@ data = v.data ?? { ...DEFAULT_DATA } summary = v.summary ?? '' policy = v.policy ?? {} + // Their draft's own fork base: the bundle is rebuilt from these pieces, so + // leaving `parentVersion` on ours would deploy their content claiming a base + // it never had. + parentVersion = v.parent_version + draftBaseVersion = v.parent_version != null ? String(v.parent_version) : undefined newPath = v.draft_path ?? savedApp?.path ?? path if (hasOwnDraft) { OtherUserDraftLoad.beginOverlay({ @@ -409,6 +446,7 @@ summary, policy, custom_path: savedApp?.custom_path, + parent_version: parentVersion, ...(pendingDraftPath ? { draft_path: pendingDraftPath } : {}) } as RawAppDraft, onResetToOwnDraft: () => loadApp({ getDraft: true }) @@ -497,7 +535,8 @@ aiChatManager.datatableCreationPolicy = { enabled: !!result.data.datatable, datatable: result.data.datatable, - schema: result.data.schema + schema: result.data.schema, + roles: result.data.roles } if (withPrompt && result.prompt) { const prompt = result.prompt @@ -549,6 +588,10 @@ bind:othersModalOpen {draftSavedAt} {deployedAt} + {draftBaseVersion} + {deployedHeadVersion} + {deployedBy} + onViewDiff={() => rawAppEditor?.openDiffDrawer()} onLoadLatestDeploy={async () => { // stopSync-bracketed; see /scripts/edit's restoreDeployed for the race. if (!$workspaceStore) return @@ -587,6 +630,56 @@ bind:savedApp {diffDrawer} newApp={isNewApp} + version={parentVersion ?? + (deployedHeadVersion != null ? Number(deployedHeadVersion) : undefined)} + {draftBaseVersion} + onTakeLatest={draftBaseVersion + ? async (shown?: string) => { + // The version the drawer showed as head; see /scripts/edit. + const head = Number(shown ?? deployedHeadVersion) + if (!Number.isFinite(head)) return + parentVersion = head + if (deployedBaseline) deployedBaseline = { ...deployedBaseline, parent_version: head } + draftBaseVersion = String(head) + // See /scripts/edit: the head this page knows moves with the base. + deployedHeadVersion = String(head) + // Persisted explicitly, as the script and flow routes do: the reactive + // bundle mirror is parked while auto-save is off, and a parked write is + // dropped on pagehide, so the new base would not survive a reload. + if (draftSync.draft) { + draftSync.draft = { ...draftSync.draft, parent_version: head } + if ($workspaceStore) { + await UserDraft.forcePersist('raw_app', page.params.path ?? '', { + workspace: $workspaceStore + }) + } + } + } + : undefined} + onDeploy={({ version, head, headBy, headAt }) => { + // The version this deploy wrote is what the next autosave forks from, so the + // editor carries it; the prompt's own pair is what the loader knows, and this + // deploy consumed the draft it described, so the route holds no base until it + // loads again (the deploy guard covers that window). + parentVersion = version + // Keep the base the editor just pinned when this deploy is the head: the + // prompt has no draft to describe either way, and the drawer needs it to + // keep offering Take latest. A raced deploy leaves it unknown, since the + // pair would then differ and open the prompt on a draft that is gone. + draftBaseVersion = version != null && version === head ? String(version) : undefined + draftSavedAt = undefined + if (head != null) { + // Named by whoever deployed the head, not by the page load's author. + deployedHeadVersion = String(head) + deployedBy = headBy + deployedAt = headAt + } + // Another deploy landed on top of this one: there is no draft for the prompt + // to talk about, so say what happened instead. + if (version != null && head != null && version !== head) { + sendUserToast(`Version ${head} was deployed on top of yours (${version})`) + } + }} onResetToDeployed={reloadDeployed} {loadedFromDraft} othersDraftsCount={otherDraftsUsers.length} diff --git a/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte index 11afa9f94b..2193288ccf 100644 --- a/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/azure_triggers/+page.svelte @@ -1,668 +1,5 @@ - { - deleteAzureTriggerCallback = undefined - }} - on:confirmed={async () => { - if (deleteAzureTriggerCallback) { - isDeleting = true - if (deleteSubscription && subscriptionDeletePayload) { - try { - const msg = await AzureTriggerService.deleteAzureSubscription({ - workspace: $workspaceStore ?? '', - path: subscriptionDeletePayload.azure_resource_path, - requestBody: { - azure_mode: subscriptionDeletePayload.azure_mode, - scope_resource_id: subscriptionDeletePayload.scope_resource_id, - topic_name: subscriptionDeletePayload.topic_name, - subscription_name: subscriptionDeletePayload.subscription_name - } - }) - sendUserToast(msg) - } catch (error) { - isDeleting = false - sendUserToast(error.body || error.message, true) - return - } - } - await deleteAzureTriggerCallback() - } - deleteAzureTriggerCallback = undefined - subscriptionToDelete = undefined - subscriptionDeletePayload = undefined - }} -> -
    - Are you sure you want to remove this trigger? - - {#if subscriptionToDelete} - - {/if} -
    -
    - - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - Azure Event Grid triggers are disabled in the multi-tenant cloud. - - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - azureTriggerEditor?.openNew(false), - aiId: 'azure-triggers-empty-add', - aiDescription: 'Add Azure Event Grid trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { azure_resource_path, azure_mode, scope_resource_id, topic_name, subscription_name, workspace_id, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_azure', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = new Date()} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - {@const is_pull = azure_mode === 'namespace_pull'} - {@const topic_label = topic_name - ? topic_name - : (scope_resource_id?.split('/')?.pop() ?? '')} - -
    -
    - - - azureTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {path} - {topic_label} ({azure_mode}, {subscription_name}){hasDraft ? '*' : ''} -
    -
    - runnable: {script_path} -
    -
    - - - - {#if is_pull} -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - Azure Event Grid trigger is starting... - {:else} - Could not connect to Azure Event Grid{error ? ': ' + error : ''} - {/if} - {:else} - Disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    - Connected to Azure Event Grid{!server_id - ? ' (shutting down...)' - : ''}
    - {/snippet} -
    - {/if} -
    - {/if} - -
    - - {#if is_pull} - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'azure', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> - {/if} -
    - -
    - {#if !is_pull} - - {/if} - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - azureTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'azure' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'azure_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - isDeleting = false - deleteSubscription = false - subscriptionToDelete = subscription_name - subscriptionDeletePayload = { - azure_mode, - scope_resource_id, - topic_name: topic_name ?? undefined, - subscription_name, - azure_resource_path - } - deleteAzureTriggerCallback = async () => { - const message = await AzureTriggerService.deleteAzureTrigger({ - workspace: $workspaceStore ?? '', - path - }) - sendUserToast(message) - loadTriggers() - } - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte index 87c9558dbb..8fb28777c8 100644 --- a/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/email_triggers/+page.svelte @@ -1,553 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers} - -{:else} - - - {#if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} - -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - emailTriggerEditor?.openNew(false), - aiId: 'email-triggers-empty-add', - aiDescription: 'Add email trigger' - } - : undefined} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { workspace_id, workspaced_local_part, path, edited_by, edited_at, script_path, is_flow, extra_perms, canWrite, marked, local_part, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'trigger_email', path) ?? is_draft} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const emailAddress = getEmailAddress( - local_part, - workspaced_local_part ?? false, - workspace_id, - emailDomain ?? '' - )} - -
    -
    - - - emailTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {#if marked} - - {@html marked} - - {:else} - {emailAddress} - {/if}{hasDraft ? '*' : ''} -
    -
    - {path} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'email', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - emailTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'routes' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'email_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: - !canWrite || !($userStore?.is_admin || $userStore?.is_super_admin), - action: async () => { - try { - await EmailTriggerService.deleteEmailTrigger({ - workspace: $workspaceStore ?? '', - path - }) - sendUserToast(`Successfully deleted Email trigger: ${path}`) - loadTriggers() - } catch (error) { - sendUserToast(error.body || error.message, true) - } - } - } - ]} - /> -
    -
    -
    -
    - {#if edited_by}
    edited by {edited_by}
    {/if} -
    {edited_by ? 'at ' : ''}{displayDate(edited_at)}
    -
    -
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    -{/if} - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index 65a7c7f510..4d240cc24b 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -58,7 +58,8 @@ let deployedAt = $state(undefined) // The flow_version the draft was forked from (pinned, doesn't drift), for the // precise staleness check in DraftEditorModals + FlowBuilder's deploy guard. - let draftBaseVersion = $state(undefined) + let draftBaseVersion = $state(undefined) + let deployedBy = $state(undefined) // Editor-displayed path; defaults to the URL path. Cleared to '' in the // `new_draft` branch so the Path widget's `initPath` seeds the friendly name. let flowInitialPath = $state(page.params.path ?? '') @@ -163,6 +164,7 @@ // bleed across the reused route and falsely trip the stale-draft modal. version = undefined draftBaseVersion = undefined + deployedBy = undefined // Brand-new flow: no deployed baseline, so never discard-on-equal. deployedBaseline = undefined // Suspend autosave around the bootstrap cascade: the Path widget's @@ -355,15 +357,23 @@ // is the deploy time (from `flow_version.created_at`), `draft_saved_at` the draft's. draftSavedAt = backendFlow.draft_saved_at as string | undefined deployedAt = backendFlow.edited_at as string | undefined + deployedBy = backendFlow.edited_by as string | undefined // Layer the draft (`.draft`, if any) over the deployed payload at the field // level. See /scripts/edit's loader for the rationale. const { draft: draftFromBackend, ...deployedFlow } = backendFlow as any - // `version_id` rides on the persisted draft (pinned at fork); undefined for a - // pre-feature draft or when editing the deployed flow directly (no draft). - draftBaseVersion = draftFromBackend?.version_id as number | undefined + // The flow_version the draft forked from; undefined when editing the deployed + // flow directly (no draft) or for a draft never forked from a deploy. + draftBaseVersion = backendFlow.draft_base const effectiveFlow: Flow = draftFromBackend ? ({ ...deployedFlow, ...draftFromBackend } as Flow) : (deployedFlow as Flow) + // The merge above would hand a draft with no base the deployed one, and the next + // autosave would persist it as `draft.base`: a draft behind the deploy would read + // as up to date after one open. A draft keeps the base it has, unknown included + // (staleness then falls back to the timestamps). + if (draftFromBackend && (draftFromBackend as any).version_id == null) { + delete (effectiveFlow as any).version_id + } savedFlow = structuredClone($state.snapshot(effectiveFlow)) as Flow // Baseline for the autosave `discardIf`: the deployed flow WITHOUT the // draft overlay (matches the unedited seed when no draft exists). @@ -373,8 +383,11 @@ // Surface the saved `draft_path` to the Path widget so the topbar shows the // pending name, not the `draft_{uuid}` URL. Else the widget seeds from the // URL, the first edit clobbers `draft_path`, and the friendly name is lost. + // Otherwise the URL, set on every load: this route instance is reused when the + // editor follows its draft to a moved item, and the path it opened on is where + // deploy would look for the flow. const renderedDraftPath = (effectiveFlow as any).draft_path as string | undefined - if (renderedDraftPath) flowInitialPath = renderedDraftPath + flowInitialPath = renderedDraftPath || (page.params.path ?? '') // "Load another user's draft" handoff: render their value over the deployed // metadata. Overlay mode (we have our own draft) never saves until the user @@ -391,6 +404,14 @@ ? ({ ...deployedFlow, ...(pendingLoad.value as object) } as Flow) : effectiveFlow flow = flowToRender + if (pendingLoad) { + // Their draft's base, not ours; see /scripts/edit. With none, the merge above + // would hand it the deployed one and the next save would persist that as its + // base, so it is dropped rather than inherited. + const theirs = (pendingLoad.value as { version_id?: number })?.version_id + draftBaseVersion = theirs != null ? String(theirs) : undefined + if (theirs == null) delete (flowToRender as any).version_id + } if (pendingLoad && hasOwnDraft) { OtherUserDraftLoad.beginOverlay({ workspace: $workspaceStore!, @@ -506,7 +527,10 @@ {draftSavedAt} {deployedAt} {draftBaseVersion} - deployedHeadVersion={version} + deployedHeadVersion={version != null ? String(version) : undefined} + {deployedBy} + onViewDiff={() => flowBuilder?.openDiffDrawer()} + onBeforeRelocate={() => flowBuilder?.saveDraft()} onLoadLatestDeploy={async () => { // stopSync-bracketed; see /scripts/edit's restoreDeployed for the race. if (!$workspaceStore) return @@ -528,6 +552,18 @@
    {:else if renderEditor} { + // The version the drawer showed as head; see /scripts/edit. + const head = shown != null ? Number(shown) : version + if (!draftSync.draft || head == null || !$workspaceStore) return + draftSync.draft = { ...draftSync.draft, version_id: head } + draftBaseVersion = String(head) + // See /scripts/edit: the head this page knows moves with the base. + version = head + await UserDraft.forcePersist('flow', flowDraftPath, { workspace: $workspaceStore }) + } + : undefined} onDeploy={(e) => { // stopSync-bracketed immediate delete; see /scripts/edit's restoreDeployed. if ($workspaceStore) { @@ -557,6 +593,7 @@ {flowStore} {flowStateStore} bind:initialPath={flowInitialPath} + userDraftPath={page.params.path ?? ''} liveEditorDraftStoragePath={flowDraftPath} newFlow={isNewFlow} {selectedId} diff --git a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte index 24c7553378..3b61eb7801 100644 --- a/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/gcp_triggers/+page.svelte @@ -1,654 +1,5 @@ - { - deleteGcpTriggerCallback = undefined - }} - on:confirmed={async () => { - if (deleteGcpTriggerCallback) { - isDeleting = true - if (deleteSubscription && deleteSubscriptionCallback) { - try { - await deleteSubscriptionCallback() - } catch (error) { - isDeleting = false - sendUserToast(error.body || error.message, true) - return - } - } - await deleteGcpTriggerCallback() - } - deleteGcpTriggerCallback = undefined - }} -> -
    - Are you sure you want to remove this trigger? - - -
    -
    - - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - GCP Pub/Sub triggers are disabled in the multi-tenant cloud. - - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - gcpTriggerEditor?.openNew(false), - aiId: 'gcp-triggers-empty-add', - aiDescription: 'Add GCP Pub/Sub trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { gcp_resource_path, project_id, topic_id, workspace_id, delivery_type, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, subscription_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_gcp', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = new Date()} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - gcpTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {path} - {topic_id}{hasDraft ? '*' : ''} -
    -
    - runnable: {script_path} -
    -
    - - - - {#if delivery_type !== 'push'} -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - GCP Pub/Sub trigger is starting... - {:else} - Could not connect to GCP Pub/Sub{error ? ': ' + error : ''} - {/if} - {:else} - Disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    - Connected to GCP Pub/Sub{!server_id ? ' (shutting down...)' : ''}
    - {/snippet} -
    - {/if} -
    - {/if} - -
    - - {#if delivery_type !== 'push'} - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'gcp', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> - {/if} -
    - -
    - {#if delivery_type === 'push'} - - {/if} - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - gcpTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'gcp' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'gcp_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - isDeleting = false - deleteSubscription = false - subscriptionToDelete = subscription_id - currentTopic = topic_id - deleteSubscriptionCallback = async () => { - const requestBody = { subscription_id, project_id } - const message = gcp_resource_path - ? await GcpTriggerService.deleteGcpSubscription({ - workspace: $workspaceStore ?? '', - path: gcp_resource_path, - requestBody - }) - : await GcpTriggerService.deleteGcpSubscriptionWithDefaultCredentials({ - workspace: $workspaceStore ?? '', - requestBody - }) - sendUserToast(message) - } - deleteGcpTriggerCallback = async () => { - const message = await GcpTriggerService.deleteGcpTrigger({ - workspace: $workspaceStore ?? '', - path - }) - sendUserToast(message) - loadTriggers() - } - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte index 6ee790818a..c4d4f904f6 100644 --- a/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/kafka_triggers/+page.svelte @@ -1,595 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers} - -{:else} - - - - - - {#if isCloudHosted()} - - Kafka triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - kafkaTriggerEditor?.openNew(false), - aiId: 'kafka-triggers-empty-add', - aiDescription: 'Add Kafka trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, kafka_resource_path, topics, extra_perms, canWrite, marked, server_id, error, last_server_ping, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'trigger_kafka', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = last_server_ping ? new Date(last_server_ping) : undefined} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - kafkaTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {#if marked} - - {@html marked} - - {:else} - {kafka_resource_path} - {topics.join(', ')} - {/if}{hasDraft ? '*' : ''} -
    -
    - {path} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - Consumer is starting... - {:else} - Consumer is not connected{error ? ': ' + error : ''} - {/if} - {:else} - Consumer was disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    Consumer is connected
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'kafka', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - await KafkaTriggerService.deleteKafkaTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - } - }, - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - kafkaTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'kafka' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'kafka_trigger') - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    Edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    -{/if} - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte index 126dfda031..41c30157ff 100644 --- a/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/mqtt_triggers/+page.svelte @@ -1,570 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - MQTT triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - mqttTriggerEditor?.openNew(false), - aiId: 'mqtt-triggers-empty-add', - aiDescription: 'Add MQTT trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, extra_perms, canWrite, error, last_server_ping, server_id, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_mqtt', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = last_server_ping ? new Date(last_server_ping) : undefined} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - mqttTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {path}{hasDraft ? '*' : ''} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - MQTT trigger is starting... - {:else} - MQTT trigger is not connected{error ? ': ' + error : ''} - {/if} - {:else} - MQTT trigger was disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    - MQTT trigger is connected{!server_id ? ' (shutting down...)' : ''} -
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'mqtt', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - mqttTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'mqtt' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'mqtt_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - await MqttTriggerService.deleteMqttTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte index 834869891a..ae96a52cfc 100644 --- a/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/nats_triggers/+page.svelte @@ -1,592 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers} - -{:else} - - - - - - {#if isCloudHosted()} - - NATS triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - natsTriggerEditor?.openNew(false), - aiId: 'nats-triggers-empty-add', - aiDescription: 'Add NATS trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, is_flow, nats_resource_path, subjects, extra_perms, canWrite, marked, server_id, error, last_server_ping, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_nats', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = last_server_ping ? new Date(last_server_ping) : undefined} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - natsTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {#if marked} - - {@html marked} - - {:else} - {nats_resource_path} - {subjects.join(', ')} - {/if}{hasDraft ? '*' : ''} -
    -
    - {path} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - Consumer is starting... - {:else} - Consumer is not connected{error ? ': ' + error : ''} - {/if} - {:else} - Consumer was disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    Consumer is connected
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'nats', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - natsTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'nats' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'nats_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - await NatsTriggerService.deleteNatsTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    -{/if} - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte index d1c07a7e75..a25078abe1 100644 --- a/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/postgres_triggers/+page.svelte @@ -1,674 +1,5 @@ - { - isDeleting = false - deletePostgresTriggerCallback = undefined - }} - on:confirmed={async () => { - if (deletePostgresTriggerCallback) { - let msg: string - isDeleting = true - if (deleteReplicationSlot && deleteReplicationSlotCallback) { - try { - msg = await deleteReplicationSlotCallback() - sendUserToast(msg) - } catch (error) { - isDeleting = false - sendUserToast(error.body || error.message, true) - return - } - } - if (deletePublication && deletePublicationCallback) { - try { - msg = await deletePublicationCallback() - sendUserToast(msg) - } catch (error) { - isDeleting = false - sendUserToast(error.body || error.message, true) - return - } - } - msg = await deletePostgresTriggerCallback() - sendUserToast(msg) - } - deletePostgresTriggerCallback = undefined - }} -> -
    - Are you sure you want to remove this trigger? - - - - -
    -
    - - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - Postgres triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - postgresTriggerEditor?.openNew(false), - aiId: 'postgres-triggers-empty-add', - aiDescription: 'Add Postgres trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { postgres_resource_path, publication_name, replication_slot_name, path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'trigger_postgres', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = new Date()} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - postgresTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {path}{hasDraft ? '*' : ''} -
    -
    - {postgres_resource_path} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - Postgres trigger is starting... - {:else} - Could not connect to database{error ? ': ' + error : ''} - {/if} - {:else} - Disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    Connected to database{!server_id ? ' (shutting down...)' : ''}
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'postgres', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - publicationToDelete = publication_name - replicationSlotToDelete = replication_slot_name - isDeleting = false - deletePublication = false - deleteReplicationSlot = false - deletePublicationCallback = async () => { - return await PostgresTriggerService.deletePostgresPublication({ - workspace: $workspaceStore!, - path: postgres_resource_path, - publication: publication_name - }) - } - - deleteReplicationSlotCallback = async () => { - return await PostgresTriggerService.deletePostgresReplicationSlot({ - workspace: $workspaceStore!, - path: postgres_resource_path, - requestBody: { - name: replication_slot_name - } - }) - } - - deletePostgresTriggerCallback = async () => { - const msg = await PostgresTriggerService.deletePostgresTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - return msg - } - } - }, - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - postgresTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'postgres' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'postgres_trigger') - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/remote_deploy/callback/+page.svelte b/frontend/src/routes/(root)/(logged)/remote_deploy/callback/+page.svelte new file mode 100644 index 0000000000..c702f66471 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/remote_deploy/callback/+page.svelte @@ -0,0 +1,74 @@ + + + + {#if status === 'connecting'} +
    + {:else if status === 'connected'} +

    Connected. You can close this tab.

    + {:else} + {error} + {#if returnTo} +
    + +
    + {/if} + {/if} +
    diff --git a/frontend/src/routes/(root)/(logged)/resources/+page.svelte b/frontend/src/routes/(root)/(logged)/resources/+page.svelte index be735f4901..8973bf83b4 100644 --- a/frontend/src/routes/(root)/(logged)/resources/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/resources/+page.svelte @@ -1,1584 +1,5 @@ - { - deleteConfirmedCallback = undefined - }} - on:confirmed={() => { - if (deleteConfirmedCallback) { - deleteConfirmedCallback() - } - deleteConfirmedCallback = undefined - }} -> -
    - Are you sure you want to remove {deletePath}? - {#if deleteIsLinked} - - This resource is linked with a variable of the same path. That variable is deleted with it, - unless another resource still references it. - - {/if} - -
    - You can press - SHIFT - while removing a resource to bypass confirmation. -
    -
    -
    -
    - - - - - inferrer?.toggleDrawer?.()}> - - {#snippet actions()} - - {/snippet} - - - - - - {#snippet titleExtra()} - - {/snippet} -
    - {#if resourceTypeViewerObj.description} - - {/if} - {#if resourceTypeViewerObj.isFileset} - - This resource type represents a collection of files. Each file is identified by its - relative path and contains text content. In the CLI, filesets are stored as directories. - - {:else if resourceTypeViewerObj.formatExtension} - - This resource type represents a plain text file with a .{resourceTypeViewerObj.formatExtension} - extension. (e.g. my_file.{resourceTypeViewerObj.formatExtension}) - - {:else} - - {/if} -
    -
    -
    - - - - {#snippet actions()} - - {/snippet} -
    - - -
    - {#if editResourceType.isFileset} - - This resource type represents a collection of files. The schema cannot be edited. - - {:else if editResourceType.formatExtension} - - This resource type represents a plain text file with a .{editResourceType.formatExtension} - extension. (e.g. my_file.{editResourceType.formatExtension}). The schema only - contains a `content` field and thus cannot be edited. - - {:else} -
    Schema
    -
    - -
    - {/if} -
    -
    -
    -
    - - - - {#snippet actions()} - - {/snippet} -
    - - -
    -
    -
    Schema
    -
    - -
    -
    - -
    - -
    -
    -
    -
    -
    - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.resources} - -{:else} - - - {#if showCreateButtons} -
    - - -
    - {/if} -
    - (showCreateButtons = v)} /> -
    - { - if (e.detail == 'cache') { - loading.resources = true - loadCache() - } else if (e.detail == 'states') { - loading.resources = true - loadState() - } else if (e.detail == 'theme') { - loading.resources = true - loadTheme() - } - }} - > - - - {#snippet extra()} - - Every resource has a Resource Type attached to it which contains its schema and make - it easy in scripts and flows to accept only resources of a specific resource type. - - {/snippet} - - - {#snippet extra()} - - States are actually resources (but excluded from the Workspace tab for clarity). - States are used by scripts to keep data persistent between runs of the same script by - the same trigger (schedule or user) - - {/snippet} - - - {#snippet extra()} - - Cached results are actually resources (but excluded from the Workspace tab for - clarity). Cache are used by flows's step to cache result to avoid recomputing - unnecessarily - - {/snippet} - - - {#snippet extra()} - - Themes are actually resources (but excluded from the Workspace tab for clarity). They - are CSS for the legacy low-code app editor only, and are added from the CSS panel of - an app rather than from this page. - - {/snippet} - - -
    -
    -
    - - {#if showTable} -
    - {#if loading.resources} - - {#each new Array(6) as _} - - {/each} - {:else if filteredItems?.length == 0} - {#if activeFilters} - - {:else} - appConnect?.open?.(), - aiId: 'resources-empty-add-resource', - aiDescription: 'Add resource' - } - : undefined} - /> - {/if} - {:else} - - - - - Path - Resource type - Description - Status - Actions - - -
    - {#if filteredItems} - {#each filteredItems as { path, description, resource_type, extra_perms, canWrite, is_oauth, is_linked, account, refresh_error, is_expired, marked, is_refreshed, labels, inherited_labels, ws_specific, draft_only, is_draft }} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'resource', path) ?? is_draft} - - - - - -
    - { - handledHash = `#/resource/${path}` - openResourceEditor(path, resource_type) - }} - >{#if marked}{@html marked}{:else}{path}{/if}{hasDraft ? '*' : ''} - - {#if labels?.length} -
    - {#each labels as label} - { - const arr = (filters.val.label ?? '').split(',').filter(Boolean) - const idx = arr.indexOf(label) - if (idx >= 0) arr.splice(idx, 1) - else arr.push(label) - const newFilters = { ...filters.val } - if (arr.length) newFilters.label = arr.join(',') - else delete newFilters.label - filters.val = newFilters - }}>{label} - {/each} -
    - {/if} - -
    -
    - - { - const linkedRt = resourceTypes?.find((rt) => rt.name === resource_type) - if (linkedRt) { - resourceTypeViewerObj = { - rt: linkedRt.name, - //@ts-ignore - schema: linkedRt.schema, - description: linkedRt.description ?? '', - formatExtension: linkedRt.format_extension, - isFileset: linkedRt.is_fileset ?? false - } - resourceTypeViewer?.openDrawer?.() - } else { - sendUserToast( - `Resource type ${resource_type} not found in workspace.`, - true - ) - } - }} - > - - - - -
    - - {removeMarkdown(truncate(description ?? '', 200))} - -
    -
    - -
    -
    - {#if is_linked} - - - {#snippet text()} -
    - This resource is linked with a variable of the same path. They - are deleted and renamed together. -
    - {/snippet} -
    - {/if} -
    -
    - {#if is_refreshed} - - - {#snippet text()} -
    - The OAuth token will be kept up-to-date in the background by - Windmill using its refresh token -
    - {/snippet} -
    - {/if} -
    - - {#if is_oauth} -
    - {#if refresh_error} - - - {#snippet text()} -
    - Latest exchange of the refresh token did not succeed. Error: {refresh_error} -
    - {/snippet} -
    - {:else if is_expired} - - - - {#snippet text()} -
    - The access_token is expired, it will get renewed the next time - this variable is fetched or you can request is to be refreshed - in the dropdown on the right. -
    - {/snippet} -
    - {:else} - - - {#snippet text()} -
    - The resource was connected through OAuth and the token is not - expired. -
    - {/snippet} -
    - {/if} -
    - {/if} -
    -
    - -
    - {#if path && assetCanBeExplored({ kind: 'resource', path }, { resource_type }) && !$userStore?.operator} - - {/if} - { - shareModal?.openDrawer?.(path, 'resource') - } - }, - { - displayName: 'Edit', - icon: Pen, - disabled: !canWrite || !showCreateButtons, - action: () => { - openResourceEditor(path, resource_type) - } - }, - // The agent form covers an agent's configuration, not everything a - // resource carries: the workspace-specific toggle in particular is - // only in the generic editor. JSON rather than that editor's form, - // which would render the configuration field by field and write a - // default into every one the agent leaves unset. Both write the same - // draft row, so this is a second view of the same edits. - ...(resource_type === 'ai_agent' - ? [ - { - displayName: 'Edit as JSON', - icon: Braces, - disabled: !canWrite || !showCreateButtons, - action: () => { - // The drawer anchors itself in the hash, which the deep-link - // effect would then read and route back to the agent editor. - // Claim it first, as the row's own link does. - handledHash = `#/resource/${path}` - resourceEditor?.initEdit?.(path, { json: true }) - } - } - ] - : []), - ...(!ws_specific && isDeployable('resource', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'resource') - } - } - ] - : []), - { - displayName: 'Delete', - disabled: !canWrite || !showCreateButtons, - icon: Trash, - type: 'delete', - action: (event) => { - // TODO - // @ts-ignore - if (event?.shiftKey) { - deleteResource(path, account) - } else { - deleteIsLinked = is_linked ?? false - deletePath = path - deleteConfirmedCallback = () => { - deleteResource(path, account) - } - } - } - }, - ...(account != undefined - ? [ - { - displayName: 'Refresh token', - icon: RotateCw, - action: async () => { - await OauthService.refreshToken({ - workspace: $workspaceStore ?? '', - id: account ?? 0, - requestBody: { - path - } - }) - sendUserToast('Token refreshed') - loadResources() - } - } - ] - : []) - ]} - /> -
    -
    - {/each} - {/if} - - - {/if} - - {:else if tab == 'types'} - {#if loading.types} - - {#each new Array(6) as _} - - {/each} - {:else if filteredResourceTypes?.length == 0} -
    - -
    - {:else} -
    - - - - Name - Description - Actions - - -
    - {#if filteredResourceTypes} - {#each filteredResourceTypes as { name, description, schema, canWrite, format_extension, is_fileset }} - - - { - resourceTypeViewerObj = { - rt: name, - //@ts-ignore - schema: schema, - description: description ?? '', - formatExtension: format_extension, - isFileset: is_fileset ?? false - } - - resourceTypeViewer?.openDrawer?.() - }} - > - - - - - -
    - - {removeMarkdown(truncate(description ?? '', 200))} - -
    -
    - - {#if !canWrite} - -
    - - Shared globally - - This resource type is from the 'admins' workspace shared with all - workspaces - - -
    - {:else if $userStore?.is_admin || $userStore?.is_super_admin} -
    - - -
    - {:else} - - Non Editable - - Since resource types are shared with the whole workspace, only admins - can edit/delete them - - - {/if} -
    -
    - {/each} - {/if} - - - - {/if} - {/if} - - -{/if} - - - - - - - { - loadResources() - }} -/> - - - -{#if agentEditorTarget()} - {#await import('$lib/components/flows/content/AgentEditorModal.svelte') then { default: AgentEditorModal }} - t.host === undefined} - onRenamed={(from, to) => { - void loadResources() - // Only while the dialog still shows the agent: closed mid-request, it already cleared the - // anchor, and writing it back would reopen the editor on refresh. - if (agentEditorTarget()?.path !== from) return - // Claimed first, as a row click does, so the deep-link effect does not reopen it. - handledHash = `#/resource/${to}` - setPageDrawerAnchor(RESOURCES_PATH, to) - }} - /> - {/await} -{/if} + diff --git a/frontend/src/routes/(root)/(logged)/routes/+page.svelte b/frontend/src/routes/(root)/(logged)/routes/+page.svelte index 53de5457d1..a79a1a0471 100644 --- a/frontend/src/routes/(root)/(logged)/routes/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/routes/+page.svelte @@ -1,592 +1,5 @@ - - - - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.triggers} - -{:else} - - -
    - - - -
    -
    -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - routeEditor?.openNew(false), - aiId: 'routes-empty-add', - aiDescription: 'Add route' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { summary, workspace_id, workspaced_route, mode, path, edited_by, edited_at, script_path, route_path, is_flow, extra_perms, canWrite, marked, http_method, static_asset_config, retry, error_handler_path, error_handler_args, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_http', path) ?? is_draft} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - -
    -
    - - - routeEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {#if marked} - - {@html marked} - - {:else if summary} - {summary} - {:else} - {http_method.toUpperCase()} - /{isCloudHosted() || workspaced_route || globalHttpWorkspacedRoute - ? workspace_id + '/' + route_path - : route_path} - {/if}{hasDraft ? '*' : ''} -
    -
    - {path} -
    -
    - {#if static_asset_config} - file: {static_asset_config.s3} - {:else} - runnable: {script_path} - {/if} -
    -
    - - - -
    - - onToggleMode(path, mode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'http', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - routeEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'routes' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'http_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - try { - await HttpTriggerService.deleteHttpTrigger({ - workspace: $workspaceStore ?? '', - path - }) - sendUserToast(`Successfully deleted HTTP route: ${path}`) - loadTriggers() - } catch (error) { - sendUserToast(error.body || error.message, true) - } - } - } - ]} - /> -
    -
    -
    -
    - {#if edited_by}
    edited by {edited_by}
    {/if} -
    {edited_by ? 'at ' : ''}{displayDate(edited_at)}
    -
    -
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    -{/if} - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte index 6e78801b8b..5fc7c1209d 100644 --- a/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/run/[...run]/+page.svelte @@ -40,8 +40,10 @@ EllipsisVertical, Share2, Globe, - Users + Users, + Play } from 'lucide-svelte' + import { forLater } from '$lib/forLater' import { isJobResolvable } from '$lib/utils' import { @@ -152,12 +154,41 @@ string, { modules: import('$lib/gen').FlowModule[]; groups?: any[] } > = $state({}) - const restart = useNestedRestartState({ + const selectedRestart = useNestedRestartState({ selectedJobStep: () => selectedJobStep, job: () => job, graphModuleStates: () => graphModuleStates, expandedSubflows: () => expandedSubflows }) + // When the selection can't be restarted from (nothing clicked, Input/Result, + // a step inside a parallel loop), the button targets the top-level step that + // failed the run, so it is visible without hunting for it in a large graph. + // A continue-on-error step keeps its `Failure` status on a run that succeeds, + // so only a failed run counts, and its last `Failure` is the one that ended it. + const failedTopLevelStep = $derived.by(() => { + if (job?.type !== 'CompletedJob' || job.success !== false || job.canceled) return undefined + const failures = job.flow_status?.modules?.filter((m) => m.type === 'Failure') ?? [] + return failures[failures.length - 1]?.id + }) + const failedRestart = useNestedRestartState({ + selectedJobStep: () => failedTopLevelStep, + job: () => job, + graphModuleStates: () => graphModuleStates, + expandedSubflows: () => expandedSubflows + }) + const selectionRestartable = $derived( + selectedJobStep !== undefined && + (selectedRestart.topLevelRestartable || selectedRestart.nestedRestartSupported) + ) + const restartStep = $derived(selectionRestartable ? selectedJobStep : failedTopLevelStep) + const restart = $derived(selectionRestartable ? selectedRestart : failedRestart) + function canRestart(state: ReturnType) { + return ( + job?.type === 'CompletedJob' && + job.job_kind === 'flow' && + (state.topLevelRestartable || state.nestedRestartSupported) + ) + } let testIsLoading = $state(false) let jobLoader: JobLoader | undefined = $state(undefined) @@ -251,6 +282,27 @@ } } + // A schedule's upcoming tick sits on a whole second and the backend refuses it; a tick + // deferred by a concurrency limit lands on a sub-second instant and can still start now. + let canRunNow = $derived( + job?.type === 'QueuedJob' && + !job.running && + !job.suspend && + !!job.scheduled_for && + forLater(job.scheduled_for) && + !(job.schedule_path && new Date(job.scheduled_for).getMilliseconds() === 0) + ) + + async function runJobNow(id: string) { + try { + await JobService.runQueuedJobNow({ workspace: $workspaceStore!, id }) + sendUserToast(`job ${id} will start as soon as a worker is available`) + getJob() + } catch (err) { + sendUserToast(`could not start job now: ${err?.body ?? err}`, true) + } + } + // Initialize view tab to logs since result is now outside tabs function initView(): void { // Result is now displayed outside tabs, so always default to logs @@ -643,6 +695,36 @@ /> {/if} +{#snippet flowRestartButton( + state: ReturnType, + step: string, + triggerStyle: 'button' | 'link' +)} + {#if job} + { + goto('/run/' + newJobId + '?workspace=' + $workspaceStore) + }} + flowPath={job.script_path} + flowVersionId={job.script_hash ? parseInt(job.script_hash, 16) : undefined} + disabled={!$enterpriseLicense} + enterpriseOnly={!$enterpriseLicense} + {triggerStyle} + unifiedSize="sm" + /> + {/if} +{/snippet} + @@ -738,13 +820,13 @@ {/if} @@ -772,7 +854,7 @@ ]} > {#snippet buttonReplacement()} - {/snippet} @@ -794,7 +876,7 @@ class="h-auto" > {#snippet buttonReplacement()} - {/if} + {#if canRunNow} + + {/if} {#if job && job?.type != 'CompletedJob' && (!job?.schedule_path || job?.['running'] == true)} {#if !forceCancel} {/if} - {#if job?.type === 'CompletedJob' && job?.job_kind === 'flow' && selectedJobStep !== undefined && (restart.topLevelRestartable || restart.nestedRestartSupported) && job.id} - { - goto('/run/' + newJobId + '?workspace=' + $workspaceStore) - }} - flowPath={job.script_path} - flowVersionId={job.script_hash ? parseInt(job.script_hash, 16) : undefined} - disabled={!$enterpriseLicense} - enterpriseOnly={!$enterpriseLicense} - /> + {#if restartStep !== undefined && canRestart(restart)} + {@render flowRestartButton(restart, restartStep, 'button')} {/if} {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} {#if showEditButton} @@ -951,7 +1025,7 @@ target: { kind: isScript ? 'script' : 'flow', path: job?.script_path ?? '' }, workspaceId: $workspaceStore ?? undefined }} - btnProps={{ unifiedSize: 'md' }} + btnProps={{ unifiedSize: 'sm' }} /> {/if} {/if} @@ -962,9 +1036,8 @@ onEditInForkClick(e, isScript ? 'script' : 'flow', job?.script_path ?? '', { hasHref: true })} - unifiedSize="md" + unifiedSize="sm" variant="default" - size="sm" startIcon={{ icon: Pen }}>{editInForkLabel($workspaceStore, $userWorkspaces)} {/if} @@ -973,7 +1046,7 @@ {#if job?.job_kind === 'script' || job?.job_kind === 'script_hub' || job?.job_kind === 'flow'} - -
    -
    - - {#snippet children({ item })} - - - - {/snippet} - - -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !schedules?.length} - {#if activeFilters} - - {:else} - scheduleEditor?.openNew(false), - aiId: 'schedules-empty-add', - aiDescription: 'Add schedule' - }} - /> - {/if} - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, error, summary, edited_by, edited_at, schedule, timezone, enabled, script_path, is_flow, extra_perms, canWrite, jobs, paused_until, labels, inherited_labels, draft_only, is_draft } (path)} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'trigger_schedule', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const avg_s = jobs - ? jobs.reduce((acc, x) => acc + x.duration_ms, 0) / jobs.length - : undefined} - -
    -
    - - - { - handledHash = `#${path}` - scheduleEditor?.openEdit(path, is_flow) - }} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {summary || script_path}{hasDraft ? '*' : ''} -
    -
    - schedule: {path} -
    -
    - {#if labels?.length} - {#each labels as label} - { - const arr = (filters.val.label ?? '').split(',').filter(Boolean) - const idx = arr.indexOf(label) - if (idx >= 0) arr.splice(idx, 1) - else arr.push(label) - const newFilters = { ...filters.val } - if (arr.length) newFilters.label = arr.join(',') - else delete newFilters.label - filters.val = newFilters - }}>{label} - {/each} - {/if} - - - {#if paused_until && new Date(paused_until) > new Date()} -
    - Paused until {new Date(paused_until).toLocaleString()} -
    - {/if} - - - - - -
    - {#if error} - - - - - - {#snippet text()} -
    - The schedule disabled itself because there was an error scheduling the - next job: {error} -
    - {/snippet} -
    - {/if} -
    - -
    - - {#key toggleResetVersions[path] ?? 0} - { - if (canWrite) { - setScheduleEnabled(path, e.detail) - } else { - sendUserToast('not enough permission', true) - // Permission denied — bump the row's reset - // counter so the Toggle remounts back to the - // prop value. Without this, the local - // `bind:checked` flip from the user's click - // stays stuck on. - bumpToggleReset(path) - } - }} - /> - {/key} -
    -
    - - - { - goto(href) - } - }, - { - displayName: `Duplicate schedule`, - icon: Copy, - action: () => { - scheduleEditor?.openNew(is_flow, script_path, path) - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - await ScheduleService.deleteSchedule({ - workspace: $workspaceStore ?? '', - path - }) - loadSchedules() - } - }, - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - scheduleEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'schedules' - } - }) - } - } - ] - : []), - { - displayName: 'View runs', - icon: List, - href: `${base}/runs/?schedule_path=${path}&job_trigger_kind=schedule&show_future_jobs=true` - }, - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Run now', - icon: Play, - action: () => { - runScheduleNow(script_path, path, is_flow, $workspaceStore!) - } - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'schedule') - } - } - ]} - /> -
    -
    -
    - {#if loadingSchedulesWithJobStats} -
    - - Job stats loading... -
    - {:else} -
    - {#if avg_s} -
    Avg: {(avg_s / 1000).toFixed(2)}s
    - {/if} - {#each jobs ?? [] as job} - {@const h = (avg_s ? job.duration_ms / avg_s : 1) * 7 + 3} - - -
    -
    - -
    -
    -
    - {/each} -
    - {/if} -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} -
    - {nbDisplayed} items out of {items.length} - -
    - {/if} - -{/if} - - { - loadSchedules() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte index 4ee73f5207..49ad076d23 100644 --- a/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/scripts/edit/[...path]/+page.svelte @@ -70,9 +70,10 @@ // Page-level draft orchestration: autosave handle (re-keyed on nav via // `draftPath`), live-editor-draft registry, `recordRemoteSync`, removal. // `draftSync.draft` stays a stable lvalue for `bind:script`. - /** Deployed script this load (with `parent_hash` grafted to match the - * unedited draft seed), the baseline the autosave `discardIf` compares - * against. `undefined` for draft-only paths so they never self-destruct. */ + /** Deployed script this load (with the same `parent_hash` the draft seed + * carries, so an unedited draft compares equal), the baseline the autosave + * `discardIf` compares against. `undefined` for draft-only paths so they + * never self-destruct. */ let deployedBaseline = $state(undefined) const draftSync = usePageDraftSync({ @@ -107,6 +108,11 @@ * (our draft is behind the latest deploy). Cleared between loads to re-fire. */ let draftSavedAt = $state(undefined) let deployedAt = $state(undefined) + /** The hash the draft forked from, and the deployed head — the script + * equivalent of the flow/app version pair. Behind ⇔ the two differ. */ + let draftBaseHash = $state(undefined) + let deployedHeadHash = $state(undefined) + let deployedBy = $state(undefined) // Remounts ScriptBuilder on nav: false while a reload runs, true once data is // ready. A synchronous `{#key}` swap instead races Monaco's init against the @@ -151,6 +157,9 @@ loadedFromDraft = false draftSavedAt = undefined deployedAt = undefined + draftBaseHash = undefined + deployedHeadHash = undefined + deployedBy = undefined // Brand-new script: no deployed baseline, so never discard-on-equal. deployedBaseline = undefined const templatePath = page.url.searchParams.get('template') @@ -332,15 +341,31 @@ // `created_at` is the latest deploy, `draft_saved_at` the draft's save. draftSavedAt = backendScript.draft_saved_at as string | undefined deployedAt = backendScript.created_at as string | undefined + deployedBy = backendScript.created_by as string | undefined // Layer the draft (`.draft`, if any) over the deployed payload at the // field level: the draft supplies editor state (content, summary, …), // the deployed supplies metadata it lacks (hash, version markers). const { draft: draftFromBackend, ...deployedScript } = backendScript as any + // Exact staleness, preferred over the timestamps: a draft carried across + // a move keeps its old save time while the move mints a fresh deploy, so + // the timestamps alone would call every carried draft stale. + draftBaseHash = backendScript.draft_base + // A draft-only script has no deployed hash to be behind: what comes back + // under `hash` is then the draft's own value, not a head. + deployedHeadHash = backendScript.no_deployed + ? undefined + : (backendScript.hash as string | undefined) const effectiveScript: EditableScript = draftFromBackend ? { ...deployedScript, ...draftFromBackend } : (deployedScript as EditableScript) savedScript = structuredClone($state.snapshot(effectiveScript)) - const parentHash = topHash ?? backendScript.hash + // The draft's base is the version it forked from and only the user moves it + // (by discarding or rebasing). A draft keeps the base it has, unknown included + // (staleness then falls back to the timestamps); seeding the head over it here + // would let the next autosave persist the head as the base, so a draft behind + // the deploy would read as up to date after one open. Only a fresh checkout + // forks from the head. + const parentHash = topHash ?? (hasOwnDraft ? backendScript.draft_base : backendScript.hash) // Baseline for the autosave `discardIf`: the deployed script with the // same `parent_hash` graft the seed below applies and the schema as the // mounted editor holds it, so the unedited draft compares equal. @@ -363,11 +388,25 @@ OtherUserDraftLoad.clear($workspaceStore!, 'script', draftPath) } if (pendingLoad) { + const theirs = (pendingLoad.value as { parent_hash?: string })?.parent_hash const loadedValue = { ...deployedScript, ...(pendingLoad.value as object), - parent_hash: parentHash + parent_hash: theirs } as EditableScript + // Their draft's base, not ours: the prompt and the deploy guard read these, + // and deploying their content on our base would claim a version it never + // forked from. A draft that has none keeps none — seeding `parentHash` + // (the head, when we have no draft here) would mark their older content as + // forked from the current deploy and silence the stale prompt. + if (theirs == null) { + delete (loadedValue as { parent_hash?: string }).parent_hash + // The autosave's `discardIf` baseline has to carry the same lineage as the + // value, or an unedited copy of their content never compares equal to the + // deployed one and the draft can never discard itself. + if (deployedBaseline) delete (deployedBaseline as { parent_hash?: string }).parent_hash + } + draftBaseHash = theirs if (hasOwnDraft) { OtherUserDraftLoad.beginOverlay({ workspace: $workspaceStore!, @@ -480,6 +519,11 @@ bind:othersModalOpen {draftSavedAt} {deployedAt} + draftBaseVersion={draftBaseHash} + deployedHeadVersion={deployedHeadHash} + {deployedBy} + onViewDiff={() => scriptBuilder?.openDiffDrawer()} + onBeforeRelocate={() => scriptBuilder?.saveDraft()} onLoadLatestDeploy={async () => { // stopSync-bracketed; see restoreDeployed for the race. if (!$workspaceStore) return @@ -499,6 +543,25 @@ bind:this={scriptBuilder} {initialPath} userDraftPath={draftPath} + {draftBaseHash} + onTakeLatest={draftBaseHash + ? async (shown?: string) => { + // The version the drawer showed as head: it offers this action only when it + // has one, and adopting the head this page loaded with would claim the + // draft is up to date with a version that may not be the latest. + const head = shown ?? deployedHeadHash + if (!draftSync.draft || !head || !$workspaceStore) return + draftSync.draft = { ...draftSync.draft, parent_hash: head } + // The baseline mirrors the draft's base so an unedited draft still + // compares equal and the autosave can discard it. + if (deployedBaseline) deployedBaseline = { ...deployedBaseline, parent_hash: head } + draftBaseHash = head + // The head this page knows moves with it, or the prompt reopens comparing + // the fresh base against the stale head, the two the wrong way round. + deployedHeadHash = head + await UserDraft.forcePersist('script', draftPath, { workspace: $workspaceStore }) + } + : undefined} bind:script={draftSync.draft} template={builderTemplate} {lockedLanguage} @@ -519,6 +582,13 @@ onDeploy={(e) => { // "Deploy & Stay here" / lib: stay on the editor (just confirm). if (e.stay) { + // `e.hash` is the hash the create call returned, so it names this deploy's own + // version with nothing to attribute (unlike an app, which has to read its + // version back). The builder re-pinned the draft to it, so the pair moves + // here too: otherwise Diff offers Take latest against a head the draft is + // already on. + draftBaseHash = e.hash + deployedHeadHash = e.hash sendUserToast('Deployed') return } diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index 84bf9d4136..07b01a84d0 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -35,6 +35,7 @@ isTearingDownOpenSession, selectSession, sessionInCurrentFamily, + sessionPageHref, sessionState, type SessionPreviewTab } from '$lib/components/sessions/sessionState.svelte' @@ -61,12 +62,15 @@ artifactKey, itemDisplayName, matchPreviewPage, + pageItemUrl, pageKey, parseArtifactRoute, - parseRunFormRoute, + parsePageItemRoute, + type PageItemRef, parsePreviewItemRoute, previewLocationLabel, resolvePreviewTab, + workspacePageHref, type PreviewTarget } from '$lib/components/sessions/previewRouter' import { @@ -74,6 +78,7 @@ tabsToReload, viewerTabsToReload } from '$lib/components/sessions/previewReload' + import { isPageItemListPath, stripBase } from '$lib/components/sessions/previewPaths' import { leafKeyFor, loadKind, @@ -110,7 +115,8 @@ () => import('$lib/components/sessions/ScriptEditorView.svelte'), () => import('$lib/components/sessions/FlowEditorView.svelte'), () => import('$lib/components/sessions/RawAppEditorView.svelte'), - () => import('$lib/components/sessions/PipelineEditorView.svelte') + () => import('$lib/components/sessions/PipelineEditorView.svelte'), + () => import('$lib/components/sessions/PageItemEditorView.svelte') ] for (const load of loaders) { if (disposed) return @@ -129,28 +135,37 @@ } }) - const sessionName = $derived(page.url.searchParams.get('session_name') ?? '') - - // Unfiltered resolution by name: drives the recovery effect below and the - // active-session lookup. - const sessionByName = $derived( - sessionName ? sessionState.sessions.find((s) => s.name === sessionName) : undefined - ) + const sessionId = $derived(page.url.searchParams.get('session') ?? '') + // Links from before sessions were addressed by id name a per-browser `session-N`. + const legacySessionName = $derived(page.url.searchParams.get('session_name') ?? '') + const requestsSession = $derived(!!sessionId || !!legacySessionName) // Opening a session deliberately does NOT switch the global workspace: the // chat targets the session's own workspace via the manager's workspace // resolver, so the user's active (navigation-mode) workspace is left alone. - // Resolve by name without applying the sidebar scope filter so an open - // chat survives within-family workspace switches. - const activeSession = $derived(sessionState.sessions.find((s) => s.name === sessionName)) + // Resolved without applying the sidebar scope filter so an open chat survives + // within-family workspace switches. + const activeSession = $derived( + sessionId + ? sessionState.sessions.find((s) => s.id === sessionId) + : legacySessionName + ? sessionState.sessions.find((s) => s.name === legacySessionName) + : undefined + ) + + $effect(() => { + if (embedded || sessionId || !activeSession) return + const id = activeSession.id + untrack(() => void goto(sessionPageHref(id), { replaceState: true })) + }) // Family reconcile: a workspace switch can land this page with no session // selected or with another family's session in the URL (the sidebar picker's // link navigation keeps the route), and a chat must not bleed across // families. Re-enter session mode scoped to the active family: keep the open // chat when it belongs there, else its most recent active session, else a - // fresh one. A `session_name` that resolves to nothing is left to the + // fresh one. A `session` that resolves to nothing is left to the // recovery effect below. $effect(() => { if (embedded || !sessionState.hydrated) return @@ -163,15 +178,14 @@ $workspaceStore $userWorkspaces const current = activeSession - const shouldReenter = current ? !sessionInCurrentFamily(current) : !sessionName + const shouldReenter = current ? !sessionInCurrentFamily(current) : !requestsSession if (!shouldReenter) return untrack(() => void enterSessionMode({ replace: true })) }) // Held across the swap so the session layout mounts once, after the navigation - // settles. A recovered session often takes the very name that was missing, so - // the URL resolves before `goto` returns; mounting the panes mid-navigation - // makes Splitpanes miss the collapsed pane's `maxSize: 0` and leave it open. + // settles: mounting the panes mid-navigation makes Splitpanes miss the collapsed + // pane's `maxSize: 0` and leave it open. let recovering = $state(false) // Bounds the wait for the workspace list the guard above needs. The list can @@ -196,7 +210,7 @@ // they just deleted couldn't be found. if (isTearingDownOpenSession()) return if ($usersWorkspaceStore === undefined && !workspaceListOverdue) return - if (!sessionName || sessionByName) return + if (!requestsSession || activeSession) return untrack(() => void recoverToNewSession()) }) @@ -222,19 +236,39 @@ // deep links the same way they react to picker clicks. selectSession(session.id) getOrCreateRuntime(session) + mountChat(session.id) }) }) - // Warm = sessions with a live runtime. The picker eagerly creates runtimes - // for its visible sessions, so this tracks whatever it shows. Keeping warm - // chats mounted (stacked, visibility-toggled) preserves their scroll/draft - // state across switches. + // Warm = sessions with a live runtime: the ones visited this page load plus + // any a chat tool or a running job started. const warmSessions = $derived( listRuntimes() .map((r) => sessionState.sessions.find((s) => s.id === r.sessionId)) .filter((s): s is NonNullable => s != null) ) + // Chat columns stay mounted (stacked, visibility-toggled) so switching back + // keeps scroll position, MRU-capped like preview tabs: every mounted column + // is a full transcript in the DOM. Unmounting drops only the column — the + // runtime keeps the chat — so a column is kept while its composer holds + // unsent input (component-local) or its turn is running. + const MAX_MOUNTED_CHATS = 5 + const mountedChatIds = new SvelteSet() + function keepsChatMounted(id: string): boolean { + const m = getRuntime(id)?.manager + return !!m && (m.loading || m.sendInFlight || m.sendPending || m.hasUnsentInput) + } + function mountChat(id: string) { + mountedChatIds.delete(id) + mountedChatIds.add(id) + for (const oldest of [...mountedChatIds]) { + if (mountedChatIds.size <= MAX_MOUNTED_CHATS) break + if (oldest !== id && !keepsChatMounted(oldest)) mountedChatIds.delete(oldest) + } + } + const mountedChatSessions = $derived(warmSessions.filter((s) => mountedChatIds.has(s.id))) + // Mark the active session "seen" up to its current message count: arrive → // clear unread; AI streams a new message while we're here → clear again. The // effect depends only on the length, not the array contents, so token-by-token @@ -258,7 +292,7 @@ const target = findEmptyLandingSession() ?? createSession() selectSession(target.id) markSessionRecovered(target.id) - await goto(`/sessions?session_name=${encodeURIComponent(target.name)}`, { + await goto(sessionPageHref(target.id), { replaceState: true }) await tick() @@ -510,13 +544,9 @@ // Page path shown after the workspace breadcrumb — the active tab's observed // location, so the breadcrumb tracks where the user browses inside the tab. const displayPath = $derived(owner?.activeTab?.loc ?? owner?.activeTab?.url ?? `${base}/`) - // Artifacts have no workspace page, so "Open in workspace" can't resolve for them. const activeArtifact = $derived(owner?.activeTab ? parseArtifactRoute(owner.activeTab.url) : null) - // Nor does a run form: it belongs to a chat, and its url is a scheme rather than a path, - // so the link would resolve to the tool call id as a route. - const activeTabHasNoWorkspacePage = $derived( - activeArtifact != null || - (owner?.activeTab ? parseRunFormRoute(owner.activeTab.url) != null : false) + const activeWorkspaceHref = $derived( + owner?.activeTab ? workspacePageHref(owner.activeTab.loc || owner.activeTab.url) : `${base}/` ) // The active session's artifacts, surfaced as an "Artifacts" branch in the // preview pickers. @@ -568,33 +598,35 @@ } } - // Reload mounted preview tabs affected by a mutating chat tool. Editor and pipeline - // tabs self-sync from the store the chat mutates, so nothing reloads them. Two kinds - // do need telling, and each only when a tool actually changed what IT shows - // (toolReloadEffect): list-page tabs (schedules, resources, …), which are iframes, and - // tabs on an item's View side, which render the deployed version over the API. So a - // schedule write leaves the Resources tab alone, and a purely local tool (saving user - // instructions) reloads nothing. + // Reload mounted preview tabs affected by a mutating chat tool. Item and pipeline + // tabs are live editors that self-sync from the store the chat mutates, so nothing + // reloads them. Three kinds do need telling, each only when a tool actually changed + // what IT shows (toolReloadEffect): list-page tabs (schedules, resources, …), page + // item tabs, and tabs on an item's View side, which render the deployed version over + // the API. So a schedule write leaves the Resources tab alone, and a purely local + // tool (saving user instructions) reloads nothing. const tabHosts: Record = {} let reloadHandle: ReturnType | undefined - // Base-stripped list-page paths (e.g. `/schedules`) a chat round touched since - // the last flush — see toolReloadEffect for how tools map to pages. - let pendingPages = new Set() - // Item paths whose DEPLOYED version a chat round changed. Only the View side of a tab - // reads that version, so these refresh those tabs and leave the editors alone. - let pendingItems = new Set() + // Per workspace a chat round touched since the last flush: base-stripped list-page paths + // (e.g. `/schedules`, see toolReloadEffect), the page items its tools named, as tab urls, + // and the item paths whose deployed version changed. By workspace because a path names an + // item only within one: a write in one fork must not remount the same path's editor in a + // session on another. + let pending = new Map; items: Set; deployed: Set }>() - // Reload the mounted list-page tabs a chat round changed, across all warm - // sessions (a hidden preview would otherwise show pre-mutation content on - // return). tabsToReload picks only the tabs whose page is in `pages`. - function reloadTabs(pages: Set, items: Set) { + // Reload the mounted tabs a chat round changed in each warm session acting on that + // workspace (a hidden preview would otherwise show pre-mutation content on return). + function flushReload() { + const touched = pending + pending = new Map() for (const s of warmSessions) { + const scope = touched.get(getEffectiveWorkspaceId(s) ?? $workspaceStore ?? '') const owner = getRuntime(s.id)?.previewTabs - if (!owner) continue + if (!scope || !owner) continue const affected = [ - ...tabsToReload(owner.tabs, pages), - ...viewerTabsToReload(owner.tabs, items) + ...tabsToReload(owner.tabs, scope.pages, scope.items), + ...viewerTabsToReload(owner.tabs, scope.deployed) ] for (const tab of affected) { const key = tabKey(s.id, tab.id) @@ -602,27 +634,26 @@ } } } - function flushReload() { - const pages = pendingPages - const items = pendingItems - pendingPages = new Set() - pendingItems = new Set() - reloadTabs(pages, items) - } $effect(() => { // Debounced so a burst of writes (the AI editing several files) reloads once. - setToolCompletionListener((name, args) => { - const { pages, items } = toolReloadEffect(name, args) - if (pages.length === 0 && items.length === 0) return - for (const p of pages) pendingPages.add(p) - for (const i of items) pendingItems.add(i) + setToolCompletionListener((name, args, workspace) => { + const { pages, items, deployed } = toolReloadEffect(name, args) + if (pages.length === 0 && deployed.length === 0) return + let scope = pending.get(workspace) + if (!scope) + pending.set( + workspace, + (scope = { pages: new Set(), items: new Set(), deployed: new Set() }) + ) + for (const p of pages) scope.pages.add(p) + for (const item of items) scope.items.add(pageItemUrl(item)) + for (const d of deployed) scope.deployed.add(d) clearTimeout(reloadHandle) reloadHandle = setTimeout(flushReload, 500) }) return () => { clearTimeout(reloadHandle) - pendingPages = new Set() - pendingItems = new Set() + pending = new Map() setToolCompletionListener(undefined) } }) @@ -640,6 +671,22 @@ o.open(target) }) }) + // Variables, resources, schedules and triggers the chat links to open as tabs of their + // own here, rather than in the drawers the layout opens them in elsewhere. + $effect(() => { + return registerToolDisplayActionHandler('open_created_resource', (action) => { + if (action.type !== 'open_created_resource') return + const ref: PageItemRef | undefined = + action.resource === 'trigger' + ? action.triggerKind && { + kind: 'trigger', + triggerKind: action.triggerKind, + path: action.path + } + : { kind: action.resource, path: action.path } + if (ref) owner?.open({ type: 'pageitem', ref }) + }) + }) // Editor-style breadcrumb over the previewed page. We only render clickable // segments when the preview is sitting on a script/flow/app route — for any @@ -778,7 +825,8 @@ const path = tab.friendlyPath ?? listedItemFor(tab, workspace)?.draftPath ?? - parsePreviewItemRoute(tab.loc)?.itemPath + parsePreviewItemRoute(tab.loc)?.itemPath ?? + parsePageItemRoute(tab.loc)?.path return path && path !== label ? `${label}\n${path}` : label } @@ -824,6 +872,13 @@ owner?.navigate({ type: 'item', item }) return } + // A preview frame navigating to a list page: re-point the active tab to the list + // mounted in process, whose rows open as tabs of their own. + if (d.type === 'wm.session.openList') { + if (typeof d.href !== 'string' || !isPageItemListPath(stripBase(d.href))) return + owner?.navigate({ type: 'page', href: d.href, label: previewLocationLabel(d.href) }) + return + } // A job clicked inside a preview tab: open the run detail in a NEW tab so the // originating page (e.g. Runs) stays put. open() focuses an existing tab for // the same run rather than duplicating it. @@ -902,9 +957,9 @@
    - {:else if !sessionName} + {:else if !requestsSession}
    No session selected — pick one in the sidebar.
    - {:else if !sessionByName || recovering} + {:else if !activeSession || recovering}
    @@ -926,11 +981,11 @@ class="flex-1 min-h-0 session-splitter {previewCollapsed ? 'splitter-off' : ''}" > {#if !fullscreen} - +
    - {#each warmSessions as s (s.id)} + {#each mountedChatSessions as s (s.id)}
    - {#if !activeTabHasNoWorkspacePage} + {#if activeWorkspaceHref} - import { getLocalDraftHint } from '$lib/localDraftHints.svelte' - import { run } from 'svelte/legacy' - - import { - SqsTriggerService, - WorkspaceService, - type SqsTrigger, - type TriggerMode, - type WorkspaceDeployUISettings - } from '$lib/gen' - import { - canWrite, - displayDate, - getLocalSetting, - sendUserToast, - storeLocalSetting, - removeTriggerKindIfUnused - } from '$lib/utils' - import { withForkConflictRetry } from '$lib/utils/forkConflict' - import { base } from '$app/paths' - import { page } from '$app/stores' - import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Alert, Badge, Button, EmptyState, Skeleton } from '$lib/components/common' - import Dropdown from '$lib/components/DropdownV2.svelte' - import PageHeader from '$lib/components/PageHeader.svelte' - import SharedBadge from '$lib/components/SharedBadge.svelte' - import DraftBadge from '$lib/components/DraftBadge.svelte' - import ShareModal from '$lib/components/ShareModal.svelte' - import Toggle from '$lib/components/Toggle.svelte' - import { enterpriseLicense, usedTriggerKinds, userStore, workspaceStore } from '$lib/stores' - import { Code, Eye, Pen, Plus, Shield, Trash, Circle, FileUp, Pause } from 'lucide-svelte' - import { goto } from '$lib/navigation' - import SearchItems from '$lib/components/SearchItems.svelte' - import NoItemFound from '$lib/components/home/NoItemFound.svelte' - import RowIcon from '$lib/components/common/table/RowIcon.svelte' - import ListFilters from '$lib/components/home/ListFilters.svelte' - import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' - import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' - import { setQuery } from '$lib/navigation' - import { onDestroy, onMount } from 'svelte' - import Popover from '$lib/components/Popover.svelte' - import { isCloudHosted } from '$lib/cloud' - import SqsTriggerEditor from '$lib/components/triggers/sqs/SqsTriggerEditor.svelte' - import { ALL_DEPLOYABLE, isDeployable } from '$lib/utils_deployable' - import DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' - import AwsIcon from '$lib/components/icons/AwsIcon.svelte' - import TriggerModeToggle from '$lib/components/triggers/TriggerModeToggle.svelte' - - type TriggerD = SqsTrigger & { canWrite: boolean } - - let triggers: TriggerD[] = $state([]) - let shareModal: ShareModal | undefined = $state() - let loading = $state(true) - let deploymentDrawer: DeployWorkspaceDrawer | undefined = $state() - let deployUiSettings: WorkspaceDeployUISettings | undefined = $state(undefined) - - async function getDeployUiSettings() { - if (!$enterpriseLicense) { - deployUiSettings = ALL_DEPLOYABLE - return - } - let settings = await WorkspaceService.getPublicSettings({ workspace: $workspaceStore! }) - deployUiSettings = settings.deploy_ui ?? ALL_DEPLOYABLE - } - getDeployUiSettings() - async function loadTriggers(): Promise { - triggers = ( - await SqsTriggerService.listSqsTriggers({ - workspace: $workspaceStore!, - includeDraftOnly: true - }) - ).map((x) => { - return { canWrite: canWrite(x.path, x.extra_perms!, $userStore), ...x } - }) - $usedTriggerKinds = removeTriggerKindIfUnused(triggers.length, 'sqs', $usedTriggerKinds) - loading = false - } - - let interval = setInterval(async () => { - try { - const newTriggers = await SqsTriggerService.listSqsTriggers({ - workspace: $workspaceStore! - }) - for (let i = 0; i < triggers.length; i++) { - const newTrigger = newTriggers.find((x) => x.path === triggers[i].path) - if (newTrigger) { - triggers[i] = { - ...triggers[i], - error: newTrigger.error, - last_server_ping: newTrigger.last_server_ping, - mode: newTrigger.mode, - server_id: newTrigger.server_id - } - } - } - } catch (err) { - console.error(err) - } - }, 5000) - - onDestroy(() => { - clearInterval(interval) - }) - - async function onToggleMode(path: string, mode: TriggerMode): Promise { - let committed = false - try { - const ok = await withForkConflictRetry( - (force) => - SqsTriggerService.setSqsTriggerMode({ - path, - workspace: $workspaceStore!, - requestBody: { mode, force } - }), - 'SQS trigger' - ) - committed = ok - if (ok) loadTriggers() - } catch (err) { - sendUserToast( - `Cannot ` + - (mode === 'enabled' ? 'enable' : mode === 'disabled' ? 'disable' : 'suspend') + - ` sqs trigger: ${err.body}`, - true - ) - loadTriggers() - } - return committed - } - - run(() => { - if ($workspaceStore && $userStore) { - loadTriggers() - } - }) - let sqsTriggerEditor: SqsTriggerEditor | undefined = $state() - - let hashHandled = false - $effect(() => { - if (!hashHandled && triggers.length > 0 && sqsTriggerEditor) { - let hash = $page.url.hash - if (hash.length > 1) { - let path = hash.slice(1) - let trigger = triggers.find((t) => t.path === path) - if (trigger) { - hashHandled = true - sqsTriggerEditor?.openEdit(path, trigger.is_flow) - } - } - } - }) - - let filteredItems: (TriggerD & { marked?: any })[] | undefined = $state([]) - let items: typeof filteredItems | undefined = $state([]) - let preFilteredItems: typeof filteredItems | undefined = $state([]) - let filter = $state('') - let ownerFilter: string | undefined = $state(undefined) - let nbDisplayed = $state(15) - - const TRIGGER_PATH_KIND_FILTER_SETTING = 'filter_path_of' - const FILTER_USER_FOLDER_SETTING_NAME = 'user_and_folders_only' - let selectedFilterKind = $state( - (getLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING) as 'trigger' | 'script_flow') ?? 'trigger' - ) - let filterUserFolders = $state(getLocalSetting(FILTER_USER_FOLDER_SETTING_NAME) == 'true') - - run(() => { - storeLocalSetting(TRIGGER_PATH_KIND_FILTER_SETTING, selectedFilterKind) - }) - run(() => { - storeLocalSetting(FILTER_USER_FOLDER_SETTING_NAME, filterUserFolders ? 'true' : undefined) - }) - - function filterItemsPathsBaseOnUserFilters( - item: TriggerD, - selectedFilterKind: 'trigger' | 'script_flow', - filterUserFolders: boolean - ) { - if ($workspaceStore == 'admins') return true - if (filterUserFolders) { - if (selectedFilterKind === 'trigger') { - return ( - !item.path.startsWith('u/') || item.path.startsWith('u/' + $userStore?.username + '/') - ) - } else { - return ( - !item.script_path.startsWith('u/') || - item.script_path.startsWith('u/' + $userStore?.username + '/') - ) - } - } else { - return true - } - } - - run(() => { - preFilteredItems = - ownerFilter != undefined - ? selectedFilterKind === 'trigger' - ? triggers?.filter( - (x) => - x.path.startsWith(ownerFilter + '/') && - filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders) - ) - : triggers?.filter( - (x) => - x.script_path.startsWith(ownerFilter + '/') && - filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders) - ) - : triggers?.filter((x) => - filterItemsPathsBaseOnUserFilters(x, selectedFilterKind, filterUserFolders) - ) - }) - - run(() => { - if ($workspaceStore) { - ownerFilter = undefined - } - }) - - let owners = $derived( - selectedFilterKind === 'trigger' - ? Array.from( - new Set(filteredItems?.map((x) => x.path.split('/').slice(0, 2).join('/')) ?? []) - ).sort() - : Array.from( - new Set(filteredItems?.map((x) => x.script_path.split('/').slice(0, 2).join('/')) ?? []) - ).sort() - ) - - run(() => { - items = filter !== '' ? filteredItems : preFilteredItems - }) - - function updateQueryFilters(selectedFilterKind, filterUserFolders) { - setQuery( - TRIGGER_PATH_KIND_FILTER_SETTING, - selectedFilterKind, - window.location.hash || undefined - ).then(() => { - setQuery( - FILTER_USER_FOLDER_SETTING_NAME, - String(filterUserFolders), - window.location.hash || undefined - ) - }) - } - - function loadQueryFilters() { - let url = new URL(window.location.href) - let queryFilterKind = url.searchParams.get(TRIGGER_PATH_KIND_FILTER_SETTING) - let queryFilterUserFolders = url.searchParams.get(FILTER_USER_FOLDER_SETTING_NAME) - if (queryFilterKind) { - selectedFilterKind = queryFilterKind as 'trigger' | 'script_flow' - } - if (queryFilterUserFolders) { - filterUserFolders = queryFilterUserFolders == 'true' - } - } - - onMount(() => { - loadQueryFilters() - }) - - run(() => { - updateQueryFilters(selectedFilterKind, filterUserFolders) - }) + import TriggerList from '$lib/components/triggers/TriggerList.svelte' - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - SQS triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - sqsTriggerEditor?.openNew(false), - aiId: 'sqs-triggers-empty-add', - aiDescription: 'Add SQS trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, edited_by, error, edited_at, script_path, is_flow, extra_perms, canWrite, mode, server_id, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = getLocalDraftHint($workspaceStore, 'trigger_sqs', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = new Date()} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - sqsTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {path}{hasDraft ? '*' : ''} -
    -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - SQS trigger is starting... - {:else} - Could not connect to SQS{error ? ': ' + error : ''} - {/if} - {:else} - Disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    Connected to SQS{!server_id ? ' (shutting down...)' : ''}
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'sqs', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - sqsTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'sqs' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'sqs_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - try { - await SqsTriggerService.deleteSqsTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - } catch (error) { - sendUserToast(error.body, true) - } - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} - - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte b/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte index 8a67d7094a..010abe2bd8 100644 --- a/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/user/cli/+page.svelte @@ -14,7 +14,9 @@ async function authorizeToken(): Promise { const username = $userStore?.username - const label = username ? `cli-${username}` : 'cli' + // `cli-login:` is reserved in `is_user_token` (windmill-common): the CLI signs in again on + // its own once this token expires, so it must not email the user about it. + const label = `cli-login:${username ?? ''}` const newToken = await UserService.createToken({ requestBody: { label, diff --git a/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte b/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte new file mode 100644 index 0000000000..2362b7f116 --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/user/remote_deploy_authorize/+page.svelte @@ -0,0 +1,132 @@ + + + + {#if framed} + + This page cannot be used inside another page. Open it in its own tab. + + {:else if invalid} + + This authorization link is malformed. Start again from the deploy drawer of the other + instance. + + {:else} +
    +

    + {callback!.origin} asks to deploy into workspace + {workspace} on this instance as + {$usersWorkspaceStore?.email ?? 'you'}. +

    +

    + It will receive a token limited to that workspace, with your permissions in it, valid for up + to 90 days. Only authorize if you just started this from {callback!.host}. You can revoke + the token at any time in your account settings, under tokens. +

    + {#if unencrypted} + + {callback!.origin} is served over plain http, so the token will cross the network unencrypted + on its way there. + + {/if} + {#if error} + {error} + {/if} +
    +
    + + +
    + {/if} + {#if framed || invalid} +
    + +
    + {/if} +
    diff --git a/frontend/src/routes/(root)/(logged)/variables/+page.svelte b/frontend/src/routes/(root)/(logged)/variables/+page.svelte index 873e812987..839695361e 100644 --- a/frontend/src/routes/(root)/(logged)/variables/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/variables/+page.svelte @@ -1,722 +1,5 @@ - - -{#if $userStore?.operator && $workspaceStore && !$userWorkspaces.find((_) => _.id === $workspaceStore)?.operator_settings?.variables} - -{:else} - - - {#if showCreateButtons} -
    - {#if tab == 'contextual' && ($userStore?.is_admin || $userStore?.is_super_admin)} - - {:else} - - {/if} -
    - {/if} -
    - (showCreateButtons = v)} /> - - - - { - loadVariables() - }} - /> - -
    - - - - {#snippet extra()} - - Contextual variables are passed as environment variables when running a script and - depends on the execution context. - - {/snippet} - - - {#if tab == 'workspace'} - - {/if} -
    - - {#if tab == 'workspace'} -
    - {#if !filteredItems} - - {#each new Array(3) as _} - - {/each} - {:else if filteredItems.length == 0} - {#if activeFilters} - - {:else} - variableEditor?.initNew(), - aiId: 'variables-empty-add-variable', - aiDescription: 'Add variable' - } - : undefined} - /> - {/if} - {:else} - - -
    - - Path - Value - Description - Status - Actions - - - - {#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft }} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'variable', path) ?? is_draft} - - - - - -
    - { - handledHash = `#${path}` - variableEditor?.editVariable(path) - }} - href="#{path}" - > - {path}{hasDraft ? '*' : ''} - - - {#if labels?.length} - {#each labels as label} - { - const arr = (filters.val.label ?? '').split(',').filter(Boolean) - const idx = arr.indexOf(label) - if (idx >= 0) arr.splice(idx, 1) - else arr.push(label) - const newFilters = { ...filters.val } - if (arr.length) newFilters.label = arr.join(',') - else delete newFilters.label - filters.val = newFilters - }}>{label} - {/each} - {/if} - -
    -
    - - -
    - {#if value} - {truncate(value, 20)} - {:else} - ∗∗∗∗ - {/if} -
    - {#if is_secret} - - - {#snippet text()} - This item is secret - {/snippet} - - {/if} -
    -
    - - {truncate(description ?? '', 50)} - - - -
    - {#if is_linked} - - - {#snippet text()} -
    - This variable is linked with a resource of the same path. They are - deleted and renamed together. -
    - {/snippet} -
    - {/if} - {#if account} - - - {#snippet text()} -
    - This OAuth token will be kept up-to-date in the background by - Windmill using its refresh token -
    - {/snippet} -
    - {/if} - - {#if is_refreshed} -
    - {#if refresh_error} - - -
    - - -
    - - {#snippet text()} -
    - Latest exchange of the refresh token did not succeed. Error: {refresh_error} -
    - {/snippet} -
    - {:else if is_expired} - - - {#snippet text()} -
    - The access_token is expired, it will get renewed the next time - this variable is fetched or you can request is to be refreshed - in the dropdown on the right. -
    - {/snippet} -
    - {:else} - - - {#snippet text()} -
    - The variable was connected through OAuth and the token is not - expired. -
    - {/snippet} -
    - {/if} -
    - {/if} -
    -
    - - { - let owner = isOwner(path, $userStore, $workspaceStore) - return [ - { - displayName: 'Edit', - icon: Pen, - action: () => variableEditor?.editVariable(path), - disabled: !canWrite || !showCreateButtons - }, - { - displayName: 'Delete', - icon: Trash, - type: 'delete', - action: (event) => { - if (event['shiftKey']) { - deleteVariable(path, account) - } else { - deleteIsLinked = is_linked ?? false - deleteConfirmedCallback = () => { - deleteVariable(path, account) - } - } - }, - disabled: !owner || !showCreateButtons - }, - ...(!ws_specific && - isDeployable(is_secret ? 'secret' : 'variable', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'variable') - } - } - ] - : []), - { - displayName: 'Permissions', - action: () => { - shareModal?.openDrawer(path, 'variable') - }, - icon: Shield - }, - ...(account != undefined - ? [ - { - displayName: 'Refresh token', - icon: RefreshCw, - action: async () => { - await OauthService.refreshToken({ - workspace: $workspaceStore ?? '', - id: account ?? 0, - requestBody: { - path - } - }) - sendUserToast('Token refreshed') - loadVariables() - } - } - ] - : []) - ] - }} - /> - -
    - {/each} - - - {/if} - - {:else if tab == 'contextual'} -
    - {#if loading.contextual} - - {#each new Array(8) as _} - - {/each} - {:else} - - {#if contextualVariables.filter((x) => x.is_custom).length === 0} - contextualVariableEditor?.initNew(), - aiId: 'variables-empty-add-contextual-variable', - aiDescription: 'Add contextual variable' - } - : undefined} - /> - {:else} - x.is_custom)} - keys={['name', 'value']} - getRowActions={$userStore?.is_admin || $userStore?.is_super_admin - ? (row) => { - return [ - { - displayName: 'Edit', - action: () => contextualVariableEditor?.editVariable(row.name, row.value) - }, - { - displayName: 'Delete', - type: 'delete', - action: () => { - deleteContextualVariable(row) - } - } - ] - } - : undefined} - /> - {/if} - - !x.is_custom)} - keys={['name', 'value', 'description']} - /> - {/if} -
    - {/if} - - -{/if} - - { - deleteConfirmedCallback = undefined - }} - on:confirmed={() => { - if (deleteConfirmedCallback) { - deleteConfirmedCallback() - } - deleteConfirmedCallback = undefined - }} -> -
    - Are you sure you want to remove this variable? - {#if deleteIsLinked} - - This variable is linked with a resource of the same path. The linked resource will also be - deleted. - - {/if} - -
    - You can press - SHIFT - while removing a variable to bypass confirmation. -
    -
    -
    -
    + diff --git a/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte b/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte index 35a167c296..176c2ccbe5 100644 --- a/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/websocket_triggers/+page.svelte @@ -1,579 +1,5 @@ - - - - (x.summary ?? '') + ' ' + x.path + ' (' + x.script_path + ')'} -/> - - - - - - - {#if isCloudHosted()} - - WebSocket triggers are disabled in the multi-tenant cloud. - -
    - {/if} -
    -
    - -
    -
    Filter by path of
    - - {#snippet children({ item })} - - - {/snippet} - -
    - - -
    - {#if $userStore?.is_super_admin && $userStore.username.includes('@')} - - {:else if $userStore?.is_admin || $userStore?.is_super_admin} - - {/if} -
    -
    - {#if loading} - {#each new Array(6) as _} - - {/each} - {:else if !triggers?.length} - websocketTriggerEditor?.openNew(false), - aiId: 'websocket-triggers-empty-add', - aiDescription: 'Add WebSocket trigger' - }} - /> - {:else if items?.length} -
    - {#each items.slice(0, nbDisplayed) as { path, edited_by, edited_at, script_path, url, is_flow, extra_perms, canWrite, marked, error, last_server_ping, server_id, mode, retry, error_handler_path, error_handler_args, labels, draft_only, is_draft } (path)} - {@const hasDraft = - getLocalDraftHint($workspaceStore, 'trigger_websocket', path) ?? is_draft} - {@const href = `${is_flow ? '/flows/get' : '/scripts/get'}/${script_path}`} - {@const ping = last_server_ping ? new Date(last_server_ping) : undefined} - {@const pinging = ping && ping.getTime() > new Date().getTime() - 15 * 1000} - {@const effectiveMode = draft_only ? 'disabled' : mode} - {@const enabled = effectiveMode === 'enabled' || effectiveMode === 'suspended'} - -
    -
    - - - websocketTriggerEditor?.openEdit(path, is_flow)} - class="min-w-0 grow hover:underline decoration-gray-400" - > -
    - {#if marked} - - {@html marked} - - {:else} - {url.startsWith('$script:') - ? 'URL: ' + url.replace('$script:', 'result of script ') - : url.startsWith('$flow:') - ? 'URL: ' + url.replace('$flow:', 'result of flow ') - : url} - {/if}{hasDraft ? '*' : ''} -
    -
    - {path} -
    -
    - runnable: {script_path} -
    -
    - - - -
    - {#if (enabled && (!pinging || error)) || (!enabled && error) || (enabled && !server_id)} - - - - - - {#snippet text()} -
    - {#if enabled} - {#if !server_id} - WebSocket is starting... - {:else} - WebSocket is not connected{error ? ': ' + error : ''} - {/if} - {:else} - WebSocket was disabled because of an error: {error} - {/if} -
    - {/snippet} -
    - {:else if enabled} - - - - - {#snippet text()} -
    - WebSocket is connected{!server_id ? ' (shutting down...)' : ''} -
    - {/snippet} -
    - {/if} -
    - -
    - - onToggleMode(path, newMode)} - triggerMode={effectiveMode} - includeModalConfig={{ - triggerPath: path, - triggerKind: 'websocket', - runnableConfig: { - path: script_path, - kind: is_flow ? 'flow' : 'script', - retry, - errorHandlerPath: error_handler_path, - errorHandlerArgs: error_handler_args - } - }} - {canWrite} - hideToggleLabels - hideDropdown - /> -
    - -
    - - { - goto(href) - } - }, - ...(canWrite && !draft_only && mode !== 'suspended' - ? [ - { - displayName: 'Suspend job execution', - icon: Pause, - action: () => { - onToggleMode(path, 'suspended') - } - } - ] - : []), - { - displayName: canWrite ? 'Edit' : 'View', - icon: canWrite ? Pen : Eye, - action: () => { - websocketTriggerEditor?.openEdit(path, is_flow) - } - }, - ...(isDeployable('trigger', path, deployUiSettings) - ? [ - { - displayName: 'Deploy to prod/staging', - icon: FileUp, - action: () => { - deploymentDrawer?.openDrawer(path, 'trigger', { - triggers: { - kind: 'websockets' - } - }) - } - } - ] - : []), - { - displayName: 'Audit logs', - icon: Eye, - href: `${base}/audit_logs?resource=${path}` - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal?.openDrawer(path, 'websocket_trigger') - } - }, - { - displayName: 'Delete', - type: 'delete', - icon: Trash, - disabled: !canWrite, - action: async () => { - await WebsocketTriggerService.deleteWebsocketTrigger({ - workspace: $workspaceStore ?? '', - path - }) - loadTriggers() - } - } - ]} - /> -
    -
    -
    -
    {#if edited_by}
    edited by {edited_by}
    {/if}
    {edited_by ? 'the ' : ''}{displayDate(edited_at)}
    -
    - {/each} -
    - {:else} - - {/if} -
    - {#if items && items?.length > 15 && nbDisplayed < items.length} - {nbDisplayed} items out of {items.length} - - {/if} -
    - - { - loadTriggers() - }} -/> + diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 424aa0267e..f055e92ca3 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -8,8 +8,11 @@ import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' import DeployToSetting from '$lib/components/DeployToSetting.svelte' + import RemoteDeployTargetSetting from '$lib/components/RemoteDeployTargetSetting.svelte' import DevWorkspaceSetting from '$lib/components/DevWorkspaceSetting.svelte' - import ErrorOrRecoveryHandler from '$lib/components/ErrorOrRecoveryHandler.svelte' + import ErrorOrRecoveryHandler, { + handlerFullPath + } from '$lib/components/ErrorOrRecoveryHandler.svelte' import PageHeader from '$lib/components/PageHeader.svelte' import ScriptPicker from '$lib/components/ScriptPicker.svelte' @@ -517,7 +520,7 @@ webhook = '' } } catch (error) { - sendUserToast(`Failed to save webhook: ${error}`, true) + sendUserToast(`Failed to save webhook: ${error?.body ?? error}`, true) } } @@ -906,7 +909,7 @@ await WorkspaceService.editErrorHandler({ workspace: $workspaceStore!, requestBody: { - path: `${errorHandlerItemKind}/${errorHandlerScriptPath}`, + path: handlerFullPath(errorHandlerSelected, errorHandlerItemKind, errorHandlerScriptPath), extra_args: errorHandlerExtraArgs, muted_on_cancel: errorHandlerMutedOnCancel, muted_on_user_path: errorHandlerMutedOnUserPath, @@ -1552,6 +1555,12 @@ + +
    + Deploy to another instance + +
    {:else if tab == 'rulesets'} + @@ -195,6 +207,77 @@ That's the full round-trip.` + +
    + + +
    + +
    +
    Sizes
    +
    + {#each toggleSizes as size} + + {/each} +
    +
    + +
    +
    Colors
    +
    + {#each toggleColors as color} + + {/each} +
    +
    + +
    +
    Labels
    +
    + + + + +
    +
    + +
    +
    Quarter-pixel offsets
    +
    + Zoom a 1x screen in on these: the knob must keep a visible ring of track on every side in + both states. It reads as overflowing the track once that ring drops below a device pixel. +
    +
    + {#each toggleSizes as size} +
    + {#each subpixelOffsets as offset} +
    + +
    + {/each} +
    + {/each} +
    +
    +
    The three markdownProse presets, same source. Each is sized for its own diff --git a/frontend/src/routes/test_dev/diff-comparison/+page.svelte b/frontend/src/routes/test_dev/diff-comparison/+page.svelte new file mode 100644 index 0000000000..c473dbcb64 --- /dev/null +++ b/frontend/src/routes/test_dev/diff-comparison/+page.svelte @@ -0,0 +1,139 @@ + + + + Diff comparison + + +
    +
    + {#each cases as item} + + {/each} +
    + +
    +
    +

    Monaco

    +
    + {#key activeCase.id} + + {/key} +
    +
    + +
    +

    Native

    +
    + {#key activeCase.id} + + {/key} +
    +
    +
    +
    diff --git a/frontend/tailwind.config.cjs b/frontend/tailwind.config.cjs index a5aea63715..5c623fbadc 100644 --- a/frontend/tailwind.config.cjs +++ b/frontend/tailwind.config.cjs @@ -603,6 +603,12 @@ const config = { backgroundColor: 'rgb(var(--color-surface-primary))', color: 'rgb(var(--color-text-primary))', + // Sidebar rail chrome — consumed via var(--sidebar-bg-light) (see sidebarChrome.ts). + // The `-base` vars hold the theme's color; the accent (accentColor.ts) tints + // `--sidebar-bg-*` from them, which a self-reference could not do. + '--sidebar-bg-light-base': '#F3F3F7', + '--sidebar-bg-light': 'var(--sidebar-bg-light-base)', + '--vscode-editorSuggestWidget-background': '#f3f3f3', '--vscode-editorHoverWidget-foreground': '#616161', '--vscode-editorHoverWidget-border': '#c8c8c8', @@ -622,7 +628,8 @@ const config = { ...darkColorVariables, // Sidebar rail chrome — consumed via var(--sidebar-bg-dark) (see sidebarChrome.ts). - '--sidebar-bg-dark': '#1e232e', + '--sidebar-bg-dark-base': '#1e232e', + '--sidebar-bg-dark': 'var(--sidebar-bg-dark-base)', '--vscode-editorSuggestWidget-background': '#252526', '--vscode-editorHoverWidget-foreground': '#cccccc', @@ -638,7 +645,7 @@ const config = { // GitHub's canvas.inset — the authentic recessed surface, sitting below // the canvas (#0d1117) like the sidebar-vs-primary relationship in the // default dark theme. - '--sidebar-bg-dark': '#010409', + '--sidebar-bg-dark-base': '#010409', // Monaco popup chrome — the &.dark defaults above are VS Code's greys; // override so the widgets match the GitHub Dark palette in this variant. diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 0140c38739..36c353e28a 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -6,7 +6,10 @@ import mkcert from 'vite-plugin-mkcert' const file = fileURLToPath(new URL('package.json', import.meta.url)) const json = readFileSync(file, 'utf8') -const version = JSON.parse(json) +// Only the version is exposed to the client (see `define` below). Defining the +// whole parsed package.json would inline it — scripts, dependency lists, ... — +// into every chunk that reads `__pkg__.version`. +const { version } = JSON.parse(json) // The postinstall downloads the pinned UI Builder artifact into static/ui_builder, // which SvelteKit serves at /ui_builder. Serve that directly; only proxy to a @@ -278,7 +281,7 @@ const config = { assertAcyclicChunks(), assertLeanPublicAppRoutes() ], - define: { __pkg__: version }, + define: { '__pkg__.version': JSON.stringify(version) }, optimizeDeps: { include: ['highlight.js', 'highlight.js/lib/core', 'monaco-vim'], exclude: [ diff --git a/lsp/Pipfile b/lsp/Pipfile index 054896a5ce..79d2672f4a 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.814.0" +wmill = ">=1.817.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/multiplayer/package-lock.json b/multiplayer/package-lock.json index 0d8c1985ce..532dbd226e 100644 --- a/multiplayer/package-lock.json +++ b/multiplayer/package-lock.json @@ -47,9 +47,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -88,9 +88,9 @@ } }, "node_modules/y-websocket": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/y-websocket/-/y-websocket-3.0.0.tgz", - "integrity": "sha512-mUHy7AzkOZ834T/7piqtlA8Yk6AchqKqcrCXjKW8J1w2lPtRDjz8W5/CvXz9higKAHgKRKqpI3T33YkRFLkPtg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/y-websocket/-/y-websocket-3.1.0.tgz", + "integrity": "sha512-ZNzwH84Ysxv7zjpFNZHjTJvrBZgcAqMljTe+6zrWciAML9LQ18aVylyPNH9faxCXqEOV8I0JY4TGtrIHFX+Xwg==", "license": "MIT", "dependencies": { "lib0": "^0.2.102", @@ -109,9 +109,9 @@ } }, "node_modules/yjs": { - "version": "13.6.29", - "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz", - "integrity": "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==", + "version": "13.6.32", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.32.tgz", + "integrity": "sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==", "license": "MIT", "dependencies": { "lib0": "^0.2.99" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 73c11300d7..48f573bab1 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.814.0 + version: 1.817.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 7abe5afa7c..ad6c0e8bc1 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.814.0' + ModuleVersion = '1.817.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index ad591ded27..baab12d832 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.814.0" +version = "1.817.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/python-client/wmill/uv.lock b/python-client/wmill/uv.lock index 6c1c3ab219..1c366b42bd 100644 --- a/python-client/wmill/uv.lock +++ b/python-client/wmill/uv.lock @@ -79,11 +79,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, ] [[package]] @@ -115,16 +115,16 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.21.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] [[package]] name = "pytest" -version = "9.0.2" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -133,7 +133,7 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] diff --git a/python-client/wmill/wmill/client.py b/python-client/wmill/wmill/client.py index dbc9d9b193..81e5b34e16 100644 --- a/python-client/wmill/wmill/client.py +++ b/python-client/wmill/wmill/client.py @@ -1430,16 +1430,19 @@ class Windmill: }, ) - def datatable(self, name: str = "main"): + def datatable(self, name: str = "main", *, role: Optional[str] = None): """Get a DataTable client for SQL queries. Args: name: Database name (default: "main") + role: Connect as this data table role instead of the data table's default one. + Only meaningful on a data table under roles, and only for a role you are a + tenant of. Returns: DataTableClient instance """ - return DataTableClient(self, name) + return DataTableClient(self, name, role=role) def ducklake(self, name: str = "main"): """Get a DuckLake client for DuckDB queries. @@ -2278,16 +2281,17 @@ def username_to_email(username: str) -> str: @init_global_client -def datatable(name: str = "main") -> DataTableClient: +def datatable(name: str = "main", *, role: Optional[str] = None) -> DataTableClient: """Get a DataTable client for SQL queries. Args: name: Database name (default: "main") + role: Connect as this data table role instead of the data table's default one. Returns: DataTableClient instance """ - return _client.datatable(name) + return _client.datatable(name, role=role) @init_global_client def ducklake(name: str = "main") -> DucklakeClient: @@ -2362,17 +2366,28 @@ def stream_result(stream) -> None: for text in stream: append_to_result_stream(text) +# Interpolated into a `-- role ` line, so a value carrying a newline could append +# statements of its own. Mirrors the server's own role-name rule. +_ROLE_NAME_RE = re.compile(r"^[A-Za-z0-9_-]{1,63}$") + + class DataTableClient: """Client for executing SQL queries against Windmill DataTables.""" - def __init__(self, client: Windmill, name: str): + def __init__(self, client: Windmill, name: str, role: Optional[str] = None): """Initialize DataTableClient. Args: client: Windmill client instance name: DataTable name + role: Data table role to connect as, or None for the data table's default """ + if role is not None and not _ROLE_NAME_RE.match(role): + raise ValueError( + f"Invalid data table role '{role}': only letters, digits, '_' and '-' are allowed" + ) self.client = client + self.role = role self.name, self.schema = parse_sql_client_name(name) def query(self, sql: str, *args) -> SqlQuery: """Execute a SQL query against the DataTable. @@ -2393,6 +2408,9 @@ class DataTableClient: args_dict[f"arg{i+1}"] = arg args_def += f"-- ${i+1} arg{i+1} ({infer_sql_type(arg)})\n" sql = args_def + sql + # Must lead: the executor's annotation parser stops at the first non-comment line. + if self.role is not None: + sql = f"-- role {self.role}\n" + sql return SqlQuery( sql, lambda sql: self.client.run_inline_script_preview( diff --git a/rust-client/.gitignore b/rust-client/.gitignore index 4924b1576c..777fcbdb05 100644 --- a/rust-client/.gitignore +++ b/rust-client/.gitignore @@ -1,3 +1,5 @@ windmill-api/ windmill_api/ api/ +# library crate: the lockfile is regenerated by dev.nu and not consumed by downstream users +Cargo.lock diff --git a/rust-client/Cargo.lock b/rust-client/Cargo.lock deleted file mode 100644 index 54464b9aa2..0000000000 --- a/rust-client/Cargo.lock +++ /dev/null @@ -1,1857 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" - -[[package]] -name = "android-tzdata" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "anyhow" -version = "1.0.98" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" - -[[package]] -name = "autocfg" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" - -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "2.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" - -[[package]] -name = "bumpalo" -version = "3.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1628fb46dfa0b37568d12e5edd512553eccf6a22a78e8bde00bb4aed84d5bdbf" - -[[package]] -name = "bytes" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" - -[[package]] -name = "cc" -version = "1.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc" -dependencies = [ - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" - -[[package]] -name = "cfg_aliases" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" - -[[package]] -name = "chrono" -version = "0.4.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" -dependencies = [ - "android-tzdata", - "iana-time-zone", - "num-traits", - "serde", - "windows-link", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn", -] - -[[package]] -name = "deranged" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" -dependencies = [ - "powerfmt", - "serde", -] - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dyn-clone" -version = "1.0.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "form_urlencoded" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi", - "wasi 0.14.2+wasi-0.2.4", - "wasm-bindgen", -] - -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - -[[package]] -name = "hashbrown" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.15.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "http" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "hyper" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots", -] - -[[package]] -name = "hyper-util" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" - -[[package]] -name = "icu_properties" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "potential_utf", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" - -[[package]] -name = "icu_provider" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" -dependencies = [ - "displaydoc", - "icu_locale_core", - "stable_deref_trait", - "tinystr", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - -[[package]] -name = "idna" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "1.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" -dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", -] - -[[package]] -name = "indexmap" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cea70ddb795996207ad57735b50c5982d8844f38ba9ee5f1aedcfb708a2aa11e" -dependencies = [ - "equivalent", - "hashbrown 0.15.3", - "serde", -] - -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "js-sys" -version = "0.3.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.172" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" - -[[package]] -name = "litemap" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" - -[[package]] -name = "log" -version = "0.4.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "memchr" -version = "2.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - -[[package]] -name = "miniz_oxide" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be647b768db090acb35d5ec5db2b0e1f1de11133ca123b9eacf5137868f892a" -dependencies = [ - "adler2", -] - -[[package]] -name = "mio" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" -dependencies = [ - "libc", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", -] - -[[package]] -name = "num-conv" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "percent-encoding" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "potential_utf" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro2" -version = "1.0.95" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quinn" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" -dependencies = [ - "bytes", - "getrandom 0.3.3", - "lru-slab", - "rand", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.59.0", -] - -[[package]] -name = "quote" -version = "1.0.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" -dependencies = [ - "rand_chacha", - "rand_core", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" -dependencies = [ - "getrandom 0.3.3", -] - -[[package]] -name = "ref-cast" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a0ae411dbe946a674d89546582cea4ba2bb8defac896622d6496f14c23ba5cf" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "reqwest" -version = "0.12.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" -dependencies = [ - "base64", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "mime_guess", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.16", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" - -[[package]] -name = "rustc-hash" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" - -[[package]] -name = "rustls" -version = "0.23.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" -dependencies = [ - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" -dependencies = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "schemars" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" -dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", -] - -[[package]] -name = "serde" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.219" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.140" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", -] - -[[package]] -name = "serde_repr" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_with" -version = "3.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf65a400f8f66fb7b0552869ad70157166676db75ed8181f8104ea91cf9d0b42" -dependencies = [ - "base64", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.9.0", - "schemars", - "serde", - "serde_derive", - "serde_json", - "serde_with_macros", - "time", -] - -[[package]] -name = "serde_with_macros" -version = "3.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81679d9ed988d5e9a5e6531dc3f2c28efbd639cbd1dfb628df08edea6004da77" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.9.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "slab" -version = "0.4.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" -dependencies = [ - "autocfg", -] - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror" -version = "2.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567b8a2dae586314f7be2a752ec7474332959c6460e02bde30d702a66d488708" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "time" -version = "0.3.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" - -[[package]] -name = "time-macros" -version = "0.2.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09b3661f17e86524eccd4371ab0429194e0d7c008abb45f7a7495b1719463c71" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.45.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" -dependencies = [ - "backtrace", - "bytes", - "libc", - "mio", - "pin-project-lite", - "socket2", - "tokio-macros", - "windows-sys 0.52.0", -] - -[[package]] -name = "tokio-macros" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "unicase" -version = "2.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" - -[[package]] -name = "unicode-ident" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" - -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" -dependencies = [ - "getrandom 0.3.3", - "js-sys", - "serde", - "wasm-bindgen", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasi" -version = "0.14.2+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" -dependencies = [ - "wit-bindgen-rt", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" -dependencies = [ - "cfg-if", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-backend", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "web-sys" -version = "0.3.77" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8782dd5a41a24eed3a4f40b606249b3e236ca61adf1f25ea4d45c73de122b502" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "windmill-api" -version = "1.518.2" -dependencies = [ - "reqwest", - "serde", - "serde_json", - "serde_repr", - "serde_with", - "url", - "uuid", -] - -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "wit-bindgen-rt" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags", -] - -[[package]] -name = "wmill" -version = "1.518.2" -dependencies = [ - "anyhow", - "futures", - "once_cell", - "serde", - "serde_json", - "serde_yaml", - "thiserror", - "tokio", - "uuid", - "windmill-api", -] - -[[package]] -name = "writeable" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" - -[[package]] -name = "yoke" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" -dependencies = [ - "serde", - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerocopy" -version = "0.8.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" - -[[package]] -name = "zerotrie" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 109a581dde..ba5f382651 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1911,6 +1911,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord /** * Create a SQL template function for PostgreSQL/datatable queries * @param name - Database/datatable name (default: "main") + * @param opts.role - Connect as this data table role instead of the data table's default one. + * Only meaningful on a data table under roles, and only for a role you are a tenant of. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -1920,8 +1922,11 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * SELECT * FROM friends * WHERE name = \${name} AND age = \${age}::int * \`.fetch() + * @example + * // Read through a restricted role + * let sql = wmill.datatable("main", { role: "analytics" }) */ -datatable(name: string = "main"): DatatableSqlTemplateFunction +datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction /** * Create a SQL template function for DuckDB/ducklake queries @@ -2436,10 +2441,13 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca # # Args: # name: Database name (default: "main") +# role: Connect as this data table role instead of the data table's default one. +# Only meaningful on a data table under roles, and only for a role you are a +# tenant of. # # Returns: # DataTableClient instance -def datatable(name: str = 'main') +def datatable(name: str = 'main', *, role: Optional[str] = None) # Get a DuckLake client for DuckDB queries. # @@ -2657,7 +2665,7 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]] # # @task(retry={"attempts": 3, "delay": 30, "multiplier": 2}) # async def call_api(payload: dict): ... -def task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) +def task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) # Create a task that dispatches to a separate Windmill script. # @@ -2670,7 +2678,7 @@ def task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, ti # @workflow # async def main(): # data = await extract(url="https://...") -def task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) +def task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) # Create a task that dispatches to a separate Windmill flow. # @@ -2683,7 +2691,7 @@ def task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = N # @workflow # async def main(): # result = await pipeline(input=data) -def task_flow(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) +def task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) # Decorator marking an async function as a workflow-as-code entry point. # @@ -2748,7 +2756,7 @@ async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_ # ... # # results = await parallel(items, process, concurrency=5) -async def parallel(items, fn, concurrency: Optional[int] = None) +async def parallel(items, fn, *, concurrency: Optional[int] = None) # Commit Kafka offsets for a trigger with auto_commit disabled. # @@ -3195,6 +3203,8 @@ interface DatatableSqlTemplateFunction { Create a SQL template function for PostgreSQL/datatable queries @param name - Database/datatable name (default: "main") +@param opts.role - Connect as this data table role instead of the data table's default one. + Only meaningful on a data table under roles, and only for a role you are a tenant of. @returns SQL template function for building parameterized queries @example let sql = wmill.datatable() @@ -3204,8 +3214,12 @@ await sql\` SELECT * FROM friends WHERE name = \${name} AND age = \${age}::int \`.fetch() +@example +// Read through a restricted role +let sql = wmill.datatable("main", { role: "analytics" }) \`\`\`typescript -function datatable(name: string = "main"): DatatableSqlTemplateFunction +function datatable(name: string = "main", + opts?: DatatableOptions): DatatableSqlTemplateFunction \`\`\` `; @@ -3217,10 +3231,11 @@ Import: \`import wmill\` # # Args: # name: Database name (default: "main") +# role: Connect as this data table role instead of the data table's default one. # # Returns: # DataTableClient instance -def datatable(name: str = 'main') -> DataTableClient +def datatable(name: str = 'main', *, role: Optional[str] = None) -> DataTableClient # Client for executing SQL queries against Windmill DataTables. class DataTableClient: @@ -3229,7 +3244,8 @@ class DataTableClient: # Args: # client: Windmill client instance # name: DataTable name - def __init__(client: Windmill, name: str) + # role: Data table role to connect as, or None for the data table's default + def __init__(client: Windmill, name: str, role: Optional[str] = None) # Execute a SQL query against the DataTable. # diff --git a/system_prompts/auto-generated/script.md b/system_prompts/auto-generated/script.md index f278a67fba..afc064df40 100644 --- a/system_prompts/auto-generated/script.md +++ b/system_prompts/auto-generated/script.md @@ -2000,6 +2000,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord /** * Create a SQL template function for PostgreSQL/datatable queries * @param name - Database/datatable name (default: "main") + * @param opts.role - Connect as this data table role instead of the data table's default one. + * Only meaningful on a data table under roles, and only for a role you are a tenant of. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -2009,8 +2011,11 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * SELECT * FROM friends * WHERE name = ${name} AND age = ${age}::int * `.fetch() + * @example + * // Read through a restricted role + * let sql = wmill.datatable("main", { role: "analytics" }) */ -datatable(name: string = "main"): DatatableSqlTemplateFunction +datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction /** * Create a SQL template function for DuckDB/ducklake queries @@ -2525,10 +2530,13 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca # # Args: # name: Database name (default: "main") +# role: Connect as this data table role instead of the data table's default one. +# Only meaningful on a data table under roles, and only for a role you are a +# tenant of. # # Returns: # DataTableClient instance -def datatable(name: str = 'main') +def datatable(name: str = 'main', *, role: Optional[str] = None) # Get a DuckLake client for DuckDB queries. # @@ -2746,7 +2754,7 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]] # # @task(retry={"attempts": 3, "delay": 30, "multiplier": 2}) # async def call_api(payload: dict): ... -def task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) +def task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) # Create a task that dispatches to a separate Windmill script. # @@ -2759,7 +2767,7 @@ def task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, ti # @workflow # async def main(): # data = await extract(url="https://...") -def task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) +def task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) # Create a task that dispatches to a separate Windmill flow. # @@ -2772,7 +2780,7 @@ def task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = N # @workflow # async def main(): # result = await pipeline(input=data) -def task_flow(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) +def task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) # Decorator marking an async function as a workflow-as-code entry point. # @@ -2837,7 +2845,7 @@ async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_ # ... # # results = await parallel(items, process, concurrency=5) -async def parallel(items, fn, concurrency: Optional[int] = None) +async def parallel(items, fn, *, concurrency: Optional[int] = None) # Commit Kafka offsets for a trigger with auto_commit disabled. # diff --git a/system_prompts/auto-generated/sdks/datatable-python.md b/system_prompts/auto-generated/sdks/datatable-python.md index 752019a9a3..3c7cdb1a09 100644 --- a/system_prompts/auto-generated/sdks/datatable-python.md +++ b/system_prompts/auto-generated/sdks/datatable-python.md @@ -6,10 +6,11 @@ Import: `import wmill` # # Args: # name: Database name (default: "main") +# role: Connect as this data table role instead of the data table's default one. # # Returns: # DataTableClient instance -def datatable(name: str = 'main') -> DataTableClient +def datatable(name: str = 'main', *, role: Optional[str] = None) -> DataTableClient # Client for executing SQL queries against Windmill DataTables. class DataTableClient: @@ -18,7 +19,8 @@ class DataTableClient: # Args: # client: Windmill client instance # name: DataTable name - def __init__(client: Windmill, name: str) + # role: Data table role to connect as, or None for the data table's default + def __init__(client: Windmill, name: str, role: Optional[str] = None) # Execute a SQL query against the DataTable. # diff --git a/system_prompts/auto-generated/sdks/datatable-typescript.md b/system_prompts/auto-generated/sdks/datatable-typescript.md index 0256515911..9f6fc13341 100644 --- a/system_prompts/auto-generated/sdks/datatable-typescript.md +++ b/system_prompts/auto-generated/sdks/datatable-typescript.md @@ -62,6 +62,8 @@ interface DatatableSqlTemplateFunction { Create a SQL template function for PostgreSQL/datatable queries @param name - Database/datatable name (default: "main") +@param opts.role - Connect as this data table role instead of the data table's default one. + Only meaningful on a data table under roles, and only for a role you are a tenant of. @returns SQL template function for building parameterized queries @example let sql = wmill.datatable() @@ -71,6 +73,10 @@ await sql` SELECT * FROM friends WHERE name = ${name} AND age = ${age}::int `.fetch() +@example +// Read through a restricted role +let sql = wmill.datatable("main", { role: "analytics" }) ```typescript -function datatable(name: string = "main"): DatatableSqlTemplateFunction +function datatable(name: string = "main", + opts?: DatatableOptions): DatatableSqlTemplateFunction ``` diff --git a/system_prompts/auto-generated/sdks/python.md b/system_prompts/auto-generated/sdks/python.md index 4f727c7d45..97db78b3d4 100644 --- a/system_prompts/auto-generated/sdks/python.md +++ b/system_prompts/auto-generated/sdks/python.md @@ -469,10 +469,13 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca # # Args: # name: Database name (default: "main") +# role: Connect as this data table role instead of the data table's default one. +# Only meaningful on a data table under roles, and only for a role you are a +# tenant of. # # Returns: # DataTableClient instance -def datatable(name: str = 'main') +def datatable(name: str = 'main', *, role: Optional[str] = None) # Get a DuckLake client for DuckDB queries. # @@ -690,7 +693,7 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]] # # @task(retry={"attempts": 3, "delay": 30, "multiplier": 2}) # async def call_api(payload: dict): ... -def task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) +def task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) # Create a task that dispatches to a separate Windmill script. # @@ -703,7 +706,7 @@ def task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, ti # @workflow # async def main(): # data = await extract(url="https://...") -def task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) +def task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) # Create a task that dispatches to a separate Windmill flow. # @@ -716,7 +719,7 @@ def task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = N # @workflow # async def main(): # result = await pipeline(input=data) -def task_flow(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) +def task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) # Decorator marking an async function as a workflow-as-code entry point. # @@ -781,7 +784,7 @@ async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_ # ... # # results = await parallel(items, process, concurrency=5) -async def parallel(items, fn, concurrency: Optional[int] = None) +async def parallel(items, fn, *, concurrency: Optional[int] = None) # Commit Kafka offsets for a trigger with auto_commit disabled. # diff --git a/system_prompts/auto-generated/sdks/typescript.md b/system_prompts/auto-generated/sdks/typescript.md index b764b38a9e..55f59b1f36 100644 --- a/system_prompts/auto-generated/sdks/typescript.md +++ b/system_prompts/auto-generated/sdks/typescript.md @@ -565,6 +565,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord /** * Create a SQL template function for PostgreSQL/datatable queries * @param name - Database/datatable name (default: "main") + * @param opts.role - Connect as this data table role instead of the data table's default one. + * Only meaningful on a data table under roles, and only for a role you are a tenant of. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -574,8 +576,11 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * SELECT * FROM friends * WHERE name = ${name} AND age = ${age}::int * `.fetch() + * @example + * // Read through a restricted role + * let sql = wmill.datatable("main", { role: "analytics" }) */ -datatable(name: string = "main"): DatatableSqlTemplateFunction +datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction /** * Create a SQL template function for DuckDB/ducklake queries diff --git a/system_prompts/auto-generated/skills/raw-app/SKILL.md b/system_prompts/auto-generated/skills/raw-app/SKILL.md index ed79172b9d..8c6b8e09da 100644 --- a/system_prompts/auto-generated/skills/raw-app/SKILL.md +++ b/system_prompts/auto-generated/skills/raw-app/SKILL.md @@ -172,6 +172,8 @@ data: tables: - main/users # Table in public schema - main/app_schema:items # Table in specific schema + roles: # Optional: the role the app uses each datatable through + main: analyst ``` **Table reference formats:** @@ -179,6 +181,8 @@ data: - `/
    ` — Specific table in public schema - `/:
    ` — Table in specific schema +**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. `roles` records the role the app uses each datatable through; the app's code must pass the same role: `wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python. A datatable without an entry is used as its default role. + ## SQL Migrations (sql_to_apply/) The `sql_to_apply/` folder is for creating/modifying database tables during development. diff --git a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md index c89a87fa5d..1d52d0a8a1 100644 --- a/system_prompts/auto-generated/skills/write-script-bun/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bun/SKILL.md @@ -736,6 +736,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord /** * Create a SQL template function for PostgreSQL/datatable queries * @param name - Database/datatable name (default: "main") + * @param opts.role - Connect as this data table role instead of the data table's default one. + * Only meaningful on a data table under roles, and only for a role you are a tenant of. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -745,8 +747,11 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * SELECT * FROM friends * WHERE name = ${name} AND age = ${age}::int * `.fetch() + * @example + * // Read through a restricted role + * let sql = wmill.datatable("main", { role: "analytics" }) */ -datatable(name: string = "main"): DatatableSqlTemplateFunction +datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction /** * Create a SQL template function for DuckDB/ducklake queries diff --git a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md index 82911c1096..4943c11baa 100644 --- a/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-bunnative/SKILL.md @@ -736,6 +736,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord /** * Create a SQL template function for PostgreSQL/datatable queries * @param name - Database/datatable name (default: "main") + * @param opts.role - Connect as this data table role instead of the data table's default one. + * Only meaningful on a data table under roles, and only for a role you are a tenant of. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -745,8 +747,11 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * SELECT * FROM friends * WHERE name = ${name} AND age = ${age}::int * `.fetch() + * @example + * // Read through a restricted role + * let sql = wmill.datatable("main", { role: "analytics" }) */ -datatable(name: string = "main"): DatatableSqlTemplateFunction +datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction /** * Create a SQL template function for DuckDB/ducklake queries diff --git a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md index 95f528223d..fcc2d68847 100644 --- a/system_prompts/auto-generated/skills/write-script-deno/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-deno/SKILL.md @@ -738,6 +738,8 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord /** * Create a SQL template function for PostgreSQL/datatable queries * @param name - Database/datatable name (default: "main") + * @param opts.role - Connect as this data table role instead of the data table's default one. + * Only meaningful on a data table under roles, and only for a role you are a tenant of. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -747,8 +749,11 @@ parseS3Object(s3Object: S3Object): S3ObjectRecord * SELECT * FROM friends * WHERE name = ${name} AND age = ${age}::int * `.fetch() + * @example + * // Read through a restricted role + * let sql = wmill.datatable("main", { role: "analytics" }) */ -datatable(name: string = "main"): DatatableSqlTemplateFunction +datatable(name: string = "main", opts?: DatatableOptions): DatatableSqlTemplateFunction /** * Create a SQL template function for DuckDB/ducklake queries diff --git a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md index 1ccb376a9f..42916f7af5 100644 --- a/system_prompts/auto-generated/skills/write-script-python3/SKILL.md +++ b/system_prompts/auto-generated/skills/write-script-python3/SKILL.md @@ -654,10 +654,13 @@ def send_teams_message(conversation_id: str, text: str, success: bool = True, ca # # Args: # name: Database name (default: "main") +# role: Connect as this data table role instead of the data table's default one. +# Only meaningful on a data table under roles, and only for a role you are a +# tenant of. # # Returns: # DataTableClient instance -def datatable(name: str = 'main') +def datatable(name: str = 'main', *, role: Optional[str] = None) # Get a DuckLake client for DuckDB queries. # @@ -875,7 +878,7 @@ def parse_sql_client_name(name: str) -> tuple[str, Optional[str]] # # @task(retry={"attempts": 3, "delay": 30, "multiplier": 2}) # async def call_api(payload: dict): ... -def task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) +def task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) # Create a task that dispatches to a separate Windmill script. # @@ -888,7 +891,7 @@ def task(_func = None, path: Optional[str] = None, tag: Optional[str] = None, ti # @workflow # async def main(): # data = await extract(url="https://...") -def task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) +def task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) # Create a task that dispatches to a separate Windmill flow. # @@ -901,7 +904,7 @@ def task_script(path: str, timeout: Optional[int] = None, tag: Optional[str] = N # @workflow # async def main(): # result = await pipeline(input=data) -def task_flow(path: str, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) +def task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None) # Decorator marking an async function as a workflow-as-code entry point. # @@ -966,7 +969,7 @@ async def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_ # ... # # results = await parallel(items, process, concurrency=5) -async def parallel(items, fn, concurrency: Optional[int] = None) +async def parallel(items, fn, *, concurrency: Optional[int] = None) # Commit Kafka offsets for a trigger with auto_commit disabled. # diff --git a/system_prompts/base/raw-app-cli.md b/system_prompts/base/raw-app-cli.md index cd98e06ff5..030b958379 100644 --- a/system_prompts/base/raw-app-cli.md +++ b/system_prompts/base/raw-app-cli.md @@ -167,6 +167,8 @@ data: tables: - main/users # Table in public schema - main/app_schema:items # Table in specific schema + roles: # Optional: the role the app uses each datatable through + main: analyst ``` **Table reference formats:** @@ -174,6 +176,8 @@ data: - `/
    ` — Specific table in public schema - `/:
    ` — Table in specific schema +**Roles:** when a datatable is under roles, its queries run as a role, which only reaches what it was granted. `roles` records the role the app uses each datatable through; the app's code must pass the same role: `wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python. A datatable without an entry is used as its default role. + ## SQL Migrations (sql_to_apply/) The `sql_to_apply/` folder is for creating/modifying database tables during development. diff --git a/system_prompts/generate.py b/system_prompts/generate.py index 439fb7df9f..2860b9d15c 100644 --- a/system_prompts/generate.py +++ b/system_prompts/generate.py @@ -223,7 +223,12 @@ def extract_py_functions(content: str) -> list[dict]: if args.vararg: params.append(f"*{args.vararg.arg}") - # Handle keyword-only args + # Handle keyword-only args. Same bare `*` as `_format_py_params`: without it the rendered + # signature reads as all-positional, and code written against this reference passes a + # keyword-only argument positionally and gets a TypeError. + if args.kwonlyargs and not args.vararg: + params.append('*') + for i, arg in enumerate(args.kwonlyargs): param_str = arg.arg if arg.annotation: @@ -1266,6 +1271,12 @@ def _format_py_params(node: ast.FunctionDef, skip_self: bool = False) -> str: vararg_str += f": {ast.unparse(args.vararg.annotation)}" params.append(vararg_str) + # A bare `*` before the keyword-only args, when nothing else already introduced them. + # Without it the rendered signature reads as all-positional, and a caller written against + # these docs passes a keyword-only argument positionally and gets a TypeError. + if args.kwonlyargs and not args.vararg: + params.append('*') + for i, arg in enumerate(args.kwonlyargs): param_str = arg.arg if arg.annotation: diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 753b906793..26441041a1 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.814.0", + "version": "1.817.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package-lock.json b/typescript-client/package-lock.json index 20b2270c6e..f881fc7308 100644 --- a/typescript-client/package-lock.json +++ b/typescript-client/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-client", - "version": "1.999.21", + "version": "1.813.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-client", - "version": "1.999.21", + "version": "1.813.0", "license": "Apache 2.0", "devDependencies": { "@types/node": "^20.17.16", @@ -480,9 +480,9 @@ } }, "node_modules/defu": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz", - "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "dev": true, "license": "MIT" }, @@ -603,9 +603,9 @@ "license": "MIT" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { diff --git a/typescript-client/package.json b/typescript-client/package.json index 327a37e3f5..4badfa307b 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.814.0", + "version": "1.817.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/typescript-client/sqlUtils.d.ts b/typescript-client/sqlUtils.d.ts index c791c8833d..c7463bf7b0 100644 --- a/typescript-client/sqlUtils.d.ts +++ b/typescript-client/sqlUtils.d.ts @@ -85,7 +85,10 @@ export interface DatatableSqlTemplateFunction extends SqlTemplateFunction { query(sql: string, ...params: any[]): SqlStatement; } -export declare function datatable(name: string): DatatableSqlTemplateFunction; +export interface DatatableOptions { + role?: string; +} +export declare function datatable(name?: string, opts?: DatatableOptions): DatatableSqlTemplateFunction; export declare function ducklake(name: string): SqlTemplateFunction; export interface DucklakeMaterializeOptions { diff --git a/typescript-client/sqlUtils.ts b/typescript-client/sqlUtils.ts index 85c563095b..4c1ccd7ea4 100644 --- a/typescript-client/sqlUtils.ts +++ b/typescript-client/sqlUtils.ts @@ -108,11 +108,19 @@ export interface DatatableSqlTemplateFunction extends SqlTemplateFunction { query(sql: string, ...params: any[]): SqlStatement; } +export interface DatatableOptions { + /** The data table role to connect as. Omit to get the data table's default role. */ + role?: string; +} + // --------------------------------------------------------------------------- // Provider interface — captures what differs between datatable and ducklake // --------------------------------------------------------------------------- interface SqlProvider { + /** Leading annotation lines. Emitted before everything else: the executor's annotation parser + * stops at the first non-comment line. */ + annotations(): string; formatArgDecl(argNum: number, argType: string): string; formatArgUsage( argNum: number, @@ -125,11 +133,25 @@ interface SqlProvider { providerName: string; } -function datatableProvider(name: string, schema?: string): SqlProvider { +// Interpolated into a `-- role ` line, so a value carrying a newline could append +// statements of its own. Mirrors the server's own role-name rule. +const ROLE_NAME_RE = /^[A-Za-z0-9_-]{1,63}$/; + +function datatableProvider( + name: string, + schema?: string, + role?: string +): SqlProvider { + if (role !== undefined && !ROLE_NAME_RE.test(role)) { + throw new Error( + `Invalid data table role '${role}': only letters, digits, '_' and '-' are allowed` + ); + } return { providerName: "datatable", language: "postgresql", extraArgs: { database: `datatable://${name}` }, + annotations: () => (role !== undefined ? `-- role ${role}\n` : ""), formatArgDecl: (argNum) => `-- $${argNum} arg${argNum}`, formatArgUsage: (argNum, explicitType, inferredType) => explicitType !== undefined @@ -144,6 +166,7 @@ function ducklakeProvider(name: string, schema?: string): SqlProvider { providerName: "ducklake", language: "duckdb", extraArgs: {}, + annotations: () => "", formatArgDecl: (argNum, argType) => `-- $arg${argNum} (${argType})`, formatArgUsage: (argNum) => `$arg${argNum}`, // `USE dl."schema"` sets the active schema so unqualified tables resolve there. @@ -272,7 +295,8 @@ function buildSqlTemplateFunction(provider: SqlProvider): SqlTemplateFunction { return provider.formatArgDecl(info.argNum, argType); }); - let content = argDecls.length ? argDecls.join("\n") + "\n" : ""; + let content = provider.annotations(); + content += argDecls.length ? argDecls.join("\n") + "\n" : ""; content += provider.preamble(); // SQL body — inline raw values, reference params via provider syntax @@ -322,6 +346,8 @@ function buildSqlTemplateFunction(provider: SqlProvider): SqlTemplateFunction { /** * Create a SQL template function for PostgreSQL/datatable queries * @param name - Database/datatable name (default: "main") + * @param opts.role - Connect as this data table role instead of the data table's default one. + * Only meaningful on a data table under roles, and only for a role you are a tenant of. * @returns SQL template function for building parameterized queries * @example * let sql = wmill.datatable() @@ -331,10 +357,16 @@ function buildSqlTemplateFunction(provider: SqlProvider): SqlTemplateFunction { * SELECT * FROM friends * WHERE name = ${name} AND age = ${age}::int * `.fetch() + * @example + * // Read through a restricted role + * let sql = wmill.datatable("main", { role: "analytics" }) */ -export function datatable(name: string = "main"): DatatableSqlTemplateFunction { +export function datatable( + name: string = "main", + opts?: DatatableOptions +): DatatableSqlTemplateFunction { let { name: n, schema } = parseName(name); - let provider = datatableProvider(n, schema); + let provider = datatableProvider(n, schema, opts?.role); let sqlFn = buildSqlTemplateFunction(provider) as DatatableSqlTemplateFunction; // `.query(sql, ...params)` is for SQL strings that already contain // positional placeholders ($1, $2, ...). We DON'T go through the template @@ -353,7 +385,10 @@ export function datatable(name: string = "main"): DatatableSqlTemplateFunction { .join("\n"); let contentBody = sqlString; let content = - (argDecls ? argDecls + "\n" : "") + provider.preamble() + sqlString; + provider.annotations() + + (argDecls ? argDecls + "\n" : "") + + provider.preamble() + + sqlString; let args = { ...Object.fromEntries( params.map((v, i) => [`arg${i + 1}`, serializeArgValue(v)]) diff --git a/version.txt b/version.txt index 9f38cdf20a..2edd9ebec3 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.814.0 +1.817.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index 0c9e94cd0a..e07061650d 100644 --- a/windmill-yaml-validator/package-lock.json +++ b/windmill-yaml-validator/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-yaml-validator", - "version": "1.814.0", + "version": "1.817.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.814.0", + "version": "1.817.0", "license": "Apache 2.0", "dependencies": { "@stoplight/yaml": "^4.3.0", @@ -20,28 +20,14 @@ "typescript": "^5.0.0" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -50,9 +36,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", - "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -60,22 +46,22 @@ } }, "node_modules/@babel/core": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.0.tgz", - "integrity": "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", - "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.6", - "@babel/parser": "^7.28.0", - "@babel/template": "^7.27.2", - "@babel/traverse": "^7.28.0", - "@babel/types": "^7.28.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -91,14 +77,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", - "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.0", - "@babel/types": "^7.28.0", + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -108,14 +94,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -125,9 +111,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -135,29 +121,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -177,9 +163,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -187,9 +173,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -197,9 +183,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -207,27 +193,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.2.tgz", - "integrity": "sha512-/V9771t+EgXz62aCcyofnQhGM8DQACbRhvzKFsXKC9QM+5MadF8ZmIm0crDMaz3+o0h0zXfJnd4EhbYbxsrcFw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.2" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", - "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -476,33 +462,33 @@ } }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", - "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.0", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", "debug": "^4.3.1" }, "engines": { @@ -510,14 +496,14 @@ } }, "node_modules/@babel/types": { - "version": "7.28.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.2.tgz", - "integrity": "sha512-ruv7Ae4J5dUYULmeXw1gmb7rYRz57OWCPM57pHojnLq/3Z1CK2lNSLTCVjxVk1F/TZHwOZZrOWi0ur95BbLxNQ==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -860,6 +846,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1092,9 +1089,9 @@ "license": "MIT" }, "node_modules/ajv": { - "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1303,10 +1300,23 @@ "dev": true, "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.24", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.24.tgz", + "integrity": "sha512-hYrgxie335U08WqICoGqKRzV1HFXv6zdxwJE4ekCb80CM9a0SVVsN4QPwT67RraRo+9h8IATk6uxHJw7QSkdOg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -1328,9 +1338,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", - "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "version": "4.29.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.0.tgz", + "integrity": "sha512-3GSvyjvDI4Dur1Meg2BekJquu5uF+9R9a1+5M1Mde192eZoXbeXjzgOsgqPS2V8D5wrrip0gR5Hf/GhWQ9ZzaA==", "dev": true, "funding": [ { @@ -1348,10 +1358,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001726", - "electron-to-chromium": "^1.5.173", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.11.23", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.427", + "node-releases": "^2.0.55", + "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" @@ -1411,9 +1422,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001731", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001731.tgz", - "integrity": "sha512-lDdp2/wrOmTRWuoB5DpfNkC0rJDU8DqRa6nYL6HK6sytw70QMopt/NIc/9SM7ylItlBWfACXk0tEn37UWM/+mg==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -1665,9 +1676,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.194", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.194.tgz", - "integrity": "sha512-SdnWJwSUot04UR51I2oPD8kuP2VI37/CADR1OHsFOUzZIvfWJBO6q11k5P/uKNyTT3cdOsnyjkrZ+DDShqYqJA==", + "version": "1.5.430", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.430.tgz", + "integrity": "sha512-e1QEj72Y4zd8RlNZVmoTg+iCOSVwpk05IOiiQwdrkwCSVlZfPthevErhE+nckGd2YbsXfp1SkisznhGVIXP2NQ==", "dev": true, "license": "ISC" }, @@ -1799,9 +1810,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz", - "integrity": "sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", "funding": [ { "type": "github", @@ -1835,9 +1846,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz", + "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==", "dev": true, "license": "MIT", "dependencies": { @@ -1845,9 +1856,9 @@ } }, "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", "dev": true, "license": "ISC", "dependencies": { @@ -2857,9 +2868,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.15.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz", + "integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==", "dev": true, "license": "MIT", "dependencies": { @@ -3044,9 +3055,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -3078,11 +3089,14 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/normalize-path": { "version": "3.0.0", @@ -3252,9 +3266,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -3790,9 +3804,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", + "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", "dev": true, "funding": [ { diff --git a/windmill-yaml-validator/package.json b/windmill-yaml-validator/package.json index 7a15686549..01bb378dcd 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.814.0", + "version": "1.817.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts",