diff --git a/.github/DockerfileBackendTests b/.github/DockerfileBackendTests index 4473c00f0a..88275f204b 100644 --- a/.github/DockerfileBackendTests +++ b/.github/DockerfileBackendTests @@ -28,7 +28,7 @@ ENV PATH="${PATH}:/usr/local/go/bin" ENV GO_PATH=/usr/local/go/bin/go # UV -RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.24/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv +RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv ENV TZ=Etc/UTC diff --git a/.github/change-versions-mac.sh b/.github/change-versions-mac.sh index 8a1d1c5cfc..2f23b21eb8 100755 --- a/.github/change-versions-mac.sh +++ b/.github/change-versions-mac.sh @@ -7,7 +7,7 @@ VERSION=$1 echo "Updating versions to: $VERSION" sed -i '' -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/backend/Cargo.toml -sed -i '' -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/cli/src/main.ts +sed -i '' -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/cli/src/core/constants.ts sed -i '' -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/benchmarks/lib.ts sed -i '' -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/backend/windmill-api/openapi.yaml sed -i '' -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/openflow.openapi.yaml diff --git a/.github/change-versions.sh b/.github/change-versions.sh index 57fe28b343..ddebbbca87 100755 --- a/.github/change-versions.sh +++ b/.github/change-versions.sh @@ -7,7 +7,7 @@ VERSION=$1 echo "Updating versions to: $VERSION" sed -i -e "/^version =/s/= .*/= \"$VERSION\"/" ${root_dirpath}/backend/Cargo.toml -sed -i -e "/^export const VERSION =/s/= .*/= \"$VERSION\";/" ${root_dirpath}/cli/src/main.ts +sed -i -e "/^export const VERSION =/s/= .*/= \"$VERSION\";/" ${root_dirpath}/cli/src/core/constants.ts sed -i -e "/^export const VERSION =/s/= .*/= \"v$VERSION\";/" ${root_dirpath}/benchmarks/lib.ts sed -i -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/backend/windmill-api/openapi.yaml sed -i -e "/version: /s/: .*/: $VERSION/" ${root_dirpath}/openflow.openapi.yaml diff --git a/.github/workflows/backend-test-windows.yml b/.github/workflows/backend-test-windows.yml index 96b2719737..1c73e5d429 100644 --- a/.github/workflows/backend-test-windows.yml +++ b/.github/workflows/backend-test-windows.yml @@ -74,7 +74,7 @@ jobs: - uses: astral-sh/setup-uv@v6.2.1 with: - version: "0.9.24" + version: "0.9.25" - uses: shivammathur/setup-php@v2 with: @@ -98,6 +98,21 @@ jobs: vcpkg.exe install openssl:x64-windows-static vcpkg.exe integrate install + - name: Free disk space (post-vcpkg) + shell: pwsh + run: | + # vcpkg leaves multi-GB of buildtrees/downloads after installing openssl; + # we only need the installed/ dir for linking. + $vcpkgRoot = $env:VCPKG_INSTALLATION_ROOT + foreach ($sub in @("buildtrees", "downloads", "packages")) { + $path = Join-Path $vcpkgRoot $sub + if (Test-Path $path) { + Write-Host "Removing $path" + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue $path + } + } + Get-PSDrive C | Select-Object Used,Free | Format-Table -AutoSize + - name: Get runtime paths id: runtime-paths shell: pwsh @@ -119,6 +134,10 @@ jobs: cargo build --release -p windmill_duckdb_ffi_internal New-Item -ItemType Directory -Path ..\target\debug -Force Copy-Item target\release\windmill_duckdb_ffi_internal.dll ..\target\debug\ + # duckdb is bundled (~2GB of build artifacts); the DLL is the only + # thing we need from this excluded-crate target dir. + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue target + Get-PSDrive C | Select-Object Used,Free | Format-Table -AutoSize - name: Print runtime versions and env shell: pwsh @@ -136,6 +155,10 @@ jobs: echo "USERPROFILE=$env:USERPROFILE" echo "HOME=$env:HOME" + - name: Disk space before cargo test + shell: pwsh + run: Get-PSDrive C | Select-Object Used,Free | Format-Table -AutoSize + - name: cargo test working-directory: backend timeout-minutes: 60 @@ -144,13 +167,16 @@ jobs: RUST_LOG: "off" RUST_LOG_STYLE: never CARGO_NET_GIT_FETCH_WITH_CLI: true - CARGO_BUILD_JOBS: 12 + # 16-vcpu runners with disabled PDB still hit LNK1180 ("insufficient + # disk space") at link time with 12 parallel link jobs: each test + # binary link spikes several hundred MB of transient I/O. Capping at + # 8 trades ~25% wall time for headroom on the ~75GB runner disk. + CARGO_BUILD_JOBS: 8 # backend/Cargo.toml sets split-debuginfo = "unpacked", which on # windows-msvc is coerced to "packed": every test-binary link spawns - # the mspdbsrv.exe PDB type server and writes a large .pdb. With 12 - # parallel link jobs this races the type-server cap (LNK1318 "LIMIT - # (12)") and exhausts the runner disk (LNK1180). CI needs no debug - # info, so disable PDB generation for the dev/test profiles here. + # the mspdbsrv.exe PDB type server and writes a large .pdb. CI needs + # no debug info, so disable PDB generation for the dev/test profiles + # here (avoids both LNK1318 type-server limit and PDB disk usage). CARGO_PROFILE_DEV_SPLIT_DEBUGINFO: "off" CARGO_PROFILE_TEST_SPLIT_DEBUGINFO: "off" # Tests' poll-time stack frames (deep nested async fn chains in diff --git a/.github/workflows/backend-test.yml b/.github/workflows/backend-test.yml index 9009b47e9d..8f1f15447c 100644 --- a/.github/workflows/backend-test.yml +++ b/.github/workflows/backend-test.yml @@ -62,7 +62,7 @@ jobs: node-version: "20" - uses: astral-sh/setup-uv@v6.2.1 with: - version: "0.9.24" + version: "0.9.25" - uses: shivammathur/setup-php@v2 with: php-version: "8.3" diff --git a/.github/workflows/check-empty-fixture.yml b/.github/workflows/check-empty-fixture.yml new file mode 100644 index 0000000000..4842260645 --- /dev/null +++ b/.github/workflows/check-empty-fixture.yml @@ -0,0 +1,19 @@ +name: Check fixture is empty + +on: + push: + branches: [main] + paths: + - "fixtures/**" + pull_request: + paths: + - "fixtures/**" + +jobs: + check-empty-fixture: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Ensure fixtures/cli-sync/ has no committed snapshot + run: bash fixtures/check-empty.sh diff --git a/.github/workflows/git-sync-test.yml b/.github/workflows/git-sync-test.yml index 09f69bb701..12b75932ce 100644 --- a/.github/workflows/git-sync-test.yml +++ b/.github/workflows/git-sync-test.yml @@ -8,6 +8,7 @@ on: - "backend/windmill-git-sync/**" - "backend/windmill-api-integration-tests/tests/git_sync*" - "backend/ee-repo-ref.txt" + - "backend/windmill-common/src/workspaces.rs" - "integration_tests/test/git_sync_test.py" - ".github/workflows/git-sync-test.yml" pull_request: @@ -16,6 +17,7 @@ on: - "backend/windmill-git-sync/**" - "backend/windmill-api-integration-tests/tests/git_sync*" - "backend/ee-repo-ref.txt" + - "backend/windmill-common/src/workspaces.rs" - "integration_tests/test/git_sync_test.py" - ".github/workflows/git-sync-test.yml" @@ -49,7 +51,7 @@ jobs: echo "$CHANGED_FILES" # Direct git sync file changes — always relevant - if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-api-integration-tests/tests/git_sync|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then + if echo "$CHANGED_FILES" | grep -qE '^(backend/windmill-git-sync/|backend/windmill-api-integration-tests/tests/git_sync|backend/windmill-common/src/workspaces\.rs|integration_tests/test/git_sync|\.github/workflows/git-sync-test\.yml)'; then echo "should_run=true" >> "$GITHUB_OUTPUT" echo "Relevant: direct git sync file changes" exit 0 diff --git a/AGENTS.md b/AGENTS.md index 5dda12ab70..6cf4e3d7f3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,3 +106,4 @@ $NAV --root backend callees "X" # what does X call? - Search for existing code to reuse before writing new code - Follow established patterns in the codebase - Keep changes focused — don't refactor beyond what's asked +- **Never attribute work to a specific customer, account, or "requested by a customer" in repo-tracked content** (PR descriptions, commit messages, code comments, docs). Describe changes by their technical motivation instead. diff --git a/CHANGELOG.md b/CHANGELOG.md index edf9d417fd..639d1ad7ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,222 @@ # Changelog +## [1.719.0](https://github.com/windmill-labs/windmill/compare/v1.718.0...v1.719.0) (2026-06-06) + + +### Features + +* **otel:** connect jobs to the inbound distributed trace ([#9456](https://github.com/windmill-labs/windmill/issues/9456)) ([fad1a54](https://github.com/windmill-labs/windmill/commit/fad1a549d95c00d0746a48163c4f95fc69733e1a)) + + +### Bug Fixes + +* authenticate slack callback payload with per-workspace hmac ([#9461](https://github.com/windmill-labs/windmill/issues/9461)) ([fbdf81b](https://github.com/windmill-labs/windmill/commit/fbdf81ba5f77d282c025360ecee14138dd4cb4a2)) +* prevent token label collision bypassing job read access control ([#9462](https://github.com/windmill-labs/windmill/issues/9462)) ([e1e7af6](https://github.com/windmill-labs/windmill/commit/e1e7af6a25a44eb06b67332ce1efeae2a21e0c6d)) +* **python:** escape reserved-keyword step ids in wrapper codegen ([#9460](https://github.com/windmill-labs/windmill/issues/9460)) ([6a15a9b](https://github.com/windmill-labs/windmill/commit/6a15a9b152ad20be4b5c3de6000516da231e41e0)), closes [#8893](https://github.com/windmill-labs/windmill/issues/8893) + +## [1.718.0](https://github.com/windmill-labs/windmill/compare/v1.717.1...v1.718.0) (2026-06-05) + + +### Features + +* **flows:** opt-in to include the stopping step's result in early-stop errors ([#9446](https://github.com/windmill-labs/windmill/issues/9446)) ([f2f0812](https://github.com/windmill-labs/windmill/commit/f2f0812a04c9256cfc8eba5e0dcf38d71d971410)) +* make C# dotnet target framework configurable via DOTNET_TARGET_FRAMEWORK ([#9454](https://github.com/windmill-labs/windmill/issues/9454)) ([9a609bf](https://github.com/windmill-labs/windmill/commit/9a609bf08ac1b6157dbdfb827fc01e771d71262e)) +* sandboxed daemonless container runtime via '# sandbox <image>' ([#9453](https://github.com/windmill-labs/windmill/issues/9453)) ([1727271](https://github.com/windmill-labs/windmill/commit/1727271e197b34026efeaf1b6561bb404a440baa)) +* **sandbox:** pull/extract images with crane instead of podman ([#9455](https://github.com/windmill-labs/windmill/issues/9455)) ([7590b28](https://github.com/windmill-labs/windmill/commit/7590b281085afd1fc2774e8fb37a4c0af3aedbad)) + + +### Bug Fixes + +* distinguish canceled jobs in runs ([#9452](https://github.com/windmill-labs/windmill/issues/9452)) ([9067787](https://github.com/windmill-labs/windmill/commit/90677872f6185eb0c81e0e84a426a54653818457)) + +## [1.717.1](https://github.com/windmill-labs/windmill/compare/v1.717.0...v1.717.1) (2026-06-04) + + +### Bug Fixes + +* invalidate relative-import cache when imported script changes ([#9443](https://github.com/windmill-labs/windmill/issues/9443)) ([f595787](https://github.com/windmill-labs/windmill/commit/f595787409a3fcda9278bbcf2cfcc80092f16460)) + +## [1.717.0](https://github.com/windmill-labs/windmill/compare/v1.716.0...v1.717.0) (2026-06-04) + + +### Features + +* let flow AI chat create and edit sticky notes ([#9412](https://github.com/windmill-labs/windmill/issues/9412)) ([e4e0984](https://github.com/windmill-labs/windmill/commit/e4e0984e55afd3c73f1c365cd0608493a9fd87ed)) + + +### Bug Fixes + +* **cli:** push whole raw app instead of treating frontend files as scripts ([#9442](https://github.com/windmill-labs/windmill/issues/9442)) ([b5a6a1e](https://github.com/windmill-labs/windmill/commit/b5a6a1eeab663c2d6aaec2c89eab7a550cb0bb6b)) +* read latest db draft for scripts/flows in global mode read tool ([#9441](https://github.com/windmill-labs/windmill/issues/9441)) ([819ba5e](https://github.com/windmill-labs/windmill/commit/819ba5e150ec9f5199919fbea50874fc156d0189)) + +## [1.716.0](https://github.com/windmill-labs/windmill/compare/v1.715.0...v1.716.0) (2026-06-03) + + +### Features + +* add metadata generation model setting ([#9418](https://github.com/windmill-labs/windmill/issues/9418)) ([cf5fefb](https://github.com/windmill-labs/windmill/commit/cf5fefb521479170b9dc64b884630c4dac789931)) +* auto-generate AI session names ([#9399](https://github.com/windmill-labs/windmill/issues/9399)) ([26b7270](https://github.com/windmill-labs/windmill/commit/26b727041830c9b741668a9ab73e2eb90c7cec74)) +* support $f/ and $u/ import path aliases for scripts ([#9378](https://github.com/windmill-labs/windmill/issues/9378)) ([220cd35](https://github.com/windmill-labs/windmill/commit/220cd35cf799c42ebf588bc97a6d8e6f4e97c2e3)) +* use metadata model for small AI tasks ([#9431](https://github.com/windmill-labs/windmill/issues/9431)) ([79178f6](https://github.com/windmill-labs/windmill/commit/79178f6f5a7c606a2e05677c6efcbdd84c608325)) + + +### Bug Fixes + +* **apps:** relock no longer reverts raw app to a stale version ([#9432](https://github.com/windmill-labs/windmill/issues/9432)) ([073857a](https://github.com/windmill-labs/windmill/commit/073857ac0a9ed54bdeac8f373f7c855fe34eb0ac)) +* **security:** scope variable and resource value caches by caller identity ([#9427](https://github.com/windmill-labs/windmill/issues/9427)) ([0ba128a](https://github.com/windmill-labs/windmill/commit/0ba128afe797bd016da60563949ac3abbbfe1978)) + +## [1.715.0](https://github.com/windmill-labs/windmill/compare/v1.714.1...v1.715.0) (2026-06-03) + + +### Features + +* **frontend:** add rebuild dependency map button to workspace settings ([#9424](https://github.com/windmill-labs/windmill/issues/9424)) ([3b2e748](https://github.com/windmill-labs/windmill/commit/3b2e748daf0a8ec4447c30423068df803f3f9ca2)) + + +### Bug Fixes + +* **auth:** filter script/flow listings by token scope (GHSA-2ppx-66jv-wpw5) ([#9426](https://github.com/windmill-labs/windmill/issues/9426)) ([7edf3f0](https://github.com/windmill-labs/windmill/commit/7edf3f02122e20fde1e95e0252e7bda641075326)) +* **backend:** authorize single-job read endpoints by job/flow visibility ([#9416](https://github.com/windmill-labs/windmill/issues/9416)) ([89a7a37](https://github.com/windmill-labs/windmill/commit/89a7a377764086911db18252f2478f42f0e1e3ea)) +* **mcp:** resolve MCP resource token via caller RLS + SSRF-guard url ([#9428](https://github.com/windmill-labs/windmill/issues/9428)) ([8053266](https://github.com/windmill-labs/windmill/commit/8053266f88bd4c94fc86278412df5a0beeed5e77)) +* **nsjail:** precompile python stdlib + raise download rlimit_as ([#9429](https://github.com/windmill-labs/windmill/issues/9429)) ([7031744](https://github.com/windmill-labs/windmill/commit/7031744a199f0bf8b8e35043afa959977e5ecdbd)) +* omit temperature for gpt-5+ and o-series models on all providers ([#9422](https://github.com/windmill-labs/windmill/issues/9422)) ([11d1ad9](https://github.com/windmill-labs/windmill/commit/11d1ad9a872d2ec2f14cde35708c84a0c7bdc172)) + +## [1.714.1](https://github.com/windmill-labs/windmill/compare/v1.714.0...v1.714.1) (2026-06-02) + + +### Bug Fixes + +* **backend:** route //native TypeScript previews to native workers (WIN-2007) ([#9407](https://github.com/windmill-labs/windmill/issues/9407)) ([73edebc](https://github.com/windmill-labs/windmill/commit/73edebc833a981488a8ea116f4f13c020a011a6f)) +* **nsjail:** raise python download fd limit for --compile-bytecode (WIN-2009) ([#9414](https://github.com/windmill-labs/windmill/issues/9414)) ([9e6559a](https://github.com/windmill-labs/windmill/commit/9e6559a6f688cc8d982277b19920219ea6d0fd8e)) +* **triggers:** prevent Zoom challenge handler from being used as a signing oracle ([#9413](https://github.com/windmill-labs/windmill/issues/9413)) ([ab2a15b](https://github.com/windmill-labs/windmill/commit/ab2a15b2a859096eabde718bf6e60289ae187118)) + +## [1.714.0](https://github.com/windmill-labs/windmill/compare/v1.713.1...v1.714.0) (2026-06-02) + + +### Features + +* add global ai chat test tools ([#9391](https://github.com/windmill-labs/windmill/issues/9391)) ([5c20d6b](https://github.com/windmill-labs/windmill/commit/5c20d6b4f79f2ccc1987ce7fdaf74e6b8f697846)) +* add workspace datatable tools to global AI chat mode ([#9395](https://github.com/windmill-labs/windmill/issues/9395)) ([943ef6e](https://github.com/windmill-labs/windmill/commit/943ef6eb2089f4b744cfa7945ce47f7f3b361ec7)) +* **flow-ai:** constrain flow-group colors to the NoteColor palette ([#9343](https://github.com/windmill-labs/windmill/issues/9343)) ([e4213c1](https://github.com/windmill-labs/windmill/commit/e4213c1ab8c448f492f372580f5c9df37e33fffc)) +* **frontend:** surface local drafts in drawer editors with an unsaved-changes banner ([#9335](https://github.com/windmill-labs/windmill/issues/9335)) ([075faab](https://github.com/windmill-labs/windmill/commit/075faabf3bba16a10a02ae3973008e5a13473085)) +* handle CTRL_BREAK_EVENT for graceful shutdown on Windows ([#9400](https://github.com/windmill-labs/windmill/issues/9400)) ([2e14456](https://github.com/windmill-labs/windmill/commit/2e1445616a412c5112ad2247b4087c7ddc218845)) +* refine ask-user-question chat display and keyboard nav ([#9392](https://github.com/windmill-labs/windmill/issues/9392)) ([1275487](https://github.com/windmill-labs/windmill/commit/1275487f028d4c74a9eeb18981ed05c225505be0)) +* sessions page with isolated AI chat + flow editor ([#9034](https://github.com/windmill-labs/windmill/issues/9034)) ([eadeac2](https://github.com/windmill-labs/windmill/commit/eadeac248bd022c2796cfe638eb617c6143b8fc4)) + + +### Bug Fixes + +* **cli:** make encryption key push non-interactive-safe + add --skip-reencrypt-on-key-change ([#9402](https://github.com/windmill-labs/windmill/issues/9402)) ([e356bb1](https://github.com/windmill-labs/windmill/commit/e356bb1f5df92eca3fbb0ca2114b9f4c32d4c496)) +* **cli:** stop git-sync promotion deploys from dropping triggers/schedules ([#9403](https://github.com/windmill-labs/windmill/issues/9403)) ([24e3ef2](https://github.com/windmill-labs/windmill/commit/24e3ef27be8498fb820c228a52febf6a0a91b487)) +* **frontend:** align Monaco editor font size with text-xs ([#9161](https://github.com/windmill-labs/windmill/issues/9161)) ([de76668](https://github.com/windmill-labs/windmill/commit/de76668c10c04abe8771a8ca7bba7b2259819a1c)) +* resolve username rename failing on apps with runnable deps ([#9401](https://github.com/windmill-labs/windmill/issues/9401)) ([e8ad53d](https://github.com/windmill-labs/windmill/commit/e8ad53dae92597f5a1a8b76f38a7d8c24f578a47)) + + +### Performance Improvements + +* **python:** add --compile-bytecode to uv pip install ([#9393](https://github.com/windmill-labs/windmill/issues/9393)) ([c19441b](https://github.com/windmill-labs/windmill/commit/c19441bc8cb2da064e4ad44d77dc04ab8bbb22ec)) + +## [1.713.1](https://github.com/windmill-labs/windmill/compare/v1.713.0...v1.713.1) (2026-06-01) + + +### Bug Fixes + +* **api:** handle multi-version scripts when removing granular ACL ([#9388](https://github.com/windmill-labs/windmill/issues/9388)) ([9d9c503](https://github.com/windmill-labs/windmill/commit/9d9c5038ce8b0016320a670c434ef9063cb40441)) + +## [1.713.0](https://github.com/windmill-labs/windmill/compare/v1.712.0...v1.713.0) (2026-05-31) + + +### Features + +* **flows:** preserve step/subflow worker tags under a custom-tagged flow ([#9375](https://github.com/windmill-labs/windmill/issues/9375)) ([f0301b1](https://github.com/windmill-labs/windmill/commit/f0301b1605cee5fba4024803555333e6fa5c40ee)) +* **oauth:** support per-provider sandbox URLs ([#9358](https://github.com/windmill-labs/windmill/issues/9358)) ([2bf11dc](https://github.com/windmill-labs/windmill/commit/2bf11dcb15540c538ea2ac3cf70dcbe589060b4e)) + + +### Bug Fixes + +* **ai:** validate token_url for SSRF in OAuth credentials flow ([#9385](https://github.com/windmill-labs/windmill/issues/9385)) ([4b06881](https://github.com/windmill-labs/windmill/commit/4b06881918b76c5a411cc70b318e46efcc1393a7)) +* **api:** authorize and harden log-file reading endpoints ([#9368](https://github.com/windmill-labs/windmill/issues/9368)) ([bb90f4c](https://github.com/windmill-labs/windmill/commit/bb90f4ce83a0e60af219b11c12ab4fe1d13f47a4)) +* **apps:** make public apps opt into cross-origin isolation via wm_coep (GIT-884) ([#9374](https://github.com/windmill-labs/windmill/issues/9374)) ([2c0c2c4](https://github.com/windmill-labs/windmill/commit/2c0c2c467f163cd24c14c7be2db07af9cf2ce020)) +* **auth:** enforce monotonic privilege on user token lifecycle endpoints ([#9371](https://github.com/windmill-labs/windmill/issues/9371)) ([2ddf93d](https://github.com/windmill-labs/windmill/commit/2ddf93de96622b2a1b2b6f59398a7a1f59360efd)) +* batch encryption-key rotation into one git-sync job ([#9355](https://github.com/windmill-labs/windmill/issues/9355)) ([04a0897](https://github.com/windmill-labs/windmill/commit/04a08976aec4ba9b0516350316df303e9f96bfd3)) +* **cli:** preserve user drafts on sync push and permissioned-as ([#9381](https://github.com/windmill-labs/windmill/issues/9381)) ([b0c3b01](https://github.com/windmill-labs/windmill/commit/b0c3b01d31b0ab3a6566e1f5fec60e3e230cfadb)) +* **frontend:** sanitize user markdown to prevent stored XSS ([#9386](https://github.com/windmill-labs/windmill/issues/9386)) ([def01b8](https://github.com/windmill-labs/windmill/commit/def01b8ff6f331cc36ce02b947adc31c766042c4)) +* **security:** re-pin cached hub scripts to CVE-patched versions (+ HUB_BASE_URL override for cache mode) ([#9387](https://github.com/windmill-labs/windmill/issues/9387)) ([edf340c](https://github.com/windmill-labs/windmill/commit/edf340c4d4f18b16b142cb7deb67afa586f10946)) + +## [1.712.0](https://github.com/windmill-labs/windmill/compare/v1.711.0...v1.712.0) (2026-05-28) + + +### Features + +* add deepseek fim support ([#9365](https://github.com/windmill-labs/windmill/issues/9365)) ([2553fbf](https://github.com/windmill-labs/windmill/commit/2553fbfe31417bd985e7994eac695bf918f97ce2)) +* deploy raw apps from global chat ([#9349](https://github.com/windmill-labs/windmill/issues/9349)) ([dec58e6](https://github.com/windmill-labs/windmill/commit/dec58e6c4f55062b42a752c43c89ef05903e713a)) +* inject active editor into global chat ([#9361](https://github.com/windmill-labs/windmill/issues/9361)) ([9e7eaf3](https://github.com/windmill-labs/windmill/commit/9e7eaf36847ad3a004ec84e8b7d4784771b7b451)) +* **queue:** duration-weighted fairness admission ([#9334](https://github.com/windmill-labs/windmill/issues/9334)) ([045d120](https://github.com/windmill-labs/windmill/commit/045d12043e7c99830ef90bc0da798c94e2094711)) +* warn when custom instance db is shared across workspaces ([#9359](https://github.com/windmill-labs/windmill/issues/9359)) ([a9e5140](https://github.com/windmill-labs/windmill/commit/a9e514099585e5ee72df21bd551a223cceb20fb0)) + + +### Bug Fixes + +* **cli:** redact encryption_key diff in stdout by default ([#9347](https://github.com/windmill-labs/windmill/issues/9347)) ([88056f8](https://github.com/windmill-labs/windmill/commit/88056f8d4c91c1d14d85a08851ecf0bd97e2260d)) +* **cli:** stop re-prompting on wmill refresh prompts ([#9357](https://github.com/windmill-labs/windmill/issues/9357)) ([c2b5ba8](https://github.com/windmill-labs/windmill/commit/c2b5ba8871abbbcff6de69c90e2f09fee70586c1)) +* **frontend:** close other sidebar menus when hovering Help ([#9354](https://github.com/windmill-labs/windmill/issues/9354)) ([da882c5](https://github.com/windmill-labs/windmill/commit/da882c54b21e3eaf2c1d1abccd0996b243d96dce)) +* **frontend:** prevent duplicate asset node ids crashing flow graph ([#9367](https://github.com/windmill-labs/windmill/issues/9367)) ([9a659b6](https://github.com/windmill-labs/windmill/commit/9a659b636d713ee8fdfbdad41c58bb3d7c79e0d9)) +* **frontend:** prevent MultiSelect crash on undefined value ([#9364](https://github.com/windmill-labs/windmill/issues/9364)) ([aea0061](https://github.com/windmill-labs/windmill/commit/aea00611c41379be2afdad0eedd608c9537d03f7)) +* **git-sync:** publish fork branch on only_create_branch from the CLI ([#9366](https://github.com/windmill-labs/windmill/issues/9366)) ([2fdc51e](https://github.com/windmill-labs/windmill/commit/2fdc51e62985fc755884436130bdd58e294247c8)) +* infer script arg schema when deploying via AI chat ([#9356](https://github.com/windmill-labs/windmill/issues/9356)) ([4efc372](https://github.com/windmill-labs/windmill/commit/4efc37212a98571214aba135b0fbb10dc263fd4f)) +* **monitor:** cleanup stale server_heartbeat background_task_state rows ([#9338](https://github.com/windmill-labs/windmill/issues/9338)) ([59ab038](https://github.com/windmill-labs/windmill/commit/59ab038d7718d8a4c25efa5928f42e1393ebbf40)) + +## [1.711.0](https://github.com/windmill-labs/windmill/compare/v1.710.1...v1.711.0) (2026-05-26) + + +### Features + +* **cli:** add object-storage commands and flow test-step ([#9326](https://github.com/windmill-labs/windmill/issues/9326)) ([36f574f](https://github.com/windmill-labs/windmill/commit/36f574ff951198a4d40ee068a27d74c41ce32154)) + + +### Bug Fixes + +* **cli:** handle __flow suffix when deriving the flow's Windmill path ([#9333](https://github.com/windmill-labs/windmill/issues/9333)) ([6f77034](https://github.com/windmill-labs/windmill/commit/6f770346fb330997a836c39fba347df4c088a83c)) +* **queue:** duration-weighted workspace fairness signal ([#9329](https://github.com/windmill-labs/windmill/issues/9329)) ([42d2121](https://github.com/windmill-labs/windmill/commit/42d2121af925de50f549ecb72ffb5132f5c41079)) + +## [1.710.1](https://github.com/windmill-labs/windmill/compare/v1.710.0...v1.710.1) (2026-05-26) + + +### Bug Fixes + +* improve workspace fairness ([896add0](https://github.com/windmill-labs/windmill/commit/896add0350f4de31f5674d6be0907a582c5ec17e)) + +## [1.710.0](https://github.com/windmill-labs/windmill/compare/v1.709.0...v1.710.0) (2026-05-26) + + +### Features + +* **queue:** stochastic admission + EE availability of workspace fairness algorithm ([#9321](https://github.com/windmill-labs/windmill/issues/9321)) ([8bf7fd2](https://github.com/windmill-labs/windmill/commit/8bf7fd2c921c48861b71731a085b18ea8f72fb68)) + + +### Bug Fixes + +* **websocket-trigger:** honor HTTPS_PROXY/HTTP_PROXY/NO_PROXY ([#9324](https://github.com/windmill-labs/windmill/issues/9324)) ([6f36316](https://github.com/windmill-labs/windmill/commit/6f363163df9cd15f5af7d56cf34a01b70d236830)) + +## [1.709.0](https://github.com/windmill-labs/windmill/compare/v1.708.0...v1.709.0) (2026-05-25) + + +### Features + +* add copy button to Path component ([#9311](https://github.com/windmill-labs/windmill/issues/9311)) ([98bd5e7](https://github.com/windmill-labs/windmill/commit/98bd5e7f2a437b8b534028838b6ed0d7c59f7011)) +* **ai-chat:** align footer bar + DropdownV2 mode/autonomy selectors ([#9308](https://github.com/windmill-labs/windmill/issues/9308)) ([2f50e8b](https://github.com/windmill-labs/windmill/commit/2f50e8bab0b5ae9ae297c79abfe96df441f405e2)) +* **ai-chat:** expand chat question answers ([#9310](https://github.com/windmill-labs/windmill/issues/9310)) ([3f219ae](https://github.com/windmill-labs/windmill/commit/3f219aed98d93158aefce01bb51ed12dcb4711a1)) +* plug global chat drafts into userdraft ([#9291](https://github.com/windmill-labs/windmill/issues/9291)) ([1eef531](https://github.com/windmill-labs/windmill/commit/1eef53170b1b2afb75b9812e33787d1f28cf50dd)) +* **raw_apps:** surface UI Builder build errors over the preview pane ([#9316](https://github.com/windmill-labs/windmill/issues/9316)) ([90a196d](https://github.com/windmill-labs/windmill/commit/90a196d8d81993ffc2377d7088ab98f7b0f5ddcc)) +* **raw_apps:** tab-based editor surface with split-with-preview ([#9273](https://github.com/windmill-labs/windmill/issues/9273)) ([368e677](https://github.com/windmill-labs/windmill/commit/368e6774194a58058f28d1b4a42f8f4a7ec4ab63)) +* **service-accounts:** allow choosing role at creation time ([#9307](https://github.com/windmill-labs/windmill/issues/9307)) ([b125eca](https://github.com/windmill-labs/windmill/commit/b125eca7628b07c071bd102b161d389259fd6c62)) + + +### Bug Fixes + +* **auth:** filter resource/variable listings by token scope (WIN-1981) ([#9302](https://github.com/windmill-labs/windmill/issues/9302)) ([b5a0d46](https://github.com/windmill-labs/windmill/commit/b5a0d46695fdfe692d64573d1cfa06511e3b33f5)) +* **jobs:** authorization bypass in only_result job updates (WIN-1980) ([#9301](https://github.com/windmill-labs/windmill/issues/9301)) ([108a88a](https://github.com/windmill-labs/windmill/commit/108a88a1801548c8570d56aa3e1eb80246367bf4)) + ## [1.708.0](https://github.com/windmill-labs/windmill/compare/v1.707.0...v1.708.0) (2026-05-24) diff --git a/Dockerfile b/Dockerfile index e11cf9cecd..d327c9b394 100644 --- a/Dockerfile +++ b/Dockerfile @@ -66,6 +66,7 @@ RUN npm ci COPY frontend . RUN mkdir /backend COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml +COPY /backend/oauth_connect.json /backend/oauth_connect.json COPY /openflow.openapi.yaml /openflow.openapi.yaml COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh COPY /system_prompts/auto-generated /system_prompts/auto-generated @@ -232,11 +233,14 @@ ENV PATH="${PATH}:/usr/local/go/bin" ENV GO_PATH=/usr/local/go/bin/go # Install UV -RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv +RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv # Preinstall python runtimes to temp build location (will copy with world-writable perms later) -RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install 3.11 -RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY +# --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run +# under the read-only nsjail runtime mount (uv >= 0.9.25). The copy below MUST preserve +# timestamps or Python's mtime-based .pyc invalidation discards these compiled files. +RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install 3.11 --compile-bytecode +RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY --compile-bytecode RUN curl -sL https://deb.nodesource.com/setup_20.x | bash - @@ -258,7 +262,7 @@ RUN export GOCACHE=/tmp/build_cache/go && \ # chmod a+rw adds read+write WITHOUT removing execute bits (755->777, 644->666) # Note: uv python install only creates py_runtime, not uv cache - we create uv/go dirs for runtime RUN mkdir -p /tmp/windmill/cache && \ - cp -r /tmp/build_cache/* /tmp/windmill/cache/ && \ + cp -r --preserve=timestamps /tmp/build_cache/* /tmp/windmill/cache/ && \ chmod -R a+rw /tmp/windmill/cache && \ rm -rf /tmp/build_cache && \ mkdir -p -m 777 /tmp/windmill/cache/uv /tmp/windmill/cache/go /tmp/windmill/cache/rustup /tmp/windmill/cache/cargo @@ -299,10 +303,20 @@ ENV CARGO_HOME="/tmp/windmill/cache/cargo" ENV LD_LIBRARY_PATH="." # nsjail runtime deps and binary -RUN apt-get update && apt-get install -y libprotobuf-dev libnl-route-3-dev \ +RUN apt-get update && apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 libnl-3-200 \ && apt-get clean && rm -rf /var/lib/apt/lists/* COPY --from=nsjail /nsjail/nsjail /bin/nsjail +# crane: pulls + flattens images for the sandboxed container runtime (`# sandbox `). +# Single static binary — no daemon/store/root needed. See docs/docker-v2-runtime.md. +ARG CRANE_VERSION=v0.20.6 +RUN arch="$(dpkg --print-architecture)"; \ + case "$arch" in amd64) crane_arch=x86_64 ;; arm64) crane_arch=arm64 ;; *) echo >&2 "error: unsupported arch '$arch' for crane"; exit 1 ;; esac; \ + wget -O /tmp/crane.tgz "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_${crane_arch}.tar.gz" \ + && tar -xzf /tmp/crane.tgz -C /usr/local/bin crane \ + && rm /tmp/crane.tgz \ + && chmod +x /usr/local/bin/crane + WORKDIR ${APP} RUN ln -s ${APP}/windmill /usr/local/bin/windmill diff --git a/ai_evals/AGENTS.md b/ai_evals/AGENTS.md index d26e6d60ea..af5427abae 100644 --- a/ai_evals/AGENTS.md +++ b/ai_evals/AGENTS.md @@ -86,6 +86,31 @@ Global prompts should exercise workspace-level drafting behavior: Keep deterministic validation focused on the draft contract: required draft type/path, required content snippets, forbidden draft paths, and forbidden mutating tools such as deploy/delete unless the case explicitly asks for them. +Datatable cases should set `skipJudge: true` and validate through tool-use +(`requiredToolsUsed` / `forbiddenToolsUsed`) and SQL-argument assertions +(`toolCallArgs` with `stringIncludesAnyOf`, e.g. `['select']`, `['create table']`, +`['update', 'insert into']`). Two reasons the judge is unreliable here: + +- `list_datatables`, `get_datatable_table_schema`, and `exec_datatable_sql` + produce no drafts, and the global judge only sees the drafts artifact — it + scores a no-draft conversational answer as empty (same as the + `askUserQuestion` cases). +- Even a case that *does* produce a draft (a script reading the data table via + `wmill.datatable()` at runtime) is mis-judged: the judge has no datatable SDK + reference and penalizes correct `wmill.datatable()` usage as wrong. Verify the + SDK call deterministically instead — `requiredDrafts.valueIncludes: ['wmill.datatable(']` + plus forbidding `exec_datatable_sql` (keeping chat-time SQL distinct from + runtime SDK use). + +`stringIncludesAnyOf` is existential over calls (at least one matching call), so a +mutation case still passes when the model mixes its UPDATE/INSERT with +verification SELECTs. The in-memory engine (`datatableSqlEngine.ts`) is stateful +within a case — writes persist, so a model that re-queries to verify its +CREATE/UPDATE sees the change and does not loop. But the engine is best-effort +(SELECT returns all rows of the referenced/first table with no WHERE/projection), +so still never assert specific returned row values. Seed data via +`workspace.datatables` in the `initial` fixture (see README). + ## Deterministic validation Use deterministic validation only for hard failures such as: diff --git a/ai_evals/README.md b/ai_evals/README.md index 6982d70da9..88825f4ee7 100644 --- a/ai_evals/README.md +++ b/ai_evals/README.md @@ -56,7 +56,7 @@ bun run cli -- run flow flow-test4-order-processing-loop --model opus bun run cli -- run flow flow-test0-sum-two-numbers --models haiku,opus,4o bun run cli -- run flow flow-test0-sum-two-numbers --runs 3 --verbose bun run cli -- run flow --record -GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro +GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-3-flash-preview WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000 bun run cli -- run flow --backend-validation preview bun run cli -- run global global-test1-script-create bun run cli -- run cli bun-hello-script @@ -88,15 +88,16 @@ Today: - `sonnet` - `opus` - `4o` -- `gemini-flash` -- `gemini-pro` +- `gpt-5.5` - `gemini-3-flash-preview` - `gemini-3.1-pro-preview` +- `deepseek-v4-flash` +- `deepseek-v4-pro` Notes: -- the command also prints accepted alias spellings such as `gpt-4o`, `claude-opus-4.6`, and `claude-haiku-4.5` -- frontend modes (`flow`, `script`, `app`, `global`) can use Anthropic, OpenAI, and Gemini-backed aliases +- the command also prints accepted alias spellings such as `gpt-4o`, `gpt-55`, `claude-opus-4.6`, and `claude-haiku-4.5` +- frontend modes (`flow`, `script`, `app`, `global`) can use Anthropic, OpenAI, Gemini, and DeepSeek-backed aliases - `cli` mode always uses the Anthropic agent SDK, so only Anthropic aliases are valid there - the judge model is separate and currently defaults to `claude-sonnet-4-6` @@ -142,6 +143,32 @@ For `global` mode, `validate` can express draft-level requirements such as: - required or forbidden draft counts - forbidden draft paths +Global initial fixtures can also seed `liveEditorDrafts` with `type`, +`storagePath`, `effectivePath`, and `value` fields. These drafts emulate the +currently open script, flow, or raw app editor so cases can test prompts that +refer to "this" or the "current" item. + +Global (and flow) initial fixtures can seed `workspace.datatables` so the +`list_datatables`, `get_datatable_table_schema`, and `exec_datatable_sql` tools +return seeded data during evals. Each entry is +`{ datatable_name, schemas: { : { : { columns, rows? } } } }`. +SQL runs through a small in-memory engine (`datatableSqlEngine.ts`), not a real +database. Writes are **stateful within a case**: `CREATE`/`DROP`/`INSERT`/`UPDATE`/ +`DELETE` mutate the seeded datatable in place, so a later `list_datatables`, +`get_datatable_table_schema`, `SELECT`, or `information_schema` query reflects them +— this is what stops a model from looping when it re-queries to verify a write. +The engine is best-effort: `SELECT` returns all rows of the referenced (or first) +table with no WHERE filtering/projection/joins, `WHERE` on UPDATE/DELETE supports +`col = value` predicates joined by `AND`, and anything unparseable is a no-op +success. So validate datatable cases through tool-use and SQL-argument assertions +(`requiredToolsUsed`, `stringIncludesAnyOf`) — not through exact returned row +values. An empty/absent `datatables` seed makes `list_datatables` return `[]`, +which is what the "no datatable configured" blocking cases rely on. + +Set `WMILL_AI_EVAL_DISABLE_ACTIVE_EDITOR_CONTEXT=1` to run those cases with +the old behavior where the live editor is only discoverable through +`list_workspace_items`. + App fixtures can also include an optional `datatables.json` file at the fixture root. For `flow` mode, an `initial` fixture can also include a benchmark workspace catalog of @@ -189,11 +216,15 @@ If `--record` is used, the CLI also appends one compact JSON line to: Each recorded line contains: - run metadata (`createdAt`, `gitSha`, `mode`, `runModel`, `judgeModel`) -- suite totals (`caseCount`, `attemptCount`, `passedAttempts`, `passRate`, `averageDurationMs`, `averageJudgeScore`) -- average token usage (`averageTokenUsagePerAttempt`) -- per-case metrics under `cases[]` (`averageDurationMs`, `averageJudgeScore`, `averageTokenUsagePerAttempt`, pass rate) +- suite totals (`caseCount`, `attemptCount`, `passedAttempts`, `passRate`, `averageDurationMs`, `averagePassedDurationMs`, `averageJudgeScore`) +- average token usage (`averageTokenUsagePerAttempt`, `averageTokenUsagePerPassedAttempt`) +- per-case metrics under `cases[]` (`averageDurationMs`, `averagePassedDurationMs`, `averageJudgeScore`, `averageTokenUsagePerAttempt`, `averageTokenUsagePerPassedAttempt`, pass rate) - `failedCaseIds` +The CLI headline duration and token averages use passed attempts only. +All-attempt averages are still recorded to make failures auditable without +letting failed attempts skew success cost comparisons. + Example: - summary: `ai_evals/results/2026-04-09T09-40-33.051Z__flow.json` diff --git a/ai_evals/adapters/frontend/benchmarkRunner.ts b/ai_evals/adapters/frontend/benchmarkRunner.ts index 14be108a10..1729df7170 100644 --- a/ai_evals/adapters/frontend/benchmarkRunner.ts +++ b/ai_evals/adapters/frontend/benchmarkRunner.ts @@ -10,10 +10,6 @@ import { runSuite } from "../../core/runSuite"; import type { BenchmarkRunResult, ModeRunner } from "../../core/types"; import { resolveWindmillBackendSettings } from "../../core/windmillBackendSettings"; import { emitFrontendBenchmarkProgress } from "./progress"; -import { createAppModeRunner } from "../../modes/app"; -import { createFlowModeRunner } from "../../modes/flow"; -import { createGlobalModeRunner } from "../../modes/global"; -import { createScriptModeRunner } from "../../modes/script"; import { DEFAULT_JUDGE_MODEL } from "../../core/judge"; export type FrontendBenchmarkMode = "flow" | "app" | "script" | "global"; @@ -40,7 +36,7 @@ export async function runFrontendBenchmarkFromEnv(): Promise const backendSettings = resolveWindmillBackendSettings(); const selectedCases = await loadSelectedCases(mode, caseIds); - const modeRunner = getModeRunner( + const modeRunner = await getModeRunner( mode, getFrontendEvalModel(model), backendValidation, @@ -69,25 +65,33 @@ export async function runFrontendBenchmarkFromEnv(): Promise }); } -function getModeRunner( +async function getModeRunner( mode: FrontendBenchmarkMode, model: ReturnType, backendValidation: ReturnType, backendSettings: ReturnType, -): ModeRunner { +): Promise> { switch (mode) { - case "flow": + case "flow": { + const { createFlowModeRunner } = await import("../../modes/flow"); return createFlowModeRunner(model, backendValidation, backendSettings); - case "app": + } + case "app": { + const { createAppModeRunner } = await import("../../modes/app"); return createAppModeRunner(model, backendSettings); - case "script": + } + case "script": { + const { createScriptModeRunner } = await import("../../modes/script"); return createScriptModeRunner( model, backendValidation, backendSettings, ); - case "global": + } + case "global": { + const { createGlobalModeRunner } = await import("../../modes/global"); return createGlobalModeRunner(model, backendSettings); + } } } diff --git a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts index 5e00dd6f34..058adc3644 100644 --- a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts @@ -7,8 +7,12 @@ import { prepareGlobalSystemMessage, prepareGlobalUserMessage, } from "../../../../../frontend/src/lib/components/copilot/chat/global/core"; -import { globalDraftStore } from "../../../../../frontend/src/lib/components/copilot/chat/global/draftStore.svelte"; +import { + clearGlobalDrafts, + listGlobalDrafts, +} from "../../../../../frontend/src/lib/components/copilot/chat/global/userDraftAdapter"; import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared"; +import { UserDraft } from "../../../../../frontend/src/lib/userDraft.svelte"; import type { ModeRunContext } from "../../../../core/types"; import type { GlobalDraftState } from "../../../../core/validators"; import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings"; @@ -24,6 +28,21 @@ const MUTATING_GLOBAL_TOOLS = new Set([ "deploy_workspace_item", "delete_workspace_item", ]); +const DISABLE_ACTIVE_EDITOR_CONTEXT_ENV = + "WMILL_AI_EVAL_DISABLE_ACTIVE_EDITOR_CONTEXT"; + +const LIVE_EDITOR_ITEM_KINDS = { + script: "script", + flow: "flow", + app: "raw_app", +} as const; + +export interface GlobalLiveEditorDraftFixture { + type: keyof typeof LIVE_EDITOR_ITEM_KINDS; + storagePath?: string; + effectivePath?: string; + value?: unknown; +} export interface GlobalEvalResult { success: boolean; @@ -38,6 +57,7 @@ export interface GlobalEvalResult { export interface GlobalEvalOptions { workspaceFixtures?: BenchmarkWorkspaceRunnables; + liveEditorDrafts?: GlobalLiveEditorDraftFixture[]; model?: string; maxIterations?: number; provider?: AIProvider; @@ -55,19 +75,26 @@ export async function runGlobalEval( options.workspaceRoot ?? (await mkdtemp(join(tmpdir(), "wmill-frontend-global-benchmark-"))); - globalDraftStore.clearDrafts(workspaceRoot); + clearGlobalDrafts(workspaceRoot); registerBenchmarkWorkspaceRunnables(workspaceRoot, options.workspaceFixtures ?? {}); + seedLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []); try { const model = options.model ?? "claude-haiku-4-5-20251001"; + const injectActiveEditorContext = + process.env[DISABLE_ACTIVE_EDITOR_CONTEXT_ENV] !== "1"; const rawResult = await runEval({ userPrompt, systemMessage: prepareGlobalSystemMessage(), - userMessage: prepareGlobalUserMessage(userPrompt), + userMessage: prepareGlobalUserMessage( + userPrompt, + [], + injectActiveEditorContext ? { workspace: workspaceRoot } : {}, + ), tools: getGlobalEvalTools(), helpers: {}, apiKey, - getOutput: () => ({ drafts: globalDraftStore.listDrafts(workspaceRoot) }), + getOutput: () => ({ drafts: listGlobalDrafts(workspaceRoot) }), onAssistantMessageStart: options.runContext?.onAssistantMessageStart, onAssistantToken: options.runContext?.onAssistantChunk, onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd, @@ -94,7 +121,8 @@ export async function runGlobalEval( tokenUsage: rawResult.tokenUsage, }; } finally { - globalDraftStore.clearDrafts(workspaceRoot); + clearGlobalDrafts(workspaceRoot); + clearLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []); unregisterBenchmarkWorkspaceRunnables(workspaceRoot); if (!options.workspaceRoot) { await rm(workspaceRoot, { recursive: true, force: true }); @@ -102,6 +130,36 @@ export async function runGlobalEval( } } +function seedLiveEditorDrafts( + workspace: string, + fixtures: GlobalLiveEditorDraftFixture[], +): void { + for (const fixture of fixtures) { + const itemKind = LIVE_EDITOR_ITEM_KINDS[fixture.type]; + const storagePath = fixture.storagePath ?? fixture.effectivePath ?? ""; + if (fixture.value !== undefined) { + UserDraft.save(itemKind, storagePath, fixture.value, { workspace }); + } + UserDraft.setLiveEditorDraft({ + workspace, + itemKind, + storagePath, + effectivePath: fixture.effectivePath ?? fixture.storagePath, + }); + } +} + +function clearLiveEditorDrafts( + workspace: string, + fixtures: GlobalLiveEditorDraftFixture[], +): void { + for (const fixture of fixtures) { + const itemKind = LIVE_EDITOR_ITEM_KINDS[fixture.type]; + const storagePath = fixture.storagePath ?? fixture.effectivePath ?? ""; + UserDraft.clearLiveEditorDraft(itemKind, { workspace, storagePath }); + } +} + function getGlobalEvalTools(): ProductionTool<{}>[] { return (globalTools as ProductionTool<{}>[]).map((tool) => { if (!MUTATING_GLOBAL_TOOLS.has(tool.def.function.name)) { diff --git a/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts b/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts index 819300bd62..01a55e048e 100644 --- a/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts +++ b/ai_evals/adapters/frontend/core/shared/providerConfig.test.ts @@ -21,9 +21,9 @@ describe("proxy helpers", () => { describe("resolveEvalModelProvider", () => { it("infers googleai from Gemini model ids", () => { - expect(resolveEvalModelProvider("gemini-2.5-flash")).toEqual({ + expect(resolveEvalModelProvider("gemini-3-flash-preview")).toEqual({ provider: "googleai", - model: "gemini-2.5-flash", + model: "gemini-3-flash-preview", }); }); @@ -35,9 +35,11 @@ describe("resolveEvalModelProvider", () => { }); it("preserves an explicit provider", () => { - expect(resolveEvalModelProvider("gemini-2.5-pro", "googleai")).toEqual({ + expect( + resolveEvalModelProvider("gemini-3.1-pro-preview", "googleai"), + ).toEqual({ provider: "googleai", - model: "gemini-2.5-pro", + model: "gemini-3.1-pro-preview", }); }); }); diff --git a/ai_evals/adapters/frontend/datatableSqlEngine.test.ts b/ai_evals/adapters/frontend/datatableSqlEngine.test.ts new file mode 100644 index 0000000000..e0398b407b --- /dev/null +++ b/ai_evals/adapters/frontend/datatableSqlEngine.test.ts @@ -0,0 +1,262 @@ +import { describe, expect, it } from 'bun:test' +import { applyDatatableSql, type BenchmarkDatatableSeed } from './datatableSqlEngine' + +function makeDatatable(): BenchmarkDatatableSeed { + return { + datatable_name: 'main', + schemas: { + public: { + orders: { + columns: { id: 'int4', customer_id: 'int4', total: 'numeric', status: 'text' }, + rows: [ + { id: 1, customer_id: 1, total: 42.5, status: 'shipped' }, + { id: 2, customer_id: 2, total: 19.99, status: 'pending' }, + { id: 3, customer_id: 1, total: 88, status: 'shipped' } + ] + }, + customers: { + columns: { id: 'int4', name: 'text' }, + rows: [{ id: 1, name: 'Alice' }] + } + } + } + } +} + +describe('SELECT', () => { + it('returns the referenced table rows', () => { + const dt = makeDatatable() + expect(applyDatatableSql(dt, 'SELECT id, name FROM customers').rows).toEqual([ + { id: 1, name: 'Alice' } + ]) + }) + + it('falls back to the first table when no known table is referenced', () => { + const dt = makeDatatable() + expect(applyDatatableSql(dt, 'select 1').rows).toHaveLength(3) + }) + + it('resolves a schema-qualified table', () => { + const dt = makeDatatable() + expect(applyDatatableSql(dt, 'SELECT * FROM public.customers').rows).toEqual([ + { id: 1, name: 'Alice' } + ]) + }) +}) + +describe('CREATE TABLE', () => { + it('adds a table with parsed columns, skipping table constraints and FK clauses', () => { + const dt = makeDatatable() + const result = applyDatatableSql( + dt, + 'CREATE TABLE public.refunds (\n order_id int4 NOT NULL REFERENCES public.orders(id),\n amount numeric(10,2),\n PRIMARY KEY (order_id)\n)' + ) + expect(result.rows).toEqual([]) + expect(dt.schemas.public.refunds).toEqual({ + columns: { order_id: 'int4', amount: 'numeric(10,2)' }, + rows: [] + }) + }) + + it('defaults an unqualified table to the public schema', () => { + const dt = makeDatatable() + applyDatatableSql(dt, 'CREATE TABLE notes (id int4, body text)') + expect(dt.schemas.public.notes.columns).toEqual({ id: 'int4', body: 'text' }) + }) + + it('is a no-op for an existing table with IF NOT EXISTS', () => { + const dt = makeDatatable() + applyDatatableSql(dt, 'CREATE TABLE IF NOT EXISTS public.orders (x int4)') + expect(Object.keys(dt.schemas.public.orders.columns)).toContain('status') + }) +}) + +describe('DROP TABLE', () => { + it('removes the table', () => { + const dt = makeDatatable() + applyDatatableSql(dt, 'DROP TABLE IF EXISTS public.customers') + expect(dt.schemas.public.customers).toBeUndefined() + }) +}) + +describe('INSERT', () => { + it('appends a row using an explicit column list', () => { + const dt = makeDatatable() + applyDatatableSql(dt, "INSERT INTO customers (id, name) VALUES (2, 'Bob')") + expect(dt.schemas.public.customers.rows).toContainEqual({ id: 2, name: 'Bob' }) + }) + + it('infers columns from the table when none are given, and appends multiple tuples', () => { + const dt = makeDatatable() + applyDatatableSql(dt, "INSERT INTO customers VALUES (2, 'Bob'), (3, 'Carol')") + expect(dt.schemas.public.customers.rows).toHaveLength(3) + }) + + it('returns the inserted rows when RETURNING is present', () => { + const dt = makeDatatable() + const result = applyDatatableSql( + dt, + "INSERT INTO customers (id, name) VALUES (2, 'Bob') RETURNING *" + ) + expect(result.rows).toEqual([{ id: 2, name: 'Bob' }]) + }) +}) + +describe('UPDATE', () => { + it('updates only the rows matching an equality WHERE', () => { + const dt = makeDatatable() + const result = applyDatatableSql( + dt, + "UPDATE public.orders SET status = 'shipped' WHERE id = 2" + ) + expect(result.rows).toEqual([]) + expect(dt.schemas.public.orders.rows?.find((r) => r.id === 2)?.status).toBe('shipped') + expect(dt.schemas.public.orders.rows?.find((r) => r.id === 1)?.status).toBe('shipped') + }) + + it('strips a Postgres cast in the WHERE value', () => { + const dt = makeDatatable() + applyDatatableSql(dt, "UPDATE orders SET status = 'done' WHERE id = 2::int4") + expect(dt.schemas.public.orders.rows?.find((r) => r.id === 2)?.status).toBe('done') + }) + + it('matches multiple AND predicates including a numeric literal', () => { + const dt = makeDatatable() + applyDatatableSql( + dt, + "UPDATE orders SET status = 'done' WHERE customer_id = 2 AND total = 19.99" + ) + expect(dt.schemas.public.orders.rows?.find((r) => r.id === 2)?.status).toBe('done') + expect(dt.schemas.public.orders.rows?.find((r) => r.id === 1)?.status).toBe('shipped') + }) + + it('updates every row when there is no WHERE', () => { + const dt = makeDatatable() + applyDatatableSql(dt, "UPDATE orders SET status = 'archived'") + expect(dt.schemas.public.orders.rows?.every((r) => r.status === 'archived')).toBe(true) + }) + + it('returns the affected rows when RETURNING is present', () => { + const dt = makeDatatable() + const result = applyDatatableSql( + dt, + "UPDATE orders SET status = 'shipped' WHERE id = 2 RETURNING *" + ) + expect(result.rows).toHaveLength(1) + expect(result.rows[0]).toMatchObject({ id: 2, status: 'shipped' }) + }) + + it('affects no rows when the WHERE clause cannot be parsed', () => { + const dt = makeDatatable() + applyDatatableSql(dt, "UPDATE orders SET status = 'x' WHERE total > 20") + expect(dt.schemas.public.orders.rows?.some((r) => r.status === 'x')).toBe(false) + }) +}) + +describe('DELETE', () => { + it('removes only the matching rows', () => { + const dt = makeDatatable() + applyDatatableSql(dt, 'DELETE FROM orders WHERE id = 2') + expect(dt.schemas.public.orders.rows?.map((r) => r.id)).toEqual([1, 3]) + }) + + it('returns the removed rows when RETURNING is present', () => { + const dt = makeDatatable() + const result = applyDatatableSql(dt, 'DELETE FROM orders WHERE id = 2 RETURNING *') + expect(result.rows).toEqual([{ id: 2, customer_id: 2, total: 19.99, status: 'pending' }]) + }) +}) + +describe('writes are reflected by later reads', () => { + it('UPDATE then SELECT sees the new value (the verify-loop fix)', () => { + const dt = makeDatatable() + applyDatatableSql(dt, "UPDATE orders SET status = 'shipped' WHERE id = 2") + const seen = applyDatatableSql(dt, 'SELECT * FROM orders').rows + expect(seen.find((r) => r.id === 2)?.status).toBe('shipped') + }) + + it('INSERT then SELECT sees the new row', () => { + const dt = makeDatatable() + applyDatatableSql(dt, "INSERT INTO customers (id, name) VALUES (9, 'Zed')") + const seen = applyDatatableSql(dt, 'SELECT * FROM customers').rows + expect(seen).toContainEqual({ id: 9, name: 'Zed' }) + }) + + it('CREATE then SELECT on the new table returns its (empty) rows', () => { + const dt = makeDatatable() + applyDatatableSql(dt, 'CREATE TABLE public.refunds (order_id int4, amount numeric)') + expect(applyDatatableSql(dt, 'SELECT * FROM refunds').rows).toEqual([]) + }) +}) + +describe('system-catalog queries reflect the current tables/columns', () => { + it('lists current tables (including a freshly created one) via information_schema.tables', () => { + const dt = makeDatatable() + applyDatatableSql(dt, 'CREATE TABLE public.refunds (order_id int4)') + const rows = applyDatatableSql( + dt, + "SELECT table_name FROM information_schema.tables WHERE table_name = 'refunds'" + ).rows + expect(rows.map((r) => r.table_name)).toContain('refunds') + }) + + it('does not list a dropped table', () => { + const dt = makeDatatable() + applyDatatableSql(dt, 'DROP TABLE public.customers') + const rows = applyDatatableSql(dt, 'SELECT table_name FROM information_schema.tables').rows + expect(rows.map((r) => r.table_name)).not.toContain('customers') + }) + + it('reports columns via information_schema.columns', () => { + const dt = makeDatatable() + const rows = applyDatatableSql( + dt, + "SELECT column_name FROM information_schema.columns WHERE table_name = 'orders'" + ).rows + expect(rows.map((r) => r.column_name)).toContain('status') + }) +}) + +describe('parser robustness (string/paren-aware splitting)', () => { + it('does not treat the word "returning" inside a string value as a RETURNING clause', () => { + const dt = makeDatatable() + const result = applyDatatableSql( + dt, + "INSERT INTO customers (id, name) VALUES (5, 'is returning soon')" + ) + expect(result.rows).toEqual([]) + expect(dt.schemas.public.customers.rows).toContainEqual({ id: 5, name: 'is returning soon' }) + }) + + it('does not split on the word "where" inside a SET string value', () => { + const dt = makeDatatable() + applyDatatableSql(dt, "UPDATE orders SET status = 'ship where ordered' WHERE id = 2") + expect(dt.schemas.public.orders.rows?.find((r) => r.id === 2)?.status).toBe('ship where ordered') + expect(dt.schemas.public.orders.rows?.find((r) => r.id === 1)?.status).toBe('shipped') + }) + + it('keeps INSERT tuples intact when a value contains a function call', () => { + const dt = makeDatatable() + applyDatatableSql(dt, "INSERT INTO customers (id, name) VALUES (6, coalesce(NULL, 'x'))") + expect(dt.schemas.public.customers.rows).toHaveLength(2) + expect(dt.schemas.public.customers.rows?.[1]).toMatchObject({ id: 6 }) + }) + + it('CREATE TABLE ignores a trailing semicolon-separated statement', () => { + const dt = makeDatatable() + applyDatatableSql( + dt, + 'CREATE TABLE public.refunds (id int4, amount numeric); INSERT INTO refunds VALUES (1, 5)' + ) + expect(dt.schemas.public.refunds.columns).toEqual({ id: 'int4', amount: 'numeric' }) + expect(dt.schemas.public.refunds.rows).toEqual([]) + }) +}) + +describe('unparseable statements are a safe no-op', () => { + it('returns [] and does not throw', () => { + const dt = makeDatatable() + expect(applyDatatableSql(dt, 'VACUUM ANALYZE').rows).toEqual([]) + expect(applyDatatableSql(dt, 'GRANT SELECT ON orders TO someone').rows).toEqual([]) + }) +}) diff --git a/ai_evals/adapters/frontend/datatableSqlEngine.ts b/ai_evals/adapters/frontend/datatableSqlEngine.ts new file mode 100644 index 0000000000..453eca286e --- /dev/null +++ b/ai_evals/adapters/frontend/datatableSqlEngine.ts @@ -0,0 +1,541 @@ +/** + * A deliberately small, best-effort SQL engine for the benchmark datatable mock. + * + * This is NOT a real SQL implementation — it exists only so that writes a model + * issues during an eval (`CREATE TABLE`, `INSERT`, `UPDATE`, `DELETE`, `DROP`) + * become visible to its later reads (`list_datatables`, `get_datatable_table_schema`, + * `SELECT`). Without that, a model that re-queries to verify a write sees stale + * seed data, concludes the write failed, and loops until it exhausts its turns. + * + * It parses only the common statement shapes models produce. Anything it cannot + * parse is a no-op success (it never throws) — behavioral evals assert that the + * right statement was issued, not its exact data effects. Notable limits: + * - `SELECT` returns all rows of the referenced (or first) table — no WHERE + * filtering, projection, joins, or aggregation. + * - `WHERE` supports `col = value` predicates joined by `AND` only; an + * unparseable WHERE on UPDATE/DELETE affects zero rows (never the whole table). + */ + +/** One seeded datatable table: its columns (col -> compact_type) and optional rows. */ +export interface BenchmarkDatatableTableSeed { + columns: Record + rows?: Record[] +} + +/** A seeded datatable: `datatable_name` plus a `schema -> table -> seed` map. */ +export interface BenchmarkDatatableSeed { + datatable_name: string + schemas: { + [schema: string]: { + [table: string]: BenchmarkDatatableTableSeed + } + } +} + +export interface DatatableSqlResult { + rows: Record[] +} + +const DEFAULT_SCHEMA = 'public' + +type ParsedRef = { schema: string; table: string } +type Predicate = { column: string; value: unknown } + +/** + * Apply one SQL statement to `datatable` IN PLACE and return the result rows. + * SELECT returns the referenced/first table's rows; a mutation returns its + * affected rows when it has a RETURNING clause, otherwise `[]`. + */ +export function applyDatatableSql( + datatable: BenchmarkDatatableSeed, + sql: string +): DatatableSqlResult { + const statement = stripTrailingSemicolon(sql.trim()) + if (/^\s*(with|select)\b/i.test(statement)) { + return { rows: selectRows(datatable, statement) } + } + if (/^\s*create\s+table\b/i.test(statement)) { + return { rows: applyCreateTable(datatable, statement) } + } + if (/^\s*drop\s+table\b/i.test(statement)) { + return { rows: applyDropTable(datatable, statement) } + } + if (/^\s*insert\s+into\b/i.test(statement)) { + return { rows: applyInsert(datatable, statement) } + } + if (/^\s*update\b/i.test(statement)) { + return { rows: applyUpdate(datatable, statement) } + } + if (/^\s*delete\s+from\b/i.test(statement)) { + return { rows: applyDelete(datatable, statement) } + } + return { rows: [] } +} + +// ============= Reads ============= + +function selectRows( + datatable: BenchmarkDatatableSeed, + sql: string +): Record[] { + const fromRef = sql.match(/\bfrom\s+([a-zA-Z_"][\w."]*)/i)?.[1] + if (fromRef) { + const catalog = catalogRows(datatable, fromRef) + if (catalog) { + return catalog + } + } + const table = fromRef ? resolveTable(datatable, fromRef) : undefined + const seed = table ?? firstTable(datatable) + return seed?.rows ?? [] +} + +/** + * Synthesize rows for a system-catalog query so a model verifying a `CREATE`/`DROP` + * via `information_schema.tables` / `.columns` (or `pg_tables`) sees the current + * tables/columns instead of fallback data. WHERE is not applied, so the model gets + * the full set and finds (or no longer finds) the table it just changed. + * Returns `undefined` for non-catalog refs so normal table resolution proceeds. + */ +function catalogRows( + datatable: BenchmarkDatatableSeed, + ref: string +): Record[] | undefined { + const normalized = ref.toLowerCase().replace(/"/g, '') + const name = normalized.split('.').pop() + const isCatalog = normalized.includes('information_schema.') || normalized.startsWith('pg_') + if (!isCatalog) { + return undefined + } + const tables = allTables(datatable) + if (name === 'tables' || name === 'pg_tables') { + return tables.map(({ schema, table }) => ({ + table_schema: schema, + table_name: table, + schemaname: schema, + tablename: table + })) + } + if (name === 'columns') { + return tables.flatMap(({ schema, table, seed }) => + Object.entries(seed.columns).map(([column, type]) => ({ + table_schema: schema, + table_name: table, + column_name: column, + data_type: type + })) + ) + } + return undefined +} + +function allTables( + datatable: BenchmarkDatatableSeed +): { schema: string; table: string; seed: BenchmarkDatatableTableSeed }[] { + return Object.entries(datatable.schemas).flatMap(([schema, tables]) => + Object.entries(tables).map(([table, seed]) => ({ schema, table, seed })) + ) +} + +// ============= DDL ============= + +function applyCreateTable( + datatable: BenchmarkDatatableSeed, + sql: string +): Record[] { + const head = sql.match( + /^\s*create\s+table\s+(?:if\s+not\s+exists\s+)?([a-zA-Z_"][\w."]*)/i + ) + // The first top-level paren group is the column-definition list; using it (rather + // than a greedy `(...)` capture) ignores any trailing `;`-separated statement. + const columnText = extractParenGroups(sql)[0] + if (!head || columnText === undefined) { + return [] + } + const { schema, table } = parseRef(head[1]) + const existing = datatable.schemas[schema]?.[table] + if (existing) { + return [] + } + const columns: Record = {} + for (const rawDef of splitTopLevel(columnText)) { + const def = rawDef.trim() + if (!def || isTableConstraint(def)) { + continue + } + const tokens = def.split(/\s+/) + const column = unquoteIdentifier(tokens[0]) + if (!column) { + continue + } + columns[column] = tokens[1] ?? 'text' + } + if (!datatable.schemas[schema]) { + datatable.schemas[schema] = {} + } + datatable.schemas[schema][table] = { columns, rows: [] } + return [] +} + +function applyDropTable( + datatable: BenchmarkDatatableSeed, + sql: string +): Record[] { + const match = sql.match( + /^\s*drop\s+table\s+(?:if\s+exists\s+)?([a-zA-Z_"][\w."]*)/i + ) + if (!match) { + return [] + } + const { schema, table } = parseRef(match[1]) + if (datatable.schemas[schema]?.[table]) { + delete datatable.schemas[schema][table] + } + return [] +} + +// ============= DML ============= + +function applyInsert( + datatable: BenchmarkDatatableSeed, + sql: string +): Record[] { + const { body, returning } = splitOffReturning(sql) + const match = body.match( + /^\s*insert\s+into\s+([a-zA-Z_"][\w."]*)\s*(?:\(([^)]*)\))?\s*values\s*([\s\S]+)$/i + ) + if (!match) { + return [] + } + const table = resolveTable(datatable, match[1]) + if (!table) { + return [] + } + const columns = match[2] + ? splitTopLevel(match[2]).map((entry) => unquoteIdentifier(entry.trim())) + : Object.keys(table.columns) + const inserted: Record[] = [] + for (const tuple of extractParenGroups(match[3])) { + const values = splitTopLevel(tuple).map((entry) => parseValue(entry)) + const row: Record = {} + columns.forEach((column, index) => { + row[column] = values[index] + }) + inserted.push(row) + } + table.rows ??= [] + table.rows.push(...inserted) + return returning ? inserted : [] +} + +function applyUpdate( + datatable: BenchmarkDatatableSeed, + sql: string +): Record[] { + const { body, returning } = splitOffReturning(sql) + const match = body.match(/^\s*update\s+([a-zA-Z_"][\w."]*)\s+set\s+([\s\S]+)$/i) + if (!match) { + return [] + } + const table = resolveTable(datatable, match[1]) + if (!table) { + return [] + } + let assignmentText = match[2] + let whereText: string | undefined + const whereMatch = maskForClauseScan(assignmentText).match(/\swhere\s/i) + if (whereMatch && whereMatch.index !== undefined) { + whereText = assignmentText.slice(whereMatch.index + whereMatch[0].length) + assignmentText = assignmentText.slice(0, whereMatch.index) + } + const predicates = parsePredicates(whereText) + if (predicates === null) { + return [] + } + const assignments: Record = {} + for (const entry of splitTopLevel(assignmentText)) { + const pair = entry.match(/^\s*([a-zA-Z_"][\w."]*)\s*=\s*([\s\S]+?)\s*$/) + if (pair) { + assignments[lastIdentifier(pair[1])] = parseValue(pair[2]) + } + } + const affected = (table.rows ?? []).filter((row) => rowMatches(row, predicates)) + for (const row of affected) { + Object.assign(row, assignments) + } + return returning ? affected : [] +} + +function applyDelete( + datatable: BenchmarkDatatableSeed, + sql: string +): Record[] { + const { body, returning } = splitOffReturning(sql) + const match = body.match(/^\s*delete\s+from\s+([a-zA-Z_"][\w."]*)\s*([\s\S]*)$/i) + if (!match) { + return [] + } + const table = resolveTable(datatable, match[1]) + if (!table) { + return [] + } + const whereText = match[2].replace(/^\s*where\s+/i, '').trim() || undefined + const predicates = parsePredicates(whereText) + if (predicates === null) { + return [] + } + const rows = table.rows ?? [] + const removed = rows.filter((row) => rowMatches(row, predicates)) + table.rows = rows.filter((row) => !rowMatches(row, predicates)) + return returning ? removed : [] +} + +// ============= Parsing helpers ============= + +function resolveTable( + datatable: BenchmarkDatatableSeed, + ref: string +): BenchmarkDatatableTableSeed | undefined { + const { schema, table } = parseRef(ref) + const direct = datatable.schemas[schema]?.[table] + if (direct) { + return direct + } + // Bare table name: fall back to searching every schema for a matching table. + if (!ref.includes('.')) { + for (const tables of Object.values(datatable.schemas)) { + if (tables[table]) { + return tables[table] + } + } + } + return undefined +} + +function firstTable( + datatable: BenchmarkDatatableSeed +): BenchmarkDatatableTableSeed | undefined { + for (const tables of Object.values(datatable.schemas)) { + for (const seed of Object.values(tables)) { + return seed + } + } + return undefined +} + +function parseRef(ref: string): ParsedRef { + const parts = ref.split('.').map(unquoteIdentifier) + if (parts.length >= 2) { + return { schema: parts[parts.length - 2], table: parts[parts.length - 1] } + } + return { schema: DEFAULT_SCHEMA, table: parts[0] } +} + +/** A WHERE clause with no parseable form returns `null`; absent WHERE returns `[]` (match all). */ +function parsePredicates(whereText: string | undefined): Predicate[] | null { + if (whereText === undefined || whereText.trim() === '') { + return [] + } + const predicates: Predicate[] = [] + for (const part of whereText.split(/\s+and\s+/i)) { + const match = part.match(/^\s*([a-zA-Z_"][\w."]*)\s*=\s*([\s\S]+?)\s*$/) + if (!match) { + return null + } + predicates.push({ column: lastIdentifier(match[1]), value: parseValue(match[2]) }) + } + return predicates +} + +function rowMatches(row: Record, predicates: Predicate[]): boolean { + return predicates.every((predicate) => looseEquals(row[predicate.column], predicate.value)) +} + +function looseEquals(left: unknown, right: unknown): boolean { + if (left === null || left === undefined) { + return right === null || right === undefined + } + if (typeof left === 'number' && typeof right === 'number') { + return left === right + } + return String(left) === String(right) +} + +function parseValue(raw: string): unknown { + // Drop a trailing Postgres cast (e.g. `2::int4`) before interpreting the literal. + const token = raw.trim().replace(/::\s*[a-zA-Z_][\w]*(\([^)]*\))?\s*$/, '').trim() + const stringMatch = token.match(/^'([\s\S]*)'$/) + if (stringMatch) { + return stringMatch[1].replace(/''/g, "'") + } + if (/^-?\d+(\.\d+)?$/.test(token)) { + return Number(token) + } + if (/^true$/i.test(token)) { + return true + } + if (/^false$/i.test(token)) { + return false + } + if (/^null$/i.test(token)) { + return null + } + return token +} + +function splitOffReturning(sql: string): { body: string; returning: boolean } { + const match = maskForClauseScan(sql).match(/\sreturning\s/i) + if (!match || match.index === undefined) { + return { body: sql, returning: false } + } + return { body: sql.slice(0, match.index), returning: true } +} + +/** + * A same-length copy of `sql` with the contents of single-quoted strings and + * parenthesized groups blanked to spaces, so a top-level keyword scan + * (WHERE / RETURNING) cannot match inside a string literal or a subquery. Index + * positions in the result map 1:1 back onto the original. + */ +function maskForClauseScan(sql: string): string { + let masked = '' + let depth = 0 + let inString = false + for (let i = 0; i < sql.length; i++) { + const char = sql[i] + if (inString) { + if (char === "'") { + if (sql[i + 1] === "'") { + masked += ' ' + i++ + continue + } + inString = false + } + masked += ' ' + continue + } + if (char === "'") { + inString = true + masked += ' ' + } else if (char === '(') { + depth++ + masked += ' ' + } else if (char === ')') { + depth = Math.max(0, depth - 1) + masked += ' ' + } else { + masked += depth > 0 ? ' ' : char + } + } + return masked +} + +/** + * Inner text of each top-level `( ... )` group in `input`, honoring nested parens + * (e.g. `now()`, `numeric(10,2)`) and single-quoted strings. Used for the CREATE + * column-definition group and INSERT value tuples. + */ +function extractParenGroups(input: string): string[] { + const groups: string[] = [] + let depth = 0 + let inString = false + let current = '' + for (let i = 0; i < input.length; i++) { + const char = input[i] + if (inString) { + current += char + if (char === "'") { + if (input[i + 1] === "'") { + current += input[++i] + } else { + inString = false + } + } + continue + } + if (char === "'") { + inString = true + current += char + } else if (char === '(') { + depth++ + if (depth === 1) { + current = '' + } else { + current += char + } + } else if (char === ')') { + depth = Math.max(0, depth - 1) + if (depth === 0) { + groups.push(current) + current = '' + } else { + current += char + } + } else if (depth > 0) { + current += char + } + } + return groups +} + +/** Split on commas that are not inside parentheses or single-quoted strings. */ +function splitTopLevel(input: string): string[] { + const parts: string[] = [] + let depth = 0 + let inString = false + let current = '' + for (let i = 0; i < input.length; i++) { + const char = input[i] + if (inString) { + current += char + if (char === "'") { + if (input[i + 1] === "'") { + current += input[++i] + } else { + inString = false + } + } + continue + } + if (char === "'") { + inString = true + current += char + } else if (char === '(') { + depth++ + current += char + } else if (char === ')') { + depth = Math.max(0, depth - 1) + current += char + } else if (char === ',' && depth === 0) { + parts.push(current) + current = '' + } else { + current += char + } + } + if (current.trim() !== '') { + parts.push(current) + } + return parts +} + +function isTableConstraint(def: string): boolean { + return /^(primary\s+key|foreign\s+key|constraint|unique|check|exclude|like)\b/i.test(def) +} + +function unquoteIdentifier(identifier: string): string { + const trimmed = identifier.trim() + const quoted = trimmed.match(/^"([\s\S]*)"$/) + return quoted ? quoted[1] : trimmed +} + +/** For a qualified reference like `orders.id`, keep only the final identifier. */ +function lastIdentifier(reference: string): string { + const parts = reference.split('.') + return unquoteIdentifier(parts[parts.length - 1]) +} + +function stripTrailingSemicolon(sql: string): string { + return sql.replace(/;\s*$/, '') +} diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index be4868d8d4..2750058a68 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -1,7 +1,14 @@ import { randomUUID } from 'node:crypto' import type { CompletedJob, Flow, Script } from '../../../frontend/src/lib/gen' -import type { ScriptLang } from '../../../frontend/src/lib/gen/types.gen' +import type { + DataTableTables, + DataTableTableSchema, + ScriptLang +} from '../../../frontend/src/lib/gen/types.gen' import { buildScriptLintResult } from './core/script/preview' +import { applyDatatableSql, type BenchmarkDatatableSeed } from './datatableSqlEngine' + +export type { BenchmarkDatatableSeed, BenchmarkDatatableTableSeed } from './datatableSqlEngine' const BENCHMARK_TIMESTAMP = '1970-01-01T00:00:00.000Z' @@ -25,6 +32,7 @@ export interface BenchmarkWorkspaceFlow { export interface BenchmarkWorkspaceRunnables { scripts?: BenchmarkWorkspaceScript[] flows?: BenchmarkWorkspaceFlow[] + datatables?: BenchmarkDatatableSeed[] } type BenchmarkCompletedJob = CompletedJob & { type: 'CompletedJob' } @@ -48,7 +56,12 @@ export function registerBenchmarkWorkspaceRunnables( runnables: BenchmarkWorkspaceRunnables ): void { benchmarkWorkspaces.add(workspace) - benchmarkWorkspaceRunnables.set(workspace, runnables) + // Datatables are mutated in place by exec_datatable_sql (a write must be visible + // to later reads), so store an isolated deep copy — never mutate the caller's seed. + benchmarkWorkspaceRunnables.set(workspace, { + ...runnables, + datatables: runnables.datatables ? structuredClone(runnables.datatables) : undefined + }) } export function unregisterBenchmarkWorkspace(workspace: string): void { @@ -161,6 +174,99 @@ export function getBenchmarkCompletedJob( return structuredClone(entry.job) } +// ============= Datatables (best-effort in-memory SQL) ============= + +/** + * Project the seeded datatables down to the `list_datatable_tables` response: + * `datatable_name` + `schema -> table_names`, with no column detail. + * Returns `null` for a non-benchmark workspace so callers can fall through to + * the real backend; an empty seed yields `[]`. + */ +export function listBenchmarkDatatables(workspace: string): DataTableTables[] | null { + const runnables = benchmarkWorkspaceRunnables.get(workspace) + if (!runnables) { + return null + } + return (runnables.datatables ?? []).map((datatable) => ({ + datatable_name: datatable.datatable_name, + schemas: Object.fromEntries( + Object.entries(datatable.schemas).map(([schema, tables]) => [schema, Object.keys(tables)]) + ) + })) +} + +export function getBenchmarkDatatableSchema(input: { + workspace: string + datatableName: string + schemaName: string + tableName: string +}): DataTableTableSchema { + const runnables = benchmarkWorkspaceRunnables.get(input.workspace) + const datatable = (runnables?.datatables ?? []).find( + (entry) => entry.datatable_name === input.datatableName + ) + if (!datatable) { + // Message MUST match the production `isDatatableNotConfiguredError` regex + // (/datatable\s+\S+\s+not found/i in datatableTools.ts) so the + // get_datatable_table_schema not-configured mapping is actually exercised. + throw new Error(`datatable "${input.datatableName}" not found`) + } + const table = datatable.schemas?.[input.schemaName]?.[input.tableName] + if (!table) { + throw new Error( + `table "${input.schemaName}.${input.tableName}" not found in datatable "${input.datatableName}"` + ) + } + return { + datatable_name: input.datatableName, + schema_name: input.schemaName, + table_name: input.tableName, + columns: table.columns + } +} + +/** + * Execute SQL against a seeded datatable through the best-effort in-memory engine + * (`applyDatatableSql`). Writes (CREATE/INSERT/UPDATE/DELETE/DROP) mutate the + * stored datatable in place so a later list/schema/SELECT reflects them; SELECT + * (and RETURNING) yield rows, other statements yield `[]`. Creates a benchmark + * completed job and returns its id, like `runBenchmarkScriptPreview`. + */ +export function runBenchmarkDatatableSql(input: { + workspace: string + datatableName: string + sql: string +}): string { + const runnables = benchmarkWorkspaceRunnables.get(input.workspace) + const datatable = (runnables?.datatables ?? []).find( + (entry) => entry.datatable_name === input.datatableName + ) + const rows = datatable ? applyDatatableSql(datatable, input.sql).rows : [] + return createBenchmarkCompletedJob({ + workspace: input.workspace, + jobKind: 'preview', + success: true, + args: { database: `datatable://${input.datatableName}` }, + result: rows + }) +} + +/** + * Mirror `JobService.getCompletedJobResultMaybe` for benchmark workspaces — the + * shape `pollJobResult` consumes. The job is created synchronously before + * polling, so it is always present and completed. + */ +export function getBenchmarkCompletedJobResultMaybe(input: { + workspace: string + id: string +}): { success: boolean; completed: boolean; result: unknown } { + const job = getBenchmarkCompletedJob(input.workspace, input.id) + if (!job) { + throw new Error(`Job "${input.id}" not found in benchmark workspace`) + } + return { success: job.success, completed: true, result: job.result } +} + export function runBenchmarkScriptPreview(input: { workspace: string requestBody: { diff --git a/ai_evals/adapters/frontend/mockBackendDatatables.test.ts b/ai_evals/adapters/frontend/mockBackendDatatables.test.ts new file mode 100644 index 0000000000..5d12ebb661 --- /dev/null +++ b/ai_evals/adapters/frontend/mockBackendDatatables.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' +import { + getBenchmarkCompletedJobResultMaybe, + getBenchmarkDatatableSchema, + listBenchmarkDatatables, + registerBenchmarkWorkspaceRunnables, + resetBenchmarkMockBackend, + runBenchmarkDatatableSql, + type BenchmarkWorkspaceRunnables +} from './mockBackend' + +const WORKSPACE = 'benchmark-datatable-ws' + +// Mirrors the production `isDatatableNotConfiguredError` regex in +// datatableTools.ts. The schema mock's "not configured" message MUST match it, +// otherwise the not-configured mapping in get_datatable_table_schema is silently +// untested. +const NOT_CONFIGURED_RE = /datatable\s+\S+\s+not found/i + +const SEED: BenchmarkWorkspaceRunnables = { + datatables: [ + { + datatable_name: 'main', + schemas: { + public: { + orders: { + columns: { id: 'int', total: 'numeric' }, + rows: [ + { id: 1, total: 10 }, + { id: 2, total: 20 } + ] + }, + customers: { + columns: { id: 'int', name: 'text' }, + rows: [{ id: 1, name: 'alice' }] + } + } + } + } + ] +} + +beforeEach(() => resetBenchmarkMockBackend()) +afterEach(() => resetBenchmarkMockBackend()) + +describe('listBenchmarkDatatables', () => { + it('returns null for a non-benchmark workspace (caller falls through to real backend)', () => { + expect(listBenchmarkDatatables('unregistered')).toBeNull() + }) + + it('returns [] for a registered workspace with no datatables seed', () => { + registerBenchmarkWorkspaceRunnables(WORKSPACE, {}) + expect(listBenchmarkDatatables(WORKSPACE)).toEqual([]) + }) + + it('projects seeded datatables to schema -> table names only (no columns)', () => { + registerBenchmarkWorkspaceRunnables(WORKSPACE, SEED) + expect(listBenchmarkDatatables(WORKSPACE)).toEqual([ + { datatable_name: 'main', schemas: { public: ['orders', 'customers'] } } + ]) + }) +}) + +describe('getBenchmarkDatatableSchema', () => { + beforeEach(() => registerBenchmarkWorkspaceRunnables(WORKSPACE, SEED)) + + it('returns the columns for a seeded table', () => { + expect( + getBenchmarkDatatableSchema({ + workspace: WORKSPACE, + datatableName: 'main', + schemaName: 'public', + tableName: 'orders' + }) + ).toEqual({ + datatable_name: 'main', + schema_name: 'public', + table_name: 'orders', + columns: { id: 'int', total: 'numeric' } + }) + }) + + it('throws a not-configured error matching the production regex for an unknown datatable', () => { + let error: Error | undefined + try { + getBenchmarkDatatableSchema({ + workspace: WORKSPACE, + datatableName: 'ghost', + schemaName: 'public', + tableName: 'orders' + }) + } catch (e) { + error = e as Error + } + expect(error).toBeDefined() + expect(error!.message).toMatch(NOT_CONFIGURED_RE) + }) + + it('throws a table-not-found error that does NOT match the datatable-not-configured regex', () => { + // The datatable IS configured; only the table is missing. Production maps + // this to a generic "error getting schema", not the blocking message. + let error: Error | undefined + try { + getBenchmarkDatatableSchema({ + workspace: WORKSPACE, + datatableName: 'main', + schemaName: 'public', + tableName: 'ghost' + }) + } catch (e) { + error = e as Error + } + expect(error).toBeDefined() + expect(error!.message).not.toMatch(NOT_CONFIGURED_RE) + }) +}) + +describe('runBenchmarkDatatableSql + getBenchmarkCompletedJobResultMaybe', () => { + beforeEach(() => registerBenchmarkWorkspaceRunnables(WORKSPACE, SEED)) + + function exec(sql: string): { success: boolean; completed: boolean; result: unknown } { + const jobId = runBenchmarkDatatableSql({ workspace: WORKSPACE, datatableName: 'main', sql }) + return getBenchmarkCompletedJobResultMaybe({ workspace: WORKSPACE, id: jobId }) + } + + it('returns the canned rows of the table named in a SELECT FROM clause', () => { + expect(exec('SELECT * FROM customers')).toEqual({ + success: true, + completed: true, + result: [{ id: 1, name: 'alice' }] + }) + }) + + it('falls back to the first seeded table when the SELECT references no known table', () => { + expect(exec('select 1').result).toEqual([ + { id: 1, total: 10 }, + { id: 2, total: 20 } + ]) + }) + + it('returns [] success for DDL and DML statements without RETURNING', () => { + expect(exec('CREATE TABLE foo (id int)').result).toEqual([]) + expect(exec('INSERT INTO orders VALUES (3, 30)').result).toEqual([]) + expect(exec('update orders set total = 0').result).toEqual([]) + }) + + it('reflects a write in a later SELECT, isolated from the shared seed', () => { + exec('UPDATE orders SET total = 999 WHERE id = 1') + expect((exec('SELECT * FROM orders').result as Record[])).toContainEqual({ + id: 1, + total: 999 + }) + // Registration deep-clones the seed, so the shared SEED const stays pristine. + expect(SEED.datatables![0].schemas.public.orders.rows).toContainEqual({ id: 1, total: 10 }) + }) + + it('reflects a CREATE in list_datatables and get_datatable_table_schema', () => { + exec('CREATE TABLE public.refunds (order_id int4, amount numeric)') + expect(listBenchmarkDatatables(WORKSPACE)?.[0].schemas.public).toContain('refunds') + expect( + getBenchmarkDatatableSchema({ + workspace: WORKSPACE, + datatableName: 'main', + schemaName: 'public', + tableName: 'refunds' + }).columns + ).toEqual({ order_id: 'int4', amount: 'numeric' }) + }) + + it('throws for an unknown job id', () => { + expect(() => + getBenchmarkCompletedJobResultMaybe({ workspace: WORKSPACE, id: 'does-not-exist' }) + ).toThrow() + }) +}) diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index 1275acf0b4..a84f990483 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -34,15 +34,19 @@ vi.mock('$lib/gen', async () => { const actual = await vi.importActual('$lib/gen') const { getBenchmarkCompletedJob, + getBenchmarkCompletedJobResultMaybe, + getBenchmarkDatatableSchema, getBenchmarkFlowByPath, getBenchmarkScriptByHash, getBenchmarkScriptByPath, hasBenchmarkWorkspace, + listBenchmarkDatatables, listBenchmarkFlows, listBenchmarkScripts, createBenchmarkHttpTrigger, createBenchmarkSchedule, previewBenchmarkSchedule, + runBenchmarkDatatableSql, runBenchmarkFlowByPath, runBenchmarkScriptPreview } = await import('./mockBackend') @@ -79,6 +83,16 @@ vi.mock('$lib/gen', async () => { } return actual.ScriptService.getScriptByPath(data) }, + getScriptByPathWithDraft: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + const script = getBenchmarkScriptByPath(data.workspace, data.path) + if (!script) { + throw new Error(`Script "${data.path}" not found in benchmark workspace`) + } + return script + } + return actual.ScriptService.getScriptByPathWithDraft(data) + }, getScriptByHash: async (data: { workspace: string; hash: string }) => { if (hasBenchmarkWorkspace(data.workspace)) { const script = getBenchmarkScriptByHash(data.workspace, data.hash) @@ -108,6 +122,26 @@ vi.mock('$lib/gen', async () => { return flow } return actual.FlowService.getFlowByPath(data) + }, + getFlowByPathWithDraft: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + const flow = getBenchmarkFlowByPath(data.workspace, data.path) + if (!flow) { + throw new Error(`Flow "${data.path}" not found in benchmark workspace`) + } + return flow + } + return actual.FlowService.getFlowByPathWithDraft(data) + }, + getFlowLatestVersion: async (data: { workspace: string; path: string }) => { + if (hasBenchmarkWorkspace(data.workspace)) { + const flow = getBenchmarkFlowByPath(data.workspace, data.path) + if (!flow) { + throw new Error(`Flow "${data.path}" not found in benchmark workspace`) + } + return { id: 1 } + } + return actual.FlowService.getFlowLatestVersion(data) } }), JobService: wrapService(actual.JobService, { @@ -119,13 +153,27 @@ vi.mock('$lib/gen', async () => { args?: Record path?: string } - }) => - hasBenchmarkWorkspace(data.workspace) - ? runBenchmarkScriptPreview({ - workspace: data.workspace, - requestBody: data.requestBody ?? {} - }) - : actual.JobService.runScriptPreview(data), + }) => { + if (!hasBenchmarkWorkspace(data.workspace)) { + return actual.JobService.runScriptPreview(data) + } + const requestBody = data.requestBody ?? {} + const database = requestBody.args?.database + // Datatable SQL runs as a `postgresql` preview against `datatable://`. + // Execute it through the canned-SQL mock instead of linting it as a script. + if ( + requestBody.language === 'postgresql' && + typeof database === 'string' && + database.startsWith('datatable://') + ) { + return runBenchmarkDatatableSql({ + workspace: data.workspace, + datatableName: database.slice('datatable://'.length), + sql: requestBody.content ?? '' + }) + } + return runBenchmarkScriptPreview({ workspace: data.workspace, requestBody }) + }, runFlowByPath: async (data: { workspace: string path: string @@ -147,7 +195,31 @@ vi.mock('$lib/gen', async () => { return job } return actual.JobService.getJob(data) - } + }, + getCompletedJobResultMaybe: async (data: { workspace: string; id: string }) => + hasBenchmarkWorkspace(data.workspace) + ? getBenchmarkCompletedJobResultMaybe({ workspace: data.workspace, id: data.id }) + : actual.JobService.getCompletedJobResultMaybe(data) + }), + WorkspaceService: wrapService(actual.WorkspaceService, { + listDataTableTables: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? (listBenchmarkDatatables(data.workspace) ?? []) + : actual.WorkspaceService.listDataTableTables(data), + getDataTableTableSchema: async (data: { + workspace: string + datatableName: string + schemaName: string + tableName: string + }) => + hasBenchmarkWorkspace(data.workspace) + ? getBenchmarkDatatableSchema({ + workspace: data.workspace, + datatableName: data.datatableName, + schemaName: data.schemaName, + tableName: data.tableName + }) + : actual.WorkspaceService.getDataTableTableSchema(data) }), ScheduleService: wrapService(actual.ScheduleService, { existsSchedule: async (data: { workspace: string; path: string }) => diff --git a/ai_evals/cases/flow.yaml b/ai_evals/cases/flow.yaml index af7added38..5f4abafc48 100644 --- a/ai_evals/cases/flow.yaml +++ b/ai_evals/cases/flow.yaml @@ -8,6 +8,9 @@ args: a: 4 b: 5 + toolExpect: + requiredToolsUsed: + - test_run_flow judgeChecklist: - "the flow takes `a` and `b` as inputs" - "the main step is named `sum_numbers`" @@ -25,6 +28,9 @@ args: a: 2 b: 3 + toolExpect: + requiredToolsUsed: + - test_run_flow judgeChecklist: - "the flow takes `a` and `b` as inputs" - "the main step is named `sum_numbers`" @@ -42,6 +48,9 @@ args: a: 7 b: 8 + toolExpect: + requiredToolsUsed: + - test_run_flow judgeChecklist: - "the parent flow takes `a` and `b` as inputs" - "the main step is named `call_add_numbers`" @@ -426,6 +435,7 @@ - return_schedule_status toolExpect: requiredToolsUsed: + - test_run_flow - create_schedule toolCallArgs: - tool: create_schedule @@ -453,6 +463,7 @@ - webhook_response toolExpect: requiredToolsUsed: + - test_run_flow - create_trigger toolCallArgs: - tool: create_trigger diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index b4526f8a85..8d58248d62 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -87,3 +87,737 @@ - the flow accepts numeric inputs a and b - the flow returns the sum of a and b - the result stays as an AI draft and is not deployed or saved to the workspace + +- id: global-test4-multi-artifact-notification-job + prompt: |- + Set up a draft stale-trial notification job. + Create a Bun script at `f/evals/global/check_stale_trials` that accepts `max_age_days`, uses mocked inline trial account data, and returns the stale trial account IDs. + Also create a weekday 09:00 UTC schedule at `f/evals/global/check_stale_trials_weekday` for that script with `max_age_days` set to 14. + Add an HTTP POST trigger at `f/evals/global/check_stale_trials_manual` with route path `evals/check-stale-trials` that runs the same script manually. + Leave everything as AI drafts only; do not deploy or save anything to the workspace. + runtime: + maxTurns: 12 + validate: + draftCountExactly: 3 + requiredDrafts: + - type: script + path: f/evals/global/check_stale_trials + language: bun + valueIncludes: + - max_age_days + - trial + - type: schedule + path: f/evals/global/check_stale_trials_weekday + valueIncludes: + - f/evals/global/check_stale_trials + - UTC + - "14" + - type: trigger + triggerKind: http + path: f/evals/global/check_stale_trials_manual + valueIncludes: + - evals/check-stale-trials + - f/evals/global/check_stale_trials + toolExpect: + requiredToolsUsed: + - write_script + - write_schedule + - write_trigger + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - creates a Bun script draft for stale trial accounts + - creates a weekday 09:00 UTC schedule draft for the script with max_age_days set to 14 + - creates an HTTP POST trigger draft with route path evals/check-stale-trials for the same script + - leaves all artifacts as drafts only and does not deploy + +- id: global-test5-existing-flow-inline-code-edit + prompt: |- + Update the existing flow at `f/evals/global/process_invoice`. + Only change the `calculate_total` inline code so it applies 8% tax and returns an object containing `subtotal`, `tax`, and `total`. + Leave the updated flow as an AI draft only; do not deploy or save it. + initial: ai_evals/fixtures/frontend/global/initial/process_invoice_flow.json + runtime: + maxTurns: 10 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: flow + path: f/evals/global/process_invoice + valueIncludes: + - calculate_total + - tax + - total + toolExpect: + requiredToolsUsed: + - read_workspace_item + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - reads the existing process_invoice flow before editing it + - updates the calculate_total inline code to apply 8% tax + - returns subtotal, tax, and total from the updated flow logic + - leaves the result as an AI draft only + +- id: global-test6-secret-variable-draft + prompt: |- + Create a secret variable draft at `f/evals/global/slack_bot_token`. + Use the placeholder value `xoxb-redacted-test-token` and description `Slack bot token for eval notifications`. + Do not create any resource or deploy anything. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: variable + path: f/evals/global/slack_bot_token + valueIncludes: + - Slack bot token + - "true" + forbiddenDrafts: + - type: resource + path: f/evals/global/slack_bot_token + toolExpect: + requiredToolsUsed: + - write_variable + forbiddenToolsUsed: + - write_resource + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: write_variable + field: value + stringStartsWithAnyOf: + - xoxb-redacted-test-token + skipJudge: true + judgeChecklist: + - creates exactly one secret variable draft at f/evals/global/slack_bot_token + - uses the requested placeholder value and description + - does not create a resource or deploy anything + +- id: global-test7-ambiguous-app-asks-question + prompt: |- + Create a new raw app for triaging support tickets. + runtime: + maxTurns: 4 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - askUserQuestion + forbiddenToolsUsed: + - init_app + - write_app_file + - write_app_runnable + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + +- id: global-test8-human-script-infer-path-language + prompt: |- + I need a small helper that formats a customer-facing welcome line. + It should take a person's name and return "Welcome aboard, !". + Please just stage it as a draft for now. + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + valueIncludes: + - Welcome aboard + - name + toolExpect: + requiredToolsUsed: + - write_script + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - creates a single script draft for a welcome-line helper + - accepts a person's name as input + - returns a message containing Welcome aboard, the provided name, and an exclamation mark + - chooses a reasonable workspace path and script language without needing the user to specify them + - leaves the result as an AI draft only + +- id: global-test9-human-weekday-trial-job + prompt: |- + Can you set up a draft daily job that checks a few hard-coded trial accounts and returns the ones whose trial has ended? + It should run every weekday morning around 9 in UTC with a 30 day cutoff. + Keep it as draft work only. + runtime: + maxTurns: 10 + validate: + draftCountExactly: 2 + requiredDrafts: + - type: script + pathIncludes: + - trial + valueIncludes: + - trial + - "30" + - type: schedule + pathIncludes: + - trial + valueIncludes: + - UTC + toolExpect: + requiredToolsUsed: + - write_script + - write_schedule + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - creates a script draft that checks hard-coded trial accounts + - returns the accounts whose trial has ended based on a 30 day cutoff + - creates a schedule draft for weekday mornings around 09:00 UTC + - links the schedule to the generated script + - leaves both artifacts as drafts only + +- id: global-test10-human-secret-variable + prompt: |- + I need a placeholder Slack bot token stored securely for future notification work. + Use xoxb-redacted-test-token and note that it is for eval notifications. + Only prepare a draft. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: variable + pathIncludes: + - slack + valueIncludes: + - eval notifications + - "true" + toolExpect: + requiredToolsUsed: + - write_variable + forbiddenToolsUsed: + - write_resource + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: write_variable + field: value + stringStartsWithAnyOf: + - xoxb-redacted-test-token + skipJudge: true + judgeChecklist: + - creates a single secret variable draft for the Slack bot token placeholder + - uses the requested placeholder value + - includes a note or description that it is for eval notifications + - does not create a resource or deploy anything + +- id: global-test11-human-existing-flow-informal-edit + prompt: |- + There is an invoice processing flow in this workspace. + Can you adjust its total calculation so it adds 8% tax and returns subtotal, tax, and total? + Keep the change as a draft. + initial: ai_evals/fixtures/frontend/global/initial/process_invoice_flow.json + runtime: + maxTurns: 10 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: flow + pathIncludes: + - invoice + valueIncludes: + - calculate_total + - tax + - total + toolExpect: + requiredToolsUsed: + - read_workspace_item + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - finds and edits the existing invoice processing flow without the user providing its exact path + - updates the total calculation to apply 8% tax + - returns subtotal, tax, and total from the updated flow logic + - leaves the result as an AI draft only + +- id: global-test12-current-live-script-edit + prompt: |- + The script I have open formats greetings. + Can you update this script so it uppercases the name before greeting them and ends with an exclamation mark? + Keep it as draft work. + initial: ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/current_greeting + language: bun + valueIncludes: + - toUpperCase + - "!" + forbiddenDrafts: + - type: script + path: f/evals/global/format_greeting + - type: script + path: f/evals/global/format_greeting_archive + toolExpect: + requiredToolsUsed: + - read_workspace_item + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - resolves "this script" to the active live editor script instead of another similarly named workspace script + - updates the greeting logic to uppercase the provided name + - returns a greeting ending with an exclamation mark + - leaves the result as a draft only + +- id: global-test13-current-live-flow-edit + prompt: |- + I have the invoice flow open. + In the current flow, update the total calculation to add 8% tax and return subtotal, tax, and total. + Keep the change as a draft. + initial: ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json + runtime: + maxTurns: 10 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: flow + path: f/evals/global/current_invoice_flow + valueIncludes: + - calculate_total + - tax + - total + forbiddenDrafts: + - type: flow + path: f/evals/global/process_invoice + - type: flow + path: f/evals/global/process_refund + toolExpect: + requiredToolsUsed: + - read_workspace_item + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - resolves "current flow" to the active live editor flow + - does not edit the similarly named deployed invoice or refund flows + - updates the calculate_total logic to apply 8% tax + - returns subtotal, tax, and total from the updated flow logic + - leaves the result as a draft only + +- id: global-test14-current-without-live-editor-asks-question + prompt: |- + Please update this script so it returns `ok`. + Keep it as a draft. + runtime: + maxTurns: 4 + validate: + draftCountExactly: 0 + toolExpect: + forbiddenToolsUsed: + - write_script + - edit_script + - write_flow + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + judgeChecklist: + - asks which script to update when the user refers to "this script" without selected or active editor context + - does not guess a path or create a new script draft + +- id: global-test15-human-postgres-resource + prompt: |- + I'm wiring the eval reporting database into this workspace. + Can you stage a Postgres connection for it in the shared evals/global folder? + Use host `reports-db.internal`, port 5432, database `evals_reporting`, user `report_reader`, and password `pg-redacted-reporting-password`. + Keep the credentials safe. + This is just draft work for now. + runtime: + maxTurns: 10 + validate: + draftCountExactly: 2 + requiredDrafts: + - type: variable + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - report + - password + valueIncludes: + - "true" + - report + - type: resource + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - report + valueIncludes: + - postgres + - reports-db.internal + - "5432" + - evals_reporting + - report_reader + - "$var:" + valueExcludes: + - pg-redacted-reporting-password + toolExpect: + requiredToolsUsed: + - write_variable + - search_resource_types + - write_resource + forbiddenToolsUsed: + - write_schedule + - write_trigger + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: write_variable + field: value + stringStartsWithAnyOf: + - pg-redacted-reporting-password + skipJudge: true + judgeChecklist: + - creates a Postgres resource draft for the eval reporting database + - creates a secret variable draft for the database password + - puts the drafts in sensible eval/global reporting-related paths + - uses the requested host, port, database, and user + - references the secret variable from the resource instead of embedding the password + - leaves the work as a draft only + +- id: global-test16-human-visible-variable + prompt: |- + We keep reusing a 30 day trial cutoff in eval notification jobs. + Can you stage that as a normal workspace variable in the shared evals/global folder, with a short description so people know what it controls? + It is not a secret. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: variable + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - trial + valueIncludes: + - "30" + - "false" + - trial + toolExpect: + requiredToolsUsed: + - write_variable + forbiddenToolsUsed: + - write_resource + - write_schedule + - write_trigger + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - creates exactly one non-secret variable draft for the trial cutoff + - stores the value 30 + - chooses a sensible eval/global path related to trials or notifications + - includes a useful description of what the value controls + - does not create resources, schedules, triggers, or deployed workspace changes + +- id: global-test17-human-schedule-existing-helper + prompt: |- + The workspace already has a report digest helper. + Can you stage a weekday 8:30 AM UTC run for it with `dry_run` turned on? + I only want the schedule draft for review. + initial: ai_evals/fixtures/frontend/global/initial/report_digest_script.json + runtime: + maxTurns: 8 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: schedule + pathIncludes: + - digest + valueIncludes: + - f/evals/global/send_report_digest + - UTC + - dry_run + - "true" + toolExpect: + requiredToolsUsed: + - list_workspace_items + - write_schedule + forbiddenToolsUsed: + - write_script + - write_flow + - write_resource + - write_variable + - write_trigger + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - finds the existing report digest helper rather than creating a new script or flow + - creates one schedule draft for that helper + - schedules it for weekdays around 08:30 UTC + - passes dry_run as true + - leaves only the schedule draft for review + +- id: global-test18-human-slack-resource-with-secret + prompt: |- + I'm preparing Slack notifications for eval failures. + Can you stage a Slack connection in the shared evals/global folder? + The bot token is `xoxb-redacted-test-token`; keep it safe. + Don't deploy anything yet. + runtime: + maxTurns: 8 + validate: + draftCountExactly: 2 + requiredDrafts: + - type: variable + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - slack + - token + valueIncludes: + - "true" + - type: resource + pathStartsWith: f/evals/global/ + pathIncludes: + - evals + - global + - slack + valueIncludes: + - slack + - "$var:" + valueExcludes: + - xoxb-redacted-test-token + toolExpect: + requiredToolsUsed: + - write_variable + - search_resource_types + - write_resource + forbiddenToolsUsed: + - write_schedule + - write_trigger + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: write_variable + field: value + stringStartsWithAnyOf: + - xoxb-redacted-test-token + skipJudge: true + judgeChecklist: + - creates a secret variable draft for the Slack bot token placeholder + - creates a Slack resource draft that references the secret variable instead of embedding the token + - keeps both drafts under a sensible eval/global Slack-related path + - does not create schedules, triggers, or deployed workspace changes + +- id: global-test19-datatable-not-configured-asks-to-set-up + prompt: |- + Here are two newsletter signups: alice@example.com and bob@example.com. + Save them into a workspace data table for me. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - list_datatables + forbiddenToolsUsed: + - exec_datatable_sql + - write_script + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + judgeChecklist: + - checks which data tables exist in the workspace before acting + - recognizes that no data table is configured in this workspace + - explains that a data table must first be set up by the user in the workspace settings (Data Tables) and is not created via SQL + - does not run SQL, write a script, or invent a data table to work around the missing configuration + - tells the user to configure a data table and then try again + +- id: global-test20-datatable-no-hallucinated-main + prompt: |- + Pull the latest rows from the orders table in our data table so I can see recent orders. + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - list_datatables + forbiddenToolsUsed: + - exec_datatable_sql + - write_script + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + judgeChecklist: + - checks which data tables exist in the workspace before querying + - recognizes that no data table is configured in this workspace + - does not assume a data table named "main" (or any other name) exists + - does not run SQL against a guessed data table or fabricate order rows + - tells the user they need to set up a data table in the workspace settings first + +- id: global-test21-datatable-list-summarize + prompt: |- + What tables do we have in our workspace data table? Just give me the list. + initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - list_datatables + forbiddenToolsUsed: + - get_datatable_table_schema + - exec_datatable_sql + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + judgeChecklist: + - lists the tables available in the workspace data table (orders and customers) + - answers from the data table listing rather than fabricating table names + - does not fetch column details or run SQL just to produce a table list + +- id: global-test22-datatable-inspect-columns + prompt: |- + What columns does the orders table have in our workspace data table? + initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - get_datatable_table_schema + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + skipJudge: true + judgeChecklist: + - inspects the orders table schema in the workspace data table + - reports the orders columns (such as id, customer_id, total, status, created_at) + - answers from the retrieved schema rather than guessing the columns + +- id: global-test23-datatable-query-select + prompt: |- + Show me the orders in our workspace data table, including their status and total. + initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - exec_datatable_sql + forbiddenToolsUsed: + - write_script + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: exec_datatable_sql + field: sql + stringIncludesAnyOf: + - select + skipJudge: true + judgeChecklist: + - runs a SELECT query against the orders table in the workspace data table + - reports the orders returned by the query back to the user instead of fabricating data + - does not tell the user to set up a data table, since one already exists + +- id: global-test24-datatable-create-table + prompt: |- + Add a new table called refunds to our workspace data table, with an order id and a refund amount. + initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - exec_datatable_sql + forbiddenToolsUsed: + - write_script + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: exec_datatable_sql + field: sql + stringIncludesAnyOf: + - create table + skipJudge: true + judgeChecklist: + - creates the refunds table with a plain CREATE TABLE statement on the data table + - includes an order id and a refund amount column + - treats creating the table as a normal SQL statement and does not claim a separate registration step is needed + - does not write a script to create the table + +- id: global-test25-datatable-mutate-rows + prompt: |- + Mark order number 2 as shipped in our workspace data table. + initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json + runtime: + # Headroom for inspect-schema -> UPDATE -> verify; the in-memory engine now + # persists the write, so verification confirms on the first try (no loop). + maxTurns: 12 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - exec_datatable_sql + forbiddenToolsUsed: + - write_script + - deploy_workspace_item + - delete_workspace_item + toolCallArgs: + - tool: exec_datatable_sql + field: sql + stringIncludesAnyOf: + - update + - insert into + skipJudge: true + judgeChecklist: + - runs an UPDATE on the orders table setting the status of order id 2 to shipped + - targets only order number 2 rather than rewriting the whole table + - confirms the change back to the user + +- id: global-test26-datatable-script-sdk + prompt: |- + Write a script that reads our workspace data table and returns the total revenue across all orders. + Leave it as an AI draft only; do not deploy or save it. + initial: ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json + runtime: + maxTurns: 10 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + valueIncludes: + - wmill.datatable( + toolExpect: + requiredToolsUsed: + - get_instructions + - write_script + forbiddenToolsUsed: + - exec_datatable_sql + - deploy_workspace_item + - delete_workspace_item + # The judge has no datatable SDK reference and wrongly penalizes correct + # wmill.datatable() tagged-template usage, so rely on the deterministic checks: + # required get_instructions + write_script, forbidden exec_datatable_sql, and a + # draft that contains wmill.datatable(. + skipJudge: true + judgeChecklist: + - writes a script (not a chat-time SQL execution) that reads the workspace data table at runtime + - uses the wmill.datatable() SDK to query the orders table and sum the order totals + - returns the total revenue from the script + - leaves the result as an AI draft and does not deploy or save it diff --git a/ai_evals/cases/script.yaml b/ai_evals/cases/script.yaml index feae74dcda..f0910e939a 100644 --- a/ai_evals/cases/script.yaml +++ b/ai_evals/cases/script.yaml @@ -5,6 +5,9 @@ Keep it simple and do not add external dependencies. initial: ai_evals/fixtures/frontend/script/initial/test1_empty_bun.json expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json + toolExpect: + requiredToolsUsed: + - test_run_script judgeChecklist: - uses the existing `name` input - returns a plain greeting string @@ -20,6 +23,7 @@ expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json toolExpect: requiredToolsUsed: + - test_run_script - create_schedule toolCallArgs: - tool: create_schedule @@ -44,6 +48,7 @@ expected: ai_evals/fixtures/frontend/script/expected/test1_greet_user.json toolExpect: requiredToolsUsed: + - test_run_script - create_trigger toolCallArgs: - tool: create_trigger diff --git a/ai_evals/cli/index.ts b/ai_evals/cli/index.ts index 8ed61740c8..f92d6d7027 100644 --- a/ai_evals/cli/index.ts +++ b/ai_evals/cli/index.ts @@ -211,7 +211,7 @@ async function handleRun(input: { const summaries: Array<{ label: string; passRate: number; - averageDurationMs: number; + averagePassedDurationMs: number | null; }> = []; for (const [index, model] of models.entries()) { @@ -259,7 +259,7 @@ async function handleRun(input: { summaries.push({ label: `${model.id} (${runModel})`, passRate: result.passRate, - averageDurationMs: result.averageDurationMs, + averagePassedDurationMs: result.averagePassedDurationMs ?? null, }); } @@ -267,7 +267,7 @@ async function handleRun(input: { process.stdout.write("\nModel summary\n"); for (const summary of summaries) { process.stdout.write( - `- ${summary.label}: ${formatPercent(summary.passRate)} | ${Math.round(summary.averageDurationMs)}ms\n`, + `- ${summary.label}: ${formatPercent(summary.passRate)} | passed avg ${formatNullableDuration(summary.averagePassedDurationMs)}\n`, ); } } @@ -351,6 +351,10 @@ function formatPercent(value: number): string { return `${(value * 100).toFixed(1)}%`; } +function formatNullableDuration(value: number | null): string { + return value === null ? "n/a" : `${Math.round(value)}ms`; +} + void main().catch((error) => { const message = error instanceof Error ? error.message : String(error); process.stderr.write(`${message}\n`); diff --git a/ai_evals/core/cases.test.ts b/ai_evals/core/cases.test.ts index 733d34ddd2..05e2f1527b 100644 --- a/ai_evals/core/cases.test.ts +++ b/ai_evals/core/cases.test.ts @@ -14,6 +14,21 @@ describe("loadCases", () => { }, }, }); + expect(caseEntry?.toolExpect).toEqual({ + requiredToolsUsed: ["test_run_flow"], + }); + }); + + it("loads script and flow test tool expectations", async () => { + const scriptCases = await loadCases("script"); + const flowCases = await loadCases("flow"); + + expect(scriptCases.find((entry) => entry.id === "script-test1-greet-user")?.toolExpect).toEqual({ + requiredToolsUsed: ["test_run_script"], + }); + expect(flowCases.find((entry) => entry.id === "flow-test0-sum-two-numbers")?.toolExpect).toEqual({ + requiredToolsUsed: ["test_run_flow"], + }); }); it("loads the workspace-flow preference benchmark case", async () => { @@ -203,6 +218,34 @@ describe("loadCases", () => { }); }); + it("loads global active-editor eval cases", async () => { + const globalCases = await loadCases("global"); + const scriptCase = globalCases.find( + (entry) => entry.id === "global-test12-current-live-script-edit" + ); + const flowCase = globalCases.find( + (entry) => entry.id === "global-test13-current-live-flow-edit" + ); + + expect(scriptCase?.initialPath).toContain( + "ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json" + ); + expect(scriptCase?.toolExpect).toMatchObject({ + requiredToolsUsed: ["read_workspace_item"], + }); + expect(flowCase?.initialPath).toContain( + "ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json" + ); + expect(flowCase?.validate).toMatchObject({ + requiredDrafts: [ + { + type: "flow", + path: "f/evals/global/current_invoice_flow", + }, + ], + }); + }); + it("loads tool expectations for workspace mutation cases", async () => { const scriptCases = await loadCases("script"); const caseEntry = scriptCases.find( @@ -210,7 +253,7 @@ describe("loadCases", () => { ); expect(caseEntry?.toolExpect).toEqual({ - requiredToolsUsed: ["create_schedule"], + requiredToolsUsed: ["test_run_script", "create_schedule"], toolCallArgs: [ { tool: "create_schedule", diff --git a/ai_evals/core/models.test.ts b/ai_evals/core/models.test.ts index a11fe40530..ba53c24592 100644 --- a/ai_evals/core/models.test.ts +++ b/ai_evals/core/models.test.ts @@ -2,15 +2,22 @@ import { describe, expect, it } from "bun:test"; import { resolveEvalModel } from "./models"; describe("resolveEvalModel", () => { + it("supports GPT-5.5 aliases for frontend evals", () => { + expect(resolveEvalModel("flow", "gpt-5.5").frontend).toEqual({ + provider: "openai", + model: "gpt-5.5", + }); + expect(resolveEvalModel("app", "gpt-55").frontend).toEqual({ + provider: "openai", + model: "gpt-5.5", + }); + expect(resolveEvalModel("script", "5.5").frontend).toEqual({ + provider: "openai", + model: "gpt-5.5", + }); + }); + it("supports Gemini aliases for frontend evals", () => { - expect(resolveEvalModel("flow", "gemini").frontend).toEqual({ - provider: "googleai", - model: "gemini-2.5-flash", - }); - expect(resolveEvalModel("app", "gemini-pro").frontend).toEqual({ - provider: "googleai", - model: "gemini-2.5-pro", - }); expect( resolveEvalModel("script", "gemini-3-flash-preview").frontend, ).toEqual({ @@ -37,8 +44,8 @@ describe("resolveEvalModel", () => { }); it("rejects Gemini aliases for cli evals", () => { - expect(() => resolveEvalModel("cli", "gemini")).toThrow( - "Model gemini-flash is not supported for cli mode", + expect(() => resolveEvalModel("cli", "gemini-3-flash-preview")).toThrow( + "Model gemini-3-flash-preview is not supported for cli mode", ); }); }); diff --git a/ai_evals/core/models.ts b/ai_evals/core/models.ts index 054a27bc1c..295cd36135 100644 --- a/ai_evals/core/models.ts +++ b/ai_evals/core/models.ts @@ -88,21 +88,12 @@ export const EVAL_MODELS: EvalModelSpec[] = [ }, }, { - id: "gemini-flash", - label: "Gemini 2.5 Flash", - aliases: ["gemini", "gemini-flash", "gemini-2.5-flash"], + id: "gpt-5.5", + label: "GPT-5.5", + aliases: ["gpt-5.5", "gpt-55", "5.5"], frontend: { - provider: "googleai", - model: "gemini-2.5-flash", - }, - }, - { - id: "gemini-pro", - label: "Gemini 2.5 Pro", - aliases: ["gemini-pro", "gemini-2.5-pro"], - frontend: { - provider: "googleai", - model: "gemini-2.5-pro", + provider: "openai", + model: "gpt-5.5", }, }, { diff --git a/ai_evals/core/results.test.ts b/ai_evals/core/results.test.ts new file mode 100644 index 0000000000..2d6077c5bd --- /dev/null +++ b/ai_evals/core/results.test.ts @@ -0,0 +1,242 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, it } from "bun:test"; +import { + appendHistoryRecord, + buildRunResult, + formatRunSummary, +} from "./results"; +import type { BenchmarkCaseResult } from "./types"; + +function caseResult( + attempts: BenchmarkCaseResult["attempts"], +): BenchmarkCaseResult { + return { + id: "case-1", + prompt: "Do the thing", + attempts, + }; +} + +describe("benchmark results", () => { + it("keeps success cost metrics separate from failed attempts", () => { + const result = buildRunResult({ + mode: "global", + runs: 1, + runModel: "model-under-test", + judgeModel: "judge-model", + caseResults: [ + caseResult([ + { + attempt: 1, + passed: true, + durationMs: 1000, + assistantMessageCount: 1, + toolCallCount: 1, + toolsUsed: ["edit_script"], + skillsInvoked: [], + checks: [{ name: "edited", passed: true }], + judgeScore: 100, + judgeSummary: "ok", + error: null, + tokenUsage: { prompt: 100, completion: 20, total: 120 }, + }, + { + attempt: 2, + passed: false, + durationMs: 100, + assistantMessageCount: 1, + toolCallCount: 0, + toolsUsed: [], + skillsInvoked: [], + checks: [{ name: "edited", passed: false }], + judgeScore: 10, + judgeSummary: "missed", + error: "failed", + tokenUsage: { prompt: 10, completion: 5, total: 15 }, + }, + ]), + ], + }); + + expect(result.attemptCount).toBe(2); + expect(result.passedAttempts).toBe(1); + expect(result.passRate).toBe(0.5); + expect(result.averageDurationMs).toBe(550); + expect(result.averagePassedDurationMs).toBe(1000); + expect(result.totalTokenUsage).toEqual({ + prompt: 110, + completion: 25, + total: 135, + }); + expect(result.totalPassedTokenUsage).toEqual({ + prompt: 100, + completion: 20, + total: 120, + }); + expect(result.averageTokenUsagePerAttempt).toEqual({ + prompt: 55, + completion: 12.5, + total: 67.5, + }); + expect(result.averageTokenUsagePerPassedAttempt).toEqual({ + prompt: 100, + completion: 20, + total: 120, + }); + + const summary = formatRunSummary(result); + expect(summary).toContain("Average duration (passed): 1000ms"); + expect(summary).toContain("Average tokens (passed): 120 total"); + expect(summary).toContain("Average duration (all attempts): 550ms"); + }); + + it("reports passed averages as unavailable when no attempt passes", () => { + const result = buildRunResult({ + mode: "global", + runs: 1, + runModel: "model-under-test", + judgeModel: "judge-model", + caseResults: [ + caseResult([ + { + attempt: 1, + passed: false, + durationMs: 100, + assistantMessageCount: 1, + toolCallCount: 0, + toolsUsed: [], + skillsInvoked: [], + checks: [{ name: "edited", passed: false }], + judgeScore: 10, + judgeSummary: "missed", + error: "failed", + tokenUsage: { prompt: 10, completion: 5, total: 15 }, + }, + ]), + ], + }); + + expect(result.averagePassedDurationMs).toBeNull(); + expect(result.totalPassedTokenUsage).toBeNull(); + expect(result.averageTokenUsagePerPassedAttempt).toBeNull(); + expect(formatRunSummary(result)).toContain( + "Average duration (passed): n/a", + ); + }); + + it("normalizes passed token averages by passed attempts", () => { + const result = buildRunResult({ + mode: "global", + runs: 1, + runModel: "model-under-test", + judgeModel: "judge-model", + caseResults: [ + caseResult([ + { + attempt: 1, + passed: true, + durationMs: 1000, + assistantMessageCount: 1, + toolCallCount: 1, + toolsUsed: ["edit_script"], + skillsInvoked: [], + checks: [{ name: "edited", passed: true }], + judgeScore: 100, + judgeSummary: "ok", + error: null, + tokenUsage: { prompt: 100, completion: 20, total: 120 }, + }, + { + attempt: 2, + passed: true, + durationMs: 1200, + assistantMessageCount: 1, + toolCallCount: 1, + toolsUsed: ["edit_script"], + skillsInvoked: [], + checks: [{ name: "edited", passed: true }], + judgeScore: 100, + judgeSummary: "ok", + error: null, + tokenUsage: null, + }, + ]), + ], + }); + + expect(result.passedAttempts).toBe(2); + expect(result.totalPassedTokenUsage).toEqual({ + prompt: 100, + completion: 20, + total: 120, + }); + expect(result.averageTokenUsagePerPassedAttempt).toEqual({ + prompt: 50, + completion: 10, + total: 60, + }); + }); + + it("records passed-attempt metrics in history", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "windmill-ai-evals-")); + try { + const historyPath = join(tempDir, "history.jsonl"); + const result = buildRunResult({ + mode: "global", + runs: 1, + runModel: "model-under-test", + judgeModel: "judge-model", + caseResults: [ + caseResult([ + { + attempt: 1, + passed: true, + durationMs: 1000, + assistantMessageCount: 1, + toolCallCount: 1, + toolsUsed: ["edit_script"], + skillsInvoked: [], + checks: [{ name: "edited", passed: true }], + judgeScore: 100, + judgeSummary: "ok", + error: null, + tokenUsage: { prompt: 100, completion: 20, total: 120 }, + }, + { + attempt: 2, + passed: false, + durationMs: 100, + assistantMessageCount: 1, + toolCallCount: 0, + toolsUsed: [], + skillsInvoked: [], + checks: [{ name: "edited", passed: false }], + judgeScore: 10, + judgeSummary: "missed", + error: "failed", + tokenUsage: { prompt: 10, completion: 5, total: 15 }, + }, + ]), + ], + }); + + await appendHistoryRecord(result, historyPath); + const record = JSON.parse(await readFile(historyPath, "utf8")); + + expect(record.averageDurationMs).toBe(550); + expect(record.averagePassedDurationMs).toBe(1000); + expect(record.averageTokenUsagePerAttempt.total).toBe(67.5); + expect(record.averageTokenUsagePerPassedAttempt.total).toBe(120); + expect(record.cases[0].averageDurationMs).toBe(550); + expect(record.cases[0].averagePassedDurationMs).toBe(1000); + expect(record.cases[0].averageTokenUsagePerAttempt.total).toBe(67.5); + expect(record.cases[0].averageTokenUsagePerPassedAttempt.total).toBe( + 120, + ); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/ai_evals/core/results.ts b/ai_evals/core/results.ts index e58840f911..0b84497165 100644 --- a/ai_evals/core/results.ts +++ b/ai_evals/core/results.ts @@ -4,12 +4,20 @@ import { execFileSync } from "node:child_process"; import { getAiEvalsRoot, getRepoRoot } from "./cases"; import type { BenchmarkArtifactFile, + BenchmarkAttemptResult, BenchmarkCaseResult, BenchmarkRunResult, BenchmarkTokenUsage, EvalMode, } from "./types"; +type AttemptAggregate = { + attemptCount: number; + durationTotal: number; + tokenUsageAttemptCount: number; + tokenUsageTotal: BenchmarkTokenUsage | null; +}; + export async function writeRunResult( result: BenchmarkRunResult, outputPath?: string, @@ -77,36 +85,12 @@ export function buildRunResult(input: { judgeModel: string | null; caseResults: BenchmarkCaseResult[]; }): BenchmarkRunResult { - const attemptCount = input.caseResults.reduce( - (sum, entry) => sum + entry.attempts.length, - 0, - ); - const passedAttempts = input.caseResults.reduce( - (sum, entry) => - sum + entry.attempts.filter((attempt) => attempt.passed).length, - 0, - ); - const durationTotal = input.caseResults.reduce( - (sum, entry) => - sum + - entry.attempts.reduce((inner, attempt) => inner + attempt.durationMs, 0), - 0, - ); - const tokenUsageTotal = input.caseResults.reduce( - (sum, entry) => { - for (const attempt of entry.attempts) { - if (!attempt.tokenUsage) { - continue; - } - sum ??= { prompt: 0, completion: 0, total: 0 }; - sum.prompt += attempt.tokenUsage.prompt; - sum.completion += attempt.tokenUsage.completion; - sum.total += attempt.tokenUsage.total; - } - return sum; - }, - null, - ); + const attempts = input.caseResults.flatMap((entry) => entry.attempts); + const passedAttemptResults = attempts.filter((attempt) => attempt.passed); + const attemptAggregate = aggregateAttempts(attempts); + const passedAttemptAggregate = aggregateAttempts(passedAttemptResults); + const attemptCount = attemptAggregate.attemptCount; + const passedAttempts = passedAttemptAggregate.attemptCount; return { version: 1, @@ -120,16 +104,19 @@ export function buildRunResult(input: { attemptCount, passedAttempts, passRate: attemptCount === 0 ? 0 : passedAttempts / attemptCount, - averageDurationMs: attemptCount === 0 ? 0 : durationTotal / attemptCount, - totalTokenUsage: tokenUsageTotal, + averageDurationMs: + attemptCount === 0 ? 0 : attemptAggregate.durationTotal / attemptCount, + averagePassedDurationMs: averageDuration(passedAttemptAggregate), + totalTokenUsage: attemptAggregate.tokenUsageTotal, + totalPassedTokenUsage: passedAttemptAggregate.tokenUsageTotal, averageTokenUsagePerAttempt: - attemptCount === 0 || !tokenUsageTotal + attemptCount === 0 ? null - : { - prompt: tokenUsageTotal.prompt / attemptCount, - completion: tokenUsageTotal.completion / attemptCount, - total: tokenUsageTotal.total / attemptCount, - }, + : averageTokenUsage(attemptAggregate, attemptCount), + averageTokenUsagePerPassedAttempt: averageTokenUsage( + passedAttemptAggregate, + passedAttempts, + ), cases: input.caseResults, }; } @@ -138,9 +125,25 @@ export function formatRunSummary(result: BenchmarkRunResult): string { const lines = [ `${result.mode} benchmark complete`, `Pass rate: ${formatPercent(result.passRate)} (${result.passedAttempts}/${result.attemptCount})`, - `Average duration: ${Math.round(result.averageDurationMs)}ms`, + `Average duration (passed): ${formatNullableDuration(result.averagePassedDurationMs ?? null)}`, ]; + if (result.averageTokenUsagePerPassedAttempt) { + lines.push( + `Average tokens (passed): ${formatTokenUsage(result.averageTokenUsagePerPassedAttempt)}`, + ); + } + if (result.passedAttempts < result.attemptCount) { + lines.push( + `Average duration (all attempts): ${Math.round(result.averageDurationMs)}ms`, + ); + if (result.averageTokenUsagePerAttempt) { + lines.push( + `Average tokens (all attempts): ${formatTokenUsage(result.averageTokenUsagePerAttempt)}`, + ); + } + } + const failures = collectFailures(result); if (failures.length > 0) { lines.push("Failures:"); @@ -172,6 +175,60 @@ function collectFailures(result: BenchmarkRunResult): string[] { return failures; } +function aggregateAttempts(attempts: BenchmarkAttemptResult[]): AttemptAggregate { + const aggregate: AttemptAggregate = { + attemptCount: attempts.length, + durationTotal: 0, + tokenUsageAttemptCount: 0, + tokenUsageTotal: null, + }; + + for (const attempt of attempts) { + aggregate.durationTotal += attempt.durationMs; + if (!attempt.tokenUsage) { + continue; + } + aggregate.tokenUsageAttemptCount += 1; + aggregate.tokenUsageTotal ??= { prompt: 0, completion: 0, total: 0 }; + aggregate.tokenUsageTotal.prompt += attempt.tokenUsage.prompt; + aggregate.tokenUsageTotal.completion += attempt.tokenUsage.completion; + aggregate.tokenUsageTotal.total += attempt.tokenUsage.total; + } + + return aggregate; +} + +function averageDuration(aggregate: AttemptAggregate): number | null { + return aggregate.attemptCount === 0 + ? null + : aggregate.durationTotal / aggregate.attemptCount; +} + +function averageTokenUsage( + aggregate: AttemptAggregate, + denominator: number, +): BenchmarkTokenUsage | null { + if (denominator === 0 || !aggregate.tokenUsageTotal) { + return null; + } + return { + prompt: aggregate.tokenUsageTotal.prompt / denominator, + completion: aggregate.tokenUsageTotal.completion / denominator, + total: aggregate.tokenUsageTotal.total / denominator, + }; +} + +function formatNullableDuration(value: number | null): string { + return value === null ? "n/a" : `${Math.round(value)}ms`; +} + +function formatTokenUsage(value: BenchmarkTokenUsage): string { + const total = Math.round(value.total); + const prompt = Math.round(value.prompt); + const completion = Math.round(value.completion); + return `${total} total (${prompt} prompt, ${completion} completion)`; +} + function defaultFileName(mode: EvalMode): string { return `${new Date().toISOString().replaceAll(":", "-")}__${mode}.json`; } @@ -252,12 +309,15 @@ function toHistoryRecord(result: BenchmarkRunResult) { passedAttempts: result.passedAttempts, passRate: result.passRate, averageDurationMs: result.averageDurationMs, + averagePassedDurationMs: result.averagePassedDurationMs ?? null, averageJudgeScore: judgeScores.length === 0 ? null : judgeScores.reduce((sum, score) => sum + score, 0) / judgeScores.length, averageTokenUsagePerAttempt: result.averageTokenUsagePerAttempt ?? null, + averageTokenUsagePerPassedAttempt: + result.averageTokenUsagePerPassedAttempt ?? null, failedCaseIds: Array.from( new Set( result.cases @@ -268,31 +328,15 @@ function toHistoryRecord(result: BenchmarkRunResult) { ), ), cases: result.cases.map((caseResult) => { - const attemptCount = caseResult.attempts.length; - const passedAttempts = caseResult.attempts.filter( - (attempt) => attempt.passed, - ).length; - const totalDurationMs = caseResult.attempts.reduce( - (sum, attempt) => sum + attempt.durationMs, - 0, + const attemptAggregate = aggregateAttempts(caseResult.attempts); + const passedAttemptAggregate = aggregateAttempts( + caseResult.attempts.filter((attempt) => attempt.passed), ); + const attemptCount = attemptAggregate.attemptCount; + const passedAttempts = passedAttemptAggregate.attemptCount; const judgeScores = caseResult.attempts.flatMap((attempt) => typeof attempt.judgeScore === "number" ? [attempt.judgeScore] : [], ); - const totalTokenUsage = - caseResult.attempts.reduce( - (sum, attempt) => { - if (!attempt.tokenUsage) { - return sum; - } - sum ??= { prompt: 0, completion: 0, total: 0 }; - sum.prompt += attempt.tokenUsage.prompt; - sum.completion += attempt.tokenUsage.completion; - sum.total += attempt.tokenUsage.total; - return sum; - }, - null, - ); return { id: caseResult.id, @@ -300,20 +344,23 @@ function toHistoryRecord(result: BenchmarkRunResult) { passedAttempts, passRate: attemptCount === 0 ? 0 : passedAttempts / attemptCount, averageDurationMs: - attemptCount === 0 ? 0 : totalDurationMs / attemptCount, + attemptCount === 0 + ? 0 + : attemptAggregate.durationTotal / attemptCount, + averagePassedDurationMs: averageDuration(passedAttemptAggregate), averageJudgeScore: judgeScores.length === 0 ? null : judgeScores.reduce((sum, score) => sum + score, 0) / judgeScores.length, averageTokenUsagePerAttempt: - attemptCount === 0 || !totalTokenUsage + attemptCount === 0 ? null - : { - prompt: totalTokenUsage.prompt / attemptCount, - completion: totalTokenUsage.completion / attemptCount, - total: totalTokenUsage.total / attemptCount, - }, + : averageTokenUsage(attemptAggregate, attemptCount), + averageTokenUsagePerPassedAttempt: averageTokenUsage( + passedAttemptAggregate, + passedAttempts, + ), }; }), }; diff --git a/ai_evals/core/types.ts b/ai_evals/core/types.ts index 2b42a0dfc5..27c2fcddac 100644 --- a/ai_evals/core/types.ts +++ b/ai_evals/core/types.ts @@ -110,7 +110,9 @@ export interface AppValidationSpec { export interface GlobalDraftRequirement { type: string; - path: string; + path?: string; + pathIncludes?: string[]; + pathStartsWith?: string; triggerKind?: string; language?: string; summaryIncludes?: string[]; @@ -153,6 +155,15 @@ export interface ToolCallArgumentRule { field: string; stringStartsWithAnyOf?: string[]; stringMustNotStartWithAnyOf?: string[]; + /** + * Case-insensitive "contains", existential over calls: at least one recorded + * call to `tool` must have `field` containing one of these substrings. Other + * calls to the same tool may do anything. Use instead of `stringStartsWithAnyOf` + * (which is universal over calls) when the meaningful token can appear anywhere + * in the value and the model may make additional, unrelated calls to the same + * tool — e.g. SQL where a mutation is mixed with verification SELECTs. + */ + stringIncludesAnyOf?: string[]; } export interface ToolValidationSpec { @@ -324,8 +335,11 @@ export interface BenchmarkRunResult { passedAttempts: number; passRate: number; averageDurationMs: number; + averagePassedDurationMs?: number | null; totalTokenUsage?: BenchmarkTokenUsage | null; + totalPassedTokenUsage?: BenchmarkTokenUsage | null; averageTokenUsagePerAttempt?: BenchmarkTokenUsage | null; + averageTokenUsagePerPassedAttempt?: BenchmarkTokenUsage | null; artifactsPath?: string | null; cases: BenchmarkCaseResult[]; } diff --git a/ai_evals/core/validators.test.ts b/ai_evals/core/validators.test.ts index d2a6e954bb..7f0e841366 100644 --- a/ai_evals/core/validators.test.ts +++ b/ai_evals/core/validators.test.ts @@ -140,6 +140,111 @@ describe("validateToolExpectations", () => { details: "tools used: write_script, deploy_workspace_item", }); }); + + it("accepts a stringIncludesAnyOf substring regardless of case or position", () => { + const checks = validateToolExpectations({ + run: { + success: true, + actual: {}, + assistantMessageCount: 1, + toolCallCount: 1, + toolsUsed: ["exec_datatable_sql"], + toolCallDetails: [ + { + name: "exec_datatable_sql", + arguments: { + sql: "WITH recent AS (SELECT * FROM orders) SELECT count(*) FROM recent", + }, + }, + ], + skillsInvoked: [], + }, + toolExpect: { + requiredToolsUsed: ["exec_datatable_sql"], + toolCallArgs: [ + { + tool: "exec_datatable_sql", + field: "sql", + stringIncludesAnyOf: ["select"], + }, + ], + }, + }); + + expect(checks.every((check) => check.passed)).toBe(true); + }); + + it("accepts stringIncludesAnyOf when only one of several calls matches", () => { + // Existential: a mutation mixed with verification SELECTs still passes. + const checks = validateToolExpectations({ + run: { + success: true, + actual: {}, + assistantMessageCount: 1, + toolCallCount: 2, + toolsUsed: ["exec_datatable_sql"], + toolCallDetails: [ + { + name: "exec_datatable_sql", + arguments: { sql: "UPDATE orders SET status = 'shipped' WHERE id = 2" }, + }, + { + name: "exec_datatable_sql", + arguments: { sql: "SELECT * FROM orders WHERE id = 2" }, + }, + ], + skillsInvoked: [], + }, + toolExpect: { + toolCallArgs: [ + { + tool: "exec_datatable_sql", + field: "sql", + stringIncludesAnyOf: ["insert into", "update"], + }, + ], + }, + }); + + expect(checks.every((check) => check.passed)).toBe(true); + }); + + it("rejects stringIncludesAnyOf when no call matches any substring", () => { + const checks = validateToolExpectations({ + run: { + success: true, + actual: {}, + assistantMessageCount: 1, + toolCallCount: 1, + toolsUsed: ["exec_datatable_sql"], + toolCallDetails: [ + { + name: "exec_datatable_sql", + arguments: { + sql: "DROP TABLE orders", + }, + }, + ], + skillsInvoked: [], + }, + toolExpect: { + toolCallArgs: [ + { + tool: "exec_datatable_sql", + field: "sql", + stringIncludesAnyOf: ["insert into", "update"], + }, + ], + }, + }); + + expect(checks).toContainEqual({ + name: "exec_datatable_sql.sql includes a required substring", + passed: false, + details: + 'accepted substrings: insert into, update; values: "DROP TABLE orders"', + }); + }); }); describe("validateGlobalState", () => { @@ -195,6 +300,69 @@ describe("validateGlobalState", () => { }); }); + it("accepts a required script draft without an exact path", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/team_tools/friendly_greeting", + language: "bun", + summary: "Friendly greeting helper", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + validate: { + draftCountExactly: 1, + requiredDrafts: [ + { + type: "script", + pathIncludes: ["greeting"], + language: "bun", + summaryIncludes: ["Friendly"], + valueIncludes: ["Hello"], + }, + ], + }, + }); + + expect(checks.every((check) => check.passed)).toBe(true); + }); + + it("reports flexible global draft path filters when no draft matches", () => { + const checks = validateGlobalState({ + actual: { + drafts: [ + { + type: "script", + path: "f/team_tools/friendly_greeting", + language: "bun", + value: + "export async function main(name: string) {\n return `Hello, ${name}!`\n}\n", + isDraft: true, + }, + ], + }, + validate: { + requiredDrafts: [ + { + type: "script", + pathIncludes: ["invoice"], + }, + ], + }, + }); + + expect(checks).toContainEqual({ + name: "global includes script draft (path includes invoice)", + passed: false, + details: "drafts: script:f/team_tools/friendly_greeting", + }); + }); + it("does not require a TypeScript entrypoint for non-TypeScript script drafts", () => { const checks = validateGlobalState({ actual: { diff --git a/ai_evals/core/validators.ts b/ai_evals/core/validators.ts index 4f59368113..e7a7641c00 100644 --- a/ai_evals/core/validators.ts +++ b/ai_evals/core/validators.ts @@ -222,6 +222,25 @@ export function validateToolExpectations(input: { ) ); } + + if (rule.stringIncludesAnyOf && rule.stringIncludesAnyOf.length > 0) { + // Existential: at least one call must contain one of the substrings. + // Other calls to the same tool may do anything — this suits SQL, where a + // model mixes the requested statement (e.g. an UPDATE) with verification + // SELECTs that would otherwise fail an "all calls" check. + const needles = rule.stringIncludesAnyOf.map((needle) => needle.toLowerCase()); + const hasMatch = values.some( + (value) => + typeof value === "string" && needles.some((needle) => value.toLowerCase().includes(needle)) + ); + checks.push( + check( + `${rule.tool}.${rule.field} includes a required substring`, + hasMatch, + `accepted substrings: ${rule.stringIncludesAnyOf.join(", ")}; values: ${summarizeToolValues(values)}` + ) + ); + } } return checks; @@ -315,10 +334,11 @@ export function validateGlobalState(input: { } for (const required of validate.requiredDrafts ?? []) { - const draft = findGlobalDraft(drafts, required.type, required.path, required.triggerKind); + const requirementLabel = formatGlobalDraftRequirement(required); + const draft = findGlobalDraft(drafts, required); checks.push( check( - `global includes ${required.type} draft ${required.path}`, + `global includes ${requirementLabel}`, Boolean(draft), summarizeGlobalDrafts(drafts) ) @@ -330,7 +350,7 @@ export function validateGlobalState(input: { if (required.language !== undefined) { checks.push( check( - `${required.type} draft ${required.path} uses ${required.language}`, + `${requirementLabel} uses ${required.language}`, draft.language === required.language, `language=${draft.language ?? "(none)"}` ) @@ -340,7 +360,7 @@ export function validateGlobalState(input: { for (const snippet of required.summaryIncludes ?? []) { checks.push( check( - `${required.type} draft ${required.path} summary includes '${snippet}'`, + `${requirementLabel} summary includes '${snippet}'`, normalizeText(draft.summary ?? "").includes(normalizeText(snippet)), `summary=${draft.summary ?? ""}` ) @@ -351,7 +371,7 @@ export function validateGlobalState(input: { for (const snippet of required.valueIncludes ?? []) { checks.push( check( - `${required.type} draft ${required.path} value includes '${snippet}'`, + `${requirementLabel} value includes '${snippet}'`, normalizeText(valueText).includes(normalizeText(snippet)), truncateForDetails(valueText) ) @@ -361,7 +381,7 @@ export function validateGlobalState(input: { for (const snippet of required.valueExcludes ?? []) { checks.push( check( - `${required.type} draft ${required.path} value excludes '${snippet}'`, + `${requirementLabel} value excludes '${snippet}'`, !normalizeText(valueText).includes(normalizeText(snippet)), truncateForDetails(valueText) ) @@ -373,7 +393,7 @@ export function validateGlobalState(input: { checks.push( check( `global does not include ${forbidden.type} draft ${forbidden.path}`, - !findGlobalDraft(drafts, forbidden.type, forbidden.path, forbidden.triggerKind), + !findGlobalDraft(drafts, forbidden), summarizeGlobalDrafts(drafts) ) ); @@ -615,16 +635,100 @@ function summarizeProblems(problems: string[], limit = 5): string | undefined { function findGlobalDraft( drafts: GlobalDraft[], - type: string, - path: string, - triggerKind?: string + requirement: { + type: string; + path?: string; + pathIncludes?: string[]; + pathStartsWith?: string; + triggerKind?: string; + summaryIncludes?: string[]; + valueIncludes?: string[]; + valueExcludes?: string[]; + } ): GlobalDraft | undefined { - return drafts.find( - (draft) => - draft.type === type && - draft.path === path && - (triggerKind === undefined || draft.triggerKind === triggerKind) + const candidates = drafts.filter((draft) => + globalDraftMatchesLocator(draft, requirement) ); + return ( + candidates.find((draft) => globalDraftMatchesContent(draft, requirement)) ?? + candidates[0] + ); +} + +function globalDraftMatchesLocator( + draft: GlobalDraft, + requirement: { + type: string; + path?: string; + pathIncludes?: string[]; + pathStartsWith?: string; + triggerKind?: string; + } +): boolean { + return ( + draft.type === requirement.type && + (requirement.path === undefined || draft.path === requirement.path) && + (requirement.pathStartsWith === undefined || + draft.path.startsWith(requirement.pathStartsWith)) && + (requirement.pathIncludes ?? []).every((snippet) => + normalizeText(draft.path).includes(normalizeText(snippet)) + ) && + (requirement.triggerKind === undefined || + draft.triggerKind === requirement.triggerKind) + ); +} + +function globalDraftMatchesContent( + draft: GlobalDraft, + requirement: { + summaryIncludes?: string[]; + valueIncludes?: string[]; + valueExcludes?: string[]; + } +): boolean { + const summary = normalizeText(draft.summary ?? ""); + const value = normalizeText(stringifyGlobalDraftValue(draft.value)); + return ( + (requirement.summaryIncludes ?? []).every((snippet) => + summary.includes(normalizeText(snippet)) + ) && + (requirement.valueIncludes ?? []).every((snippet) => + value.includes(normalizeText(snippet)) + ) && + (requirement.valueExcludes ?? []).every( + (snippet) => !value.includes(normalizeText(snippet)) + ) + ); +} + +function formatGlobalDraftRequirement( + requirement: { + type: string; + path?: string; + pathIncludes?: string[]; + pathStartsWith?: string; + triggerKind?: string; + } +): string { + const typeLabel = + requirement.triggerKind === undefined + ? requirement.type + : `${requirement.triggerKind} ${requirement.type}`; + if (requirement.path !== undefined) { + return `${typeLabel} draft ${requirement.path}`; + } + + const filters = [ + ...(requirement.pathStartsWith === undefined + ? [] + : [`path starts with ${requirement.pathStartsWith}`]), + ...(requirement.pathIncludes ?? []).map( + (snippet) => `path includes ${snippet}` + ), + ]; + return filters.length === 0 + ? `${typeLabel} draft` + : `${typeLabel} draft (${filters.join(", ")})`; } function summarizeGlobalDrafts(drafts: GlobalDraft[]): string { diff --git a/ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json b/ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json new file mode 100644 index 0000000000..98538a1ccb --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/current_greeting_live_script.json @@ -0,0 +1,66 @@ +{ + "workspace": { + "scripts": [ + { + "path": "f/evals/global/format_greeting", + "summary": "Format a deployed greeting", + "description": "Returns a plain greeting for a provided name.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + }, + "content": "export async function main(name: string) {\n return `Hello, ${name}`\n}\n" + }, + { + "path": "f/evals/global/format_greeting_archive", + "summary": "Archived greeting formatter", + "description": "Older greeting formatter kept for reference.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + }, + "content": "export async function main(name: string) {\n return `Hi, ${name}`\n}\n" + } + ] + }, + "liveEditorDrafts": [ + { + "type": "script", + "storagePath": "f/evals/global/current_greeting", + "effectivePath": "f/evals/global/current_greeting", + "value": { + "path": "f/evals/global/current_greeting", + "summary": "Open greeting formatter", + "description": "Formats a greeting in the live editor.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"] + }, + "content": "export async function main(name: string) {\n return `Hello, ${name}`\n}\n", + "is_template": false, + "kind": "script" + } + } + ] +} diff --git a/ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json b/ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json new file mode 100644 index 0000000000..5c2ffcb0f0 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/current_invoice_live_flow.json @@ -0,0 +1,118 @@ +{ + "workspace": { + "flows": [ + { + "path": "f/evals/global/process_invoice", + "summary": "Deployed invoice processor", + "description": "Calculates invoice totals from a subtotal.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "subtotal": { + "type": "number" + } + }, + "required": ["subtotal"] + }, + "value": { + "modules": [ + { + "id": "calculate_total", + "summary": "Calculate total from subtotal", + "value": { + "type": "rawscript", + "language": "bun", + "content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n", + "input_transforms": { + "subtotal": { + "type": "javascript", + "expr": "flow_input.subtotal" + } + } + } + } + ] + } + }, + { + "path": "f/evals/global/process_refund", + "summary": "Refund processor", + "description": "Calculates refund totals.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "subtotal": { + "type": "number" + } + }, + "required": ["subtotal"] + }, + "value": { + "modules": [ + { + "id": "calculate_total", + "summary": "Calculate refund total", + "value": { + "type": "rawscript", + "language": "bun", + "content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n", + "input_transforms": { + "subtotal": { + "type": "javascript", + "expr": "flow_input.subtotal" + } + } + } + } + ] + } + } + ] + }, + "liveEditorDrafts": [ + { + "type": "flow", + "storagePath": "f/evals/global/current_invoice_flow", + "effectivePath": "f/evals/global/current_invoice_flow", + "value": { + "path": "f/evals/global/current_invoice_flow", + "summary": "Open invoice processor", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "subtotal": { + "type": "number" + } + }, + "required": ["subtotal"] + }, + "value": { + "modules": [ + { + "id": "calculate_total", + "summary": "Calculate total from subtotal", + "value": { + "type": "rawscript", + "language": "bun", + "content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n", + "input_transforms": { + "subtotal": { + "type": "javascript", + "expr": "flow_input.subtotal" + } + } + } + } + ] + }, + "edited_by": "", + "edited_at": "", + "archived": false, + "extra_perms": {} + } + } + ] +} diff --git a/ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json b/ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json new file mode 100644 index 0000000000..23300ca41e --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/datatable_orders_seed.json @@ -0,0 +1,39 @@ +{ + "workspace": { + "datatables": [ + { + "datatable_name": "main", + "schemas": { + "public": { + "orders": { + "columns": { + "id": "int4", + "customer_id": "int4", + "total": "numeric", + "status": "text", + "created_at": "timestamptz" + }, + "rows": [ + { "id": 1, "customer_id": 1, "total": 42.5, "status": "shipped", "created_at": "2026-05-01T10:00:00Z" }, + { "id": 2, "customer_id": 2, "total": 19.99, "status": "pending", "created_at": "2026-05-02T11:30:00Z" }, + { "id": 3, "customer_id": 1, "total": 88, "status": "shipped", "created_at": "2026-05-03T09:15:00Z" } + ] + }, + "customers": { + "columns": { + "id": "int4", + "name": "text", + "email": "text", + "tier": "text" + }, + "rows": [ + { "id": 1, "name": "Alice", "email": "alice@example.com", "tier": "gold" }, + { "id": 2, "name": "Bob", "email": "bob@example.com", "tier": "silver" } + ] + } + } + } + } + ] + } +} diff --git a/ai_evals/fixtures/frontend/global/initial/process_invoice_flow.json b/ai_evals/fixtures/frontend/global/initial/process_invoice_flow.json new file mode 100644 index 0000000000..b9b4c675c5 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/process_invoice_flow.json @@ -0,0 +1,40 @@ +{ + "workspace": { + "flows": [ + { + "path": "f/evals/global/process_invoice", + "summary": "Process an invoice subtotal", + "description": "Calculates invoice totals from a subtotal.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "subtotal": { + "type": "number" + } + }, + "required": ["subtotal"] + }, + "value": { + "modules": [ + { + "id": "calculate_total", + "summary": "Calculate total from subtotal", + "value": { + "type": "rawscript", + "language": "bun", + "content": "export async function main(subtotal: number) {\n return { subtotal, total: subtotal }\n}\n", + "input_transforms": { + "subtotal": { + "type": "javascript", + "expr": "flow_input.subtotal" + } + } + } + } + ] + } + } + ] + } +} diff --git a/ai_evals/fixtures/frontend/global/initial/report_digest_script.json b/ai_evals/fixtures/frontend/global/initial/report_digest_script.json new file mode 100644 index 0000000000..832b06712f --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/report_digest_script.json @@ -0,0 +1,23 @@ +{ + "workspace": { + "scripts": [ + { + "path": "f/evals/global/send_report_digest", + "summary": "Build and send the eval report digest", + "description": "Returns a dry-run summary for eval report digest notifications.", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "dry_run": { + "type": "boolean" + } + }, + "required": ["dry_run"] + }, + "content": "export async function main(dry_run: boolean) {\n return { dry_run, sent: !dry_run, message: dry_run ? 'Preview digest' : 'Digest sent' }\n}\n" + } + ] + } +} diff --git a/ai_evals/modes/global.ts b/ai_evals/modes/global.ts index d68df9f5f8..f3cbf6fd86 100644 --- a/ai_evals/modes/global.ts +++ b/ai_evals/modes/global.ts @@ -1,5 +1,8 @@ import { readFile } from "node:fs/promises"; -import { runGlobalEval } from "../adapters/frontend/core/global/globalEvalRunner"; +import { + runGlobalEval, + type GlobalLiveEditorDraftFixture, +} from "../adapters/frontend/core/global/globalEvalRunner"; import type { BenchmarkWorkspaceRunnables } from "../adapters/frontend/mockBackend"; import type { FrontendEvalModelConfig } from "../core/models"; import type { BenchmarkArtifactFile, GlobalValidationSpec, ModeRunner } from "../core/types"; @@ -9,6 +12,7 @@ import { getFrontendApiKey } from "./frontendCommon"; export interface GlobalInitialFixture { workspace?: BenchmarkWorkspaceRunnables; + liveEditorDrafts?: GlobalLiveEditorDraftFixture[]; } export function createGlobalModeRunner( @@ -31,6 +35,7 @@ export function createGlobalModeRunner( getFrontendApiKey(modelConfig.provider), { workspaceFixtures: initial?.workspace, + liveEditorDrafts: initial?.liveEditorDrafts, maxIterations: context.evalCase?.runtime?.maxTurns, provider: modelConfig.provider, model: modelConfig.model, @@ -73,6 +78,7 @@ async function loadGlobalInitialFixture(path: string): Promise'catalog'->>'resource_path' AS dbname\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(\n CASE WHEN jsonb_typeof(ws.ducklake->'ducklakes') = 'object'\n THEN ws.ducklake->'ducklakes'\n ELSE '{}'::jsonb END\n ) AS dl(k, entry)\n WHERE entry->'catalog'->>'resource_type' = 'instance'\n AND entry->'catalog'->>'resource_path' IS NOT NULL\n UNION ALL\n SELECT ws.workspace_id AS \"workspace_id!\", entry->'database'->>'resource_path' AS dbname\n FROM workspace_settings ws\n CROSS JOIN LATERAL jsonb_each(\n CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object'\n THEN ws.datatable->'datatables'\n ELSE '{}'::jsonb END\n ) AS dt(k, entry)\n WHERE entry->'database'->>'resource_type' = 'instance'\n AND entry->'database'->>'resource_path' IS NOT NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "dbname", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null + ] + }, + "hash": "815d96aea4681490582b08630a30a168cc1191acaab96bed6a016c437059c2cd" +} diff --git a/backend/.sqlx/query-8597cd40f80e69edbf1bc7d7402baca32e33e871be454acb5175c11361fe1b0a.json b/backend/.sqlx/query-8597cd40f80e69edbf1bc7d7402baca32e33e871be454acb5175c11361fe1b0a.json new file mode 100644 index 0000000000..fd82867507 --- /dev/null +++ b/backend/.sqlx/query-8597cd40f80e69edbf1bc7d7402baca32e33e871be454acb5175c11361fe1b0a.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM background_task_state\n WHERE name LIKE $1\n AND updated_at < NOW() - INTERVAL '7 days'", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "8597cd40f80e69edbf1bc7d7402baca32e33e871be454acb5175c11361fe1b0a" +} diff --git a/backend/.sqlx/query-8aae160c589adf02e20b7e6ba860b66fb08f156776b96b7f3701aed330a7d000.json b/backend/.sqlx/query-8aae160c589adf02e20b7e6ba860b66fb08f156776b96b7f3701aed330a7d000.json new file mode 100644 index 0000000000..d05c5b11af --- /dev/null +++ b/backend/.sqlx/query-8aae160c589adf02e20b7e6ba860b66fb08f156776b96b7f3701aed330a7d000.json @@ -0,0 +1,101 @@ +{ + "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", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "operator_only", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "is_workspace_admin", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "login_type", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "verified!", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "super_admin!", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "devops!", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "company", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "first_time_user!", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "role_source!", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "disabled!", + "type_info": "Bool" + }, + { + "ordinal": 13, + "name": "workspace_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "8aae160c589adf02e20b7e6ba860b66fb08f156776b96b7f3701aed330a7d000" +} diff --git a/backend/.sqlx/query-8e8933fc6648a88dc35cd81559a31d10678d6c68fc920c876914e71324d5e460.json b/backend/.sqlx/query-8e8933fc6648a88dc35cd81559a31d10678d6c68fc920c876914e71324d5e460.json new file mode 100644 index 0000000000..2ad0791d3c --- /dev/null +++ b/backend/.sqlx/query-8e8933fc6648a88dc35cd81559a31d10678d6c68fc920c876914e71324d5e460.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = $1 AND workspace_id = $2 AND tag = ANY($3))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text", + "TextArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "8e8933fc6648a88dc35cd81559a31d10678d6c68fc920c876914e71324d5e460" +} diff --git a/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json b/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json new file mode 100644 index 0000000000..f0c91daf16 --- /dev/null +++ b/backend/.sqlx/query-afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE app SET versions = array_append(versions, $1::bigint) WHERE path = $2 AND workspace_id = $3 AND versions[array_upper(versions, 1)] = $1::bigint", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "afb0762c88d9232b79090f2e5966e78437a5e4d3b5e2341ec5f7725a28870270" +} \ No newline at end of file diff --git a/backend/.sqlx/query-b876f26ce90e30c3510eacddb03d9dd26fac05d18183f2d623ac91ea3876dd5c.json b/backend/.sqlx/query-b876f26ce90e30c3510eacddb03d9dd26fac05d18183f2d623ac91ea3876dd5c.json new file mode 100644 index 0000000000..e1a3640463 --- /dev/null +++ b/backend/.sqlx/query-b876f26ce90e30c3510eacddb03d9dd26fac05d18183f2d623ac91ea3876dd5c.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account)\n VALUES ($1, $2, $3, true, '', '{}'::jsonb, NULL)\n ON CONFLICT (workspace_id, path) DO UPDATE SET value = EXCLUDED.value\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "b876f26ce90e30c3510eacddb03d9dd26fac05d18183f2d623ac91ea3876dd5c" +} diff --git a/backend/.sqlx/query-bce66e3f3fecda6c226556f2f3cff27702b8fc8d2850fdb262b9a2a77b468646.json b/backend/.sqlx/query-bce66e3f3fecda6c226556f2f3cff27702b8fc8d2850fdb262b9a2a77b468646.json new file mode 100644 index 0000000000..ab4532a1f5 --- /dev/null +++ b/backend/.sqlx/query-bce66e3f3fecda6c226556f2f3cff27702b8fc8d2850fdb262b9a2a77b468646.json @@ -0,0 +1,101 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT email as \"email!\", login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, NULL::bool as operator_only, NULL::bool as is_workspace_admin, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id FROM password\n UNION ALL\n SELECT email as \"email!\", '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, operator as operator_only, is_admin as is_workspace_admin, 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, \"email!\"\n LIMIT $1 OFFSET $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email!", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "login_type", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "verified!", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "super_admin!", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "devops!", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "company", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "operator_only", + "type_info": "Bool" + }, + { + "ordinal": 9, + "name": "is_workspace_admin", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "first_time_user!", + "type_info": "Bool" + }, + { + "ordinal": 11, + "name": "role_source!", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "disabled!", + "type_info": "Bool" + }, + { + "ordinal": 13, + "name": "workspace_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "bce66e3f3fecda6c226556f2f3cff27702b8fc8d2850fdb262b9a2a77b468646" +} diff --git a/backend/.sqlx/query-bf601a919de299e44e6e418b2e711e24909fc206b7fd48541483e6316fe002d5.json b/backend/.sqlx/query-bf601a919de299e44e6e418b2e711e24909fc206b7fd48541483e6316fe002d5.json new file mode 100644 index 0000000000..6ca4cbb60f --- /dev/null +++ b/backend/.sqlx/query-bf601a919de299e44e6e418b2e711e24909fc206b7fd48541483e6316fe002d5.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT j.id, j.args\n FROM v2_job j\n JOIN v2_job_queue q ON j.id = q.id\n WHERE j.runnable_path = $1\n AND j.kind = 'deploymentcallback'\n AND j.workspace_id = 'test-workspace'\n ORDER BY j.created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "args", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "bf601a919de299e44e6e418b2e711e24909fc206b7fd48541483e6316fe002d5" +} diff --git a/backend/.sqlx/query-c533691be8136c5ed6835c2fcdb016c257a5e4ee271d221fa961c24bd119d98e.json b/backend/.sqlx/query-c533691be8136c5ed6835c2fcdb016c257a5e4ee271d221fa961c24bd119d98e.json new file mode 100644 index 0000000000..a0cb83bf14 --- /dev/null +++ b/backend/.sqlx/query-c533691be8136c5ed6835c2fcdb016c257a5e4ee271d221fa961c24bd119d98e.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT permissioned_as_email FROM v2_job WHERE id = $1 AND workspace_id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "permissioned_as_email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c533691be8136c5ed6835c2fcdb016c257a5e4ee271d221fa961c24bd119d98e" +} diff --git a/backend/.sqlx/query-ca5bb402834502432f3d7260fdd5b9fb568a4c77e2a91f55575a93461d5a7f50.json b/backend/.sqlx/query-ca5bb402834502432f3d7260fdd5b9fb568a4c77e2a91f55575a93461d5a7f50.json new file mode 100644 index 0000000000..a84b022930 --- /dev/null +++ b/backend/.sqlx/query-ca5bb402834502432f3d7260fdd5b9fb568a4c77e2a91f55575a93461d5a7f50.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = ANY($1) AND workspace_id = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "UuidArray", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ca5bb402834502432f3d7260fdd5b9fb568a4c77e2a91f55575a93461d5a7f50" +} diff --git a/backend/.sqlx/query-cce5e3e639faed8e42574730cc66f0322a83c01cc465742f54a21f8fe5f4f037.json b/backend/.sqlx/query-cce5e3e639faed8e42574730cc66f0322a83c01cc465742f54a21f8fe5f4f037.json new file mode 100644 index 0000000000..29f31c62eb --- /dev/null +++ b/backend/.sqlx/query-cce5e3e639faed8e42574730cc66f0322a83c01cc465742f54a21f8fe5f4f037.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT tag, script_lang AS \"script_lang: ScriptLang\" FROM v2_job WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "tag", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "script_lang: ScriptLang", + "type_info": { + "Custom": { + "name": "script_lang", + "kind": { + "Enum": [ + "python3", + "deno", + "go", + "bash", + "postgresql", + "nativets", + "bun", + "mysql", + "bigquery", + "snowflake", + "graphql", + "powershell", + "mssql", + "php", + "bunnative", + "rust", + "ansible", + "csharp", + "oracledb", + "nu", + "java", + "duckdb", + "ruby", + "rlang" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + true + ] + }, + "hash": "cce5e3e639faed8e42574730cc66f0322a83c01cc465742f54a21f8fe5f4f037" +} diff --git a/backend/.sqlx/query-dd540bcb206d931eb19aa85059b6a64554a9a29ebc6064718072470e45a0ae28.json b/backend/.sqlx/query-dd540bcb206d931eb19aa85059b6a64554a9a29ebc6064718072470e45a0ae28.json new file mode 100644 index 0000000000..a6b14c7b96 --- /dev/null +++ b/backend/.sqlx/query-dd540bcb206d931eb19aa85059b6a64554a9a29ebc6064718072470e45a0ae28.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO usr\n (workspace_id, email, username, is_admin, operator, is_service_account)\n VALUES ($1, $2, $3, $4, $5, true)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Bool", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "dd540bcb206d931eb19aa85059b6a64554a9a29ebc6064718072470e45a0ae28" +} diff --git a/backend/.sqlx/query-e88e1009f5359e205a523a32e4e8e72605971a3f417cb2b032c4d43652aca056.json b/backend/.sqlx/query-e88e1009f5359e205a523a32e4e8e72605971a3f417cb2b032c4d43652aca056.json new file mode 100644 index 0000000000..3db3b0c977 --- /dev/null +++ b/backend/.sqlx/query-e88e1009f5359e205a523a32e4e8e72605971a3f417cb2b032c4d43652aca056.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT hash, content FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "hash", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "content", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "e88e1009f5359e205a523a32e4e8e72605971a3f417cb2b032c4d43652aca056" +} diff --git a/backend/.sqlx/query-f699cc3644aeb35a0588bbb3a6bf2dc0746d9f3a1bea104123188fe2921bc886.json b/backend/.sqlx/query-f699cc3644aeb35a0588bbb3a6bf2dc0746d9f3a1bea104123188fe2921bc886.json deleted file mode 100644 index d352d3d69d..0000000000 --- a/backend/.sqlx/query-f699cc3644aeb35a0588bbb3a6bf2dc0746d9f3a1bea104123188fe2921bc886.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE workspace_runnable_dependencies SET app_path = REGEXP_REPLACE(app_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\\1') WHERE app_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text" - ] - }, - "nullable": [] - }, - "hash": "f699cc3644aeb35a0588bbb3a6bf2dc0746d9f3a1bea104123188fe2921bc886" -} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 3593ddfd26..80b89abee7 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -620,7 +620,7 @@ checksum = "850b60ddcc664dcd848f8a2fa8436ab9336e051d6dd2b3f21f897dd8e9c24703" dependencies = [ "base64 0.22.1", "bytes", - "http 1.4.0", + "http 1.4.1", "rand 0.8.5", "reqwest 0.12.28", "serde", @@ -752,7 +752,7 @@ dependencies = [ "bytes", "fastrand", "hex", - "http 1.4.0", + "http 1.4.1", "ring 0.17.14", "time", "tokio", @@ -813,7 +813,7 @@ dependencies = [ "bytes", "bytes-utils", "fastrand", - "http 1.4.0", + "http 1.4.1", "http-body 1.0.1", "percent-encoding", "pin-project-lite", @@ -840,7 +840,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.1", "regex-lite", "tracing", ] @@ -915,7 +915,7 @@ dependencies = [ "aws-types", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.1", "regex-lite", "tracing", "url", @@ -940,7 +940,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.1", "regex-lite", "tracing", ] @@ -986,7 +986,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.1", "regex-lite", "tracing", ] @@ -1010,7 +1010,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.1", "regex-lite", "tracing", ] @@ -1054,7 +1054,7 @@ dependencies = [ "hex", "hmac", "http 0.2.12", - "http 1.4.0", + "http 1.4.1", "percent-encoding", "sha2 0.10.9", "time", @@ -1097,7 +1097,7 @@ dependencies = [ "futures-core", "futures-util", "http 0.2.12", - "http 1.4.0", + "http 1.4.1", "http-body 0.4.6", "percent-encoding", "pin-project-lite", @@ -1117,7 +1117,7 @@ dependencies = [ "bytes-utils", "futures-core", "futures-util", - "http 1.4.0", + "http 1.4.1", "http-body 1.0.1", "http-body-util", "percent-encoding", @@ -1138,17 +1138,17 @@ dependencies = [ "h2 0.3.27", "h2 0.4.14", "http 0.2.12", - "http 1.4.0", + "http 1.4.1", "http-body 0.4.6", "hyper 0.14.32", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-rustls 0.24.2", "hyper-rustls 0.27.9", "hyper-util", "pin-project-lite", "rustls 0.21.12", "rustls 0.23.35", - "rustls-native-certs 0.8.3", + "rustls-native-certs 0.8.4", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", @@ -1208,7 +1208,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.1", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -1228,7 +1228,7 @@ dependencies = [ "aws-smithy-types", "bytes", "http 0.2.12", - "http 1.4.0", + "http 1.4.1", "pin-project-lite", "tokio", "tracing", @@ -1246,7 +1246,7 @@ dependencies = [ "bytes-utils", "futures-core", "http 0.2.12", - "http 1.4.0", + "http 1.4.1", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -1304,7 +1304,7 @@ dependencies = [ "axum-core 0.4.5", "bytes", "futures-util", - "http 1.4.0", + "http 1.4.1", "http-body 1.0.1", "http-body-util", "itoa", @@ -1332,10 +1332,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http 1.4.0", + "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-util", "itoa", "matchit 0.8.4", @@ -1365,7 +1365,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.4.0", + "http 1.4.1", "http-body 1.0.1", "http-body-util", "mime", @@ -1384,7 +1384,7 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.4.1", "http-body 1.0.1", "http-body-util", "mime", @@ -1521,7 +1521,7 @@ version = "0.71.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cexpr", "clang-sys", "itertools 0.13.0", @@ -1531,7 +1531,7 @@ dependencies = [ "quote", "regex", "rustc-hash 2.1.2", - "shlex", + "shlex 1.3.0", "syn 2.0.117", ] @@ -1541,7 +1541,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cexpr", "clang-sys", "itertools 0.13.0", @@ -1551,7 +1551,7 @@ dependencies = [ "quote", "regex", "rustc-hash 2.1.2", - "shlex", + "shlex 1.3.0", "syn 2.0.117", ] @@ -1584,9 +1584,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -1682,9 +1682,9 @@ dependencies = [ "futures-core", "futures-util", "hex", - "http 1.4.0", + "http 1.4.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-named-pipe", "hyper-util", "hyperlocal", @@ -1786,13 +1786,13 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", - "brotli-decompressor 5.0.0", + "brotli-decompressor 5.0.1", ] [[package]] @@ -1807,9 +1807,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1837,9 +1837,9 @@ dependencies = [ [[package]] name = "btoi" -version = "0.4.3" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd6407f73a9b8b6162d8a2ef999fe6afd7cc15902ebf42c5cd296addf17e0ad" +checksum = "3b5ab9db53bcda568284df0fd39f6eac24ad6f7ba7ff1168b9e76eba6576b976" dependencies = [ "num-traits", ] @@ -1924,9 +1924,9 @@ dependencies = [ [[package]] name = "bytes-str" -version = "0.2.7" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c60b5ce37e0b883c37eb89f79a1e26fbe9c1081945d024eee93e8d91a7e18b3" +checksum = "577d2bf5650f8554d5a372af5ac93535110a0fc75b3e702bb853369febf227c2" dependencies = [ "bytes", "serde", @@ -2056,14 +2056,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -2112,9 +2112,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -2202,7 +2202,7 @@ version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -3597,9 +3597,9 @@ dependencies = [ "error_reporter", "h2 0.4.14", "hickory-resolver", - "http 1.4.0", + "http 1.4.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-rustls 0.27.9", "hyper-util", "ipnet", @@ -3731,8 +3731,8 @@ dependencies = [ "proc-macro2", "quote", "stringcase", - "strum 0.27.2", - "strum_macros 0.27.2", + "strum", + "strum_macros", "syn 2.0.117", "thiserror 2.0.18", ] @@ -3802,7 +3802,7 @@ dependencies = [ "deno_error 0.6.1", "deno_tls", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-rustls 0.27.9", "hyper-util", "log", @@ -4155,9 +4155,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -4364,7 +4364,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -4585,7 +4585,7 @@ version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "rustc_version 0.4.1", ] @@ -4984,9 +4984,9 @@ dependencies = [ [[package]] name = "generator" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" dependencies = [ "cc", "cfg-if", @@ -5175,7 +5175,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de13e62d7e0ffc3eb40a0113ddf753cf6ec741be739164442b08893db4f9bfca" dependencies = [ "google-cloud-token", - "http 1.4.0", + "http 1.4.1", "thiserror 1.0.69", "tokio", "tokio-retry2", @@ -5236,13 +5236,13 @@ dependencies = [ [[package]] name = "gosyn" -version = "0.2.9" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb37859fda6792e95231aef1c5838f4043ec0ee352d8313421e311c606df612" +checksum = "c99c1502d84229dc7ddb6af755f40ebe80e7e932fa78ecef979cedcf9999ba93" dependencies = [ "anyhow", - "strum 0.25.0", - "thiserror 1.0.69", + "strum", + "thiserror 2.0.18", "unic-ucd-category", ] @@ -5296,7 +5296,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http 1.4.0", + "http 1.4.1", "indexmap 2.14.0", "slab", "tokio", @@ -5367,6 +5367,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashify" @@ -5398,7 +5403,7 @@ dependencies = [ "base64 0.22.1", "bytes", "headers-core", - "http 1.4.0", + "http 1.4.1", "httpdate", "mime", "sha1", @@ -5410,15 +5415,9 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http 1.4.0", + "http 1.4.1", ] -[[package]] -name = "heck" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" - [[package]] name = "heck" version = "0.5.0" @@ -5445,7 +5444,7 @@ checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" dependencies = [ "dirs 6.0.0", "futures", - "http 1.4.0", + "http 1.4.1", "indicatif", "libc", "log", @@ -5579,9 +5578,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -5605,7 +5604,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http 1.4.1", ] [[package]] @@ -5616,7 +5615,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.4.1", "http-body 1.0.1", "pin-project-lite", ] @@ -5642,9 +5641,9 @@ dependencies = [ "async-compression", "bstr", "futures", - "http 1.4.0", + "http 1.4.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-rustls 0.26.0", "hyper-tls", "hyper-tungstenite", @@ -5694,16 +5693,16 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "h2 0.4.14", - "http 1.4.0", + "http 1.4.1", "http-body 1.0.1", "httparse", "httpdate", @@ -5723,8 +5722,8 @@ dependencies = [ "bytes", "futures-util", "headers", - "http 1.4.0", - "hyper 1.9.0", + "http 1.4.1", + "hyper 1.10.1", "hyper-rustls 0.27.9", "hyper-tls", "hyper-util", @@ -5744,7 +5743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" dependencies = [ "hex", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-util", "pin-project-lite", "tokio", @@ -5775,8 +5774,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0bea761b46ae2b24eb4aef630d8d1c398157b6fc29e6350ecf090a0b70c952c" dependencies = [ "futures-util", - "http 1.4.0", - "hyper 1.9.0", + "http 1.4.1", + "hyper 1.10.1", "hyper-util", "log", "rustls 0.22.4", @@ -5793,12 +5792,12 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.0", - "hyper 1.9.0", + "http 1.4.1", + "hyper 1.10.1", "hyper-util", "log", "rustls 0.23.35", - "rustls-native-certs 0.8.3", + "rustls-native-certs 0.8.4", "tokio", "tokio-rustls 0.26.4", "tower-service", @@ -5811,7 +5810,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "hyper 1.9.0", + "hyper 1.10.1", "hyper-util", "pin-project-lite", "tokio", @@ -5826,7 +5825,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-util", "native-tls", "tokio", @@ -5841,7 +5840,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a343d17fe7885302ed7252767dc7bb83609a874b6ff581142241ec4b73957ad" dependencies = [ "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-util", "pin-project-lite", "tokio", @@ -5859,14 +5858,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.0", + "http 1.4.1", "http-body 1.0.1", - "hyper 1.9.0", + "hyper 1.10.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.6.4", "system-configuration", "tokio", "tower-service", @@ -5882,7 +5881,7 @@ checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" dependencies = [ "hex", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-util", "pin-project-lite", "tokio", @@ -6100,7 +6099,7 @@ version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d09b98f7eace8982db770e4408e7470b028ce513ac28fecdc6bf4c30fe92b62" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "libc", ] @@ -6111,7 +6110,7 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" dependencies = [ - "socket2 0.6.3", + "socket2 0.6.4", "widestring", "windows-registry", "windows-result 0.4.1", @@ -6139,7 +6138,7 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -6373,6 +6372,16 @@ version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37" +[[package]] +name = "kstat-rs" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27964e4632377753acb0898ce6f28770d50cbca1339200ae63d700cff97b5c2b" +dependencies = [ + "libc", + "thiserror 1.0.69", +] + [[package]] name = "kube" version = "1.1.0" @@ -6398,10 +6407,10 @@ dependencies = [ "either", "futures", "home", - "http 1.4.0", + "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-http-proxy", "hyper-rustls 0.27.9", "hyper-timeout", @@ -6432,7 +6441,7 @@ dependencies = [ "chrono", "derive_more 2.1.1", "form_urlencoded", - "http 1.4.0", + "http 1.4.1", "json-patch", "k8s-openapi", "schemars 0.8.22", @@ -6588,7 +6597,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e668df13f2e97f3eed52d9301f6b1c4c1ccfccc30eab9e6628e4a8c1fc3546" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "lazy_static", "libgssapi-sys", @@ -6596,9 +6605,9 @@ dependencies = [ [[package]] name = "libgssapi-sys" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7518e6902e94f92e7c7271232684b60988b4bd813529b4ef9d97aead96956ae8" +checksum = "5103ac4557eacd36ff678b654b943f8966d3db9688fbd180a0b4c5464759ce17" dependencies = [ "bindgen 0.71.1", "pkg-config", @@ -6633,14 +6642,14 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "libc", "plain", - "redox_syscall 0.7.5", + "redox_syscall 0.8.1", ] [[package]] @@ -6676,9 +6685,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.28" +version = "1.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc3a226e576f50782b3305c5ccf458698f92798987f551c6a02efe8276721e22" +checksum = "85bc9657773828b90eeb625adff10eeac83cc21bbfd8e23a03eaa8a33c9e28d9" dependencies = [ "cc", "libc", @@ -6721,9 +6730,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "loom" @@ -6759,6 +6768,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a860605968fce16869fd239cf4237a82f3ac470723415db603b0e8b6c8d4fb9" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -6809,6 +6827,12 @@ dependencies = [ "libc", ] +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + [[package]] name = "macro_rules_attribute" version = "0.2.2" @@ -7008,9 +7032,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memmap2" @@ -7103,9 +7127,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -7163,7 +7187,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http 1.4.0", + "http 1.4.1", "httparse", "memchr", "mime", @@ -7184,7 +7208,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66f62cad7623a9cb6f8f64037f0c4f69c8db8e82914334a83c9788201c2c1bfa" dependencies = [ "darling 0.20.11", - "heck 0.5.0", + "heck", "num-bigint", "proc-macro-crate", "proc-macro-error2", @@ -7197,26 +7221,26 @@ dependencies = [ [[package]] name = "mysql_async" -version = "0.36.2" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1d9585dc9058886ff3a1f48a23024dd1d054264dee7c5ae0e4bd640c953bee5" +checksum = "3519e91b0d254ac1ffa495bc42053286cb2172ad7241d5b3b1b9f8a891f21ee2" dependencies = [ "bytes", "crossbeam-queue", + "crossbeam-utils", "flate2", "futures-core", "futures-sink", "futures-util", "keyed_priority_queue", - "lru 0.16.4", + "lru 0.18.0", "mysql_common", "native-tls", "pem 3.0.6", "percent-encoding", - "rand 0.9.0", + "rand 0.10.1", "serde", - "serde_json", - "socket2 0.5.10", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tokio-native-tls", @@ -7227,12 +7251,12 @@ dependencies = [ [[package]] name = "mysql_common" -version = "0.35.5" +version = "0.37.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbb9f371618ce723f095c61fbcdc36e8936956d2b62832f9c7648689b338e052" +checksum = "4b42ced54aa8ac97226486337973f9bc3956e24f03a23e88a6e18f640959d6e2" dependencies = [ "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.0", "btoi", "byteorder", "bytes", @@ -7291,7 +7315,7 @@ version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "libc", ] @@ -7302,7 +7326,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -7314,7 +7338,7 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -7326,7 +7350,7 @@ version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "cfg_aliases", "libc", @@ -7382,7 +7406,7 @@ version = "0.101.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71f7c8ed6ba88a567ec6f7c4cad4a7a8465ab93b8cdaf89d3dc72347a83c2d1f" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro-error", "proc-macro2", "quote", @@ -7450,7 +7474,7 @@ dependencies = [ "dirs 5.0.1", "dirs-sys 0.4.1", "fancy-regex 0.14.0", - "heck 0.5.0", + "heck", "indexmap 2.14.0", "log", "lru 0.12.5", @@ -7479,7 +7503,7 @@ dependencies = [ "libc", "libproc", "log", - "mach2", + "mach2 0.4.3", "nix 0.29.0", "ntapi", "procfs", @@ -7671,7 +7695,7 @@ dependencies = [ "base64 0.22.1", "chrono", "getrandom 0.2.17", - "http 1.4.0", + "http 1.4.1", "rand 0.8.5", "reqwest 0.12.28", "serde", @@ -7702,11 +7726,11 @@ dependencies = [ "chrono", "form_urlencoded", "futures", - "http 1.4.0", + "http 1.4.1", "http-body-util", "httparse", "humantime", - "hyper 1.9.0", + "hyper 1.10.1", "itertools 0.14.0", "md-5 0.10.6", "parking_lot", @@ -7776,7 +7800,7 @@ version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "libc", "once_cell", "onig_sys", @@ -7809,7 +7833,7 @@ dependencies = [ "dyn-clone", "ed25519-dalek", "hmac", - "http 1.4.0", + "http 1.4.1", "itertools 0.10.5", "log", "oauth2", @@ -7835,7 +7859,7 @@ version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "cfg-if", "foreign-types", "libc", @@ -7936,7 +7960,7 @@ checksum = "10a8a7f5f6ba7c1b286c2fbca0454eaba116f63bbe69ed250b642d36fbb04d80" dependencies = [ "async-trait", "bytes", - "http 1.4.0", + "http 1.4.1", "opentelemetry 0.27.1", ] @@ -7948,7 +7972,7 @@ checksum = "50f6639e842a97dbea8886e3439710ae463120091e2e064518ba8e716e6ac36d" dependencies = [ "async-trait", "bytes", - "http 1.4.0", + "http 1.4.1", "opentelemetry 0.30.0", "reqwest 0.12.28", ] @@ -7961,7 +7985,7 @@ checksum = "91cf61a1868dacc576bf2b2a1c3e9ab150af7272909e80085c3173384fe11f76" dependencies = [ "async-trait", "futures-core", - "http 1.4.0", + "http 1.4.1", "opentelemetry 0.27.1", "opentelemetry-http 0.27.0", "opentelemetry-proto 0.27.0", @@ -7980,7 +8004,7 @@ version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbee664a43e07615731afc539ca60c6d9f1a9425e25ca09c57bc36c87c55852b" dependencies = [ - "http 1.4.0", + "http 1.4.1", "opentelemetry 0.30.0", "opentelemetry-http 0.30.0", "opentelemetry-proto 0.30.0", @@ -8231,7 +8255,7 @@ dependencies = [ "arrow-schema", "arrow-select", "base64 0.22.1", - "brotli 8.0.2", + "brotli 8.0.3", "bytes", "chrono", "flate2", @@ -8684,7 +8708,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -8784,7 +8808,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc5b72d8145275d844d4b5f6d4e1eef00c8cd889edb6035c21675d1bb1f45c9f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "chrono", "flate2", "hex", @@ -8798,7 +8822,7 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "239df02d8349b06fc07398a3a1697b06418223b1c7725085e801e7c0fc6a12ec" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "chrono", "hex", ] @@ -8885,7 +8909,7 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57206b407293d2bcd3af849ce869d52068623f19e1b5ff8e8778e3309439682b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "getopts", "memchr", "unicase", @@ -8942,9 +8966,9 @@ dependencies = [ [[package]] name = "quick_cache" -version = "0.6.22" +version = "0.6.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1c821816e9b928e20e92ed59bb3ac4aab321d16ca2316871c9fe7ca739cd477" +checksum = "3a3db184a8b66cfe87f0263a1de147a6b554c864d1767c6f7fa4eb0e5497b565" dependencies = [ "ahash 0.8.12", "equivalent", @@ -8965,7 +8989,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls 0.23.35", - "socket2 0.6.3", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -9003,7 +9027,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.6.4", "tracing", "windows-sys 0.60.2", ] @@ -9169,7 +9193,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -9283,16 +9307,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] name = "redox_syscall" -version = "0.7.5" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -9409,10 +9433,10 @@ dependencies = [ "futures-core", "futures-util", "h2 0.4.14", - "http 1.4.0", + "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-rustls 0.27.9", "hyper-tls", "hyper-util", @@ -9424,7 +9448,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls 0.23.35", - "rustls-native-certs 0.8.3", + "rustls-native-certs 0.8.4", "rustls-pki-types", "serde", "serde_json", @@ -9457,10 +9481,10 @@ dependencies = [ "futures-core", "futures-util", "h2 0.4.14", - "http 1.4.0", + "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-rustls 0.27.9", "hyper-util", "js-sys", @@ -9498,7 +9522,7 @@ checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" dependencies = [ "anyhow", "async-trait", - "http 1.4.0", + "http 1.4.1", "reqwest 0.13.1", "serde", "thiserror 2.0.18", @@ -9515,8 +9539,8 @@ dependencies = [ "async-trait", "futures", "getrandom 0.2.17", - "http 1.4.0", - "hyper 1.9.0", + "http 1.4.1", + "hyper 1.10.1", "reqwest 0.13.1", "reqwest-middleware", "retry-policies", @@ -9621,7 +9645,7 @@ dependencies = [ "bytes", "chrono", "futures", - "http 1.4.0", + "http 1.4.1", "http-body 1.0.1", "http-body-util", "oauth2", @@ -9859,7 +9883,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -9872,7 +9896,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys 0.12.1", @@ -9948,9 +9972,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe 0.2.1", "rustls-pki-types", @@ -9998,7 +10022,7 @@ dependencies = [ "log", "once_cell", "rustls 0.23.35", - "rustls-native-certs 0.8.3", + "rustls-native-certs 0.8.4", "rustls-platform-verifier-android", "rustls-webpki 0.103.13", "security-framework 3.7.0", @@ -10197,15 +10221,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ece8e78b2f38ec51c51f5d475df0a7187ba5111b2a28bdc761ee05b075d40a71" -[[package]] -name = "scc" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" -dependencies = [ - "sdd", -] - [[package]] name = "schannel" version = "0.1.29" @@ -10299,12 +10314,6 @@ dependencies = [ "untrusted 0.9.0", ] -[[package]] -name = "sdd" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" - [[package]] name = "seahash" version = "4.1.0" @@ -10340,7 +10349,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -10353,7 +10362,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -10562,9 +10571,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", "bs58", @@ -10582,9 +10591,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -10622,24 +10631,23 @@ dependencies = [ [[package]] name = "serial_test" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "911bd979bf1070a3f3aa7b691a3b3e9968f339ceeec89e08c280a8a22207a32f" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" dependencies = [ "futures-executor", "futures-util", "log", "once_cell", "parking_lot", - "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a7d91949b85b0d2fb687445e448b40d322b6b3e4af6b44a29b21d9a5f33e6d9" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" dependencies = [ "proc-macro2", "quote", @@ -10715,6 +10723,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -10848,9 +10862,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -11038,7 +11052,7 @@ checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", - "heck 0.5.0", + "heck", "hex", "once_cell", "proc-macro2", @@ -11064,7 +11078,7 @@ dependencies = [ "atoi", "base64 0.22.1", "bigdecimal", - "bitflags 2.11.1", + "bitflags 2.13.0", "byteorder", "bytes", "chrono", @@ -11109,7 +11123,7 @@ dependencies = [ "atoi", "base64 0.22.1", "bigdecimal", - "bitflags 2.11.1", + "bitflags 2.13.0", "byteorder", "chrono", "crc", @@ -11254,35 +11268,13 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" -dependencies = [ - "strum_macros 0.25.3", -] - [[package]] name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ - "strum_macros 0.27.2", -] - -[[package]] -name = "strum_macros" -version = "0.25.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23dc1fa9ac9c169a78ba62f0b841814b7abae11bdd047b9c58f893439e309ea0" -dependencies = [ - "heck 0.4.1", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.117", + "strum_macros", ] [[package]] @@ -11291,7 +11283,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 2.0.117", @@ -11406,7 +11398,7 @@ version = "15.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c25af97d53cf8aab66a6c68f3418663313fc969ad267fc2a4d19402c329be1" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "is-macro", "num-bigint", "once_cell", @@ -11462,7 +11454,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "017d06ea85008234aa9fb34d805c7dc563f2ea6e03869ed5ac5a2dc27d561e4d" dependencies = [ "arrayvec", - "bitflags 2.11.1", + "bitflags 2.13.0", "either", "num-bigint", "phf 0.11.3", @@ -11784,7 +11776,7 @@ version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01198a2debb237c62b6826ec7081082d951f46dbb64b0e8c7649a452230d1dfc" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "byteorder", "enum-as-inner", "libc", @@ -11812,7 +11804,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -11829,13 +11821,15 @@ dependencies = [ [[package]] name = "systemstat" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6e89b75de097d0c52a1dc2114e19439d55f0e2e42d32168c6df44f139dfb66f" +checksum = "a583abe520746270ffdbdaf0e3039a806f29be9d7034d66466a4839a01de0610" dependencies = [ "bytesize", + "kstat-rs", "lazy_static", "libc", + "mach2 0.6.0", "nom", "time", "winapi", @@ -12383,7 +12377,7 @@ dependencies = [ "postgres-protocol", "postgres-types", "rand 0.9.0", - "socket2 0.6.3", + "socket2 0.6.4", "tokio", "tokio-util", "whoami", @@ -12432,9 +12426,9 @@ dependencies = [ [[package]] name = "tokio-socks" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" dependencies = [ "either", "futures-util", @@ -12524,11 +12518,11 @@ dependencies = [ "bytes", "futures-core", "futures-sink", - "http 1.4.0", + "http 1.4.1", "httparse", "rand 0.8.5", "ring 0.17.14", - "rustls-native-certs 0.8.3", + "rustls-native-certs 0.8.4", "rustls-pki-types", "tokio", "tokio-rustls 0.26.4", @@ -12580,9 +12574,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", @@ -12612,10 +12606,10 @@ dependencies = [ "bytes", "flate2", "h2 0.4.14", - "http 1.4.0", + "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-timeout", "hyper-util", "percent-encoding", @@ -12644,16 +12638,16 @@ dependencies = [ "base64 0.22.1", "bytes", "h2 0.4.14", - "http 1.4.0", + "http 1.4.1", "http-body 1.0.1", "http-body-util", - "hyper 1.9.0", + "hyper 1.10.1", "hyper-timeout", "hyper-util", "percent-encoding", "pin-project", "prost", - "rustls-native-certs 0.8.3", + "rustls-native-certs 0.8.4", "socket2 0.5.10", "tokio", "tokio-rustls 0.26.4", @@ -12712,7 +12706,7 @@ dependencies = [ "axum-core 0.5.6", "cookie", "futures-util", - "http 1.4.0", + "http 1.4.1", "parking_lot", "pin-project-lite", "tower-layer", @@ -12727,11 +12721,11 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "futures-core", "futures-util", - "http 1.4.0", + "http 1.4.1", "http-body 1.0.1", "http-body-util", "mime", @@ -12966,7 +12960,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.4.0", + "http 1.4.1", "httparse", "log", "native-tls", @@ -12988,7 +12982,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http 1.4.0", + "http 1.4.1", "httparse", "log", "native-tls", @@ -13018,9 +13012,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typetag" @@ -13183,9 +13177,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -13350,9 +13344,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -13367,7 +13361,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33995a1fee055ff743281cde33a41f0d618ee0bdbe8bdf6859e11864499c2595" dependencies = [ "bindgen 0.71.1", - "bitflags 2.11.1", + "bitflags 2.13.0", "fslock", "gzip-header", "home", @@ -13622,7 +13616,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap 2.14.0", "semver 1.0.28", @@ -13669,7 +13663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "974fa1e325e6cc5327de8887f189a441fcff4f8eedcd31ec87f0ef0cc5283fbc" dependencies = [ "bytes", - "http 1.4.0", + "http 1.4.1", "thiserror 2.0.18", "url", ] @@ -13788,7 +13782,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-nats", @@ -13822,7 +13816,7 @@ dependencies = [ "sha2 0.10.9", "sql-builder", "sqlx", - "strum 0.27.2", + "strum", "tar", "tempfile", "tikv-jemalloc-ctl", @@ -13839,6 +13833,7 @@ dependencies = [ "windmill-api-agent-workers", "windmill-api-auth", "windmill-api-client", + "windmill-api-scripts", "windmill-api-settings", "windmill-autoscaling", "windmill-common", @@ -13869,19 +13864,20 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.708.0" +version = "1.719.0" dependencies = [ "async-stream", "async-trait", "aws-config", "aws-credential-types", + "aws-sdk-bedrock", "aws-sdk-bedrockruntime", "aws-smithy-types", "base64 0.22.1", "bytes", "eventsource-stream", "futures", - "http 1.4.0", + "http 1.4.1", "lazy_static", "mime_guess", "reqwest 0.13.1", @@ -13901,7 +13897,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.708.0" +version = "1.719.0" dependencies = [ "axum 0.8.9", "chrono", @@ -13914,7 +13910,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "argon2", @@ -13923,13 +13919,8 @@ dependencies = [ "async-stream", "async-trait", "async_zip", - "aws-config", - "aws-credential-types", - "aws-sdk-bedrock", - "aws-sdk-bedrockruntime", "aws-sdk-config", "aws-sigv4", - "aws-smithy-types", "axum 0.8.9", "base32", "base64 0.22.1", @@ -13948,8 +13939,8 @@ dependencies = [ "git-version", "hex", "hmac", - "http 1.4.0", - "hyper 1.9.0", + "http 1.4.1", + "hyper 1.10.1", "indexmap 2.14.0", "itertools 0.14.0", "jsonwebtoken 8.3.0", @@ -13980,7 +13971,7 @@ dependencies = [ "sha2 0.10.9", "sql-builder", "sqlx", - "strum 0.27.2", + "strum", "tar", "tempfile", "time", @@ -14057,12 +14048,12 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.708.0" +version = "1.719.0" dependencies = [ "axum 0.8.9", "chrono", - "http 1.4.0", - "hyper 1.9.0", + "http 1.4.1", + "hyper 1.10.1", "lazy_static", "quick_cache", "serde", @@ -14080,7 +14071,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.708.0" +version = "1.719.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14093,12 +14084,12 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "axum 0.8.9", "chrono", - "http 1.4.0", + "http 1.4.1", "itertools 0.14.0", "jsonwebtoken 8.3.0", "lazy_static", @@ -14119,7 +14110,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.708.0" +version = "1.719.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -14129,7 +14120,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.708.0" +version = "1.719.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14146,7 +14137,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.708.0" +version = "1.719.0" dependencies = [ "axum 0.8.9", "base64 0.22.1", @@ -14168,7 +14159,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14191,7 +14182,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.708.0" +version = "1.719.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14207,11 +14198,11 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.708.0" +version = "1.719.0" dependencies = [ "axum 0.8.9", "chrono", - "hyper 1.9.0", + "hyper 1.10.1", "serde", "serde_json", "sql-builder", @@ -14228,7 +14219,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.708.0" +version = "1.719.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14249,7 +14240,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.708.0" +version = "1.719.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14263,7 +14254,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-nats", @@ -14273,6 +14264,8 @@ dependencies = [ "axum 0.8.9", "base64 0.22.1", "futures", + "hex", + "hmac", "rand 0.9.0", "rdkafka", "reqwest 0.13.1", @@ -14280,6 +14273,7 @@ dependencies = [ "rumqttc", "serde", "serde_json", + "sha2 0.10.9", "sqlx", "tokio", "uuid", @@ -14295,14 +14289,14 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "axum 0.8.9", "base64 0.22.1", "chrono", - "http 1.4.0", - "hyper 1.9.0", + "http 1.4.1", + "hyper 1.10.1", "lazy_static", "serde", "serde_json", @@ -14320,7 +14314,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.708.0" +version = "1.719.0" dependencies = [ "axum 0.8.9", "flate2", @@ -14338,11 +14332,11 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "axum 0.8.9", - "http 1.4.0", + "http 1.4.1", "indexmap 2.14.0", "itertools 0.14.0", "lazy_static", @@ -14360,7 +14354,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.708.0" +version = "1.719.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14380,15 +14374,16 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.708.0" +version = "1.719.0" dependencies = [ "axum 0.8.9", "chrono", "futures", - "http 1.4.0", - "hyper 1.9.0", + "http 1.4.1", + "hyper 1.10.1", "itertools 0.14.0", "lazy_static", + "prometheus", "quick_cache", "reqwest 0.13.1", "serde", @@ -14410,7 +14405,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14438,7 +14433,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.708.0" +version = "1.719.0" dependencies = [ "lazy_static", "serde", @@ -14450,14 +14445,14 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.708.0" +version = "1.719.0" dependencies = [ "argon2", "axum 0.8.9", "chrono", "dashmap", - "http 1.4.0", - "hyper 1.9.0", + "http 1.4.1", + "hyper 1.10.1", "lazy_static", "serde", "serde_json", @@ -14475,7 +14470,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.708.0" +version = "1.719.0" dependencies = [ "axum 0.8.9", "chrono", @@ -14489,13 +14484,13 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.708.0" +version = "1.719.0" dependencies = [ "axum 0.8.9", "chrono", "hex", - "http 1.4.0", - "hyper 1.9.0", + "http 1.4.1", + "hyper 1.10.1", "lazy_static", "magic-crypt", "regex", @@ -14503,7 +14498,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx", - "strum 0.27.2", + "strum", "tokio", "tracing", "uuid", @@ -14522,7 +14517,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.708.0" +version = "1.719.0" dependencies = [ "chrono", "lazy_static", @@ -14536,7 +14531,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "axum 0.8.9", @@ -14555,7 +14550,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.708.0" +version = "1.719.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -14572,7 +14567,7 @@ dependencies = [ "axum 0.8.9", "backon", "base64 0.22.1", - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "chrono", "chrono-tz", @@ -14591,7 +14586,7 @@ dependencies = [ "globset", "hex", "hmac", - "hyper 1.9.0", + "hyper 1.10.1", "indexmap 2.14.0", "itertools 0.14.0", "jsonwebtoken 8.3.0", @@ -14626,8 +14621,8 @@ dependencies = [ "sha2 0.10.9", "size", "sqlx", - "strum 0.27.2", - "strum_macros 0.27.2", + "strum", + "strum_macros", "sysinfo", "systemstat", "tar", @@ -14656,7 +14651,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.708.0" +version = "1.719.0" dependencies = [ "chrono", "itertools 0.14.0", @@ -14675,7 +14670,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.708.0" +version = "1.719.0" dependencies = [ "regex", "serde", @@ -14690,7 +14685,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -14714,7 +14709,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "futures", @@ -14731,7 +14726,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.708.0" +version = "1.719.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -14747,13 +14742,13 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-trait", "chrono", "futures", - "http 1.4.0", + "http 1.4.1", "oauth2", "reqwest 0.12.28", "rmcp", @@ -14768,7 +14763,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-trait", @@ -14777,7 +14772,7 @@ dependencies = [ "base64 0.22.1", "chrono", "hmac", - "http 1.4.0", + "http 1.4.1", "itertools 0.14.0", "lazy_static", "reqwest 0.13.1", @@ -14785,7 +14780,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "sqlx", - "strum 0.27.2", + "strum", "tokio", "tracing", "urlencoding", @@ -14799,7 +14794,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "arc-swap", @@ -14824,7 +14819,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-stream", @@ -14858,7 +14853,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "futures", @@ -14876,7 +14871,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.708.0" +version = "1.719.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -14885,7 +14880,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "lazy_static", @@ -14897,7 +14892,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "serde_json", @@ -14909,7 +14904,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "gosyn", @@ -14921,7 +14916,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "lazy_static", @@ -14933,7 +14928,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "serde_json", @@ -14945,7 +14940,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "nu-parser", @@ -14956,7 +14951,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14967,7 +14962,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -14979,7 +14974,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "rustpython-ast", @@ -14990,7 +14985,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-recursion", @@ -15012,7 +15007,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "serde_json", @@ -15024,7 +15019,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "lazy_static", @@ -15038,7 +15033,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -15055,7 +15050,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "lazy_static", @@ -15068,7 +15063,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "serde", @@ -15080,7 +15075,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "lazy_static", @@ -15098,7 +15093,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -15114,7 +15109,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "rustpython-ast", @@ -15130,7 +15125,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "serde", @@ -15141,7 +15136,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-recursion", @@ -15160,6 +15155,7 @@ dependencies = [ "once_cell", "prometheus", "quick_cache", + "rand 0.9.0", "regex", "reqwest 0.13.1", "serde", @@ -15178,7 +15174,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "const_format", @@ -15216,7 +15212,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.708.0" +version = "1.719.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -15227,20 +15223,22 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-recursion", "axum 0.8.9", "chrono", "futures", - "http 1.4.0", - "hyper 1.9.0", + "hex", + "http 1.4.1", + "hyper 1.10.1", "lazy_static", "quick_cache", "reqwest 0.13.1", "serde", "serde_json", + "sha2 0.10.9", "sql-builder", "sqlx", "tokio", @@ -15257,7 +15255,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-trait", @@ -15281,14 +15279,14 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-trait", "axum 0.8.9", "chrono", - "http 1.4.0", - "hyper 1.9.0", + "http 1.4.1", + "hyper 1.10.1", "itertools 0.14.0", "lazy_static", "rand 0.9.0", @@ -15314,7 +15312,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-trait", @@ -15324,7 +15322,7 @@ dependencies = [ "chrono", "constant_time_eq 0.3.1", "hex", - "http 1.4.0", + "http 1.4.1", "itertools 0.14.0", "lazy_static", "quick_cache", @@ -15347,7 +15345,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-trait", @@ -15367,7 +15365,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-trait", @@ -15377,7 +15375,7 @@ dependencies = [ "chrono", "google-cloud-googleapis", "google-cloud-pubsub", - "http 1.4.0", + "http 1.4.1", "itertools 0.14.0", "jsonwebtoken 8.3.0", "lazy_static", @@ -15401,7 +15399,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-trait", @@ -15412,8 +15410,8 @@ dependencies = [ "futures", "hex", "hmac", - "http 1.4.0", - "hyper 1.9.0", + "http 1.4.1", + "hyper 1.10.1", "itertools 0.14.0", "lazy_static", "matchit 0.7.3", @@ -15437,7 +15435,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-trait", @@ -15460,7 +15458,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-trait", @@ -15484,7 +15482,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-nats", @@ -15508,7 +15506,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-trait", @@ -15543,7 +15541,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-trait", @@ -15571,13 +15569,14 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-trait", "axum 0.8.9", + "base64 0.22.1", "futures", - "http 1.4.0", + "http 1.4.1", "itertools 0.14.0", "serde", "serde_json", @@ -15585,6 +15584,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.24.0", "tracing", + "url", "windmill-api-auth", "windmill-common", "windmill-git-sync", @@ -15594,10 +15594,10 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.0", "chrono", "hex", "itertools 0.14.0", @@ -15605,7 +15605,7 @@ dependencies = [ "serde", "serde_json", "sqlx", - "strum 0.27.2", + "strum", "tracing", "uuid", "windmill-parser", @@ -15613,7 +15613,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-once-cell", @@ -15723,7 +15723,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.708.0" +version = "1.719.0" dependencies = [ "bytes", "futures", @@ -15974,7 +15974,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "widestring", "windows-sys 0.52.0", ] @@ -16358,7 +16358,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "wit-parser", ] @@ -16369,7 +16369,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck 0.5.0", + "heck", "indexmap 2.14.0", "prettyplease", "syn 2.0.117", @@ -16400,7 +16400,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.0", "indexmap 2.14.0", "log", "serde", @@ -16514,9 +16514,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -16537,18 +16537,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 421139a311..df637c3f75 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.708.0" +version = "1.719.0" authors.workspace = true edition.workspace = true @@ -87,7 +87,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.708.0" +version = "1.719.0" authors = ["Ruben Fiszel "] edition = "2021" @@ -251,6 +251,7 @@ windmill-object-store.workspace = true windmill-git-sync.workspace = true windmill-api = { workspace = true, default-features = false } windmill-api-agent-workers = { workspace = true, optional = true } +windmill-api-scripts.workspace = true windmill-api-settings.workspace = true windmill-worker.workspace = true windmill-indexer = { workspace = true, optional = true } diff --git a/backend/THREAT_MODEL.md b/backend/THREAT_MODEL.md new file mode 100644 index 0000000000..5a891bda77 --- /dev/null +++ b/backend/THREAT_MODEL.md @@ -0,0 +1,172 @@ +# Threat Model: Windmill Backend + +## 1. System context + +Windmill is an open-source (AGPLv3) developer platform for internal tools, +workflows, background jobs, API integrations, and UIs — a self-hostable +alternative to Retool / Pipedream / Airplane. The backend is a Rust workspace +(~60 crates: `windmill-api`, `windmill-worker`, `windmill-queue`, +`windmill-common`, a family of `windmill-trigger-*` crates, `windmill-mcp`, +`windmill-sandbox`, etc.) fronting a PostgreSQL database. A Svelte 5 frontend +(not in scope here, but referenced where stored-XSS threats originate) is +served by the same instance. The product ships in a Community Edition (CE, +public Docker images) and an Enterprise Edition (EE, `*_ee.rs` files gated by +`enterprise`/`private`/`license` cargo features). + +The defining characteristic for threat modeling is that **Windmill executes +arbitrary user-supplied code** (Python, TypeScript via Bun/Deno, Go, Bash, +SQL, GraphQL, PowerShell, Rust, …) on its workers, and **stores the +credentials to every system its users connect to** (databases, cloud +accounts, SaaS APIs, OAuth tokens). It is therefore simultaneously an +arbitrary-code-execution engine and a credential vault — compromising one +instance can pivot into an organization's entire connected estate. Crucially, +the owner confirms `nsjail` is **off by default everywhere** (`ENABLE_NSJAIL` +is opt-in) and network isolation (`clone_newnet`) is separately gated: the +*only* job isolation present in a default install is PID-namespace `unshare`. +Filesystem and outbound-network isolation are therefore absent unless an +operator deliberately enables them, which makes "weak-by-default isolation" a +more accurate frame than "sandbox escape" for typical deployments. Cross-tenant +separation is enforced in software via workspace IDs, token scopes, folder +ACLs, and Postgres row-level security; on the managed offering, sensitive +customers can opt into dedicated DB / worker / namespace infrastructure, but +the shared tier relies entirely on that software boundary. Administrators are +strongly encouraged to use nsjail sandboxing and are reminded that if they don't, +their security model is that they trust their developers that write code ran on windmill +to not do anything TOO malicious on the workers. When the default +database secret backend is used, only per-workspace secret *variables* are +encrypted at rest — instance-level `global_settings` (OAuth client secrets, +SMTP, object-store keys, license) are stored plaintext, so a database read +yields the instance-wide credential set. Internet-facing instances are +typically exposed directly with no built-in rate limiting or WAF. + +It is deployed self-hosted (Docker Compose, Kubernetes/Helm, bare metal), on +cloud providers, and as a Windmill-Labs-managed multi-tenant service. The API +server is internet-facing in most deployments; workers pull jobs from the +Postgres queue. The large public attack surface (a sprawling authenticated +HTTP API, unauthenticated public-app and webhook/trigger endpoints, outbound +HTTP from user code and proxies) combined with the high-value assets makes +authorization-enforcement bugs, SSRF, SQL injection, and sandbox escape the +dominant risk categories — a pattern strongly confirmed by the project's +published advisory history (73 GHSA advisories, several rated 9.9 critical). + +## 2. Assets + +| asset | description | sensitivity | +|---|---|---| +| Workspace encryption keys | Per-workspace key (`workspace_key`) used to encrypt secret variables (MagicCrypt256); decrypts all secrets in the workspace | critical | +| Secret variables | User secrets stored encrypted in `variable` (is_secret) | critical | +| Resource credentials | DB passwords, cloud creds, API keys, connection strings in `resource` JSONB | critical | +| OAuth / external-account tokens | Refresh/access tokens in `account`, MCP OAuth tables | critical | +| User password hashes | Argon2 hashes in `password` table | critical | +| API tokens & session cookies | Bearer tokens / cookies in `token`; superadmin & scoped tokens | critical | +| Instance global settings | License key, JWT secret, SUPERADMIN_SECRET, SMTP, object-store + secret-backend (Vault/KMS/SM) creds in `global_settings` | critical | +| Worker host & process integrity | The host that runs untrusted user code | critical | +| Cross-tenant / cross-workspace isolation | The software boundary separating workspaces, folders, and tenants | critical | +| Downstream connected systems | Windmill is a credential vault: stored creds reach external DBs, cloud accounts, SaaS | critical | +| Script / flow / app source | Customer IP & business logic in `script`, `flow`, `app`, `raw_app` | high | +| Job arguments, results & logs | `queue`/`completed_job` args+result, `job_logs`; routinely contain secrets | high | +| Object store / S3 data | Files uploaded/produced by jobs | high | +| Audit logs | `audit`/`audit_partitioned` action trail | high | +| Service availability | API server + worker fleet uptime | high | +| PII | User emails, group membership | medium | + +## 3. Entry points & trust boundaries + +| entry_point | description | trust_boundary | reachable_assets | +|---|---|---|---| +| EP1 Authenticated job-execution API | `jobs/run/preview`, `run/h/{hash}`, `run_flow/run_script` — runs user code on workers | authenticated user → arbitrary code on worker | Worker host, downstream systems, isolation, job args/results/logs | +| EP2 Unauthenticated public endpoints | `apps_u/*`, `jobs_u/getupdate*`, `scripts_u`, `settings_u`, `resources_u` (`public_app_layer.rs`) | unauth HTTP → app logic & job data | Job results, scripts, secrets, PII | +| EP3 HTTP-trigger & webhook ingestion | `/api/r/*`, GCP/Azure push, Slack callback, `capture_u/*` | untrusted webhook → job queue | Job execution integrity, worker host | +| EP4 Message-queue / native triggers | kafka, postgres, mqtt, websocket, nats, sqs, email triggers | external broker/message → job queue | Job execution integrity, availability | +| EP5 HTTP API authorization layer | Token/scope/RLS/folder-ACL enforcement across all workspaced routes (`windmill-api-auth`) | scoped token / low-priv user → other users' & workspaces' data | Scripts, job data, secrets, isolation | +| EP6 AI proxy & MCP endpoints | `ai/proxy/*`, `mcp` — resolve `$var:`/resources, proxy to LLM APIs, `X-Resource-Path` | authenticated user → outbound HTTP + secret resolution | Secrets, resource creds, internal network, downstream | +| EP7 Outbound HTTP from executors/resources | GraphQL/HTTP/Postgres executors, webhook delivery, `test_object_storage_config`, git clone, npm tarball fetch | user-controlled URL → server-side request | Cloud metadata, internal network, downstream creds | +| EP8 SQL query builders & contextual-var substitution | App DB query builder (`whereClause`/`tags`), Postgres-trigger `where_clause`, `%%WM_*%%` interpolation, `WM_INTERNAL_DB` | user input → raw SQL | Database, connected DBs | +| EP9 Worker sandbox | nsjail / unshare / dind / rootless podman isolating user code | user code → host & cross-tenant filesystem/network | Worker host, isolation, downstream | +| EP10 Worker code generation / wrappers | Entrypoint override, env-var names, workspace env interpolated into generated wrapper code | user-controlled identifier → executable code | Worker host, isolation | +| EP11 OAuth / OIDC / SAML / MCP-OAuth / logout | Login callbacks, MCP OAuth client registration, logout `rd` redirect | untrusted IdP / redirect input → session | Session tokens, accounts | +| EP12 Stored-content rendering | App builder HTML component, markdown, S3 download response headers | stored user content → admin browser (same origin) | Admin session, account takeover | +| EP13 Log/file reading & export endpoints | `service_logs`, `jobs_u/getupdate` log file read (symlinks), workspace/tarball export | authed/unauth request → arbitrary file or admin-only config | Arbitrary files, global settings | +| EP14 Secret-value & resource-value caches | In-memory caches in `windmill-store` keyed (historically un-keyed) by path | cache lookup crossing identity/folder boundary | Secret variables, resource creds | +| EP15 Deployment & runtime config | docker-compose defaults: dind, debugger (`REQUIRE_SIGNED_DEBUG_REQUESTS=false`), CORS `Any`, default admin/`changeme`, exposed Postgres, `SUPERADMIN_SECRET`, `ENABLE_NSJAIL=false`, privileged containers | operator/infra default → full instance | All assets | +| EP16 Supply chain | Cached hub scripts, GitHub workflow actions, vendored deps, Docker base image | build/update-time input → host & build integrity | Worker host, build integrity | +| EP17 Token lifecycle | Token create/rescope/refresh, script-issued JWTs | scoped caller → broader privilege | Tokens, accounts, isolation | + +## 4. Threats + +| 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; 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 | +| T6 | Disclosure of secrets, resource credentials, and workspace encryption keys across the authorization boundary (AI proxy, MCP, caches, export); database read additionally yields plaintext instance-level `global_settings` secrets | remote_auth | EP6, EP14, EP13 | Secret variables, encryption keys, resource creds, global settings | critical | likely | partially_mitigated | RLS on `$var:`, cache scoping by caller, admin checks on export; per-workspace secret *variables* encrypted at rest, but `global_settings` is plaintext under the default DB secret backend | GHSA-jwg4-v3cj-rvfm, GHSA-8m2p-2crh-9h3w, GHSA-6635-6fch-v8px, GHSA-437f-725p-7w84, GHSA-f27g-j463-q85w (CVE-2026-26964), GHSA-j679-v6vj-jfxc, GHSA-6vrr-fq33-qpfp, 0ba128afe7, 7836a4e733, ff8e39c69b | +| T7 | Full instance compromise from insecure deployment defaults (dind control, default admin/`changeme`, exposed Postgres, publicly readable SUPERADMIN_SECRET) | remote_unauth | EP15 | All assets | critical | likely | partially_mitigated | first-time-setup warning on default admin; docs recommend hardening | GHSA-3vpp-vf62-wqp6, GHSA-24fr-44f8-fqwg (CVE-2026-29059), GHSA-6q36-5p3h-766j | +| T8 | Unauthenticated RCE via the Debugger WebSocket in the default `windmill_extra` configuration | remote_unauth | EP15 | Worker host, all assets | critical | possible | unmitigated | `REQUIRE_SIGNED_DEBUG_REQUESTS` exists but defaults to false | GHSA-725h-99vx-9xr4 | +| T9 | Supply-chain compromise via cached hub scripts, GitHub workflow command injection, or vulnerable base-image deps | supply_chain | EP16 | Worker host, build integrity | critical | possible | partially_mitigated | hub-script re-pin to patched versions; HUB_BASE_URL override | GHSA-w2m9-q5f7-3gpq, edf340c4d4, GHSA-8rq7-w7g6-8wvr, GHSA-vch9-39v5-4wg7 (CVE-2024-37371) | +| T10 | Unauthenticated disclosure of job results, args, logs, and admin config via missing-authz public endpoints | remote_unauth | EP2, EP13 | Job results/args/logs, global settings, scripts | high | likely | partially_mitigated | anonymous-job checks, log-endpoint authz hardening | GHSA-qfg7-x243-5hg4, GHSA-v448-fmm4-52fp, 108a88a180, bb90f4ce83 | +| T11 | Stored XSS leading to admin/account takeover via app HTML component, markdown, or S3 download content-type | remote_auth | EP12 | Admin session, accounts | high | likely | partially_mitigated | DOMPurify markdown sanitization, `X-Content-Type-Options: nosniff` + CSP sandbox on downloads | GHSA-9c5c-hh3c-r9mc, GHSA-qxj7-hpx3-r892, GHSA-cf2x-rg8c-v63v, bb78b1c06d, 625b67dff0 | +| T12 | Webhook authentication bypass / signature replay forges trigger invocations and approvals | remote_unauth | EP3 | Job execution integrity, approvals | high | likely | partially_mitigated | HMAC verification on some triggers; signing-oracle fix | GHSA-jw8c-h45c-xpjw, GHSA-hh9x-rcf8-xjr2, GHSA-q9g3-q6fj-hc2x, GHSA-8jc4-wj2p-2vmp, ab2a15b2a8 | +| T13 | Path traversal / arbitrary file read via log-reading and MCP path endpoints (incl. symlink following) | remote_auth | EP13 | Arbitrary files on server, global settings | high | likely | partially_mitigated | traversal checks + no-symlink-follow added | GHSA-4hrf-mgvv-xp9x, bb90f4ce83, df451aa64f, ad5ec293b5, 5f2d3e6812 | +| T14 | Privilege escalation via token rescope/refresh, script-issued JWTs, or operator-permission gaps | remote_auth | EP17, EP5 | Tokens, isolation, accounts | high | likely | partially_mitigated | monotonic-privilege enforcement on token lifecycle; SECURITY DEFINER triggers | GHSA-p62p-67xp-v775, GHSA-vv9w-wx3c-q3x2, 2ddf93de96, 865ab70c89, 33fb08cf3d | +| T15 | Credential leakage via worker `/proc` environment and unmasked secrets in job logs | remote_auth | EP9, EP1 | DB creds, secrets, downstream | high | likely | partially_mitigated | Aho-Corasick secret masking in logs | GHSA-pmp9-9924-f9cx, 0885d8c986 | +| T16 | Denial of service via resource exhaustion: unbounded uploads, runaway jobs, queue flooding, or trigger-message storms | remote_auth | EP1, EP3, EP4 | Service availability, worker fleet | high | likely | risk_accepted | Per-job rlimits/timeouts exist; instance-wide DoS by an authenticated tenant is largely accepted on shared self-host (operator's job to add global quotas). Hard requirement only for managed multi-tenant | | +| T17 | Account/credential theft via unauthenticated MCP-OAuth client registration and open redirect on logout | remote_unauth | EP11 | Accounts, session tokens | high | possible | partially_mitigated | redirect-URI handling / registration hardening | GHSA-q9xg-f2v2-695g, GHSA-53xj-pvqf-wpm9, GHSA-rr8j-ffc4-pf7h, GHSA-6c5w-777m-8rv5 | +| T18 | Account takeover via missing rate limiting / brute force on auth endpoints | remote_unauth | EP11 | Accounts | medium | likely | unmitigated | none built-in; owner confirms instances are typically exposed directly with no app-level rate limiting or WAF | GHSA-cmv6-m7wc-c87p | +| T19 | Enterprise license bypass and account impersonation | remote_auth | EP5 | Global settings, accounts | medium | possible | unmitigated | license validation gated by `license` feature | GHSA-48j5-p323-4mpx, GHSA-pv35-65rq-w29h, GHSA-2qx7-634r-qj6r | +| T20 | Trigger spoofing: an actor with broker/queue access injects messages that execute jobs without app-level auth | adjacent_network | EP4 | Job execution integrity, downstream | medium | possible | risk_accepted | Owner confirms trust is delegated to broker ACLs by design; no app-level message authenticity check. Anyone able to publish to a subscribed topic/queue can cause job execution | | +| T21 | Data-in-transit interception/tampering from TLS-disabled defaults (DB `sslmode=disable`, HTTP-only Caddy) | adjacent_network | EP15 | DB creds, secrets, session tokens | medium | possible | unmitigated | docs recommend TLS; not default | | +| T22 | Repudiation / incident blind spots from gaps in audit coverage of sensitive actions | remote_auth | EP5 | Audit logs | medium | possible | partially_mitigated | `windmill-audit` records many actions | | + +## 5. Deprioritized + +| threat | reason | +|---|---| +| Physical access to the host / cold-boot key extraction | Out of scope; deployment-environment responsibility, not addressable in this codebase | +| Memory-safety RCE in the Rust backend itself | Rust's safety model makes this rare; no evidence in history. Note: `unsafe` FFI (duckdb) is a narrow exception folded into supply-chain/T9 | +| Client-side-only nuisance bugs (CSS, layout) with no security impact | No asset compromised | +| Insider with legitimate superadmin / DB-root access | Trusted role; mitigations are operational (least privilege, audit), not technical controls in scope | +| Spoofing of a fully-trusted upstream IdP that has itself been compromised | Out of model; Windmill trusts the configured IdP by design | +| Instance-wide DoS by an authenticated tenant on shared self-host (T16) | Risk accepted (owner): per-job rlimits/timeouts are in place; global concurrency/queue quotas are the operator's responsibility on self-host. Remains a hard requirement for the managed multi-tenant fleet | +| Job execution triggered by an actor with legitimate broker/queue publish access (T20) | Risk accepted (owner): trigger authenticity is delegated to broker ACLs by design; consuming from a configured source and acting on its messages is the intended behavior | + +## 6. Open questions + +Facts that drove the score changes above. Two were confirmed in code during +the interview (`[Code-verified]`); the rest remain `[Owner-states]` pending a +check. + +- [Code-verified] nsjail is off by default in every configuration: `DISABLE_NSJAIL` defaults to `true` (`windmill-worker/src/worker.rs:346`), and `is_sandboxing_enabled()` requires `DISABLE_NSJAIL=false` or the `job_isolation` global setting = `nsjail_sandboxing` (`worker.rs:890`). PID-ns `unshare` is also off at the code level (`is_unshare_enabled()`, `worker.rs:903`); the shipped `docker-compose.yml` sets `FAVOR_UNSHARE_PID=true` (line 91), so the official compose gives PID-ns unshare only, nsjail off — a bare install gets no isolation at all. No separate `clone_newnet` flag exists; network isolation is an nsjail feature, so outbound network from user code is unrestricted by default. Affects: T2 controls/likelihood, T5 status (unmitigated), T8. +- [Code-verified] `global_settings` is plaintext at rest under the default DB backend: `set_value_in_global_settings` stores the raw JSON value with no encryption (`windmill-common/src/global_settings.rs:259`); the encrypting secret backend (`secret_backend/database.rs:66`) only encrypts per-workspace `variable` rows with `is_secret=true`. Instance-level SMTP/OAuth/AI/object-store secrets are therefore plaintext. Affects: T6 impact/controls, T7. +- [Owner-states] Internet-facing instances are typically exposed directly with no built-in rate limiting / WAF. Affects: T16, T18 likelihood. Verify by: confirm absence of a rate-limit layer in `windmill-api/src/lib.rs` middleware stack. +- [Owner-states] Managed offering provides an optional dedicated DB/worker/namespace tier for sensitive tenants; the shared tier relies solely on the software authz boundary. Affects: T3 controls. Verify by: deployment topology (not in this repo) — out-of-tree. +- [Owner-states] Per-job rlimits/timeouts exist; instance-wide DoS by an authed tenant is risk-accepted on shared self-host. Affects: T16 status. Verify by: locate the rlimit/timeout enforcement in the worker execution path and confirm there is no global queue/concurrency cap. +- [Owner-states] Message-queue trigger authenticity is delegated to broker ACLs only. Affects: T20 status. Verify by: review `windmill-trigger-{kafka,sqs,nats,mqtt,postgres}` consume paths for any payload authentication. + +## 7. Provenance + +- mode: bootstrap-then-interview +- date: 2026-06-05 +- target: /home/rfiszel/windmill/backend @ 819ba5e150 +- inputs: git-log mined + GitHub security advisories (gh api, 73 advisories) + CHANGELOG; seed: THREAT_MODEL.md (bootstrap pass) +- owner: Ruben Fiszel (Windmill core dev) + +## 8. Recommended mitigations + +| mitigation | threat_ids | closes_class | effort | +|---|---|---|---| +| Centralize a single audited query-builder that forbids string-interpolated SQL; ban `format!`-built queries via lint/CI | T1 | yes | M | +| Route all outbound requests through one SSRF-guarded HTTP client (allowlist/denylist of private+metadata ranges, redirects disabled, re-validated per hop) | T2 | yes | M | +| Enforce authorization centrally in middleware (scope + RLS + folder ACL) with deny-by-default and a per-route coverage test, instead of per-handler checks | T3, T10, T14, T22 | yes | L | +| Treat all user-supplied identifiers as data: pass via argv/env/structured params, never splice into generated wrapper source; validate against strict allowlists at the boundary | T4 | yes | M | +| Make `nsjail` + network-namespace isolation default-on / fail-closed (flip `ENABLE_NSJAIL` and `clone_newnet` defaults) and remove privileged/dind defaults from shipped compose; default-deny debugger | T2, T5, T7, T8 | partial | L | +| Encrypt `global_settings` at rest under the workspace/instance key even on the default DB secret backend, so a DB read no longer yields plaintext instance-wide credentials | T6, T7 | partial | M | +| Ship hardened defaults: random per-install secrets, no default admin password, Postgres not exposed, CORS locked to configured origin, TLS-on | T7, T18, T21 | partial | M | +| Resolve secrets/resources only with the caller's identity and scope every cache entry by (caller, scope); apply uniformly to AI proxy, MCP, and exports | T6 | yes | M | +| Output-encode/sanitize all stored content at render and force `nosniff` + restrictive CSP on every user-content response | T11 | yes | M | +| Verify webhook authenticity uniformly (constant-time HMAC + timestamp/nonce anti-replay) in a shared trigger-auth helper | T12 | yes | S | +| Canonicalize + confine all file-path inputs to a base dir and never follow symlinks in log/file readers | T13 | yes | S | +| Mask secrets at the log sink and keep secrets out of worker process env (`/proc`) — pass via files/pipes scrubbed after use | T15 | partial | M | +| Add global rate limiting and per-tenant resource/queue quotas at the edge | T16, T18 | partial | M | +| Pin and integrity-verify hub scripts and CI actions; SBOM + automated base-image CVE scanning in release | T9 | partial | M | diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 1e357752b5..9dad41fdc8 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -da5189cf69a453de3855057f41be0d84e5910707 +2c7964460327fab5e3a27c0f74b8d6f26ab7f79a diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index c9693b2311..01becd80f3 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -154,28 +154,61 @@ "zoho": { "auth_url": "https://accounts.zoho.com/oauth/v2/auth", "token_url": "https://accounts.zoho.com/oauth/v2/token", - "scopes": [ - "ZohoAssist.sessionapi.ALL" - ], + "scopes": ["ZohoAssist.sessionapi.ALL"], "extra_params": { "access_type": "offline" } }, - "snowflake_oauth": {}, + "snowflake_oauth": { + "connect_config_template": { + "display_name": "Snowflake", + "label": "Snowflake Account Identifier", + "placeholder": "-", + "help_url": "https://docs.snowflake.com/en/user-guide/admin-account-identifier#using-an-account-name-as-an-identifier", + "auth_url": "https://{instance}.snowflakecomputing.com/oauth/authorize", + "token_url": "https://{instance}.snowflakecomputing.com/oauth/token-request", + "req_body_auth": false, + "extra_params_key": "account_identifier", + "resource_mapping": { "account_identifier": "{instance}" } + } + }, "apify": { "auth_url": "https://console.apify.com/authorize/oauth", "token_url": "https://console-backend.apify.com/oauth/apps/token", - "scopes": [ - "profile", - "full_api_access" - ], + "scopes": ["profile", "full_api_access"], "extra_params": {} }, "docusign": { "auth_url": "https://account.docusign.com/oauth/auth", "token_url": "https://account.docusign.com/oauth/token", - "scopes": [ - "signature" - ] + "scopes": ["signature"], + "sandbox": { + "auth_url": "https://account-d.docusign.com/oauth/auth", + "token_url": "https://account-d.docusign.com/oauth/token" + } + }, + "salesforce": { + "auth_url": "https://login.salesforce.com/services/oauth2/authorize", + "token_url": "https://login.salesforce.com/services/oauth2/token", + "scopes": ["api", "refresh_token", "offline_access"], + "sandbox": { + "auth_url": "https://test.salesforce.com/services/oauth2/authorize", + "token_url": "https://test.salesforce.com/services/oauth2/token" + } + }, + "servicenow": { + "connect_config_template": { + "display_name": "ServiceNow", + "label": "ServiceNow Instance", + "placeholder": " (e.g. dev12345)", + "help_url": "https://www.servicenow.com/docs/bundle/zurich-platform-security/page/administer/security/concept/c_OAuthApplications.html", + "auth_url": "https://{instance}.service-now.com/oauth_auth.do", + "token_url": "https://{instance}.service-now.com/oauth_token.do", + "req_body_auth": true, + "strip_suffix": ".service-now.com", + "resource_mapping": { + "instance_url": "https://{instance}.service-now.com" + } + } } } diff --git a/backend/parsers/windmill-parser-ts/src/lib.rs b/backend/parsers/windmill-parser-ts/src/lib.rs index 1e78ec8665..b0ed12fb1a 100644 --- a/backend/parsers/windmill-parser-ts/src/lib.rs +++ b/backend/parsers/windmill-parser-ts/src/lib.rs @@ -129,7 +129,10 @@ impl Visit for ImportsFinder { /// See also: [`parse_relative_imports`] for resolved absolute paths. pub fn parse_expr_for_imports(code: &str, skip_type_only: bool) -> anyhow::Result> { let cm: Lrc = Default::default(); - let fm = cm.new_source_file(FileName::Custom("main.d.ts".into()).into(), code.to_string()); + let fm = cm.new_source_file( + FileName::Custom("main.d.ts".into()).into(), + code.to_string(), + ); let mut tss = TsSyntax::default(); tss.disallow_ambiguous_jsx_like; tss.tsx = true; diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index 02012874ee..2a144ff4c3 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.708.0" +version = "1.719.0" dependencies = [ "aho-corasick", "anyhow", @@ -6263,7 +6263,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.708.0" +version = "1.719.0" dependencies = [ "proc-macro2", "quote", @@ -6275,7 +6275,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.708.0" +version = "1.719.0" dependencies = [ "convert_case", "serde", @@ -6284,7 +6284,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "lazy_static", @@ -6296,7 +6296,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "serde_json", @@ -6308,7 +6308,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "gosyn", @@ -6320,7 +6320,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "lazy_static", @@ -6332,7 +6332,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "serde_json", @@ -6344,7 +6344,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "nu-parser", @@ -6355,7 +6355,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6366,7 +6366,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6389,7 +6389,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "async-recursion", @@ -6411,7 +6411,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "serde_json", @@ -6423,7 +6423,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "lazy_static", @@ -6437,7 +6437,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "convert_case", @@ -6454,7 +6454,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "lazy_static", @@ -6467,7 +6467,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "serde", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "lazy_static", @@ -6497,7 +6497,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6513,7 +6513,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "rustpython-ast", @@ -6529,7 +6529,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6561,7 +6561,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "serde", @@ -6572,7 +6572,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.708.0" +version = "1.719.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 5455c8036d..d87cd6ee7d 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.708.0" +version = "1.719.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/main.rs b/backend/src/main.rs index 87285e2ae1..b4d3cef4f9 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -57,11 +57,14 @@ use windmill_common::{ PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING, RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING, - SCIM_TOKEN_SETTING, SMTP_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, - TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, - UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, - WORKSPACE_FAIRNESS_ENABLED_SETTING, WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, - WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, WORKSPACE_REGISTRIES_SETTING, + SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, + SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, SANDBOX_IMAGE_PULL_POLICY_SETTING, + SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, + STORE_AUDIT_LOGS_S3_SETTING, TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING, + UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, + WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, + WORKSPACE_FAIRNESS_MAX_PERCENT_SETTING, WORKSPACE_FAIRNESS_MIN_TOTAL_SETTING, + WORKSPACE_REGISTRIES_SETTING, }, scripts::ScriptLang, stats_oss::schedule_stats, @@ -134,8 +137,11 @@ use crate::monitor::{ reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key, reload_npm_config_registry_setting, reload_nsjail_tmp_backing_setting, reload_nsjail_tmpfs_size_setting, reload_otel_tracing_proxy_setting, - reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting, - reload_smtp_config, reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting, + reload_pip_index_url_setting, reload_retention_period_setting, + reload_sandbox_image_cache_max_setting, reload_sandbox_image_default_registry_setting, + reload_sandbox_image_max_size_setting, reload_sandbox_image_pull_policy_setting, + reload_sandbox_registry_auth_setting, reload_scim_token_setting, reload_smtp_config, + reload_store_audit_logs_s3_setting, reload_uv_exclude_newer_setting, reload_uv_index_strategy_setting, reload_uv_python_install_mirror_setting, reload_worker_config, MonitorIteration, }; @@ -258,6 +264,15 @@ pub fn main() -> anyhow::Result<()> { } async fn cache_hub_scripts(file_path: Option) -> anyhow::Result<()> { + // The `cache` CLI mode never connects to the DB, so HUB_BASE_URL keeps its + // compiled default. Allow overriding it via env so the prebuild cache step can + // be pointed at a private/staging hub (e.g. a local proxy for testing). + if let Ok(hub_base_url) = std::env::var("HUB_BASE_URL") { + if !hub_base_url.is_empty() { + tracing::info!("Overriding hub base url from env: {hub_base_url}"); + windmill_common::HUB_BASE_URL.store(std::sync::Arc::new(hub_base_url)); + } + } let file_path = file_path.unwrap_or("./hubPaths.json".to_string()); let mut file = File::open(&file_path) .await @@ -567,6 +582,7 @@ fn print_help() { println!(" RUN_UPDATE_CA_CERTIFICATE_AT_START = false Run system CA update at startup"); println!(" RUN_UPDATE_CA_CERTIFICATE_PATH = /usr/sbin/update-ca-certificates Path to CA update tool"); println!(" SYNC_CACHED_RT = false Sync cached resource types to admins workspace on server start"); + println!(" HUB_BASE_URL = https://hub.windmill.dev Hub to fetch scripts from in `cache` mode (server/worker use the DB setting instead)"); println!(); println!("Notes:"); println!("- Advanced and less commonly used settings are managed via the database and are omitted here."); @@ -1654,6 +1670,12 @@ async fn process_notify_event( match *source_type { "script" => { windmill_common::DEPLOYED_SCRIPT_HASH_CACHE.remove(&key); + // Evict the relative-import latest-hash cache so a redeployed + // imported script flips the content cache to its new version + // across all replicas within a poll interval (see #6769). Keyed + // by the bare path, matching this event's payload. + windmill_api_scripts::scripts::RAW_SCRIPT_LATEST_HASH_CACHE + .remove(&format!("{workspace_id}:{path}")); if *kind == "preprocessor" { match sqlx::query_scalar::<_, i64>( "SELECT fv.id @@ -1811,6 +1833,19 @@ async fn process_notify_event( JOB_ISOLATION_SETTING => reload_job_isolation_setting(conn).await, NSJAIL_TMPFS_SIZE_MB_SETTING => reload_nsjail_tmpfs_size_setting(conn).await, NSJAIL_TMP_BACKING_SETTING => reload_nsjail_tmp_backing_setting(conn).await, + SANDBOX_IMAGE_MAX_SIZE_MB_SETTING => { + reload_sandbox_image_max_size_setting(conn).await + } + SANDBOX_IMAGE_CACHE_MAX_MB_SETTING => { + reload_sandbox_image_cache_max_setting(conn).await + } + SANDBOX_IMAGE_PULL_POLICY_SETTING => { + reload_sandbox_image_pull_policy_setting(conn).await + } + SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING => { + reload_sandbox_image_default_registry_setting(conn).await + } + SANDBOX_REGISTRY_AUTH_SETTING => reload_sandbox_registry_auth_setting(conn).await, #[cfg(feature = "parquet")] OBJECT_STORE_CONFIG_SETTING => { if !disable_s3_store { diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index defd47489c..789706e7f8 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -66,7 +66,9 @@ use windmill_common::{ OTEL_SETTING, OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING, PREVIEW_TAGS_OVERRIDE_SETTING, REQUEST_SIZE_LIMIT_SETTING, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, - RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, + RETENTION_PERIOD_SECS_SETTING, SAML_METADATA_SETTING, SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, + SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, + SANDBOX_IMAGE_PULL_POLICY_SETTING, SANDBOX_REGISTRY_AUTH_SETTING, SCIM_TOKEN_SETTING, STORE_AUDIT_LOGS_S3_SETTING, TIMEOUT_WAIT_RESULT_SETTING, UV_EXCLUDE_NEWER_SETTING, UV_INDEX_STRATEGY_SETTING, UV_PYTHON_INSTALL_MIRROR_SETTING, WORKSPACE_FAIRNESS_DURATION_SECS_SETTING, WORKSPACE_FAIRNESS_ENABLED_SETTING, @@ -112,8 +114,10 @@ use windmill_worker::{ JOB_DEFAULT_TIMEOUT, JOB_ISOLATION, KEEP_JOB_DIR, MAVEN_REPOS, MAVEN_SETTINGS_XML, NO_DEFAULT_MAVEN, NPMRC, NPM_CONFIG_REGISTRY, NSJAIL_AVAILABLE, NSJAIL_TMPFS_SIZE_MB, NSJAIL_TMP_BACKING, NUGET_CONFIG, OTEL_TRACING_PROXY_SETTINGS, PIP_EXTRA_INDEX_URL, - PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, UNSHARE_PATH, UV_EXCLUDE_NEWER, - UV_INDEX_STRATEGY, UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES, + PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL, SANDBOX_IMAGE_CACHE_MAX_MB, + SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, SANDBOX_IMAGE_PULL_POLICY, + SANDBOX_REGISTRY_AUTH, UNSHARE_PATH, UV_EXCLUDE_NEWER, UV_INDEX_STRATEGY, + UV_PYTHON_INSTALL_MIRROR, WORKSPACE_REGISTRIES, }; #[cfg(feature = "parquet")] @@ -407,6 +411,11 @@ pub async fn initial_load( reload_job_isolation_setting(&conn).await; reload_nsjail_tmpfs_size_setting(&conn).await; reload_nsjail_tmp_backing_setting(&conn).await; + reload_sandbox_image_max_size_setting(&conn).await; + reload_sandbox_image_cache_max_setting(&conn).await; + reload_sandbox_image_pull_policy_setting(&conn).await; + reload_sandbox_image_default_registry_setting(&conn).await; + reload_sandbox_registry_auth_setting(&conn).await; reload_extra_pip_index_url_setting(&conn).await; reload_pip_index_url_setting(&conn).await; reload_uv_index_strategy_setting(&conn).await; @@ -2045,6 +2054,66 @@ pub async fn reload_nsjail_tmp_backing_setting(conn: &Connection) { .await; } +pub async fn reload_sandbox_image_max_size_setting(conn: &Connection) { + reload_option_setting_with_tracing( + conn, + SANDBOX_IMAGE_MAX_SIZE_MB_SETTING, + "SANDBOX_IMAGE_MAX_SIZE_MB", + SANDBOX_IMAGE_MAX_SIZE_MB.clone(), + ) + .await; +} + +pub async fn reload_sandbox_image_cache_max_setting(conn: &Connection) { + reload_option_setting_with_tracing( + conn, + SANDBOX_IMAGE_CACHE_MAX_MB_SETTING, + "SANDBOX_IMAGE_CACHE_MAX_MB", + SANDBOX_IMAGE_CACHE_MAX_MB.clone(), + ) + .await; +} + +pub async fn reload_sandbox_image_pull_policy_setting(conn: &Connection) { + reload_option_setting_with_tracing( + conn, + SANDBOX_IMAGE_PULL_POLICY_SETTING, + "SANDBOX_IMAGE_PULL_POLICY", + SANDBOX_IMAGE_PULL_POLICY.clone(), + ) + .await; +} + +pub async fn reload_sandbox_image_default_registry_setting(conn: &Connection) { + reload_option_setting_with_tracing( + conn, + SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING, + "SANDBOX_IMAGE_DEFAULT_REGISTRY", + SANDBOX_IMAGE_DEFAULT_REGISTRY.clone(), + ) + .await; +} + +pub async fn reload_sandbox_registry_auth_setting(conn: &Connection) { + // Secret-aware: the value is a raw docker/podman auth.json with credentials, so + // it must never be logged. Load directly (the generic reload_option_setting path + // logs the value via load_option_setting_value) and only log a redacted message. + let q = + match load_value_from_global_settings_with_conn(conn, SANDBOX_REGISTRY_AUTH_SETTING, true) + .await + { + Ok(q) => q, + Err(e) => { + tracing::error!("Error reloading setting SANDBOX_REGISTRY_AUTH: {e:?}"); + return; + } + }; + let value = q.and_then(|q| serde_json::from_value::(q).ok()); + let configured = value.as_ref().is_some_and(|v| !v.trim().is_empty()); + *SANDBOX_REGISTRY_AUTH.write().await = value; + tracing::info!("Loaded setting SANDBOX_REGISTRY_AUTH (redacted), configured={configured}"); +} + pub async fn reload_job_isolation_setting(conn: &Connection) { let value = match load_value_from_global_settings_with_conn(conn, JOB_ISOLATION_SETTING, true).await { @@ -2705,6 +2774,26 @@ pub async fn monitor_db( } }; + // run every hour (120 iterations * 30s = 3600s) + let cleanup_stale_server_heartbeats_f = async { + if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(120) { + if let Some(db) = conn.as_sql() { + match windmill_api::cleanup_stale_server_heartbeats(db).await { + Ok(count) if count > 0 => { + tracing::info!( + "Deleted {} stale server_heartbeat background_task_state rows", + count + ); + } + Err(e) => { + tracing::error!("Error cleaning up stale server_heartbeat rows: {:?}", e); + } + _ => {} + } + } + } + }; + // run every hour (120 iterations * 30s = 3600s) let manage_audit_partitions_f = async { if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(120) { @@ -2763,6 +2852,7 @@ pub async fn monitor_db( native_triggers_sync_f, cleanup_notify_events_f, check_expiring_tokens_f, + cleanup_stale_server_heartbeats_f, manage_audit_partitions_f, export_audit_logs_to_object_store_f, cleanup_scheduled_job_deletions_f, diff --git a/backend/tests/dependency_map.rs b/backend/tests/dependency_map.rs index d6d49c8d85..48a50ef9b9 100644 --- a/backend/tests/dependency_map.rs +++ b/backend/tests/dependency_map.rs @@ -451,6 +451,7 @@ def main(): preserve_on_behalf_of: None, ws_error_handler_muted: None, labels: None, + skip_draft_deletion: None, }) .send() .await @@ -513,6 +514,7 @@ def main(): custom_path: None, preserve_on_behalf_of: None, labels: None, + skip_draft_deletion: None, }) .send() .await diff --git a/backend/tests/fixtures/jobs_read_auth.sql b/backend/tests/fixtures/jobs_read_auth.sql new file mode 100644 index 0000000000..e6b28fba0c --- /dev/null +++ b/backend/tests/fixtures/jobs_read_auth.sql @@ -0,0 +1,192 @@ +-- Fixture for the single-job read authorization regression test +-- (see tests/jobs_read_auth.rs). +-- +-- Users available from `base`: +-- test-user (admin, token SECRET_TOKEN) +-- test-user-2 (User, token SECRET_TOKEN_2) -- owner of the secret script +-- test-user-3 (User, token SECRET_TOKEN_3) -- the unprivileged "viewer" +-- +-- test-user-3 is NOT a member of any folder/group granting access to +-- `u/test-user-2/...`, so under the same RLS as `jobs/list` they cannot see any +-- of these jobs unless they created them. + +-- A tag-scoped token for test-user-2 (who can read both VICTIM (tag 'deno') and +-- the flow (tag 'flow')). The `if_jobs:filter_tags:deno` modifier restricts it to +-- the 'deno' tag, so it must NOT be able to mint a share token for the 'flow' job. +INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES ( + encode(sha256('SCOPED_DENO_TOKEN'::bytea), 'hex'), 'SCOPED_DEN', 'SCOPED_DENO_TOKEN', + 'test2@windmill.dev', 'scoped deno token', false, + ARRAY['jobs:read', 'if_jobs:filter_tags:deno'] +); + +-- RUNNING job: queued (no completed row) and owned by test-user-2. Used to check +-- that `completed/get_result_maybe?get_started=true` authorizes before disclosing +-- running-state to a non-reader. +INSERT INTO public.v2_job ( + id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, visible_to_owner +) VALUES ( + '77777777-7777-7777-7777-777777777777', 'test-workspace', 'test-user-2', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'script', 'deno', 'u/test-user-2/running_secret', 'deno', true +); +INSERT INTO public.v2_job_queue (id, workspace_id, scheduled_for, running, tag) VALUES + ('77777777-7777-7777-7777-777777777777', 'test-workspace', '2023-01-01 00:00:00', true, 'deno'); + +-- 1. VICTIM job: a completed run of test-user-2's private script, e.g. produced +-- by a public HTTP trigger. `created_by` is the route identity (test-user-2), +-- NOT the viewer; `permissioned_as`/`runnable_path` sit in test-user-2's +-- namespace; `visible_to_owner` is true. Its args + result carry secrets. +-- Pre-fix, test-user-3 could read all of these by UUID. +INSERT INTO public.v2_job ( + id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, visible_to_owner, args +) VALUES ( + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'test-workspace', 'test-user-2', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'script', 'deno', 'u/test-user-2/secret_script', 'deno', true, + '{"secret": "LEAK_TEST_ARGS"}' +); +INSERT INTO public.v2_job_completed ( + id, workspace_id, duration_ms, status, result +) VALUES ( + 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'test-workspace', 1000, + 'success'::job_status, '{"secret": "RESULT_SECRET"}' +); +INSERT INTO public.job_logs (job_id, workspace_id, logs) VALUES + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'test-workspace', 'secret logs LEAK_TEST_LOGS'); + +-- 2. APP-style job: run by the viewer (test-user-3) on behalf of an app whose +-- policy executes as test-user-2. `created_by` is the launching viewer, but +-- `permissioned_as`/`runnable_path` are the app owner's and +-- `visible_to_owner` is false (apps hide their component runs from the runs +-- list). This is the case that must KEEP working after the fix: the viewer +-- polls their own component result by UUID. +INSERT INTO public.v2_job ( + id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, visible_to_owner, args +) VALUES ( + 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'test-workspace', 'test-user-3', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'script', 'deno', 'u/test-user-2/app_component', 'deno', false, + '{"app_arg": "ok"}' +); +INSERT INTO public.v2_job_completed ( + id, workspace_id, duration_ms, status, result +) VALUES ( + 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'test-workspace', 1000, + 'success'::job_status, '{"app_result": "visible_to_launcher"}' +); + +-- 3. ANONYMOUS job: a public-trigger run whose creator is `anonymous`. Reading +-- it without authentication must keep working (unchanged behavior). +INSERT INTO public.v2_job ( + id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, visible_to_owner, args +) VALUES ( + 'cccccccc-cccc-cccc-cccc-cccccccccccc', 'test-workspace', 'anonymous', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'script', 'deno', 'u/test-user-2/public_trigger', 'deno', true, + '{"public": "arg"}' +); +INSERT INTO public.v2_job_completed ( + id, workspace_id, duration_ms, status, result +) VALUES ( + 'cccccccc-cccc-cccc-cccc-cccccccccccc', 'test-workspace', 1000, + 'success'::job_status, '{"public": "result"}' +); + +-- 4. FLOW + STEP: test-user-3 has *read* access to folder `shared` (extra_perms), +-- so they can see flow `f/shared/flow1` (run by test-user-2) even though they +-- did not launch it. The flow's STEP job runs the inner script +-- `u/test-user-2/inner_secret` (test-user-3 has NO direct ACL on it) and is +-- not in their list. Visibility must be INHERITED from the flow root: being +-- able to see the flow means being able to inspect its steps (the flow-run UI +-- fetches each step by id). This guards against the fix over-blocking. +INSERT INTO public.folder (workspace_id, name, display_name, owners, extra_perms, created_by) +VALUES ('test-workspace', 'shared', 'Shared Folder', '{"u/test-user-2"}', + '{"u/test-user-3": false}', 'test-user-2'); + +INSERT INTO public.v2_job ( + id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, visible_to_owner +) VALUES ( + 'dddddddd-dddd-dddd-dddd-dddddddddddd', 'test-workspace', 'test-user-2', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'flow', 'deno', 'f/shared/flow1', 'flow', true +); +INSERT INTO public.v2_job_completed ( + id, workspace_id, duration_ms, status, result +) VALUES ( + 'dddddddd-dddd-dddd-dddd-dddddddddddd', 'test-workspace', 1000, + 'success'::job_status, '{"flow": "done"}' +); + +INSERT INTO public.v2_job ( + id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, visible_to_owner, + parent_job, root_job, flow_innermost_root_job, args +) VALUES ( + 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', 'test-workspace', 'test-user-2', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'script', 'deno', 'u/test-user-2/inner_secret', 'deno', true, + 'dddddddd-dddd-dddd-dddd-dddddddddddd', 'dddddddd-dddd-dddd-dddd-dddddddddddd', + 'dddddddd-dddd-dddd-dddd-dddddddddddd', '{"step_arg": "x"}' +); +INSERT INTO public.v2_job_completed ( + id, workspace_id, duration_ms, status, result +) VALUES ( + 'eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee', 'test-workspace', 1000, + 'success'::job_status, '{"step": "STEP_RESULT_INHERITED"}' +); + +-- 5. DEEP NESTING / MIDDLE-LAYER VISIBILITY: top flow `f/secret/top` is NOT +-- visible to test-user-3; it has a sub-flow step `f/shared/mid` that IS visible +-- (folder `shared`); and that sub-flow has its own leaf step running +-- `u/test-user-2/deep_secret` (not visible). The leaf's `root_job` points at the +-- *outermost* top (not visible), so visibility must come from the *intermediate* +-- sub-flow the user can see — which requires walking the full parent chain, not +-- just [self, root]. +INSERT INTO public.folder (workspace_id, name, display_name, owners, extra_perms, created_by) +VALUES ('test-workspace', 'secret', 'Secret Folder', '{"u/test-user-2"}', '{}', 'test-user-2'); + +-- top flow (not visible to test-user-3) +INSERT INTO public.v2_job ( + id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, visible_to_owner +) VALUES ( + 'ffffffff-ffff-ffff-ffff-ffffffffffff', 'test-workspace', 'test-user-2', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'flow', 'deno', 'f/secret/top', 'flow', true +); +-- intermediate sub-flow (visible via folder `shared`), child of top +INSERT INTO public.v2_job ( + id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, visible_to_owner, + parent_job, root_job, flow_innermost_root_job +) VALUES ( + '99999999-9999-9999-9999-999999999999', 'test-workspace', 'test-user-2', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'flow', 'deno', 'f/shared/mid', 'flow', true, + 'ffffffff-ffff-ffff-ffff-ffffffffffff', 'ffffffff-ffff-ffff-ffff-ffffffffffff', + 'ffffffff-ffff-ffff-ffff-ffffffffffff' +); +-- leaf step of the sub-flow; runnable not visible, root_job = outermost top (not visible) +INSERT INTO public.v2_job ( + id, workspace_id, created_by, created_at, permissioned_as, permissioned_as_email, + kind, script_lang, runnable_path, tag, visible_to_owner, + parent_job, root_job, flow_innermost_root_job +) VALUES ( + '88888888-8888-8888-8888-888888888888', 'test-workspace', 'test-user-2', + '2023-01-01 00:00:00', 'u/test-user-2', 'test2@windmill.dev', + 'script', 'deno', 'u/test-user-2/deep_secret', 'deno', true, + '99999999-9999-9999-9999-999999999999', 'ffffffff-ffff-ffff-ffff-ffffffffffff', + '99999999-9999-9999-9999-999999999999' +); +INSERT INTO public.v2_job_completed (id, workspace_id, duration_ms, status, result) VALUES + ('ffffffff-ffff-ffff-ffff-ffffffffffff', 'test-workspace', 1000, 'success'::job_status, + '{"top": "TOP_SECRET_RESULT"}'), + ('99999999-9999-9999-9999-999999999999', 'test-workspace', 1000, 'success'::job_status, + '{"mid": "MID_RESULT"}'), + ('88888888-8888-8888-8888-888888888888', 'test-workspace', 1000, 'success'::job_status, + '{"deep": "DEEP_STEP_INHERITED"}'); diff --git a/backend/tests/fixtures/mcp_token_exfil.sql b/backend/tests/fixtures/mcp_token_exfil.sql new file mode 100644 index 0000000000..edf1113137 --- /dev/null +++ b/backend/tests/fixtures/mcp_token_exfil.sql @@ -0,0 +1,29 @@ +-- Fixture for the MCP token-exfiltration regression test. +-- +-- Models a malicious developer (test-user-3, a plain workspace member) who: +-- - owns an MCP resource they are allowed to read, and +-- - points that resource's `token` field at a secret variable living in a +-- folder they have NO access to (`f/locked`, only test-user/admin owns it). +-- +-- The secret variable `f/locked/secret_token` itself is inserted by the test in +-- Rust (so it is encrypted with the real workspace key); this fixture only sets +-- up the locked folder, the resource, and their permissions. + +-- Folder the developer cannot read (empty extra_perms, owned by admin only). +INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by) +VALUES ('test-workspace', 'locked', 'Locked Folder', '{"u/test-user"}', '{}', 'test-user'); + +-- MCP resource owned by the developer (so RLS lets them read the resource), +-- whose token references the locked secret. The URL is a non-resolvable public +-- host so that, for an authorized caller, resolution succeeds but the later +-- connection/SSRF step fails deterministically without network access. +INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by) +VALUES ( + 'test-workspace', + 'u/test-user-3/evil_mcp', + '{"name": "evil", "url": "https://mcp.invalid.windmill.test", "token": "$var:f/locked/secret_token"}', + 'MCP resource whose token points at a locked secret', + 'mcp', + '{}', + 'test-user-3' +); diff --git a/backend/tests/flow_engine_parity.rs b/backend/tests/flow_engine_parity.rs index bae776e7f1..78e45e404b 100644 --- a/backend/tests/flow_engine_parity.rs +++ b/backend/tests/flow_engine_parity.rs @@ -2916,6 +2916,7 @@ export function main() { expr: "flow_env.STOP === true".to_string(), skip_if_stopped: true, error_message: None, + error_include_result: false, }); m }; @@ -2966,6 +2967,92 @@ export function main() { Ok(()) } +// stop_after_if with `error_message` + `error_include_result` should fail the +// flow but preserve the stopping step's own result inside the raised error +// object, i.e. `{ "error": { .., "result": } }`. With the flag off +// (the default) the error object carries no `result`. Regression for the +// early-stop branch in `update_flow_status_after_job_completion_internal`. +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_stop_after_if_error_include_result(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + + let make_flow = |include_result: bool| { + let mut m = flow_module( + "step", + FlowModuleValue::RawScript { + input_transforms: Default::default(), + language: ScriptLang::Deno, + content: r#" +export function main() { + return { userErrors: ["email taken"], ok: false }; +} +"# + .to_string(), + path: None, + lock: None, + tag: None, + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ); + m.stop_after_if = Some(windmill_common::flows::StopAfterIf { + expr: "true".to_string(), + skip_if_stopped: false, + error_message: Some("API returned userErrors".to_string()), + error_include_result: include_result, + }); + FlowValue { modules: vec![m], same_worker: false, ..Default::default() } + }; + + // include_result = true: result preserves both the error and the step output + let job = RunJob::from(JobPayload::RawFlow { + value: make_flow(true), + path: None, + restarted_from: None, + }) + .run_until_complete(&db, false, server.addr.port()) + .await; + assert!( + !job.success, + "flow with raised early-stop error should fail" + ); + let result = job.json_result().unwrap(); + assert_eq!( + result["error"]["name"], "EarlyStopError", + "expected EarlyStopError; got {result:?}" + ); + assert_eq!(result["error"]["message"], "API returned userErrors"); + assert_eq!( + result["error"]["result"], + json!({ "userErrors": ["email taken"], "ok": false }), + "step result should be preserved under `error.result`; got {result:?}" + ); + + // include_result = false (default behavior): result is the bare error object + let job = RunJob::from(JobPayload::RawFlow { + value: make_flow(false), + path: None, + restarted_from: None, + }) + .run_until_complete(&db, false, server.addr.port()) + .await; + assert!( + !job.success, + "flow with raised early-stop error should fail" + ); + let result = job.json_result().unwrap(); + assert_eq!(result["error"]["name"], "EarlyStopError"); + assert!( + result["error"].get("result").is_none(), + "without the flag the error must not embed the step result; got {result:?}" + ); + + Ok(()) +} + // retry_if predicate sees flow_env. Regression for the two evaluate_retry // call sites in `update_flow_status_after_job_completion_internal` (lines // 1194 and 1576) which used to pass `None` for flow_env. @@ -3093,6 +3180,7 @@ export function main(i: number) { expr: "flow_env.STOP === true".to_string(), skip_if_stopped: true, error_message: None, + error_include_result: false, }); m }; @@ -3143,3 +3231,84 @@ export function main() { Ok(()) } + +// stop_after_all_iters_if with `error_message` + `error_include_result` fails the +// flow and embeds the loop's aggregated iteration results under `error.result`. +// Covers the loop/branch-all path where `nresult` is already populated with the +// aggregated results (distinct from the per-step fallback to `result`). +#[cfg(feature = "deno_core")] +#[sqlx::test(fixtures("base"))] +async fn test_stop_after_all_iters_if_error_includes_result( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + + let inner = flow_module( + "iter_step", + FlowModuleValue::RawScript { + input_transforms: [js_input("i", "flow_input.iter.value")].into(), + language: ScriptLang::Deno, + content: r#" +export function main(i: number) { + return { iter: i }; +} +"# + .to_string(), + path: None, + lock: None, + tag: None, + concurrency_settings: Default::default(), + is_trigger: None, + assets: None, + }, + ); + + let loop_module = { + let mut m = flow_module( + "loop", + FlowModuleValue::ForloopFlow { + iterator: InputTransform::Javascript { expr: "[1, 2, 3]".to_string() }, + modules: vec![inner], + modules_node: None, + skip_failures: false, + parallel: false, + parallelism: None, + squash: None, + }, + ); + m.stop_after_all_iters_if = Some(windmill_common::flows::StopAfterIf { + expr: "true".to_string(), + skip_if_stopped: false, + error_message: Some("loop failed".to_string()), + error_include_result: true, + }); + m + }; + + let flow = FlowValue { modules: vec![loop_module], 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; + + assert!( + !job.success, + "loop with a raised early-stop error should fail" + ); + let result = job.json_result().unwrap(); + assert_eq!(result["error"]["name"], "EarlyStopError", "got {result:?}"); + assert_eq!(result["error"]["message"], "loop failed"); + // error.result holds the aggregated iteration results (one per iteration) + let iters = result["error"]["result"].as_array().unwrap_or_else(|| { + panic!("error.result should be an array of iteration results; got {result:?}") + }); + let iter_values: Vec<_> = iters.iter().map(|r| r["iter"].clone()).collect(); + assert_eq!( + iter_values, + vec![json!(1), json!(2), json!(3)], + "error.result should contain each iteration's output; got {result:?}" + ); + + Ok(()) +} diff --git a/backend/tests/jobs_read_auth.rs b/backend/tests/jobs_read_auth.rs new file mode 100644 index 0000000000..f19daf0f92 --- /dev/null +++ b/backend/tests/jobs_read_auth.rs @@ -0,0 +1,512 @@ +//! Regression test for the single-job read authorization bypass. +//! +//! The single-job read endpoints (`/jobs_u/get`, `/completed/get`, +//! `/completed/get_result`, `/get_args`, `/get_logs`, `/getupdate`, ...) fetch a +//! job through the root DB handle, filtered only by job id + workspace. That is +//! required for the unauthenticated approval / public-trigger / anonymous-job +//! flows, but for a *logged-in* user it meant any workspace member — including a +//! plain viewer with no ACL on the runnable — could read another user's job +//! args/result/logs simply by obtaining the job UUID, even though the same job is +//! hidden from them in `jobs/list` (RLS-filtered) and the underlying script +//! returns 404. +//! +//! The fix (`require_job_read_access`) gates the authenticated case: a caller may +//! read a job they created (covers app components / webhooks / their own runs) +//! or one visible to them under the same RLS as `jobs/list` (admins bypass); +//! otherwise 404. Unauthenticated access is unchanged (anonymous jobs only). +//! +//! This test pins down, against the `jobs_read_auth` fixture: +//! - a viewer is denied the victim job's full record / result / result_maybe / +//! args / logs / live update by UUID, and the secret never appears in the +//! body (the core fix; pre-fix these returned 200 with the secret), +//! - the job's owner and an admin can still read it (no over-blocking), +//! - the "app component" affordance survives: a viewer who *launched* a job +//! (created_by) running as someone else's identity can still read its result, +//! - unauthenticated behavior is unchanged: anonymous jobs readable, the +//! non-anonymous victim job rejected. + +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +const VICTIM: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; +const APP_JOB: &str = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; +const ANON_JOB: &str = "cccccccc-cccc-cccc-cccc-cccccccccccc"; +const FLOW_JOB: &str = "dddddddd-dddd-dddd-dddd-dddddddddddd"; +const STEP_JOB: &str = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"; +// Deep nesting: top (not visible) -> mid (visible via folder) -> deep leaf. +const TOP_SECRET_FLOW: &str = "ffffffff-ffff-ffff-ffff-ffffffffffff"; +const DEEP_LEAF_JOB: &str = "88888888-8888-8888-8888-888888888888"; +// A queued/running job (no completed row) owned by test-user-2. +const RUNNING_JOB: &str = "77777777-7777-7777-7777-777777777777"; + +// Secrets that must never leak to an unauthorized viewer. +const RESULT_SECRET: &str = "RESULT_SECRET"; +const ARGS_SECRET: &str = "LEAK_TEST_ARGS"; +const LOGS_SECRET: &str = "LEAK_TEST_LOGS"; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +async fn get(base: &str, path: &str, token: Option<&str>) -> (reqwest::StatusCode, String) { + let mut req = client().get(format!("{base}/{path}")); + if let Some(token) = token { + req = req.header("Authorization", format!("Bearer {token}")); + } + let resp = req.send().await.expect("request"); + let status = resp.status(); + let body = resp.text().await.expect("body"); + (status, body) +} + +#[sqlx::test(fixtures("base", "jobs_read_auth"))] +async fn test_single_job_read_authorization(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/jobs_u"); + // result_by_id / get_otel_traces live on the authed `/jobs` service, not `/jobs_u`. + let authed_base = format!("http://localhost:{port}/api/w/test-workspace/jobs"); + + // The endpoints that return the victim job's sensitive data by UUID. + let endpoints = [ + ("get", format!("get/{VICTIM}")), + ("completed/get", format!("completed/get/{VICTIM}")), + ( + "completed/get_result", + format!("completed/get_result/{VICTIM}"), + ), + ( + "completed/get_result_maybe", + format!("completed/get_result_maybe/{VICTIM}"), + ), + ("get_args", format!("get_args/{VICTIM}")), + ("get_logs", format!("get_logs/{VICTIM}")), + ( + "get_completed_logs_tail", + format!("get_completed_logs_tail/{VICTIM}"), + ), + ("get_flow_all_logs", format!("get_flow_all_logs/{VICTIM}")), + ( + "completed/get_timing", + format!("completed/get_timing/{VICTIM}"), + ), + ("getupdate", format!("getupdate/{VICTIM}?only_result=true")), + ]; + + // ---- CORE REGRESSION: the viewer (test-user-3) is denied on every endpoint + // and no secret ever appears in the body. Pre-fix these returned 200 + // and leaked the secret. + for (name, path) in &endpoints { + let (status, body) = get(&base, path, Some("SECRET_TOKEN_3")).await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "viewer must get 403 on {name} (got {status}): {body}" + ); + for secret in [RESULT_SECRET, ARGS_SECRET, LOGS_SECRET] { + assert!( + !body.contains(secret), + "viewer response for {name} leaked `{secret}`: {body}" + ); + } + } + + // The 403 for an existing-but-forbidden job carries actionable guidance + // (request a share link), distinguishing it from a plain not-found. + let (status, body) = get( + &base, + &format!("completed/get_result/{VICTIM}"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert_eq!(status, reqwest::StatusCode::FORBIDDEN); + assert!( + body.to_lowercase().contains("share"), + "403 body should guide the user to request a share link: {body}" + ); + + // A genuinely non-existent job is a 404, not a 403 — existence is only disclosed + // for jobs that actually exist in the workspace. + let missing = "00000000-0000-4000-8000-000000000000"; + let (status, _) = get( + &base, + &format!("completed/get_result/{missing}"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::NOT_FOUND, + "a non-existent job must be 404, not 403 (got {status})" + ); + + // ---- NO OVER-BLOCKING: the job's owner (test-user-2) can read its result. + let (status, body) = get( + &base, + &format!("completed/get_result/{VICTIM}"), + Some("SECRET_TOKEN_2"), + ) + .await; + assert!( + status.is_success(), + "owner must still read their own job result (got {status}): {body}" + ); + assert!( + body.contains(RESULT_SECRET), + "owner result must contain the value: {body}" + ); + + // ---- ADMIN BYPASS: an admin (test-user) can read any job in the workspace. + let (status, body) = get( + &base, + &format!("completed/get_result/{VICTIM}"), + Some("SECRET_TOKEN"), + ) + .await; + assert!( + status.is_success(), + "admin must read any job (got {status}): {body}" + ); + assert!(body.contains(RESULT_SECRET), "admin result body: {body}"); + + // ---- APP AFFORDANCE: a viewer who LAUNCHED a job (created_by = viewer) that + // runs as another identity (permissioned_as = test-user-2, + // visible_to_owner = false) can still read its result. This is the app + // component-polling path; the fix must not break it. + let (status, body) = get( + &base, + &format!("completed/get_result/{APP_JOB}"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert!( + status.is_success(), + "launcher must read a job they created even without ACL on the runnable (got {status}): {body}" + ); + assert!( + body.contains("visible_to_launcher"), + "launcher should get the result they polled: {body}" + ); + + // ---- AUTHED `/jobs` endpoints in the same class: result_by_id (flow node + // result) and get_otel_traces (job telemetry). The viewer must be denied + // the victim by UUID. The auth gate runs before result/trace resolution, + // so 404 here is the gate, not incidental resolution failure. + let (status, body) = get( + &authed_base, + &format!("result_by_id/{VICTIM}/somenode"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "viewer must get 403 on result_by_id (got {status}): {body}" + ); + assert!(!body.contains(RESULT_SECRET), "result_by_id leaked: {body}"); + + let (status, body) = get( + &authed_base, + &format!("get_otel_traces/{VICTIM}"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "viewer must get 403 on get_otel_traces (got {status}): {body}" + ); + + // ---- FLOW VISIBILITY INHERITANCE: test-user-3 has folder ACL on the flow + // `f/shared/flow1` (run by test-user-2) but did NOT launch it, and has no + // ACL on the step's inner runnable `u/test-user-2/inner_secret`. They must + // still be able to (a) read the flow they can see, and (b) inspect its + // step result — visibility is inherited from the flow root. A naive + // "same as list" gate would 404 the step and break the flow-run UI. + let (status, body) = get( + &base, + &format!("completed/get_result/{FLOW_JOB}"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert!( + status.is_success(), + "viewer with folder ACL must read the flow they can see (got {status}): {body}" + ); + let (status, body) = get( + &base, + &format!("completed/get_result/{STEP_JOB}"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert!( + status.is_success(), + "viewer must inspect a step of a flow they can see, even without ACL on the step's runnable (got {status}): {body}" + ); + assert!( + body.contains("STEP_RESULT_INHERITED"), + "step result should be returned via flow-root inheritance: {body}" + ); + + // ---- DEEP NESTING / MIDDLE-LAYER VISIBILITY: the deep leaf's root_job is the + // top flow (NOT visible to test-user-3), but an intermediate sub-flow + // (f/shared/mid) IS visible. Reading the leaf must succeed via that middle + // ancestor — i.e. the full parent chain is walked, not just [self, root]. + let (status, body) = get( + &base, + &format!("completed/get_result/{DEEP_LEAF_JOB}"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert!( + status.is_success(), + "deep leaf must be readable via a visible intermediate sub-flow (got {status}): {body}" + ); + assert!( + body.contains("DEEP_STEP_INHERITED"), + "deep leaf result should be returned via mid-ancestor visibility: {body}" + ); + // ...but the top flow itself, in a folder the viewer cannot read, stays denied. + let (status, body) = get( + &base, + &format!("completed/get_result/{TOP_SECRET_FLOW}"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "top flow in an unreadable folder must stay denied (got {status}): {body}" + ); + + // ---- UNAUTHENTICATED, unchanged: an anonymous-created job is readable + // without a token (public trigger / public app result polling). + let (status, body) = get(&base, &format!("completed/get_result/{ANON_JOB}"), None).await; + assert!( + status.is_success(), + "anonymous job must remain readable unauthenticated (got {status}): {body}" + ); + + // ---- UNAUTHENTICATED, unchanged: the non-anonymous victim job is rejected + // for an unauthenticated caller (400, the pre-existing guard). + let (status, body) = get(&base, &format!("completed/get_result/{VICTIM}"), None).await; + assert_eq!( + status, + reqwest::StatusCode::BAD_REQUEST, + "unauthenticated access to a non-anonymous job must stay rejected (got {status}): {body}" + ); + assert!( + !body.contains(RESULT_SECRET), + "unauth body must not leak: {body}" + ); + + // ---- SHARE READ LINK (view_token) ---- + // The owner (test-user-2) mints a share token for the victim job. + let (status, mint_body) = get( + &authed_base, + &format!("job_view_token/{VICTIM}"), + Some("SECRET_TOKEN_2"), + ) + .await; + assert!( + status.is_success(), + "owner must be able to mint a share token (got {status}): {mint_body}" + ); + let token = mint_body.trim().trim_matches('"').to_string(); + assert!( + token.starts_with(VICTIM), + "token must encode the job id: {token}" + ); + + // The viewer (no ACL) can now read the victim job via the share link. + let (status, body) = get( + &base, + &format!("completed/get_result/{VICTIM}?view_token={token}"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert!( + status.is_success(), + "view_token must grant the viewer read of the shared job (got {status}): {body}" + ); + assert!( + body.contains(RESULT_SECRET), + "shared job result must be returned with a valid view_token: {body}" + ); + // ...and its args/logs too (whole detail page). + let (status, _) = get( + &base, + &format!("get_args/{VICTIM}?view_token={token}"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert!( + status.is_success(), + "view_token must also grant args (got {status})" + ); + + // The token is scoped: it does NOT authorize an unrelated job. + let (status, _) = get( + &base, + &format!("completed/get_result/{ANON_JOB}?view_token={token}"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "a victim-scoped token must not authorize a different job (got {status})" + ); + + // A garbage token is rejected (falls through to the normal 404). + let (status, _) = get( + &base, + &format!("completed/get_result/{VICTIM}?view_token={VICTIM}.deadbeef"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "an invalid view_token must not grant access (got {status})" + ); + + // A share token authorizes the shared job's whole flow subtree: the owner mints + // for the top secret flow, and the viewer can then read its deep leaf. + let (status, mint_body) = get( + &authed_base, + &format!("job_view_token/{TOP_SECRET_FLOW}"), + Some("SECRET_TOKEN_2"), + ) + .await; + assert!( + status.is_success(), + "owner mints token for top flow (got {status}): {mint_body}" + ); + let top_token = mint_body.trim().trim_matches('"').to_string(); + let (status, body) = get( + &base, + &format!("completed/get_result/{DEEP_LEAF_JOB}?view_token={top_token}"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert!( + status.is_success(), + "a flow's share token must authorize its deep descendants (got {status}): {body}" + ); + + // A viewer who cannot read a job cannot mint a share token for it. + let (status, _) = get( + &authed_base, + &format!("job_view_token/{TOP_SECRET_FLOW}"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "a non-reader must not be able to mint a share token (got {status})" + ); + + // ---- TAG-SCOPED token must not mint a token outside its allowed tags ---- + // SCOPED_DENO_TOKEN (test-user-2, scope `if_jobs:filter_tags:deno`) can read both + // VICTIM (tag deno) and FLOW_JOB (tag flow) by RLS, but minting must honor the + // tag scope: allowed for the deno job, denied for the flow job. + let (status, body) = get( + &authed_base, + &format!("job_view_token/{VICTIM}"), + Some("SCOPED_DENO_TOKEN"), + ) + .await; + assert!( + status.is_success(), + "tag-scoped token may mint for an in-scope (deno) job (got {status}): {body}" + ); + let (status, _) = get( + &authed_base, + &format!("job_view_token/{FLOW_JOB}"), + Some("SCOPED_DENO_TOKEN"), + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::NOT_FOUND, + "tag-scoped token must NOT mint for an out-of-scope (flow) job (got {status})" + ); + + // ---- USE side: a tag-scoped token must not use someone else's valid view_token + // to read an out-of-scope job, even via handlers that don't tag-filter their + // data query (result_by_id, get_otel_traces, get_flow_debug_info). ---- + // An unscoped owner mints a valid token for the flow (tag 'flow'). + let (status, mint_body) = get( + &authed_base, + &format!("job_view_token/{FLOW_JOB}"), + Some("SECRET_TOKEN_2"), + ) + .await; + assert!( + status.is_success(), + "owner mints flow token (got {status}): {mint_body}" + ); + let flow_token = mint_body.trim().trim_matches('"').to_string(); + + // The deno-scoped token presents that valid flow token to the non-tag-filtered + // endpoints — must still be denied (flow tag is out of its scope). + for path in [ + format!("get_otel_traces/{FLOW_JOB}?view_token={flow_token}"), + format!("result_by_id/{FLOW_JOB}/somenode?view_token={flow_token}"), + ] { + let (status, _) = get(&authed_base, &path, Some("SCOPED_DENO_TOKEN")).await; + assert_eq!( + status, + reqwest::StatusCode::NOT_FOUND, + "tag-scoped token must not use a view_token to read an out-of-scope job ({path}, got {status})" + ); + } + + // ...but the deno-scoped token CAN use an in-scope (deno) view_token. + let (status, body) = get( + &base, + &format!("completed/get_result/{VICTIM}?view_token={token}"), + Some("SCOPED_DENO_TOKEN"), + ) + .await; + assert!( + status.is_success(), + "tag-scoped token may use a view_token for an in-scope (deno) job (got {status}): {body}" + ); + + // ---- get_result_maybe?get_started=true must authorize before disclosing the + // running-state of a queued (not-yet-completed) private job. ---- + // Viewer (no ACL) must be denied rather than told the job is started. + let (status, body) = get( + &base, + &format!("completed/get_result_maybe/{RUNNING_JOB}?get_started=true"), + Some("SECRET_TOKEN_3"), + ) + .await; + assert_eq!( + status, + reqwest::StatusCode::FORBIDDEN, + "viewer must be denied the running-state of a private queued job (got {status}): {body}" + ); + assert!( + !body.contains("\"started\""), + "denied response must not disclose started-state: {body}" + ); + // The owner still gets the in-progress response. + let (status, body) = get( + &base, + &format!("completed/get_result_maybe/{RUNNING_JOB}?get_started=true"), + Some("SECRET_TOKEN_2"), + ) + .await; + assert!( + status.is_success() && body.contains("\"started\":true"), + "owner must see the running job as started (got {status}): {body}" + ); + + Ok(()) +} diff --git a/backend/tests/mcp_token_exfil.rs b/backend/tests/mcp_token_exfil.rs new file mode 100644 index 0000000000..ce278f3c53 --- /dev/null +++ b/backend/tests/mcp_token_exfil.rs @@ -0,0 +1,111 @@ +//! Regression test for the MCP token-exfiltration vulnerability. +//! +//! `GET /api/w/{w}/resources/mcp_tools/{path}` builds an MCP client from a +//! resource whose `token` field is a `$var:` reference. Before the fix the token +//! was resolved with `get_secret_value_as_admin` on the bare DB pool — no RLS, +//! no audit — so any workspace member who could read an MCP *resource* could +//! point its token at *any* secret variable in the workspace (e.g. one in an +//! admin-only folder) and have it decrypted and shipped as a bearer token. +//! +//! The fix resolves the token through the caller's permissioned path +//! (`get_value_internal` over the authed `user_db`), so the variable RLS — the +//! same gate as `variables/get_value` — applies and the secret read is audited. +//! +//! This test pins, against the `mcp_token_exfil` fixture: +//! - a plain developer (test-user-3) who can read the MCP resource but has no +//! access to the locked secret is DENIED (401) at token resolution, before +//! any connection is attempted, and the secret never leaks; +//! - an admin (test-user) clears the variable-RLS gate, the token resolves, +//! and the request only fails later at the connect/SSRF step — proving the +//! legitimate path still resolves the token (no over-blocking). +//! +//! SSRF rejection of an author-controlled URL is covered by the unit test in +//! `windmill-mcp` (`from_resource_rejects_ssrf_url`). +#![cfg(feature = "mcp")] + +use sqlx::{Pool, Postgres}; +use windmill_test_utils::*; + +const SECRET_VALUE: &str = "S3CRET-MCP-TOKEN-VALUE"; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +async fn get(base: &str, path: &str, token: &str) -> (reqwest::StatusCode, String) { + let resp = client() + .get(format!("{base}/{path}")) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .expect("request"); + let status = resp.status(); + let body = resp.text().await.expect("body"); + (status, body) +} + +#[sqlx::test(fixtures("base", "mcp_token_exfil"))] +async fn test_mcp_token_not_exfiltrated(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + // Insert the locked secret variable with a real, workspace-key-encrypted + // value so an authorized read genuinely decrypts it. + let mc = windmill_common::variables::build_crypt(&db, "test-workspace").await?; + let encrypted = windmill_common::variables::encrypt(&mc, SECRET_VALUE); + // Runtime-checked query (not the `query!` macro) so no offline `.sqlx` cache + // entry is needed for this test-only insert. + sqlx::query( + "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms) + VALUES ('test-workspace', 'f/locked/secret_token', $1, true, 'Locked secret', '{}')", + ) + .bind(&encrypted) + .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/resources/mcp_tools"); + let path = "u/test-user-3/evil_mcp"; + + // ---- CORE REGRESSION: the developer can read the resource but must NOT be + // able to resolve the locked secret. They are denied (401) at the + // variable-RLS gate, before any MCP connection is attempted, and the + // secret never appears in the response. + let (status, body) = get(&base, path, "SECRET_TOKEN_3").await; + assert_eq!( + status, + reqwest::StatusCode::UNAUTHORIZED, + "developer must be denied resolving a secret they can't read (got {status}): {body}" + ); + assert!( + !body.contains(SECRET_VALUE), + "the locked secret must never leak to the developer: {body}" + ); + assert!( + body.contains("don't have access"), + "denial should come from the variable-RLS gate, not a connection error: {body}" + ); + // Pre-fix, the token was decrypted as admin and the handler proceeded to the + // connection step; that path must no longer be reached for the developer. + assert!( + !body.contains("Failed to connect to MCP server"), + "developer must be blocked before the connection step (would mean the token was resolved): {body}" + ); + + // ---- NO OVER-BLOCKING: an admin clears the variable-RLS gate, so the token + // resolves and the request only fails later at the connect/SSRF step. + // A different failure mode (not 401, reaches the connection) proves the + // legitimate read still works. + let (status, body) = get(&base, path, "SECRET_TOKEN").await; + assert_ne!( + status, + reqwest::StatusCode::UNAUTHORIZED, + "admin must clear the variable-RLS gate (got {status}): {body}" + ); + assert!( + body.contains("Failed to connect to MCP server"), + "admin should resolve the token and only fail at the connect/SSRF step: {body}" + ); + + Ok(()) +} diff --git a/backend/tests/otel.rs b/backend/tests/otel.rs index 2cf10b52d1..3a81f31021 100644 --- a/backend/tests/otel.rs +++ b/backend/tests/otel.rs @@ -507,3 +507,123 @@ async fn test_root_job_span_attributes_values() { assert_eq!(get_attr("workspace_id"), "test-workspace"); assert_eq!(get_attr("script_path"), "f/test/script"); } + +// ═══════════════════════════════════════════════════════════════════════ +// INBOUND TRACE CONTEXT (W3C traceparent → span link) +// ═══════════════════════════════════════════════════════════════════════ + +const SAMPLE_TRACEPARENT: &str = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; + +fn sample_trace_id() -> opentelemetry::trace::TraceId { + opentelemetry::trace::TraceId::from_hex("0af7651916cd43dd8448eb211c80319c").unwrap() +} + +fn sample_span_id() -> opentelemetry::trace::SpanId { + opentelemetry::trace::SpanId::from_hex("b7ad6b7169203331").unwrap() +} + +#[test] +fn test_span_cx_from_traceparent_valid() { + let cx = span_cx_from_traceparent(SAMPLE_TRACEPARENT).expect("valid traceparent"); + assert_eq!(cx.trace_id(), sample_trace_id()); + assert_eq!(cx.span_id(), sample_span_id()); + assert!(cx.is_remote()); + assert!(cx.is_sampled()); +} + +#[test] +fn test_span_cx_from_traceparent_unsampled_flag() { + let cx = span_cx_from_traceparent("00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-00") + .expect("valid traceparent"); + assert!(!cx.is_sampled()); +} + +#[test] +fn test_span_cx_from_traceparent_malformed() { + for bad in [ + "", + "garbage", + "00-tooshort-b7ad6b7169203331-01", + // missing flags field + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331", + // trailing extra field + "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01-extra", + // all-zero trace id / span id are invalid per the spec + "00-00000000000000000000000000000000-b7ad6b7169203331-01", + "00-0af7651916cd43dd8448eb211c80319c-0000000000000000-01", + // non-hex + "00-zzf7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01", + ] { + assert!( + span_cx_from_traceparent(bad).is_none(), + "expected None for {bad:?}" + ); + } +} + +fn job_with_traceparent(tp: Option<&str>) -> windmill_queue::MiniPulledJob { + let mut job = make_test_job(uuid::Uuid::new_v4(), None); + if let Some(tp) = tp { + let mut args = std::collections::HashMap::new(); + args.insert( + windmill_common::jobs::WM_TRACEPARENT.to_string(), + windmill_common::worker::to_raw_value(&tp), + ); + job.args = Some(sqlx::types::Json(args)); + } + job +} + +#[test] +fn test_inbound_span_cx_from_job_present() { + let job = job_with_traceparent(Some(SAMPLE_TRACEPARENT)); + let cx = windmill_worker::otel_ee::inbound_span_cx_from_job(&job).expect("link expected"); + assert_eq!(cx.trace_id(), sample_trace_id()); + assert_eq!(cx.span_id(), sample_span_id()); +} + +#[test] +fn test_inbound_span_cx_from_job_absent_or_malformed() { + // No reserved key (e.g. a flow step or internally-created job) → no link. + assert!( + windmill_worker::otel_ee::inbound_span_cx_from_job(&job_with_traceparent(None)).is_none() + ); + // Malformed header is ignored rather than producing a bogus link. + assert!( + windmill_worker::otel_ee::inbound_span_cx_from_job(&job_with_traceparent(Some("garbage"))) + .is_none() + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_root_job_span_relocated_to_inbound_trace() { + let state = ensure_setup().await; + state.span_exporter.reset(); + + let job = job_with_traceparent(Some(SAMPLE_TRACEPARENT)); + let job_id = job.id; + windmill_worker::otel_ee::add_root_flow_job_to_otlp(&job, true); + + let spans = state.span_exporter.get_finished_spans().unwrap(); + let span = spans + .iter() + .find(|s| s.name == "full_job") + .expect("full_job span not found"); + + // Relocated into the inbound trace, keeping the job-UUID-derived span id and + // parented on the inbound caller span. + assert_eq!(span.span_context.trace_id(), sample_trace_id()); + let expected_span_id = + opentelemetry::trace::SpanId::from_bytes(job_id.as_u64_pair().1.to_be_bytes()); + assert_eq!(span.span_context.span_id(), expected_span_id); + assert_eq!(span.parent_span_id, sample_span_id()); + + // Linked back to the UUID-derived context so trace-by-job-id still resolves. + assert_eq!(span.links.links.len(), 1); + let expected_uuid_trace = + opentelemetry::trace::TraceId::from_bytes(job_id.as_u128().to_be_bytes()); + assert_eq!( + span.links.links[0].span_context.trace_id(), + expected_uuid_trace + ); +} diff --git a/backend/tests/preview_native_tag.rs b/backend/tests/preview_native_tag.rs new file mode 100644 index 0000000000..29aefa588a --- /dev/null +++ b/backend/tests/preview_native_tag.rs @@ -0,0 +1,122 @@ +/* + * Regression tests for WIN-2007. + * + * Previewing a TypeScript script carrying the `//native` annotation used to be + * pushed with `language = bun` (what the editor sends), so the job was tagged + * `bun` and routed to a regular bun worker. A native-mode worker neither matches + * the `bun` tag nor accepts a non-native `script_lang`, so previewing a `//native` + * script on a native-only worker setup failed even though the *deployed* version + * of the same script runs fine (as `bunnative` / tag `nativets`). + * + * `push` now reconciles the preview language with the `//native` annotation, + * mirroring the deploy-time logic in `worker_lockfiles`. These tests assert the + * queued job ends up with the right `script_lang` and `tag` for every combination + * of declared language and annotation. No worker is spawned — we only inspect the + * row `push` writes. + */ + +use sqlx::{Pool, Postgres}; +use windmill_common::{ + jobs::{JobPayload, RawCode}, + scripts::ScriptLang, +}; +use windmill_queue::PushIsolationLevel; + +async fn push_preview_and_get_row( + db: &Pool, + content: &str, + language: ScriptLang, +) -> (String, Option) { + let hm_args = std::collections::HashMap::new(); + + let job = JobPayload::Code(RawCode { + hash: None, + content: content.to_string(), + path: None, + language, + 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, + }); + + let tx = PushIsolationLevel::IsolatedRoot(db.clone()); + let (uuid, tx) = windmill_queue::push( + db, + tx, + "test-workspace", + job, + windmill_queue::PushArgs::from(&hm_args), + /* user */ "test-user", + /* email */ "test@windmill.dev", + /* permissioned_as */ "u/test-user".to_string(), + /* token_prefix */ None, + /* scheduled_for */ None, + /* schedule_path */ None, + /* parent_job */ None, + /* root_job */ None, + /* flow_innermost_root_job */ None, + /* job_id */ None, + /* is_flow_step */ false, + /* same_worker */ false, + None, + true, + None, + None, + None, + None, + None, + false, + None, + None, + None, + ) + .await + .expect("push must succeed"); + tx.commit().await.unwrap(); + + let row = sqlx::query!( + r#"SELECT tag, script_lang AS "script_lang: ScriptLang" FROM v2_job WHERE id = $1"#, + uuid + ) + .fetch_one(db) + .await + .unwrap(); + (row.tag, row.script_lang) +} + +const NATIVE_CONTENT: &str = r#"//native + +export function main(x: number) { + return x; +} +"#; + +const PLAIN_CONTENT: &str = r#"export function main(x: number) { + return x; +} +"#; + +/// The reported case: editor sends `bun`, content has `//native`. The preview +/// must be promoted to `bunnative` so it tags `nativets` and a native worker +/// (which rejects non-native `script_lang`) can run it. +#[sqlx::test(fixtures("base"))] +async fn test_bun_with_native_annotation_becomes_nativets(db: Pool) { + let (tag, lang) = push_preview_and_get_row(&db, NATIVE_CONTENT, ScriptLang::Bun).await; + assert_eq!(lang, Some(ScriptLang::Bunnative)); + assert_eq!(tag, "nativets"); +} + +/// Guard: a plain bun preview (no `//native`) must stay `bun` / tag `bun`, so +/// the promotion above doesn't broadly retag normal previews. +#[sqlx::test(fixtures("base"))] +async fn test_bun_without_native_annotation_stays_bun(db: Pool) { + let (tag, lang) = push_preview_and_get_row(&db, PLAIN_CONTENT, ScriptLang::Bun).await; + assert_eq!(lang, Some(ScriptLang::Bun)); + assert_eq!(tag, "bun"); +} diff --git a/backend/tests/workspace_fairness.rs b/backend/tests/workspace_fairness.rs new file mode 100644 index 0000000000..f5922037e4 --- /dev/null +++ b/backend/tests/workspace_fairness.rs @@ -0,0 +1,1688 @@ +//! Tests for the workspace-fairness algorithm (Enterprise feature). +//! +//! Multi-tenant clusters with a single shared worker pool let one workspace +//! starve the others if it floods the queue. The algorithm in +//! `windmill_queue::workspace_fairness_ee` periodically aggregates +//! per-workspace activity and stochastically excludes any workspace whose +//! share of cluster activity exceeds `WORKSPACE_FAIRNESS_MAX_PERCENT`%. +//! +//! There are two layers of tests in this file: +//! +//! 1. **Unit-style tests** (the first six) exercise the algorithm's response +//! to fabricated activity tables and verify the audit-log writer. They are +//! deterministic and fast. +//! +//! 2. **Simulation tests** (`fairness_50_workers_diverse_workload`, +//! `fairness_oscillation_long_run`, `fairness_burst_then_stop`) spin up +//! 50 mock workers (async tasks doing the real pull → mark-running → +//! sleep → complete cycle over real `v2_job_queue` rows), drive sustained +//! diverse traffic from one noisy workspace + many victim workspaces, and +//! measure the per-workspace **quality of service**. They are marked +//! `#[ignore]` so the default `cargo test` stays fast — run with +//! `--ignored` to exercise them. +//! +//! The entire file is gated on `private` because the algorithm itself only +//! compiles into the binary in EE builds. In OSS the `workspace_fairness` +//! module is a thin set of no-op stubs, so a test against it would have +//! nothing to assert. + +#![cfg(feature = "private")] + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; +use serial_test::serial; +use sqlx::{Pool, Postgres}; +use tokio::sync::Mutex; +use uuid::Uuid; + +use windmill_common::worker::{ + WORKSPACE_FAIRNESS_DURATION_SECS, WORKSPACE_FAIRNESS_ENABLED, + WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS, WORKSPACE_FAIRNESS_MAX_PERCENT, + WORKSPACE_FAIRNESS_MIN_TOTAL, WORKSPACE_FAIRNESS_OVERLOADED, +}; +use windmill_queue::workspace_fairness::refresh_overloaded; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +fn reset_fairness_state() { + WORKSPACE_FAIRNESS_OVERLOADED.store(Arc::new(vec![])); + WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS.store(0, Ordering::Relaxed); + WORKSPACE_FAIRNESS_ENABLED.store(true, Ordering::Relaxed); + WORKSPACE_FAIRNESS_MAX_PERCENT.store(50, Ordering::Relaxed); + WORKSPACE_FAIRNESS_DURATION_SECS.store(10, Ordering::Relaxed); + WORKSPACE_FAIRNESS_MIN_TOTAL.store(4, Ordering::Relaxed); +} + +async fn create_workspace(db: &Pool, id: &str) { + sqlx::query( + "INSERT INTO workspace (id, name, owner) + VALUES ($1, $1, 'test-user') ON CONFLICT (id) DO NOTHING", + ) + .bind(id) + .execute(db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO workspace_settings (workspace_id) VALUES ($1) + ON CONFLICT (workspace_id) DO NOTHING", + ) + .bind(id) + .execute(db) + .await + .unwrap(); +} + +/// Insert `n` completed jobs for `workspace_id`, each ending `secs_ago` +/// seconds in the past with a 1-second wall-clock duration. The fairness +/// algorithm weights contributions by `duration_ms` (clamped to the window), +/// so each job contributes ~1 worker-second when fully inside the window. +async fn insert_completed(db: &Pool, workspace_id: &str, n: usize, secs_ago: i32) { + for _ in 0..n { + let id: Uuid = sqlx::query_scalar( + "INSERT INTO v2_job (id, workspace_id, kind) + VALUES (gen_random_uuid(), $1, 'script'::job_kind) RETURNING id", + ) + .bind(workspace_id) + .fetch_one(db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status, + started_at, completed_at) + VALUES ($1, $2, 1000, 'success'::job_status, + NOW() - make_interval(secs => ($3::int + 1)), + NOW() - make_interval(secs => $3::int))", + ) + .bind(id) + .bind(workspace_id) + .bind(secs_ago) + .execute(db) + .await + .unwrap(); + } +} + +async fn insert_queued( + db: &Pool, + workspace_id: &str, + n: usize, + running: bool, + tag: &str, +) -> Vec { + let mut ids = Vec::with_capacity(n); + for _ in 0..n { + let id: Uuid = sqlx::query_scalar( + "INSERT INTO v2_job (id, workspace_id, kind, tag) + VALUES (gen_random_uuid(), $1, 'script'::job_kind, $2) RETURNING id", + ) + .bind(workspace_id) + .bind(tag) + .fetch_one(db) + .await + .unwrap(); + // Running jobs need a `started_at` for the fairness algorithm to + // compute a positive elapsed-time contribution. Backdate by 1s so + // each running row contributes ~1 worker-second by the time the + // refresh runs, matching the `insert_completed` scale. + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, started_at) + VALUES ($1, $2, NOW(), $3, $4, + CASE WHEN $3 THEN NOW() - interval '1 second' ELSE NULL END)", + ) + .bind(id) + .bind(workspace_id) + .bind(running) + .bind(tag) + .execute(db) + .await + .unwrap(); + if running { + // The fairness algorithm bounds the running contribution by the + // per-job `v2_job_runtime.ping`. Insert a fresh ping so each + // running row accrues real-time worker-seconds. + sqlx::query( + "INSERT INTO v2_job_runtime (id, ping) VALUES ($1, NOW()) + ON CONFLICT (id) DO UPDATE SET ping = NOW()", + ) + .bind(id) + .execute(db) + .await + .unwrap(); + insert_live_worker_ping(db, workspace_id, id).await; + } + ids.push(id); + } + ids +} + +/// Insert a live `worker_ping` row claiming the given job. Each insert uses +/// a fresh randomly-named worker so callers can stack multiple pings without +/// PK collisions on `worker`. +async fn insert_live_worker_ping(db: &Pool, workspace_id: &str, job_id: Uuid) { + let worker_name = format!("test-worker-{}", Uuid::new_v4()); + sqlx::query( + "INSERT INTO worker_ping (worker, worker_instance, ping_at, ip, current_job_id, current_job_workspace_id) + VALUES ($1, 'test', NOW(), '127.0.0.1', $2, $3)", + ) + .bind(&worker_name) + .bind(job_id) + .bind(workspace_id) + .execute(db) + .await + .unwrap(); +} + +/// Insert a "zombie" running row: a row in `v2_job_queue` with `running=true` +/// but **no** live `worker_ping` claiming it (no paired worker, or the worker +/// has stopped pinging). The fairness algorithm must NOT count these — they +/// don't consume any worker slot. +async fn insert_zombie_running(db: &Pool, workspace_id: &str, n: usize) -> Vec { + let mut ids = Vec::with_capacity(n); + for _ in 0..n { + let id: Uuid = sqlx::query_scalar( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, started_at) + VALUES (gen_random_uuid(), $1, NOW() - interval '1 hour', true, 'deno', + NOW() - interval '1 hour') RETURNING id", + ) + .bind(workspace_id) + .fetch_one(db) + .await + .unwrap(); + ids.push(id); + } + ids +} + +/// Insert a concurrency-suspended row: `running=true` AND `suspend > 0`. These +/// rows are not being processed by any worker (the flow is paused), so the +/// algorithm must not count them as slot occupancy. +async fn insert_suspended_running(db: &Pool, workspace_id: &str, n: usize) -> Vec { + let mut ids = Vec::with_capacity(n); + for _ in 0..n { + let id: Uuid = sqlx::query_scalar( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, suspend, tag) + VALUES (gen_random_uuid(), $1, NOW(), true, 1, 'deno') RETURNING id", + ) + .bind(workspace_id) + .fetch_one(db) + .await + .unwrap(); + ids.push(id); + } + ids +} + +fn overloaded_set() -> Vec { + (**WORKSPACE_FAIRNESS_OVERLOADED.load()).clone() +} + +// --------------------------------------------------------------------------- +// Unit-style algorithm tests +// --------------------------------------------------------------------------- + +#[sqlx::test(fixtures("base"))] +#[serial] +async fn fairness_caps_dominant_workspace(db: Pool) { + reset_fairness_state(); + create_workspace(&db, "noisy").await; + create_workspace(&db, "victim_a").await; + create_workspace(&db, "victim_b").await; + + insert_completed(&db, "noisy", 60, 2).await; + insert_completed(&db, "victim_a", 5, 3).await; + insert_completed(&db, "victim_b", 5, 1).await; + + refresh_overloaded(&db).await.expect("refresh ok"); + + assert_eq!(overloaded_set(), vec!["noisy".to_string()]); +} + +#[sqlx::test(fixtures("base"))] +#[serial] +async fn fairness_respects_min_total(db: Pool) { + reset_fairness_state(); + create_workspace(&db, "lone").await; + insert_completed(&db, "lone", 3, 2).await; + + refresh_overloaded(&db).await.expect("refresh ok"); + + assert!(overloaded_set().is_empty()); +} + +#[sqlx::test(fixtures("base"))] +#[serial] +async fn fairness_pull_query_skips_capped_workspace(db: Pool) { + reset_fairness_state(); + create_workspace(&db, "noisy").await; + create_workspace(&db, "victim").await; + + let noisy_ids = insert_queued(&db, "noisy", 1, false, "deno").await; + let victim_ids = insert_queued(&db, "victim", 1, false, "deno").await; + + let regular_pick: Option = sqlx::query_scalar( + "SELECT id FROM v2_job_queue + WHERE running = false AND tag IN ('deno') AND scheduled_for <= now() + ORDER BY priority DESC NULLS LAST, scheduled_for LIMIT 1", + ) + .fetch_optional(&db) + .await + .unwrap(); + assert_eq!(regular_pick, Some(noisy_ids[0])); + + let capped = vec!["noisy".to_string()]; + let fairness_pick: Option = sqlx::query_scalar( + "SELECT id FROM v2_job_queue + WHERE running = false AND tag IN ('deno') AND scheduled_for <= now() + AND workspace_id <> ALL($1::text[]) + ORDER BY priority DESC NULLS LAST, scheduled_for LIMIT 1", + ) + .bind(&capped) + .fetch_optional(&db) + .await + .unwrap(); + assert_eq!(fairness_pick, Some(victim_ids[0])); +} + +#[sqlx::test(fixtures("base"))] +#[serial] +async fn fairness_lifts_when_load_drops(db: Pool) { + reset_fairness_state(); + create_workspace(&db, "noisy").await; + create_workspace(&db, "victim_a").await; + create_workspace(&db, "victim_b").await; + + insert_completed(&db, "noisy", 60, 2).await; + insert_completed(&db, "victim_a", 5, 3).await; + insert_completed(&db, "victim_b", 5, 1).await; + refresh_overloaded(&db).await.expect("refresh ok"); + assert_eq!(overloaded_set(), vec!["noisy".to_string()]); + + sqlx::query( + "UPDATE v2_job_completed + SET completed_at = NOW() - make_interval(secs => 60), + started_at = NOW() - make_interval(secs => 60) + WHERE workspace_id IN ('noisy', 'victim_a', 'victim_b')", + ) + .execute(&db) + .await + .unwrap(); + insert_completed(&db, "noisy", 10, 2).await; + insert_completed(&db, "victim_a", 10, 2).await; + insert_completed(&db, "victim_b", 10, 2).await; + + // Roll updated_at back so the DB-side claim guard lets the next refresh win. + sqlx::query( + "UPDATE background_task_state + SET updated_at = NOW() - INTERVAL '1 hour' + WHERE name = 'workspace_fairness'", + ) + .execute(&db) + .await + .unwrap(); + + refresh_overloaded(&db).await.expect("refresh ok"); + assert!(overloaded_set().is_empty()); +} + +/// Ensure today's audit_partitioned partition exists — the migration only +/// creates partitions for the day it ran + 3 days, after which production +/// relies on `monitor::manage_audit_partitions` to roll new ones. That +/// maintenance task does not run in the test binary, so inserts silently +/// fail-and-warn without it. +async fn ensure_today_audit_partition(db: &Pool) { + let today: chrono::NaiveDate = chrono::Utc::now().date_naive(); + let next = today + chrono::Duration::days(1); + let partition = format!("audit_{}", today.format("%Y%m%d")); + let sql = format!( + "CREATE TABLE IF NOT EXISTS \"{partition}\" PARTITION OF audit_partitioned \ + FOR VALUES FROM ('{today}') TO ('{next}')" + ); + let _ = sqlx::query(&sql).execute(db).await; +} + +/// Both cap AND uncap transitions must produce audit-log rows. This test +/// drives a cap → uncap cycle and inspects `audit_partitioned` directly. +#[sqlx::test(fixtures("base"))] +#[serial] +async fn fairness_audit_records_both_cap_and_uncap(db: Pool) { + reset_fairness_state(); + ensure_today_audit_partition(&db).await; + create_workspace(&db, "noisy").await; + create_workspace(&db, "victim_a").await; + create_workspace(&db, "victim_b").await; + + // Phase 1 — push noisy to dominate, cap it. + insert_completed(&db, "noisy", 60, 2).await; + insert_completed(&db, "victim_a", 5, 3).await; + insert_completed(&db, "victim_b", 5, 1).await; + refresh_overloaded(&db).await.expect("refresh ok"); + assert_eq!(overloaded_set(), vec!["noisy".to_string()]); + + // Phase 2 — roll noisy's completions outside the window, push balanced + // load, force a refresh; noisy should be uncapped. + sqlx::query( + "UPDATE v2_job_completed + SET completed_at = NOW() - make_interval(secs => 60), + started_at = NOW() - make_interval(secs => 60) + WHERE workspace_id IN ('noisy', 'victim_a', 'victim_b')", + ) + .execute(&db) + .await + .unwrap(); + insert_completed(&db, "noisy", 10, 2).await; + insert_completed(&db, "victim_a", 10, 2).await; + insert_completed(&db, "victim_b", 10, 2).await; + sqlx::query( + "UPDATE background_task_state + SET updated_at = NOW() - INTERVAL '1 hour' + WHERE name = 'workspace_fairness'", + ) + .execute(&db) + .await + .unwrap(); + refresh_overloaded(&db).await.expect("refresh ok"); + assert!(overloaded_set().is_empty()); + + // Verify both audit rows actually landed. + let capped_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM audit_partitioned + WHERE workspace_id = 'admins' + AND operation = 'workspace_fairness.capped' + AND resource = 'noisy'", + ) + .fetch_one(&db) + .await + .unwrap(); + let uncapped_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM audit_partitioned + WHERE workspace_id = 'admins' + AND operation = 'workspace_fairness.uncapped' + AND resource = 'noisy'", + ) + .fetch_one(&db) + .await + .unwrap(); + println!("audit rows: capped={capped_count}, uncapped={uncapped_count}"); + assert_eq!(capped_count, 1, "expected exactly 1 capped audit for noisy"); + assert_eq!( + uncapped_count, 1, + "expected exactly 1 uncapped audit for noisy — \ + if this is 0, the uncap transition is not being recorded" + ); +} + +#[sqlx::test(fixtures("base"))] +#[serial] +async fn fairness_catches_slot_hoggers(db: Pool) { + reset_fairness_state(); + create_workspace(&db, "hogger").await; + create_workspace(&db, "victim").await; + + insert_queued(&db, "hogger", 10, true, "deno").await; + insert_completed(&db, "victim", 2, 1).await; + + refresh_overloaded(&db).await.expect("refresh ok"); + + assert_eq!(overloaded_set(), vec!["hogger".to_string()]); +} + +/// Regression: a workspace with a large backlog of `running = true` rows that +/// have **no live worker** claiming them (worker died, ping went stale, etc.) +/// must not be counted as "active". A previous version of the algorithm +/// counted `v2_job_queue.running = true` directly and was perpetually pinned +/// on the workspace with the most zombie rows, masking every other workspace. +#[sqlx::test(fixtures("base"))] +#[serial] +#[ignore = "flaky in CI"] +async fn fairness_ignores_zombie_running_rows(db: Pool) { + reset_fairness_state(); + create_workspace(&db, "stuck_backlog").await; + create_workspace(&db, "real_noisy").await; + create_workspace(&db, "victim").await; + + // 100 zombie running rows for `stuck_backlog`. No paired worker_ping ⇒ + // no live worker is processing them. Old algorithm: 100 units of fake + // activity. New algorithm: 0 units. + insert_zombie_running(&db, "stuck_backlog", 100).await; + // `real_noisy` is genuinely flooding the cluster. + insert_completed(&db, "real_noisy", 60, 2).await; + insert_completed(&db, "victim", 5, 3).await; + + refresh_overloaded(&db).await.expect("refresh ok"); + + let set = overloaded_set(); + assert!( + !set.contains(&"stuck_backlog".to_string()), + "zombie running rows must not flag a workspace as overloaded; got {set:?}" + ); + assert_eq!( + set, + vec!["real_noisy".to_string()], + "the actually noisy workspace must surface even when another workspace \ + has a large backlog of zombie running rows; got {set:?}" + ); +} + +/// Regression: concurrency-suspended rows (`running = true AND suspend > 0`) +/// are not consuming worker slots — the flow is paused at a suspend step — +/// and must not contribute to the activity share. +#[sqlx::test(fixtures("base"))] +#[serial] +#[ignore = "flaky in CI"] +async fn fairness_ignores_concurrency_suspended_rows(db: Pool) { + reset_fairness_state(); + create_workspace(&db, "concurrency_capped").await; + create_workspace(&db, "real_noisy").await; + create_workspace(&db, "victim").await; + + // 100 concurrency-suspended rows. Each has `running = true` (the legacy + // signal) but `suspend > 0` (not actually on a worker). + insert_suspended_running(&db, "concurrency_capped", 100).await; + insert_completed(&db, "real_noisy", 60, 2).await; + insert_completed(&db, "victim", 5, 3).await; + + refresh_overloaded(&db).await.expect("refresh ok"); + + let set = overloaded_set(); + assert!( + !set.contains(&"concurrency_capped".to_string()), + "concurrency-suspended rows must not flag a workspace as overloaded; got {set:?}" + ); + assert_eq!( + set, + vec!["real_noisy".to_string()], + "noisy workspace must still surface despite another workspace's large \ + suspended backlog; got {set:?}" + ); +} + +// --------------------------------------------------------------------------- +// Simulation test +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +struct JobSpec { + duration_ms: u32, +} + +#[derive(Debug)] +struct Stats { + /// Per-workspace observed latencies in milliseconds (enqueue → complete). + per_ws: HashMap>, + /// Per-workspace queued counts (pushed by the workload generator). + pushed: HashMap, + /// Per-workspace completion events as (elapsed_ms_since_scenario_start, + /// latency_ms). Used by the oscillation simulation to compute per-second + /// latency time series. + events: HashMap>, + /// Reference t=0 for the current scenario, set by `run_scenario`. + started: Option, +} + +impl Stats { + fn new() -> Self { + Self { + per_ws: HashMap::new(), + pushed: HashMap::new(), + events: HashMap::new(), + started: None, + } + } + fn record(&mut self, ws: &str, latency_ms: u64) { + self.per_ws + .entry(ws.to_string()) + .or_default() + .push(latency_ms); + if let Some(t0) = self.started { + let elapsed_ms = t0.elapsed().as_millis() as u64; + self.events + .entry(ws.to_string()) + .or_default() + .push((elapsed_ms, latency_ms)); + } + } + fn pushed_inc(&mut self, ws: &str) { + *self.pushed.entry(ws.to_string()).or_insert(0) += 1; + } +} + +#[derive(Debug, Clone)] +struct WsSummary { + workspace: String, + pushed: u64, + completed: u64, + p50_ms: u64, + p95_ms: u64, + p99_ms: u64, + max_ms: u64, +} + +fn percentile(sorted: &[u64], p: f64) -> u64 { + if sorted.is_empty() { + return 0; + } + let idx = ((sorted.len() as f64 - 1.0) * p / 100.0).round() as usize; + sorted[idx.min(sorted.len() - 1)] +} + +fn summarize(stats: &Stats) -> Vec { + let mut workspaces: Vec<&String> = stats.per_ws.keys().collect(); + workspaces.sort(); + workspaces + .into_iter() + .map(|ws| { + let mut lat = stats.per_ws.get(ws).cloned().unwrap_or_default(); + lat.sort_unstable(); + WsSummary { + workspace: ws.clone(), + pushed: stats.pushed.get(ws).copied().unwrap_or(0), + completed: lat.len() as u64, + p50_ms: percentile(&lat, 50.0), + p95_ms: percentile(&lat, 95.0), + p99_ms: percentile(&lat, 99.0), + max_ms: *lat.last().unwrap_or(&0), + } + }) + .collect() +} + +fn print_summary(label: &str, rows: &[WsSummary]) { + println!( + "\n=== {label} ===\n{:<14} {:>7} {:>7} {:>7} {:>7} {:>7} {:>7}", + "workspace", "pushed", "done", "p50", "p95", "p99", "max" + ); + for r in rows { + println!( + "{:<14} {:>7} {:>7} {:>7} {:>7} {:>7} {:>7}", + r.workspace, r.pushed, r.completed, r.p50_ms, r.p95_ms, r.p99_ms, r.max_ms, + ); + } +} + +/// Mock worker. Loops pulling one job at a time, marking it running, sleeping +/// for the job's specified duration, then writing it to `v2_job_completed`. +/// Honors the overloaded-set bind if `fairness_on` is true. Stops when +/// `shutdown` flips. +async fn mock_worker( + worker_id: u32, + db: Pool, + fairness_on: Arc, + shutdown: Arc, + stats: Arc>, + completed_counter: Arc, +) { + let worker_name = format!("mock-worker-{worker_id}"); + // Each mock worker maintains its own `worker_ping` row, the way a real + // worker would: `current_job_*` set on pick-up, cleared on completion. + // The fairness algorithm now reads slot occupancy from `worker_ping` (so + // that concurrency-suspended rows and zombies with no live ping do not + // inflate the denominator), so the simulation must keep this in sync. + sqlx::query( + "INSERT INTO worker_ping (worker, worker_instance, ping_at) VALUES ($1, 'sim', NOW()) + ON CONFLICT (worker) DO UPDATE SET ping_at = NOW(), + current_job_id = NULL, current_job_workspace_id = NULL", + ) + .bind(&worker_name) + .execute(&db) + .await + .unwrap(); + let standard_sql = "WITH picked AS ( + SELECT id FROM v2_job_queue + WHERE running = false AND scheduled_for <= now() + ORDER BY priority DESC NULLS LAST, scheduled_for + FOR UPDATE SKIP LOCKED LIMIT 1 + ) + UPDATE v2_job_queue q + SET running = true, started_at = now() + FROM picked + WHERE q.id = picked.id + RETURNING q.id, q.workspace_id, COALESCE((q.extras->>'duration_ms')::int, 30), q.created_at"; + let fairness_sql = "WITH picked AS ( + SELECT id FROM v2_job_queue + WHERE running = false AND scheduled_for <= now() + AND workspace_id <> ALL($1::text[]) + ORDER BY priority DESC NULLS LAST, scheduled_for + FOR UPDATE SKIP LOCKED LIMIT 1 + ) + UPDATE v2_job_queue q + SET running = true, started_at = now() + FROM picked + WHERE q.id = picked.id + RETURNING q.id, q.workspace_id, COALESCE((q.extras->>'duration_ms')::int, 30), q.created_at"; + + while !shutdown.load(Ordering::Relaxed) { + // Snapshot the overloaded set at pull time so each pull reflects the + // latest refresh. Mirror the production dispatch: if there is anything + // capped, flip the same coin the real pull does to decide whether to + // admit it. Empty overloaded set => standard query unconditionally. + let overloaded = if fairness_on.load(Ordering::Relaxed) { + (**WORKSPACE_FAIRNESS_OVERLOADED.load()).clone() + } else { + vec![] + }; + let exclude_capped = + !overloaded.is_empty() && !windmill_queue::workspace_fairness::should_admit_capped(); + + // Primary query (chosen by the coin flip). + let mut row: Option<(Uuid, String, i32, chrono::DateTime)> = if exclude_capped + { + sqlx::query_as::<_, (Uuid, String, i32, chrono::DateTime)>(fairness_sql) + .bind(&overloaded) + .fetch_optional(&db) + .await + .unwrap() + } else { + sqlx::query_as::<_, (Uuid, String, i32, chrono::DateTime)>(standard_sql) + .fetch_optional(&db) + .await + .unwrap() + }; + + // Fallback: if the fairness query returned nothing (every non-capped + // workspace queue is empty), retry without the filter so workers + // don't idle when only capped jobs remain. + if row.is_none() && exclude_capped { + row = sqlx::query_as::<_, (Uuid, String, i32, chrono::DateTime)>( + standard_sql, + ) + .fetch_optional(&db) + .await + .unwrap(); + } + + match row { + Some((id, ws, dur_ms, created_at)) => { + // Claim the slot on this worker's ping so the fairness + // algorithm counts this workspace's slot occupancy. + sqlx::query( + "UPDATE worker_ping SET ping_at = NOW(), + current_job_id = $1, current_job_workspace_id = $2 + WHERE worker = $3", + ) + .bind(id) + .bind(&ws) + .bind(&worker_name) + .execute(&db) + .await + .unwrap(); + + tokio::time::sleep(Duration::from_millis(dur_ms as u64)).await; + + // Move to completed atomically: insert + delete in one query. + let completed_at: chrono::DateTime = sqlx::query_scalar( + "WITH del AS ( + DELETE FROM v2_job_queue WHERE id = $1 RETURNING id, workspace_id + ) + INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status, started_at, completed_at) + SELECT id, workspace_id, $2, 'success'::job_status, now(), now() + FROM del + RETURNING completed_at", + ) + .bind(id) + .bind(dur_ms as i64) + .fetch_one(&db) + .await + .unwrap(); + + // Release the slot. + sqlx::query( + "UPDATE worker_ping SET ping_at = NOW(), + current_job_id = NULL, current_job_workspace_id = NULL + WHERE worker = $1", + ) + .bind(&worker_name) + .execute(&db) + .await + .unwrap(); + + let latency_ms = (completed_at - created_at).num_milliseconds().max(0) as u64; + { + let mut s = stats.lock().await; + s.record(&ws, latency_ms); + } + completed_counter.fetch_add(1, Ordering::Relaxed); + } + None => { + // Empty queue (or every queued workspace is capped). Back off + // briefly so we don't hammer the DB. Refresh the heartbeat so + // this worker's ping doesn't go stale during long idle gaps. + sqlx::query("UPDATE worker_ping SET ping_at = NOW() WHERE worker = $1") + .bind(&worker_name) + .execute(&db) + .await + .unwrap(); + tokio::time::sleep(Duration::from_millis(2)).await; + } + } + } +} + +/// Push a stream of jobs from `workspace` at `rate_per_sec`. Each job's +/// duration is sampled from `[min_dur_ms, max_dur_ms)` using the given RNG seed. +async fn pusher( + db: Pool, + workspace: String, + rate_per_sec: u32, + min_dur_ms: u32, + max_dur_ms: u32, + duration: Duration, + seed: u64, + stats: Arc>, + shutdown: Arc, +) { + let mut rng = StdRng::seed_from_u64(seed); + let interval = Duration::from_micros(1_000_000 / rate_per_sec.max(1) as u64); + let deadline = Instant::now() + duration; + while Instant::now() < deadline && !shutdown.load(Ordering::Relaxed) { + let dur = if min_dur_ms == max_dur_ms { + min_dur_ms + } else { + rng.random_range(min_dur_ms..max_dur_ms) + }; + let spec = JobSpec { duration_ms: dur }; + let extras = serde_json::json!({"duration_ms": spec.duration_ms}); + let res = sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, extras) + VALUES (gen_random_uuid(), $1, NOW(), false, 'deno', $2)", + ) + .bind(&workspace) + .bind(&extras) + .execute(&db) + .await; + if res.is_ok() { + let mut s = stats.lock().await; + s.pushed_inc(&workspace); + } + tokio::time::sleep(interval).await; + } +} + +/// Like `pusher` but does NO inter-insert sleep — pushes flat out for +/// `duration`, batching every insert. Used to drive the noisy workspace into +/// genuine queue oversubscription. Multiple instances run in parallel to +/// exceed single-task push ceilings. +async fn noisy_pusher( + db: Pool, + workspace: String, + min_dur_ms: u32, + max_dur_ms: u32, + duration: Duration, + seed: u64, + stats: Arc>, + shutdown: Arc, +) { + let mut rng = StdRng::seed_from_u64(seed); + let deadline = Instant::now() + duration; + let mut local_pushed: u64 = 0; + while Instant::now() < deadline && !shutdown.load(Ordering::Relaxed) { + let dur = if min_dur_ms == max_dur_ms { + min_dur_ms + } else { + rng.random_range(min_dur_ms..max_dur_ms) + }; + let extras = serde_json::json!({"duration_ms": dur}); + let res = sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, extras) + VALUES (gen_random_uuid(), $1, NOW(), false, 'deno', $2)", + ) + .bind(&workspace) + .bind(&extras) + .execute(&db) + .await; + if res.is_ok() { + local_pushed += 1; + // Batch stats updates to avoid lock contention with workers. + if local_pushed % 32 == 0 { + let mut s = stats.lock().await; + for _ in 0..32 { + s.pushed_inc(&workspace); + } + } + } + // Yield to the scheduler so other tasks (workers, refresh) can run. + tokio::task::yield_now().await; + } + // Flush remaining counter. + let leftover = local_pushed % 32; + if leftover > 0 { + let mut s = stats.lock().await; + for _ in 0..leftover { + s.pushed_inc(&workspace); + } + } +} + +/// Background task that re-runs the fairness algorithm on a cadence so the +/// overloaded set tracks the live workload (mirrors what `maybe_refresh_overloaded` +/// does in production). +/// +/// `force_refresh = true` rolls back the DB-side claim guard every iteration, +/// so each call re-runs the heavy aggregation. Use for short-running tests +/// that need fast adaptation. `force_refresh = false` leaves the natural 2 s +/// (`ACTIVE_REFRESH_SECS`) claim guard in place — this is what production +/// behaves like and what the oscillation/burst simulations want. +async fn refresh_loop(db: Pool, shutdown: Arc, force_refresh: bool) { + while !shutdown.load(Ordering::Relaxed) { + if force_refresh { + let _ = sqlx::query( + "UPDATE background_task_state + SET updated_at = NOW() - INTERVAL '1 hour' + WHERE name = 'workspace_fairness'", + ) + .execute(&db) + .await; + } + let _ = refresh_overloaded(&db).await; + tokio::time::sleep(Duration::from_millis(250)).await; + } +} + +#[derive(Debug)] +struct ScenarioResult { + summary: Vec, + /// Wall-clock duration the scenario actually ran for (push + drain). + elapsed: Duration, + /// Per-workspace completion events: (elapsed_ms, latency_ms). Used for + /// the oscillation time-series analysis. + events: HashMap>, +} + +async fn run_scenario( + sqlx_db: &Pool, + label: &'static str, + fairness_on: bool, + duration: Duration, + drain_timeout: Duration, + n_workers: u32, + fairness_window_secs: u32, + force_refresh: bool, +) -> ScenarioResult { + reset_fairness_state(); + WORKSPACE_FAIRNESS_DURATION_SECS.store(fairness_window_secs, Ordering::Relaxed); + WORKSPACE_FAIRNESS_MIN_TOTAL.store(10, Ordering::Relaxed); + + // The sqlx::test-provided pool is capped at 10 connections — way too few + // for 50 concurrent workers + pushers + refresh. Rebuild a wider pool + // against the same database so the simulation actually runs in parallel. + let opts = (*sqlx_db.connect_options()).clone(); + let big_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(80) + .min_connections(20) + .acquire_timeout(Duration::from_secs(10)) + .connect_with(opts) + .await + .expect("build simulation pool"); + let db = &big_pool; + + // Ensure the simulation workspaces exist (idempotent across scenarios). + create_workspace(db, "noisy").await; + for i in 0..5 { + create_workspace(db, &format!("victim_{i}")).await; + } + + // Truncate residual state from any prior scenario on the same DB. + sqlx::query("DELETE FROM v2_job_queue WHERE workspace_id IN ('noisy','victim_0','victim_1','victim_2','victim_3','victim_4')") + .execute(db).await.unwrap(); + sqlx::query("DELETE FROM v2_job_completed WHERE workspace_id IN ('noisy','victim_0','victim_1','victim_2','victim_3','victim_4')") + .execute(db).await.unwrap(); + sqlx::query("DELETE FROM background_task_state WHERE name = 'workspace_fairness'") + .execute(db) + .await + .unwrap(); + + let stats = Arc::new(Mutex::new(Stats::new())); + let shutdown = Arc::new(AtomicBool::new(false)); + let fairness_flag = Arc::new(AtomicBool::new(fairness_on)); + let completed = Arc::new(AtomicU64::new(0)); + + let started = Instant::now(); + { + let mut s = stats.lock().await; + s.started = Some(started); + } + + // Spawn workers. + let mut worker_handles = Vec::with_capacity(n_workers as usize); + for wid in 0..n_workers { + let db = db.clone(); + let stats = stats.clone(); + let shutdown = shutdown.clone(); + let fairness_flag = fairness_flag.clone(); + let completed = completed.clone(); + worker_handles.push(tokio::spawn(async move { + mock_worker(wid, db, fairness_flag, shutdown, stats, completed).await + })); + } + + // Spawn fairness refresh loop (a no-op when fairness_on is false, but we + // still drive it so the DB state stays consistent). + let refresh_handle = if fairness_on { + let db = db.clone(); + let shutdown = shutdown.clone(); + Some(tokio::spawn(async move { + refresh_loop(db, shutdown, force_refresh).await + })) + } else { + None + }; + + // Pre-populate the queue with a noisy backlog so workers start saturated + // from t=0 — the realistic case where a noisy workspace has already been + // flooding the queue before the simulation window begins. + let noisy_backlog: i64 = 1500; + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, extras) + SELECT gen_random_uuid(), 'noisy', NOW(), false, 'deno', + jsonb_build_object('duration_ms', 60 + (random()*40)::int) + FROM generate_series(1, $1::int)", + ) + .bind(noisy_backlog) + .execute(db) + .await + .unwrap(); + { + let mut s = stats.lock().await; + for _ in 0..noisy_backlog { + s.pushed_inc("noisy"); + } + } + // Pre-populate v2_job_completed with synthetic noisy completions so the + // first fairness refresh (running before any real completion arrives) + // already sees noisy as dominant. Without this, fairness has nothing to + // detect for ~1s and the comparison is contaminated by an unfair + // warmup phase. + sqlx::query( + "INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status, started_at, completed_at) + SELECT gen_random_uuid(), 'noisy', 80, 'success'::job_status, + NOW() - INTERVAL '1 second', NOW() - INTERVAL '1 second' + FROM generate_series(1, 200)", + ) + .execute(db).await.unwrap(); + + // Spawn pushers. Workload: + // - "noisy": FOUR sustained pushers with no inter-insert sleep, + // job durations 60–100ms. Combined they aim to push >2000 jobs/s, + // well over the 50-worker capacity (~625 jobs/s @ 80ms avg). + // - 3 victim_high: 10 jobs/s each, 60–100 ms (moderate workspaces) + // - 2 victim_low: 4 jobs/s each, 60–100 ms (quiet workspaces) + // Total victim demand: 3*10 + 2*4 = 38 jobs/s, ~3 s/s of work — + // a rounding error against worker capacity, so under fairness their + // jobs should drain at near-zero queueing latency. + let pusher_specs: Vec<(String, u32, u32, u32, u64)> = vec![ + // Victims + ("victim_0".to_string(), 10, 60, 100, 11), + ("victim_1".to_string(), 10, 60, 100, 12), + ("victim_2".to_string(), 10, 60, 100, 13), + ("victim_3".to_string(), 4, 60, 100, 21), + ("victim_4".to_string(), 4, 60, 100, 22), + ]; + let mut pusher_handles = vec![]; + for (ws, rate, mn, mx, seed) in pusher_specs { + let db = db.clone(); + let stats = stats.clone(); + let shutdown = shutdown.clone(); + pusher_handles.push(tokio::spawn(async move { + pusher(db, ws, rate, mn, mx, duration, seed, stats, shutdown).await; + })); + } + // Four noisy pushers running flat out (no sleep). Each pushes + // continuously for `duration`, then drops. + for noisy_seed in 1..=4u64 { + let db = db.clone(); + let stats = stats.clone(); + let shutdown = shutdown.clone(); + pusher_handles.push(tokio::spawn(async move { + noisy_pusher( + db, + "noisy".to_string(), + 60, + 100, + duration, + noisy_seed, + stats, + shutdown, + ) + .await; + })); + } + + // Wait for pushers to finish pushing. + for h in pusher_handles { + let _ = h.await; + } + + // Drain phase: wait until VICTIM workspaces drain (or timeout). We + // deliberately do NOT wait for noisy to drain — when fairness is OFF the + // noisy backlog runs into tens of thousands of jobs and "fully drain" + // makes the test take minutes. Victim QoL is what we're measuring, and + // a victim job not completing inside the drain window is itself a + // signal of starvation that we want to capture in the latency record. + let drain_deadline = Instant::now() + drain_timeout; + loop { + let victim_remaining: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM v2_job_queue + WHERE workspace_id IN ('victim_0','victim_1','victim_2','victim_3','victim_4')", + ) + .fetch_one(db) + .await + .unwrap(); + if victim_remaining == 0 || Instant::now() >= drain_deadline { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + // Shut everything down. + shutdown.store(true, Ordering::Relaxed); + for h in worker_handles { + let _ = h.await; + } + if let Some(h) = refresh_handle { + let _ = h.await; + } + + let elapsed = started.elapsed(); + let stats = stats.lock().await; + let summary = summarize(&stats); + print_summary(label, &summary); + ScenarioResult { summary, elapsed, events: stats.events.clone() } +} + +fn pick<'a>(rows: &'a [WsSummary], ws: &str) -> &'a WsSummary { + rows.iter() + .find(|r| r.workspace == ws) + .expect("workspace in summary") +} + +/// **50-worker simulation.** Pushes one noisy + five victim workspaces with +/// diverse durations through a real mock-worker pool, with and without the +/// fairness algorithm enabled. Asserts the **QoL of victim workspaces** is +/// materially better with fairness on. +/// +/// Marked `#[ignore]` so the default `cargo test` keeps a sub-second profile. +/// Run with: `cargo test --test workspace_fairness -- --ignored --nocapture`. +#[sqlx::test(fixtures("base"))] +#[ignore] +#[serial] +async fn fairness_50_workers_diverse_workload(db: Pool) { + let push_dur = Duration::from_secs(5); + // Cap drain at 12s. Under fairness the victim queue drains in <1s; with + // fairness off the victim jobs are stuck behind the noisy backlog and + // may never drain inside the cap — that's the point. Whichever victim + // jobs DO complete contribute to the p95 we assert against. + let drain_dur = Duration::from_secs(12); + + // CONTROL: fairness OFF. + let control = run_scenario( + &db, + "control (fairness OFF)", + false, + push_dur, + drain_dur, + 50, + 3, + true, + ) + .await; + // TREATMENT: fairness ON. Short test → force refresh every 250ms so the + // cap takes effect inside the 5s window. (Production rate is 2s, which + // would only give ~2 refresh cycles inside a 5s test.) + let treatment = run_scenario( + &db, + "treatment (fairness ON)", + true, + push_dur, + drain_dur, + 50, + 3, + true, + ) + .await; + + let victims = ["victim_0", "victim_1", "victim_2", "victim_3", "victim_4"]; + + println!("\n=== victim p95 latency comparison ==="); + println!( + "{:<10} {:>10} {:>10} {:>10}", + "victim", "ctrl p95", "treat p95", "improvement" + ); + let mut total_ctrl_p95 = 0u64; + let mut total_treat_p95 = 0u64; + let mut min_ratio = f64::INFINITY; + for v in &victims { + let c = pick(&control.summary, v); + let t = pick(&treatment.summary, v); + let ratio = if t.p95_ms == 0 { + f64::INFINITY + } else { + c.p95_ms as f64 / t.p95_ms as f64 + }; + min_ratio = min_ratio.min(ratio); + total_ctrl_p95 += c.p95_ms; + total_treat_p95 += t.p95_ms; + println!( + "{:<10} {:>10} {:>10} {:>9.2}x", + v, c.p95_ms, t.p95_ms, ratio + ); + } + let avg_ctrl_p95 = total_ctrl_p95 / victims.len() as u64; + let avg_treat_p95 = total_treat_p95 / victims.len() as u64; + println!( + "avg victim p95: control={}ms treatment={}ms ratio={:.2}x", + avg_ctrl_p95, + avg_treat_p95, + avg_ctrl_p95 as f64 / avg_treat_p95.max(1) as f64, + ); + println!( + "scenario elapsed: control={:?} treatment={:?}", + control.elapsed, treatment.elapsed, + ); + + // Treatment ran the algorithm: confirm the noisy workspace's completed + // count is no higher than its control count — fairness must not inflate + // throughput overall, it must reallocate slots away from noisy. + let noisy_ctrl = pick(&control.summary, "noisy"); + let noisy_treat = pick(&treatment.summary, "noisy"); + println!( + "noisy: control completed={} treatment completed={}", + noisy_ctrl.completed, noisy_treat.completed, + ); + + // Completion-rate comparison. Under fairness, victim queues drain inside + // the simulation window; without fairness, victim jobs sit behind the + // noisy backlog and many never complete inside the cap. + let ctrl_v_pushed: u64 = victims + .iter() + .map(|v| pick(&control.summary, v).pushed) + .sum(); + let ctrl_v_done: u64 = victims + .iter() + .map(|v| pick(&control.summary, v).completed) + .sum(); + let treat_v_pushed: u64 = victims + .iter() + .map(|v| pick(&treatment.summary, v).pushed) + .sum(); + let treat_v_done: u64 = victims + .iter() + .map(|v| pick(&treatment.summary, v).completed) + .sum(); + let ctrl_v_rate = ctrl_v_done as f64 / ctrl_v_pushed.max(1) as f64; + let treat_v_rate = treat_v_done as f64 / treat_v_pushed.max(1) as f64; + println!( + "victim completion rate: control={:.1}% ({}/{}) treatment={:.1}% ({}/{})", + ctrl_v_rate * 100.0, + ctrl_v_done, + ctrl_v_pushed, + treat_v_rate * 100.0, + treat_v_done, + treat_v_pushed, + ); + + // Treatment-side sanity: fairness should fully drain victim queues and + // keep their p95 well sub-second. If either of these fails, the workload + // is mis-sized or the algorithm has regressed. + for v in &victims { + let t = pick(&treatment.summary, v); + let rate = t.completed as f64 / t.pushed.max(1) as f64; + assert!( + rate > 0.95, + "victim {v} completion rate under fairness was {:.1}% ({}/{}) — \ + fairness algorithm is not protecting victim throughput", + rate * 100.0, + t.completed, + t.pushed, + ); + assert!( + t.p95_ms < 1500, + "victim {v} p95 latency under fairness is {}ms — should be \ + sub-second when noisy is capped", + t.p95_ms, + ); + } + + // Headline assertion: fairness must improve victim QoL substantially. + // Either of these is sufficient: + // (a) victim p95 latency drops by ≥ 5x (slow service under starvation + // turns into fast service when the noisy workspace is capped), or + // (b) victim completion rate jumps by ≥ 1.5x (jobs that were never + // getting pulled finally complete). + // We accept either because the relative weights of (a) vs (b) shift with + // CI-machine speed: a fast box may complete more victim jobs in the + // control run (boosting completion rate, deflating p95 ratio), while a + // slow box will starve them more aggressively (boosting p95 ratio). + let p95_ratio = (avg_ctrl_p95 as f64) / (avg_treat_p95.max(1) as f64); + let rate_ratio = treat_v_rate / ctrl_v_rate.max(0.001); + println!("p95 ratio (ctrl/treat) = {p95_ratio:.2}x, completion-rate ratio (treat/ctrl) = {rate_ratio:.2}x"); + assert!( + p95_ratio >= 5.0 || rate_ratio >= 1.5, + "fairness did not materially improve victim QoL: p95 ratio={p95_ratio:.2}x \ + (want ≥5x), completion-rate ratio={rate_ratio:.2}x (want ≥1.5x)", + ); + + // Sanity: noisy must NOT be capped to zero — fairness only throttles, it + // does not exclude. Its completed count should stay > 0. + assert!( + noisy_treat.completed > 0, + "noisy was completely starved by fairness — should be throttled, not excluded", + ); +} + +/// Bucket events by 1-second windows of `elapsed_ms`. Returns +/// `Vec<(bucket_idx_seconds, count, p50, p95, max)>`. +fn time_series(events: &[(u64, u64)], buckets: usize) -> Vec<(usize, usize, u64, u64, u64)> { + let mut by_bucket: Vec> = vec![vec![]; buckets]; + for (elapsed_ms, lat_ms) in events { + let b = (*elapsed_ms / 1000) as usize; + if b < buckets { + by_bucket[b].push(*lat_ms); + } + } + by_bucket + .into_iter() + .enumerate() + .map(|(i, mut v)| { + v.sort_unstable(); + let n = v.len(); + ( + i, + n, + percentile(&v, 50.0), + percentile(&v, 95.0), + *v.last().unwrap_or(&0), + ) + }) + .collect() +} + +/// **Oscillation test.** A capped workspace's stale completions roll out of +/// the rolling window after `WORKSPACE_FAIRNESS_DURATION_SECS` seconds — at +/// which point its share drops to 0%, the algorithm un-caps it, the noisy +/// queue (which has the oldest `scheduled_for`) jumps to the front of the +/// pull, and victims briefly wait until the next refresh cycle re-caps. Over +/// a long run this manifests as periodic spikes in victim latency, roughly +/// every `(window + refresh_interval)` seconds. +/// +/// This test runs a 25-second sustained workload (long enough to cross at +/// least two cap/uncap cycles with the default 10s window) and prints +/// per-second victim p95 latency. It then asserts that the oscillation peaks +/// remain bounded — i.e. fairness still delivers good QoL on average even +/// though the cap is not perfectly stable. +/// +/// Marked `#[ignore]`. Run with: +/// `cargo test --test workspace_fairness fairness_oscillation -- --ignored --nocapture`. +#[sqlx::test(fixtures("base"))] +#[ignore] +#[serial] +async fn fairness_oscillation_long_run(db: Pool) { + // Use the *production default* 10-second window so the cap/uncap cycle + // matches what the cluster actually sees. (Other tests use a 3s window + // to keep wall-clock short.) + reset_fairness_state(); + WORKSPACE_FAIRNESS_DURATION_SECS.store(10, Ordering::Relaxed); + + let push_dur = Duration::from_secs(25); + // No drain — we don't care about post-push tail; the time-series view + // already includes everything in the active window. + let drain_dur = Duration::from_secs(2); + + // Use production refresh cadence (force_refresh=false) — the SQL claim's + // 2 s rate limit takes effect, so refresh runs every 2 s like on the + // real cluster instead of every 250 ms. This is what victims actually + // experience. + let treatment = run_scenario( + &db, + "treatment (fairness ON) — long run, 10s window, prod refresh", + true, + push_dur, + drain_dur, + 50, + 10, + false, + ) + .await; + + let total_buckets = (push_dur.as_secs() + drain_dur.as_secs() + 2) as usize; + let victims = ["victim_0", "victim_1", "victim_2", "victim_3", "victim_4"]; + + // Merge all victim events into one stream for the time-series view — + // QoL per-second across all victim workspaces is what we want to inspect. + let mut merged: Vec<(u64, u64)> = Vec::new(); + for v in &victims { + if let Some(es) = treatment.events.get(*v) { + merged.extend_from_slice(es); + } + } + let series = time_series(&merged, total_buckets); + + println!( + "\n=== victim latency per second (treatment, 10s window) ===\n{:>4} {:>6} {:>6} {:>6} {:>6}", + "sec", "count", "p50", "p95", "max" + ); + for (sec, count, p50, p95, mx) in &series { + println!("{:>4} {:>6} {:>6} {:>6} {:>6}", sec, count, p50, p95, mx); + } + + // Same view for noisy — visualises the cap on/off pattern. A capped + // bucket has near-zero completions; an uncapped bucket has many. + let noisy_events = treatment.events.get("noisy").cloned().unwrap_or_default(); + let noisy_series = time_series(&noisy_events, total_buckets); + println!("\n=== noisy completions per second (treatment) ==="); + for (sec, count, _, _, _) in &noisy_series { + println!("sec {:>3}: {:>5} noisy completions", sec, count); + } + + // Aggregate p95 and worst-bucket p95 across the active window (skip the + // first second, which is dominated by warmup before the first refresh). + let active: Vec<&(usize, usize, u64, u64, u64)> = series + .iter() + .filter(|(sec, count, ..)| *sec >= 1 && *sec < push_dur.as_secs() as usize && *count > 0) + .collect(); + let avg_p95: u64 = if active.is_empty() { + 0 + } else { + active.iter().map(|x| x.3).sum::() / active.len() as u64 + }; + let worst_p95: u64 = active.iter().map(|x| x.3).max().unwrap_or(0); + let buckets_over_2s = active.iter().filter(|x| x.3 > 2000).count(); + let buckets_over_5s = active.iter().filter(|x| x.3 > 5000).count(); + println!( + "\nactive window: {} sec, avg per-second victim p95 = {} ms, worst per-second p95 = {} ms", + active.len(), + avg_p95, + worst_p95, + ); + println!( + "seconds with victim p95 > 2s: {} / {}, > 5s: {} / {}", + buckets_over_2s, + active.len(), + buckets_over_5s, + active.len(), + ); + + // The user's hypothesis under test: "10s latency on and off". The cycle + // period is ~window + refresh interval ≈ 12-15s; the oscillation peak + // (time spent in the uncapped state, which is when victims wait) is + // bounded by the refresh interval, NOT the window. So we expect: + // - average per-second victim p95 well under 1s (cap mostly holds) + // - worst-second p95 under 5s (oscillation peaks are bounded) + // - only a small minority of seconds spent in the high-latency regime + // + // If any of these break, the cap/uncap cycle is too long or too costly, + // and the algorithm needs to revisit the refresh cadence vs window size. + let summary_v: Vec<&WsSummary> = victims + .iter() + .map(|v| pick(&treatment.summary, v)) + .collect(); + let total_completed: u64 = summary_v.iter().map(|s| s.completed).sum(); + let total_pushed: u64 = summary_v.iter().map(|s| s.pushed).sum(); + println!( + "total victim completion rate: {:.1}% ({}/{})", + 100.0 * total_completed as f64 / total_pushed.max(1) as f64, + total_completed, + total_pushed, + ); + + assert!( + avg_p95 < 1500, + "average per-second victim p95 = {} ms — cap is not holding most of the time", + avg_p95, + ); + assert!( + worst_p95 < 5_000, + "worst-second victim p95 = {} ms — oscillation peak exceeds 5s, \ + which means uncapped windows are too long. Reduce refresh interval \ + or shorten the duration window.", + worst_p95, + ); + assert!( + buckets_over_2s <= active.len() / 4, + "victims spent > 2s p95 in {} / {} buckets — oscillation is more \ + frequent than expected (more than 25% of the simulation)", + buckets_over_2s, + active.len(), + ); +} + +/// **Burst-then-stop scenario.** A noisy workspace enqueues 10,000 jobs in a +/// single burst at t=0 (all with the same `scheduled_for = now()`, so they +/// sit at the front of the FIFO queue forever after) and then stops pushing. +/// Victim workspaces push modestly throughout. +/// +/// This is the worst-case oscillation regime for the algorithm: once noisy is +/// uncapped, every worker grabs from its backlog because it has the lowest +/// `scheduled_for` in the queue — exactly the behavior the user pointed at. +/// The question is how much that costs victims. +/// +/// Mechanics with `WORKSPACE_FAIRNESS_DURATION_SECS = 10` (production default): +/// 1. t ≈ 0–1 s: workers drain ~500 noisy jobs FIFO. First refresh sees +/// noisy at ~100% of activity → CAPPED. +/// 2. t ≈ 1–11 s: noisy capped. Workers serve victims only. Noisy queue +/// stays at ~9,500. +/// 3. t ≈ 11 s: the noisy completions from step 1 age out of the rolling +/// window. Noisy share drops to 0% → UNCAPPED. +/// 4. t ≈ 11 s – 11 s + (refresh_interval): workers all switch to noisy +/// (oldest `scheduled_for`). Victims queue. Within ~1 refresh interval +/// the next refresh sees noisy dominant again → RE-CAPPED. +/// 5. Cycle repeats every ~(window + refresh_interval) ≈ 12 s. +/// +/// Asserts: +/// - average per-second victim p95 stays well under 1 s +/// - worst per-second victim p95 stays under 3 s (uncapped bursts are bounded) +/// - the bulk of noisy is still drained (the cap is throttling, not excluding) +/// +/// Run with: +/// `cargo test --test workspace_fairness fairness_burst -- --ignored --nocapture`. +#[sqlx::test(fixtures("base"))] +#[ignore] +#[serial] +async fn fairness_burst_then_stop(sqlx_db: Pool) { + reset_fairness_state(); + WORKSPACE_FAIRNESS_DURATION_SECS.store(10, Ordering::Relaxed); + WORKSPACE_FAIRNESS_MIN_TOTAL.store(10, Ordering::Relaxed); + + // Wider pool so 50 workers really run in parallel. + let opts = (*sqlx_db.connect_options()).clone(); + let db = sqlx::postgres::PgPoolOptions::new() + .max_connections(80) + .min_connections(20) + .acquire_timeout(Duration::from_secs(10)) + .connect_with(opts) + .await + .expect("build burst pool"); + let db = &db; + + create_workspace(db, "noisy").await; + for i in 0..3 { + create_workspace(db, &format!("victim_{i}")).await; + } + + sqlx::query( + "DELETE FROM v2_job_queue WHERE workspace_id IN ('noisy','victim_0','victim_1','victim_2')", + ) + .execute(db) + .await + .unwrap(); + sqlx::query( + "DELETE FROM v2_job_completed WHERE workspace_id IN ('noisy','victim_0','victim_1','victim_2')", + ) + .execute(db) + .await + .unwrap(); + sqlx::query("DELETE FROM background_task_state WHERE name = 'workspace_fairness'") + .execute(db) + .await + .unwrap(); + + // The burst: 10_000 noisy queued jobs, all with same scheduled_for. They + // will hold the front-of-queue position for the entire simulation, which + // is the scenario under test. + let burst_size = 10_000_i64; + sqlx::query( + "INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, tag, extras) + SELECT gen_random_uuid(), 'noisy', NOW(), false, 'deno', + jsonb_build_object('duration_ms', 80) + FROM generate_series(1, $1::int)", + ) + .bind(burst_size) + .execute(db) + .await + .unwrap(); + + let stats = Arc::new(Mutex::new(Stats::new())); + let shutdown = Arc::new(AtomicBool::new(false)); + let fairness_flag = Arc::new(AtomicBool::new(true)); + let completed = Arc::new(AtomicU64::new(0)); + let started = Instant::now(); + { + let mut s = stats.lock().await; + s.started = Some(started); + for _ in 0..burst_size { + s.pushed_inc("noisy"); + } + } + + // 50 workers. + let mut worker_handles = Vec::new(); + for wid in 0..50u32 { + let db = db.clone(); + let stats = stats.clone(); + let shutdown = shutdown.clone(); + let fairness_flag = fairness_flag.clone(); + let completed = completed.clone(); + worker_handles.push(tokio::spawn(async move { + mock_worker(wid, db, fairness_flag, shutdown, stats, completed).await + })); + } + + // Refresh task. force_refresh=false → the SQL claim's 2 s rate limit + // takes effect, matching `ACTIVE_REFRESH_SECS` in production. This is + // the regime the cloud cluster actually sees. + let refresh_handle = { + let db = db.clone(); + let shutdown = shutdown.clone(); + tokio::spawn(async move { refresh_loop(db, shutdown, false).await }) + }; + + // Victim pushers: 3 workspaces, 20 jobs/s each, 80 ms durations, + // sustained for the full simulation. Total victim demand: 60 jobs/s. + let sim_dur = Duration::from_secs(30); + let mut pusher_handles = vec![]; + for i in 0..3 { + let db = db.clone(); + let stats = stats.clone(); + let shutdown = shutdown.clone(); + let ws = format!("victim_{i}"); + pusher_handles.push(tokio::spawn(async move { + pusher(db, ws, 20, 80, 81, sim_dur, 100 + i as u64, stats, shutdown).await; + })); + } + for h in pusher_handles { + let _ = h.await; + } + + // Brief drain so any queued victim jobs at the end have a chance to land. + tokio::time::sleep(Duration::from_secs(2)).await; + + shutdown.store(true, Ordering::Relaxed); + for h in worker_handles { + let _ = h.await; + } + let _ = refresh_handle.await; + + let stats = stats.lock().await; + let summary = summarize(&stats); + print_summary("burst-then-stop (fairness ON, 10s window)", &summary); + + let total_buckets = (sim_dur.as_secs() + 4) as usize; + let victims = ["victim_0", "victim_1", "victim_2"]; + let mut merged: Vec<(u64, u64)> = Vec::new(); + for v in &victims { + if let Some(es) = stats.events.get(*v) { + merged.extend_from_slice(es); + } + } + let series = time_series(&merged, total_buckets); + println!("\n=== victim latency per second (burst-then-stop) ==="); + println!( + "{:>4} {:>6} {:>6} {:>6} {:>6}", + "sec", "count", "p50", "p95", "max" + ); + for (sec, count, p50, p95, mx) in &series { + println!("{:>4} {:>6} {:>6} {:>6} {:>6}", sec, count, p50, p95, mx); + } + + let noisy_events = stats.events.get("noisy").cloned().unwrap_or_default(); + let noisy_series = time_series(&noisy_events, total_buckets); + println!("\n=== noisy completions per second (burst-then-stop) ==="); + for (sec, count, _, _, _) in &noisy_series { + let bar = "#".repeat((count / 10).min(60) as usize); + println!("sec {:>3}: {:>5} {}", sec, count, bar); + } + + let active: Vec<&(usize, usize, u64, u64, u64)> = series + .iter() + .filter(|(sec, count, ..)| *sec >= 1 && *sec < sim_dur.as_secs() as usize && *count > 0) + .collect(); + let avg_p95: u64 = if active.is_empty() { + 0 + } else { + active.iter().map(|x| x.3).sum::() / active.len() as u64 + }; + let worst_p95: u64 = active.iter().map(|x| x.3).max().unwrap_or(0); + let buckets_over_1s = active.iter().filter(|x| x.3 > 1000).count(); + println!( + "\nburst-then-stop summary: avg per-second victim p95 = {} ms, worst = {} ms, \ + seconds with p95 > 1s: {} / {}", + avg_p95, + worst_p95, + buckets_over_1s, + active.len(), + ); + let noisy_drained = noisy_events.len(); + println!( + "noisy jobs drained over simulation: {} / {} ({:.1}%)", + noisy_drained, + burst_size, + 100.0 * noisy_drained as f64 / burst_size as f64, + ); + + // Sanity: every victim still completes (cap is throttling not excluding). + for v in &victims { + let t = summary.iter().find(|s| s.workspace == *v).unwrap(); + let rate = t.completed as f64 / t.pushed.max(1) as f64; + assert!( + rate > 0.95, + "victim {v} completion rate {:.1}% — fairness should protect victims even under burst", + rate * 100.0, + ); + } + + // The actual QoL claim we're testing against the user's hypothesis: + // even though workers fully switch to noisy during each uncapped + // interval, the uncap is bounded by `ACTIVE_REFRESH_SECS` (2 s in + // production). Empirically with the prod-realistic refresh cadence + // the avg per-second p95 stays under 1.5 s and the worst-second p95 + // stays under 4 s. If either of these blows out, the oscillation is + // worse than acceptable and the algorithm needs a softer rate limit + // (e.g. stochastic admission of capped workspaces). + assert!( + avg_p95 < 1_500, + "average per-second victim p95 under burst was {} ms — \ + oscillation is degrading victim QoL more than expected", + avg_p95, + ); + assert!( + worst_p95 < 4_000, + "worst per-second victim p95 under burst was {} ms — \ + uncapped bursts are too long; check ACTIVE_REFRESH_SECS", + worst_p95, + ); +} diff --git a/backend/windmill-ai/Cargo.toml b/backend/windmill-ai/Cargo.toml index 8f2f679c7e..0276240246 100644 --- a/backend/windmill-ai/Cargo.toml +++ b/backend/windmill-ai/Cargo.toml @@ -6,7 +6,7 @@ edition.workspace = true [features] default = [] -bedrock = ["dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config"] +bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config"] mcp = ["dep:windmill-mcp"] [lib] @@ -42,4 +42,5 @@ ulid.workspace = true aws-config = { workspace = true, optional = true } aws-credential-types = { workspace = true, optional = true } aws-smithy-types = { workspace = true, optional = true } +aws-sdk-bedrock = { workspace = true, optional = true } aws-sdk-bedrockruntime = { workspace = true, optional = true } diff --git a/backend/windmill-ai/src/ai_bedrock.rs b/backend/windmill-ai/src/ai_bedrock.rs index c05094b8f4..e549e63041 100644 --- a/backend/windmill-ai/src/ai_bedrock.rs +++ b/backend/windmill-ai/src/ai_bedrock.rs @@ -754,6 +754,26 @@ pub fn bedrock_stream_event_to_tool_start( } } +pub fn bedrock_stream_event_to_tool_start_with_block_index( + event: &ConverseStreamOutput, +) -> Option<(usize, StreamingToolCall)> { + match event { + ConverseStreamOutput::ContentBlockStart(start) => { + let block_index = usize::try_from(start.content_block_index()).ok()?; + let tool_use = start.start().and_then(|s| s.as_tool_use().ok())?; + Some(( + block_index, + StreamingToolCall { + id: tool_use.tool_use_id().to_string(), + name: tool_use.name().to_string(), + arguments: String::new(), + }, + )) + } + _ => None, + } +} + /// Extract tool use input delta from stream pub fn bedrock_stream_event_to_tool_delta(event: &ConverseStreamOutput) -> Option { match event { @@ -765,6 +785,22 @@ pub fn bedrock_stream_event_to_tool_delta(event: &ConverseStreamOutput) -> Optio } } +pub fn bedrock_stream_event_to_tool_delta_with_block_index( + event: &ConverseStreamOutput, +) -> Option<(usize, String)> { + match event { + ConverseStreamOutput::ContentBlockDelta(delta) => { + let block_index = usize::try_from(delta.content_block_index()).ok()?; + let input = delta + .delta() + .and_then(|d| d.as_tool_use().ok()) + .map(|tool_use| tool_use.input().to_string())?; + Some((block_index, input)) + } + _ => None, + } +} + /// Check if stream event indicates content block stop pub fn bedrock_stream_event_is_block_stop(event: &ConverseStreamOutput) -> bool { matches!(event, ConverseStreamOutput::ContentBlockStop(_)) diff --git a/backend/windmill-ai/src/ai_providers.rs b/backend/windmill-ai/src/ai_providers.rs index b568c0accb..52d04910de 100644 --- a/backend/windmill-ai/src/ai_providers.rs +++ b/backend/windmill-ai/src/ai_providers.rs @@ -20,13 +20,14 @@ where lazy_static::lazy_static! { static ref OPENAI_AZURE_BASE_PATH: Option = std::env::var("OPENAI_AZURE_BASE_PATH").ok(); - static ref ALLOW_PRIVATE_AI_BASE_URLS: bool = std::env::var("ALLOW_PRIVATE_AI_BASE_URLS") + pub static ref ALLOW_PRIVATE_AI_BASE_URLS: bool = std::env::var("ALLOW_PRIVATE_AI_BASE_URLS") .ok() .map(|v| v == "true" || v == "1") .unwrap_or(false); } pub const OPENAI_BASE_URL: &str = "https://api.openai.com/v1"; +pub const DEEPSEEK_BASE_URL: &str = "https://api.deepseek.com/v1"; pub const GOOGLE_AI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta"; /// Empty string signals BedrockClient::from_env() to use the region from AWS environment/config @@ -106,7 +107,7 @@ impl AIProvider { Ok(azure_base_path.unwrap_or("https://api.openai.com/v1".to_string())) } - AIProvider::DeepSeek => Ok("https://api.deepseek.com/v1".to_string()), + AIProvider::DeepSeek => Ok(DEEPSEEK_BASE_URL.to_string()), AIProvider::GoogleAI => Ok(GOOGLE_AI_BASE_URL.to_string()), AIProvider::Groq => Ok("https://api.groq.com/openai/v1".to_string()), AIProvider::OpenRouter => Ok("https://openrouter.ai/api/v1".to_string()), diff --git a/backend/windmill-ai/src/credentials.rs b/backend/windmill-ai/src/credentials.rs new file mode 100644 index 0000000000..75d523cf25 --- /dev/null +++ b/backend/windmill-ai/src/credentials.rs @@ -0,0 +1,25 @@ +use std::collections::HashMap; + +use crate::ai_providers::{AIPlatform, AIProvider}; + +/// Resolved provider credentials shared by API proxy and worker execution. +/// +/// Raw API resources and worker agent payloads convert into this shape at their +/// execution boundaries. Request-specific state such as the selected model stays +/// outside this type. +#[derive(Clone, Debug)] +pub struct ProviderCredentials { + pub provider: AIProvider, + pub base_url: String, + pub api_key: Option, + pub access_token: Option, + pub organization_id: Option, + pub user: Option, + pub region: Option, + pub aws_access_key_id: Option, + pub aws_secret_access_key: Option, + pub aws_session_token: Option, + pub platform: AIPlatform, + pub enable_1m_context: bool, + pub custom_headers: HashMap, +} diff --git a/backend/windmill-ai/src/lib.rs b/backend/windmill-ai/src/lib.rs index a138d72f3c..b6487c0ac5 100644 --- a/backend/windmill-ai/src/lib.rs +++ b/backend/windmill-ai/src/lib.rs @@ -4,6 +4,7 @@ pub mod ai_cache; pub mod ai_google; pub mod ai_providers; pub mod ai_types; +pub mod credentials; pub mod image_handler; pub mod providers; pub mod proxy; diff --git a/backend/windmill-ai/src/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs index 8a8a2b846d..17a27e370f 100644 --- a/backend/windmill-ai/src/providers/anthropic.rs +++ b/backend/windmill-ai/src/providers/anthropic.rs @@ -729,8 +729,7 @@ impl QueryBuilder for AnthropicQueryBuilder { mod tests { use super::*; use crate::{ - proxy::{ProviderCredentials, ProxyBuildArgs}, - query_builder::QueryBuilder, + credentials::ProviderCredentials, proxy::ProxyBuildArgs, query_builder::QueryBuilder, }; use http::{HeaderMap, HeaderValue, Method}; use std::collections::HashMap; diff --git a/backend/windmill-ai/src/providers/bedrock.rs b/backend/windmill-ai/src/providers/bedrock.rs index 005327c15b..0eef359c36 100644 --- a/backend/windmill-ai/src/providers/bedrock.rs +++ b/backend/windmill-ai/src/providers/bedrock.rs @@ -7,21 +7,753 @@ //! - Helper utilities use crate::{ + ai_bedrock::{ + bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop, + bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta, + bedrock_stream_event_to_tool_delta_with_block_index, bedrock_stream_event_to_tool_start, + bedrock_stream_event_to_tool_start_with_block_index, build_tool_config, + create_inference_config, format_bedrock_error, openai_messages_to_bedrock, + streaming_tool_calls_to_openai, BearerTokenProvider, BedrockClient, StreamingToolCall, + }, + ai_providers::USE_ENV_REGION, + ai_types::{OpenAIFunction, OpenAIToolCall, ToolDefFunction}, image_handler::prepare_messages_for_api, + proxy::ProxyBuildArgs, query_builder::{ParsedResponse, StreamEventSink}, types::{OpenAIMessage, StreamingEvent, TokenUsage, ToolDef}, }; +use bytes::Bytes; +use futures::{stream::BoxStream, StreamExt}; +use http::{HeaderMap, Method, StatusCode}; +use serde::Deserialize; use std::collections::HashMap; use windmill_common::{client::AuthedClient, error::Error}; -// Import shared Bedrock helpers for provider orchestration. -use crate::ai_bedrock::{ - bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop, - bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta, - bedrock_stream_event_to_tool_start, build_tool_config, create_inference_config, - format_bedrock_error, openai_messages_to_bedrock, streaming_tool_calls_to_openai, - BedrockClient, StreamingToolCall, -}; +// ============================================================================ +// Native Proxy Execution +// ============================================================================ + +/// OpenAI-format request body for Bedrock SDK proxy handlers. +#[derive(Deserialize, Debug)] +struct OpenAIRequest { + messages: Vec, + #[serde(default)] + tools: Option>, + #[serde(default)] + tool_choice: Option, + #[serde(default)] + max_tokens: Option, + #[serde(default)] + temperature: Option, +} + +#[derive(Deserialize, Debug)] +struct OpenAIToolDef { + #[serde(default)] + #[allow(dead_code)] + r#type: Option, + function: OpenAIToolFunction, +} + +#[derive(Deserialize, Debug)] +struct OpenAIToolFunction { + name: String, + #[serde(default)] + description: Option, + #[serde(default)] + parameters: Option, +} + +#[derive(Deserialize, Debug)] +struct BedrockProxyChatRequest { + model: String, + #[serde(default)] + stream: bool, +} + +enum BedrockAuthConfig { + BearerToken(String), + IamCredentials { + access_key_id: String, + secret_access_key: String, + session_token: Option, + }, + Environment, +} + +pub enum BedrockProxyResponseBody { + Fixed(Bytes), + Stream(BoxStream<'static, std::result::Result>), +} + +pub struct BedrockProxyResponse { + pub status_code: StatusCode, + pub headers: HeaderMap, + pub body: BedrockProxyResponseBody, +} + +/// Handle a workspace Bedrock proxy request through the AWS SDK. +/// +/// The API still owns credential resolution, route authorization, auditing, and +/// cache behavior. This helper owns Bedrock-specific control-plane and +/// OpenAI-compatible Converse transformations. +pub async fn handle_bedrock_proxy( + args: &ProxyBuildArgs<'_>, +) -> Result { + let region = args.credentials.region.as_deref().unwrap_or(USE_ENV_REGION); + + if *args.method == Method::GET { + return match args.path { + "foundation-models" => list_foundation_models(args, region).await, + "inference-profiles" => list_inference_profiles(args, region).await, + _ => Err(Error::BadRequest(format!( + "Unsupported AWS Bedrock proxy path: {}", + args.path + ))), + }; + } + + if *args.method != Method::POST { + return Err(Error::BadRequest(format!( + "Unsupported AWS Bedrock proxy method: {}", + args.method + ))); + } + + let request: BedrockProxyChatRequest = serde_json::from_slice(args.body) + .map_err(|e| Error::internal_err(format!("Failed to parse request body: {}", e)))?; + + if request.stream { + handle_bedrock_sdk_streaming(&request.model, args.body, args, region).await + } else { + handle_bedrock_sdk_non_streaming(&request.model, args.body, args, region).await + } +} + +fn determine_auth_config( + api_key: Option<&str>, + aws_access_key_id: Option<&str>, + aws_secret_access_key: Option<&str>, + aws_session_token: Option<&str>, +) -> BedrockAuthConfig { + if let Some(key) = api_key.filter(|k| !k.is_empty()) { + BedrockAuthConfig::BearerToken(key.to_string()) + } else if let (Some(access_key_id), Some(secret_access_key)) = ( + aws_access_key_id.filter(|s| !s.is_empty()), + aws_secret_access_key.filter(|s| !s.is_empty()), + ) { + BedrockAuthConfig::IamCredentials { + access_key_id: access_key_id.to_string(), + secret_access_key: secret_access_key.to_string(), + session_token: aws_session_token + .filter(|token| !token.is_empty()) + .map(str::to_string), + } + } else { + BedrockAuthConfig::Environment + } +} + +async fn create_bedrock_client( + args: &ProxyBuildArgs<'_>, + region: &str, +) -> Result { + match determine_auth_config( + args.credentials.api_key.as_deref(), + args.credentials.aws_access_key_id.as_deref(), + args.credentials.aws_secret_access_key.as_deref(), + args.credentials.aws_session_token.as_deref(), + ) { + BedrockAuthConfig::BearerToken(key) => BedrockClient::from_bearer_token(key, region).await, + BedrockAuthConfig::IamCredentials { access_key_id, secret_access_key, session_token } => { + BedrockClient::from_credentials(access_key_id, secret_access_key, session_token, region) + .await + } + BedrockAuthConfig::Environment => BedrockClient::from_env(region).await, + } +} + +fn build_tool_config_from_request( + tools: Option<&[OpenAIToolDef]>, + tool_choice: Option<&serde_json::Value>, + enable_prompt_caching: bool, +) -> Result, Error> { + if let Some(tools) = tools { + let tool_defs: Vec = tools + .iter() + .map(|t| ToolDef { + r#type: "function".to_string(), + function: ToolDefFunction { + name: t.function.name.clone(), + description: t.function.description.clone(), + parameters: Box::from( + serde_json::value::RawValue::from_string( + serde_json::to_string( + &t.function + .parameters + .clone() + .unwrap_or(serde_json::json!({})), + ) + .unwrap_or_default(), + ) + .unwrap_or_else(|_| { + serde_json::value::RawValue::from_string("{}".to_string()).unwrap() + }), + ), + }, + }) + .collect(); + + let force_tool_use = tool_choice + .map(|tc| tc == "required" || tc.as_str() == Some("required")) + .unwrap_or(false); + + build_tool_config(Some(&tool_defs), force_tool_use, enable_prompt_caching) + } else { + Ok(None) + } +} + +async fn create_bedrock_control_client( + args: &ProxyBuildArgs<'_>, + region: &str, +) -> Result { + use aws_config::BehaviorVersion; + + let region_provider = aws_sdk_bedrock::config::Region::new(region.to_string()); + + match determine_auth_config( + args.credentials.api_key.as_deref(), + args.credentials.aws_access_key_id.as_deref(), + args.credentials.aws_secret_access_key.as_deref(), + args.credentials.aws_session_token.as_deref(), + ) { + BedrockAuthConfig::BearerToken(key) => { + let config = aws_sdk_bedrock::config::Builder::new() + .region(region_provider) + .behavior_version(BehaviorVersion::latest()) + .token_provider(BearerTokenProvider::new(key)) + .build(); + Ok(aws_sdk_bedrock::Client::from_conf(config)) + } + BedrockAuthConfig::IamCredentials { access_key_id, secret_access_key, session_token } => { + let credentials = aws_credential_types::Credentials::new( + access_key_id, + secret_access_key, + session_token, + None, + "windmill", + ); + let config = aws_sdk_bedrock::config::Builder::new() + .region(region_provider) + .behavior_version(BehaviorVersion::latest()) + .credentials_provider(credentials) + .build(); + Ok(aws_sdk_bedrock::Client::from_conf(config)) + } + BedrockAuthConfig::Environment => { + let config = aws_config::defaults(BehaviorVersion::latest()) + .region(region_provider) + .load() + .await; + Ok(aws_sdk_bedrock::Client::new(&config)) + } + } +} + +async fn list_foundation_models( + args: &ProxyBuildArgs<'_>, + region: &str, +) -> Result { + let client = create_bedrock_control_client(args, region).await?; + + let response = client + .list_foundation_models() + .send() + .await + .map_err(|e| Error::internal_err(format!("Failed to list foundation models: {}", e)))?; + + let models: Vec = response + .model_summaries() + .iter() + .map(|m| { + serde_json::json!({ + "modelId": m.model_id(), + "modelName": m.model_name(), + "providerName": m.provider_name(), + "modelArn": m.model_arn(), + "inputModalities": m.input_modalities().iter().map(|i| i.as_str()).collect::>(), + "outputModalities": m.output_modalities().iter().map(|o| o.as_str()).collect::>(), + "responseStreamingSupported": m.response_streaming_supported(), + "inferenceTypesSupported": m.inference_types_supported().iter().map(|i| i.as_str()).collect::>(), + }) + }) + .collect(); + + let body = serde_json::to_vec(&serde_json::json!({ "modelSummaries": models })) + .map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?; + + Ok(BedrockProxyResponse { + status_code: StatusCode::OK, + headers: json_response_headers(), + body: BedrockProxyResponseBody::Fixed(Bytes::from(body)), + }) +} + +async fn list_inference_profiles( + args: &ProxyBuildArgs<'_>, + region: &str, +) -> Result { + let client = create_bedrock_control_client(args, region).await?; + + let response = + client.list_inference_profiles().send().await.map_err(|e| { + Error::internal_err(format!("Failed to list inference profiles: {}", e)) + })?; + + let profiles: Vec = response + .inference_profile_summaries() + .iter() + .map(|p| { + serde_json::json!({ + "inferenceProfileId": p.inference_profile_id(), + "inferenceProfileName": p.inference_profile_name(), + "inferenceProfileArn": p.inference_profile_arn(), + "description": p.description(), + "status": p.status().as_str(), + "type": p.r#type().as_str(), + }) + }) + .collect(); + + let body = serde_json::to_vec(&serde_json::json!({ "inferenceProfileSummaries": profiles })) + .map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?; + + Ok(BedrockProxyResponse { + status_code: StatusCode::OK, + headers: json_response_headers(), + body: BedrockProxyResponseBody::Fixed(Bytes::from(body)), + }) +} + +async fn handle_bedrock_sdk_streaming( + model: &str, + body: &[u8], + args: &ProxyBuildArgs<'_>, + region: &str, +) -> Result { + let openai_req: OpenAIRequest = serde_json::from_slice(body) + .map_err(|e| Error::internal_err(format!("Failed to parse OpenAI request: {}", e)))?; + + let bedrock_client = create_bedrock_client(args, region).await?; + let enable_prompt_caching = bedrock_model_supports_prompt_caching(model); + let (bedrock_messages, system_prompts) = + openai_messages_to_bedrock(&openai_req.messages, enable_prompt_caching)?; + let inference_config = create_inference_config(openai_req.temperature, openai_req.max_tokens); + let tool_config = build_tool_config_from_request( + openai_req.tools.as_deref(), + openai_req.tool_choice.as_ref(), + enable_prompt_caching, + )?; + + let mut request_builder = bedrock_client + .client() + .converse_stream() + .model_id(model) + .set_messages(Some(bedrock_messages)); + + if !system_prompts.is_empty() { + request_builder = request_builder.set_system(Some(system_prompts)); + } + + if let Some(config) = inference_config { + request_builder = request_builder.inference_config(config); + } + + if let Some(config) = tool_config { + request_builder = request_builder.set_tool_config(Some(config)); + } + + tracing::debug!("Bedrock SDK streaming: sending converse_stream request"); + let stream_output = request_builder.send().await.map_err(|e| { + let error_msg = format!("Bedrock SDK streaming error: {}", format_bedrock_error(&e)); + tracing::error!("Bedrock SDK streaming failed: {}", error_msg); + Error::internal_err(error_msg) + })?; + tracing::debug!("Bedrock SDK streaming: stream established successfully"); + + Ok(BedrockProxyResponse { + status_code: StatusCode::OK, + headers: event_stream_response_headers(), + body: BedrockProxyResponseBody::Stream( + sdk_stream_to_sse(stream_output.stream, model.to_string()).boxed(), + ), + }) +} + +pub fn sdk_stream_to_sse( + stream: aws_sdk_bedrockruntime::primitives::event_stream::EventReceiver< + aws_sdk_bedrockruntime::types::ConverseStreamOutput, + aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError, + >, + model: String, +) -> impl futures::Stream> + Send { + let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple()); + let created = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + async_stream::stream! { + let mut stream = stream; + let mut state = BedrockSseStreamState::new(id, model, created); + + loop { + match stream.recv().await { + Ok(Some(event)) => { + for chunk in bedrock_sse_chunks_for_event(&event, &mut state) { + yield Ok(chunk); + } + } + Ok(None) => break, + Err(e) => { + yield Err(std::io::Error::new( + std::io::ErrorKind::Other, + e.to_string(), + )); + break; + } + } + } + + yield Ok(Bytes::from("data: [DONE]\n\n")); + } +} + +#[derive(Debug)] +struct BedrockSseStreamState { + id: String, + model: String, + created: u64, + tool_calls: HashMap, + tool_block_indexes: HashMap, + next_tool_index: usize, +} + +impl BedrockSseStreamState { + fn new(id: String, model: String, created: u64) -> Self { + Self { + id, + model, + created, + tool_calls: HashMap::new(), + tool_block_indexes: HashMap::new(), + next_tool_index: 0, + } + } +} + +fn bedrock_sse_chunks_for_event( + event: &aws_sdk_bedrockruntime::types::ConverseStreamOutput, + state: &mut BedrockSseStreamState, +) -> Vec { + let mut chunks = Vec::new(); + + if let Some((block_index, tool_call)) = + bedrock_stream_event_to_tool_start_with_block_index(event) + { + let index = state.next_tool_index; + state.next_tool_index += 1; + state.tool_block_indexes.insert(block_index, index); + state.tool_calls.insert( + index, + (tool_call.id.clone(), tool_call.name.clone(), String::new()), + ); + + let chunk = serde_json::json!({ + "id": state.id, + "object": "chat.completion.chunk", + "created": state.created, + "model": state.model, + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": index, + "id": tool_call.id, + "type": "function", + "function": { + "name": tool_call.name, + "arguments": "" + } + }] + }, + "finish_reason": serde_json::Value::Null + }] + }); + + chunks.push(Bytes::from(format!("data: {}\n\n", chunk))); + } + + if let Some(text) = bedrock_stream_event_to_text(event) { + let chunk = serde_json::json!({ + "id": state.id, + "object": "chat.completion.chunk", + "created": state.created, + "model": state.model, + "choices": [{ + "index": 0, + "delta": { + "content": text + }, + "finish_reason": serde_json::Value::Null + }] + }); + + chunks.push(Bytes::from(format!("data: {}\n\n", chunk))); + } + + if let Some((block_index, input_delta)) = + bedrock_stream_event_to_tool_delta_with_block_index(event) + { + if let Some(index) = state.tool_block_indexes.get(&block_index).copied() { + if let Some((_id, _name, ref mut args)) = state.tool_calls.get_mut(&index) { + args.push_str(&input_delta); + + let chunk = serde_json::json!({ + "id": state.id, + "object": "chat.completion.chunk", + "created": state.created, + "model": state.model, + "choices": [{ + "index": 0, + "delta": { + "tool_calls": [{ + "index": index, + "function": { + "arguments": input_delta + } + }] + }, + "finish_reason": serde_json::Value::Null + }] + }); + + chunks.push(Bytes::from(format!("data: {}\n\n", chunk))); + } + } + } + + if let aws_sdk_bedrockruntime::types::ConverseStreamOutput::MessageStop(stop) = event { + let stop_reason = stop.stop_reason().as_str(); + let finish_reason = match stop_reason { + "end_turn" => "stop", + "max_tokens" => "length", + "tool_use" => "tool_calls", + "stop_sequence" => "stop", + "guardrail_intervened" | "content_filtered" => "content_filter", + _ => "stop", + }; + + let chunk = serde_json::json!({ + "id": state.id, + "object": "chat.completion.chunk", + "created": state.created, + "model": state.model, + "choices": [{ + "index": 0, + "delta": {}, + "finish_reason": finish_reason + }] + }); + + chunks.push(Bytes::from(format!("data: {}\n\n", chunk))); + } + + chunks +} + +async fn handle_bedrock_sdk_non_streaming( + model: &str, + body: &[u8], + args: &ProxyBuildArgs<'_>, + region: &str, +) -> Result { + let openai_req: OpenAIRequest = serde_json::from_slice(body) + .map_err(|e| Error::internal_err(format!("Failed to parse OpenAI request: {}", e)))?; + + let bedrock_client = create_bedrock_client(args, region).await?; + let enable_prompt_caching = bedrock_model_supports_prompt_caching(model); + let (bedrock_messages, system_prompts) = + openai_messages_to_bedrock(&openai_req.messages, enable_prompt_caching)?; + let inference_config = create_inference_config(openai_req.temperature, openai_req.max_tokens); + let tool_config = build_tool_config_from_request( + openai_req.tools.as_deref(), + openai_req.tool_choice.as_ref(), + enable_prompt_caching, + )?; + + let mut request_builder = bedrock_client + .client() + .converse() + .model_id(model) + .set_messages(Some(bedrock_messages)); + + if !system_prompts.is_empty() { + request_builder = request_builder.set_system(Some(system_prompts)); + } + + if let Some(config) = inference_config { + request_builder = request_builder.inference_config(config); + } + + if let Some(config) = tool_config { + request_builder = request_builder.set_tool_config(Some(config)); + } + + tracing::debug!("Bedrock SDK non-streaming: sending converse request"); + let response = request_builder.send().await.map_err(|e| { + let error_msg = format!( + "Bedrock SDK non-streaming error: {}", + format_bedrock_error(&e) + ); + tracing::error!("Bedrock SDK non-streaming failed: {}", error_msg); + Error::internal_err(error_msg) + })?; + tracing::debug!( + "Bedrock SDK non-streaming: response received, stop_reason={}", + response.stop_reason().as_str() + ); + + let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple()); + let created = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + + let stop_reason = response.stop_reason().as_str(); + let finish_reason = match stop_reason { + "end_turn" => "stop", + "max_tokens" => "length", + "tool_use" => "tool_calls", + "stop_sequence" => "stop", + "guardrail_intervened" | "content_filtered" => "content_filter", + _ => "stop", + }; + + let mut text_content = String::new(); + let mut tool_calls: Vec = Vec::new(); + + if let Some(aws_sdk_bedrockruntime::types::ConverseOutput::Message(message)) = response.output() + { + for block in message.content() { + match block { + aws_sdk_bedrockruntime::types::ContentBlock::Text(text) => { + text_content.push_str(text); + } + aws_sdk_bedrockruntime::types::ContentBlock::ToolUse(tool_use) => { + let input_json = document_to_json(tool_use.input()); + tool_calls.push(OpenAIToolCall { + id: tool_use.tool_use_id().to_string(), + function: OpenAIFunction { + name: tool_use.name().to_string(), + arguments: serde_json::to_string(&input_json).unwrap_or_default(), + }, + r#type: "function".to_string(), + extra_content: None, + }); + } + _ => {} + } + } + } + + let message = if !tool_calls.is_empty() { + serde_json::json!({ + "role": "assistant", + "content": if text_content.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(text_content) }, + "tool_calls": tool_calls + }) + } else { + serde_json::json!({ + "role": "assistant", + "content": text_content + }) + }; + + let usage = if let Some(usage_data) = response.usage() { + serde_json::json!({ + "prompt_tokens": usage_data.input_tokens(), + "completion_tokens": usage_data.output_tokens(), + "total_tokens": usage_data.total_tokens() + }) + } else { + serde_json::json!({ + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0 + }) + }; + + let openai_resp = serde_json::json!({ + "id": id, + "object": "chat.completion", + "created": created, + "model": model, + "choices": [{ + "index": 0, + "message": message, + "finish_reason": finish_reason + }], + "usage": usage + }); + + let body = serde_json::to_vec(&openai_resp) + .map_err(|e| Error::internal_err(format!("Failed to serialize OpenAI response: {}", e)))?; + + Ok(BedrockProxyResponse { + status_code: StatusCode::OK, + headers: json_response_headers(), + body: BedrockProxyResponseBody::Fixed(Bytes::from(body)), + }) +} + +fn json_response_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("content-type", "application/json".parse().unwrap()); + headers +} + +fn event_stream_response_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert("content-type", "text/event-stream".parse().unwrap()); + headers.insert("cache-control", "no-cache".parse().unwrap()); + headers.insert("connection", "keep-alive".parse().unwrap()); + headers +} + +fn document_to_json(doc: &aws_smithy_types::Document) -> serde_json::Value { + match doc { + aws_smithy_types::Document::Object(map) => { + let mut json_map = serde_json::Map::new(); + for (key, value) in map { + json_map.insert(key.clone(), document_to_json(value)); + } + serde_json::Value::Object(json_map) + } + aws_smithy_types::Document::Array(values) => { + serde_json::Value::Array(values.iter().map(document_to_json).collect()) + } + aws_smithy_types::Document::Number(number) => match number { + aws_smithy_types::Number::PosInt(number) => serde_json::Value::Number((*number).into()), + aws_smithy_types::Number::NegInt(number) => serde_json::Value::Number((*number).into()), + aws_smithy_types::Number::Float(number) => serde_json::json!(*number), + }, + aws_smithy_types::Document::String(value) => serde_json::Value::String(value.clone()), + aws_smithy_types::Document::Bool(value) => serde_json::Value::Bool(*value), + aws_smithy_types::Document::Null => serde_json::Value::Null, + } +} // ============================================================================ // Query Builder @@ -256,3 +988,138 @@ impl BedrockQueryBuilder { }) } } + +#[cfg(test)] +mod tests { + use super::*; + use aws_sdk_bedrockruntime::types::{ + ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStart, ContentBlockStartEvent, + ContentBlockStopEvent, ConverseStreamOutput, ToolUseBlockDelta, ToolUseBlockStart, + }; + + fn sse_json(chunk: &Bytes) -> serde_json::Value { + let chunk = std::str::from_utf8(chunk).expect("SSE chunk should be UTF-8"); + let payload = chunk + .strip_prefix("data: ") + .and_then(|chunk| chunk.strip_suffix("\n\n")) + .expect("chunk should be SSE data"); + serde_json::from_str(payload).expect("chunk should contain JSON") + } + + #[test] + fn determine_auth_config_prioritizes_bearer_token() { + let config = determine_auth_config( + Some("bearer-token"), + Some("AKIA123"), + Some("secret"), + Some("session-token"), + ); + + match config { + BedrockAuthConfig::BearerToken(token) => assert_eq!(token, "bearer-token"), + _ => panic!("expected bearer token auth config"), + } + } + + #[test] + fn determine_auth_config_uses_iam_with_optional_session_token() { + let config = + determine_auth_config(None, Some("AKIA123"), Some("secret"), Some("session-token")); + + match config { + BedrockAuthConfig::IamCredentials { + access_key_id, + secret_access_key, + session_token, + } => { + assert_eq!(access_key_id, "AKIA123"); + assert_eq!(secret_access_key, "secret"); + assert_eq!(session_token.as_deref(), Some("session-token")); + } + _ => panic!("expected IAM auth config"), + } + } + + #[test] + fn determine_auth_config_treats_empty_session_token_as_none() { + let config = determine_auth_config(None, Some("AKIA123"), Some("secret"), Some("")); + + match config { + BedrockAuthConfig::IamCredentials { session_token, .. } => { + assert!(session_token.is_none()); + } + _ => panic!("expected IAM auth config"), + } + } + + #[test] + fn determine_auth_config_falls_back_to_environment() { + let config = determine_auth_config(None, Some("AKIA123"), None, Some("session-token")); + assert!(matches!(config, BedrockAuthConfig::Environment)); + } + + #[test] + fn bedrock_sse_tool_indexes_ignore_text_block_stops() { + let mut state = + BedrockSseStreamState::new("chatcmpl-test".to_string(), "model".to_string(), 1); + + let text_delta = ConverseStreamOutput::ContentBlockDelta( + ContentBlockDeltaEvent::builder() + .content_block_index(0) + .delta(ContentBlockDelta::Text("hello".to_string())) + .build() + .unwrap(), + ); + assert_eq!( + bedrock_sse_chunks_for_event(&text_delta, &mut state).len(), + 1 + ); + + let text_stop = ConverseStreamOutput::ContentBlockStop( + ContentBlockStopEvent::builder() + .content_block_index(0) + .build() + .unwrap(), + ); + assert!(bedrock_sse_chunks_for_event(&text_stop, &mut state).is_empty()); + + let tool_start = ConverseStreamOutput::ContentBlockStart( + ContentBlockStartEvent::builder() + .content_block_index(1) + .start(ContentBlockStart::ToolUse( + ToolUseBlockStart::builder() + .tool_use_id("call_1") + .name("lookup") + .build() + .unwrap(), + )) + .build() + .unwrap(), + ); + let start_chunks = bedrock_sse_chunks_for_event(&tool_start, &mut state); + let start_json = sse_json(&start_chunks[0]); + assert_eq!( + start_json["choices"][0]["delta"]["tool_calls"][0]["index"], + 0 + ); + + let tool_delta = ConverseStreamOutput::ContentBlockDelta( + ContentBlockDeltaEvent::builder() + .content_block_index(1) + .delta(ContentBlockDelta::ToolUse( + ToolUseBlockDelta::builder() + .input("{\"city\":\"Paris\"}") + .build() + .unwrap(), + )) + .build() + .unwrap(), + ); + let delta_chunks = bedrock_sse_chunks_for_event(&tool_delta, &mut state); + let delta_json = sse_json(&delta_chunks[0]); + assert_eq!( + delta_json["choices"][0]["delta"]["tool_calls"][0]["index"], + 0 + ); + } +} diff --git a/backend/windmill-ai/src/providers/google_ai.rs b/backend/windmill-ai/src/providers/google_ai.rs index b6a6295d88..cf00ddc142 100644 --- a/backend/windmill-ai/src/providers/google_ai.rs +++ b/backend/windmill-ai/src/providers/google_ai.rs @@ -408,6 +408,8 @@ fn build_google_ai_model_endpoint( action: &str, is_vertex: bool, ) -> String { + let model = model.strip_prefix("models/").unwrap_or(model); + if is_vertex { format!("{}/{}:{}", base_url, model, action) } else { @@ -416,6 +418,10 @@ fn build_google_ai_model_endpoint( } fn add_google_ai_auth_header(headers: &mut Vec<(String, String)>, api_key: &str, is_vertex: bool) { + // Native Google AI proxy intentionally does not apply AI_HTTP_HEADERS or + // resource custom headers yet. Gemini/Vertex header semantics are + // provider-specific; keep this limited to required auth headers until + // explicit custom-header support is designed. if is_vertex { headers.push(("Authorization".to_string(), format!("Bearer {}", api_key))); } else { @@ -685,7 +691,7 @@ impl QueryBuilder for GoogleAIQueryBuilder { #[cfg(test)] mod tests { use super::*; - use crate::{ai_providers::AIProvider, proxy::ProviderCredentials}; + use crate::{ai_providers::AIProvider, credentials::ProviderCredentials}; use std::collections::HashMap; fn credentials(base_url: &str, platform: AIPlatform) -> ProviderCredentials { @@ -749,6 +755,32 @@ mod tests { assert!(body["contents"].is_array()); } + #[test] + fn builds_standard_google_ai_endpoint_from_model_resource_name() { + assert_eq!( + build_google_ai_model_endpoint( + "https://generativelanguage.googleapis.com/v1beta", + "models/gemini-2.0-flash", + "generateContent", + false, + ), + "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent" + ); + } + + #[test] + fn builds_vertex_google_ai_endpoint_from_model_resource_name() { + assert_eq!( + build_google_ai_model_endpoint( + "https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/publishers/google/models", + "models/gemini-2.0-flash", + "streamGenerateContent", + true, + ), + "https://us-central1-aiplatform.googleapis.com/v1/projects/p/locations/us-central1/publishers/google/models/gemini-2.0-flash:streamGenerateContent" + ); + } + #[test] fn builds_vertex_google_ai_streaming_proxy_request() { let credentials = credentials( diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index d1a602cf0e..fb50707221 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -7,8 +7,7 @@ pub mod openrouter; pub mod other; use crate::{ - ai_providers::AIProvider, proxy::ProviderCredentials, query_builder::QueryBuilder, - types::ProviderWithResource, + ai_providers::AIProvider, credentials::ProviderCredentials, query_builder::QueryBuilder, }; use self::{ @@ -16,25 +15,8 @@ use self::{ openrouter::OpenRouterQueryBuilder, other::OtherQueryBuilder, }; -/// Factory function to create the appropriate query builder for a provider. -pub fn create_query_builder(provider: &ProviderWithResource) -> Box { - match provider.kind { - AIProvider::GoogleAI => { - Box::new(GoogleAIQueryBuilder::new(provider.get_platform().clone())) - } - AIProvider::OpenAI => Box::new(OpenAIQueryBuilder::new(provider.kind.clone())), - AIProvider::Anthropic => Box::new(AnthropicQueryBuilder::new( - provider.kind.clone(), - provider.get_platform().clone(), - provider.get_enable_1m_context(), - )), - AIProvider::OpenRouter => Box::new(OpenRouterQueryBuilder::new()), - _ => Box::new(OtherQueryBuilder::new(provider.kind.clone())), - } -} - -/// Factory function to create the appropriate query builder from resolved proxy credentials. -pub fn create_proxy_query_builder(credentials: &ProviderCredentials) -> Box { +/// Factory function to create the appropriate query builder from resolved credentials. +pub fn create_query_builder(credentials: &ProviderCredentials) -> Box { match credentials.provider { AIProvider::GoogleAI => Box::new(GoogleAIQueryBuilder::new(credentials.platform.clone())), AIProvider::OpenAI => Box::new(OpenAIQueryBuilder::new(credentials.provider.clone())), diff --git a/backend/windmill-ai/src/proxy.rs b/backend/windmill-ai/src/proxy.rs index bbc570a18a..32ba35cd6d 100644 --- a/backend/windmill-ai/src/proxy.rs +++ b/backend/windmill-ai/src/proxy.rs @@ -4,29 +4,11 @@ use http::{HeaderMap, Method}; use serde_json::value::RawValue; use windmill_common::error::{Error, Result}; -use crate::ai_providers::{AIPlatform, AIProvider}; +use crate::ai_providers::AIProvider; +use crate::credentials::ProviderCredentials; use crate::utils::AI_HTTP_HEADERS; -/// Resolved provider credentials and proxy-specific context. -/// -/// This is intentionally separate from the worker's `ProviderWithResource`: API -/// proxy credentials are already resolved from workspace or instance resources. -#[derive(Clone, Debug)] -pub struct ProviderCredentials { - pub provider: AIProvider, - pub base_url: String, - pub api_key: Option, - pub access_token: Option, - pub organization_id: Option, - pub user: Option, - pub region: Option, - pub aws_access_key_id: Option, - pub aws_secret_access_key: Option, - pub aws_session_token: Option, - pub platform: AIPlatform, - pub enable_1m_context: bool, - pub custom_headers: HashMap, -} +pub mod fim; /// Inputs needed to transform an OpenAI-compatible proxy request for a provider. pub struct ProxyBuildArgs<'a> { @@ -166,6 +148,9 @@ pub(crate) fn add_user_to_body(body: &[u8], user: &str) -> Result> { #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; + + use crate::ai_providers::AIPlatform; fn credentials(provider: AIProvider, base_url: &str) -> ProviderCredentials { ProviderCredentials { diff --git a/backend/windmill-ai/src/proxy/fim.rs b/backend/windmill-ai/src/proxy/fim.rs new file mode 100644 index 0000000000..3645476441 --- /dev/null +++ b/backend/windmill-ai/src/proxy/fim.rs @@ -0,0 +1,212 @@ +use bytes::Bytes; +use serde::Deserialize; +use serde_json::json; +use windmill_common::error::{Error, Result}; + +use crate::ai_providers::{AIProvider, DEEPSEEK_BASE_URL}; + +#[derive(Debug, Eq, PartialEq)] +pub struct FimProxyTransform { + pub body: Bytes, + pub path: String, + pub base_url: Option, +} + +#[derive(Deserialize)] +struct FimRequest { + model: String, + prompt: String, + suffix: Option, + temperature: Option, + max_tokens: Option, + stop: Option>, +} + +pub fn supports_native_fim(provider: &AIProvider) -> bool { + matches!(provider, AIProvider::Mistral | AIProvider::DeepSeek) +} + +fn deepseek_fim_base_url(base_url: &str) -> String { + let trimmed = base_url.trim_end_matches('/'); + let deepseek_root_base_url = DEEPSEEK_BASE_URL + .strip_suffix("/v1") + .unwrap_or(DEEPSEEK_BASE_URL); + + if trimmed == DEEPSEEK_BASE_URL || trimmed == deepseek_root_base_url { + return format!("{deepseek_root_base_url}/beta"); + } + + if let Some(prefix) = trimmed.strip_suffix("/v1") { + return format!("{prefix}/beta"); + } + + trimmed.to_string() +} + +pub fn maybe_transform_fim_request( + provider: &AIProvider, + path: &str, + base_url: &str, + body: &[u8], +) -> Result> { + if !path.contains("fim/completions") { + return Ok(None); + } + + if matches!(provider, AIProvider::DeepSeek) { + return Ok(Some(FimProxyTransform { + body: Bytes::copy_from_slice(body), + path: "completions".to_string(), + base_url: Some(deepseek_fim_base_url(base_url)), + })); + } + + if !supports_native_fim(provider) { + return transform_fim_to_chat_completions(body).map(Some); + } + + Ok(None) +} + +fn transform_fim_to_chat_completions(body: &[u8]) -> Result { + let fim_req: FimRequest = serde_json::from_slice(body) + .map_err(|e| Error::BadRequest(format!("Failed to parse FIM request: {}", e)))?; + + let suffix = fim_req.suffix.unwrap_or_default(); + + let system_prompt = "You are a code completion assistant. Complete the code at the position between the given prefix and suffix. Output ONLY the code that goes at the cursor - no explanations, no markdown, no repeating the prefix or suffix."; + + let user_content = format!( + "\n{}\n\n\n{}", + fim_req.prompt, suffix + ); + + let chat_req = json!({ + "model": fim_req.model, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_content} + ], + "temperature": fim_req.temperature.unwrap_or(0.0), + "max_tokens": fim_req.max_tokens.unwrap_or(256), + "stop": fim_req.stop + }); + + let body = serde_json::to_vec(&chat_req) + .map_err(|e| Error::internal_err(format!("Failed to serialize chat request: {}", e)))?; + + Ok(FimProxyTransform { + body: Bytes::from(body), + path: "chat/completions".to_string(), + base_url: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn mistral_keeps_native_fim_request() { + let transformed = maybe_transform_fim_request( + &AIProvider::Mistral, + "fim/completions", + "https://api.mistral.ai/v1", + br#"{}"#, + ) + .unwrap(); + + assert!(transformed.is_none()); + assert!(supports_native_fim(&AIProvider::Mistral)); + assert!(supports_native_fim(&AIProvider::DeepSeek)); + assert!(!supports_native_fim(&AIProvider::OpenAI)); + } + + #[test] + fn deepseek_fim_base_url_uses_beta_endpoint() { + assert_eq!( + deepseek_fim_base_url("https://api.deepseek.com/v1"), + "https://api.deepseek.com/beta" + ); + assert_eq!( + deepseek_fim_base_url("https://api.deepseek.com/v1/"), + "https://api.deepseek.com/beta" + ); + assert_eq!( + deepseek_fim_base_url("https://api.deepseek.com"), + "https://api.deepseek.com/beta" + ); + assert_eq!( + deepseek_fim_base_url("https://proxy.example/deepseek/v1"), + "https://proxy.example/deepseek/beta" + ); + assert_eq!( + deepseek_fim_base_url("https://proxy.example/deepseek/beta"), + "https://proxy.example/deepseek/beta" + ); + } + + #[test] + fn deepseek_fim_request_uses_beta_completions_endpoint() { + let body = br#"{"model":"deepseek-v4-pro","prompt":"return ","suffix":";"}"#; + let transformed = maybe_transform_fim_request( + &AIProvider::DeepSeek, + "fim/completions", + DEEPSEEK_BASE_URL, + body, + ) + .unwrap() + .expect("DeepSeek FIM should be routed to the beta completions endpoint"); + + assert_eq!(transformed.path, "completions"); + assert_eq!( + transformed.base_url.as_deref(), + Some("https://api.deepseek.com/beta") + ); + assert_eq!(transformed.body, Bytes::copy_from_slice(body)); + } + + #[test] + fn openai_fim_request_is_transformed_to_chat_completion() { + let transformed = maybe_transform_fim_request( + &AIProvider::OpenAI, + "fim/completions", + "https://api.openai.com/v1", + br#"{ + "model": "gpt-4.1", + "prompt": "fn main() {", + "suffix": "}", + "stop": ["\n\n"] + }"#, + ) + .unwrap() + .expect("OpenAI FIM should be transformed"); + + assert_eq!(transformed.path, "chat/completions"); + assert_eq!(transformed.base_url, None); + + let body: serde_json::Value = serde_json::from_slice(&transformed.body).unwrap(); + assert_eq!(body["model"], "gpt-4.1"); + assert_eq!(body["temperature"], 0.0); + assert_eq!(body["max_tokens"], 256); + assert_eq!(body["stop"], serde_json::json!(["\n\n"])); + assert_eq!(body["messages"][1]["role"], "user"); + assert_eq!( + body["messages"][1]["content"], + "\nfn main() {\n\n\n}" + ); + } + + #[test] + fn invalid_fim_body_is_bad_request() { + let err = maybe_transform_fim_request( + &AIProvider::OpenAI, + "fim/completions", + "https://api.openai.com/v1", + br#"{"model": 1}"#, + ) + .unwrap_err(); + + assert!(matches!(err, Error::BadRequest(_))); + } +} diff --git a/backend/windmill-ai/src/types.rs b/backend/windmill-ai/src/types.rs index c64db5efe0..56a796e41d 100644 --- a/backend/windmill-ai/src/types.rs +++ b/backend/windmill-ai/src/types.rs @@ -18,6 +18,7 @@ pub struct McpToolSource { use crate::{ ai_google::sanitize_schema_for_google, ai_providers::{empty_string_as_none, AIProvider}, + credentials::ProviderCredentials, }; use windmill_common::{db::DB, error::Error, flow_status::AgentAction, flows::FlowModule}; use windmill_parser::Typ; @@ -222,6 +223,34 @@ impl ProviderWithResource { .await } + /// Convert worker agent provider input into resolved runtime credentials. + /// + /// Callers must only pass resources that were already authorized for the + /// current job/workspace; this helper does not perform access checks. + pub async fn to_provider_credentials(&self, db: &DB) -> Result { + let base_url = if self.kind == AIProvider::AWSBedrock { + String::new() + } else { + self.get_base_url(db).await? + }; + + Ok(ProviderCredentials { + provider: self.kind.clone(), + base_url, + api_key: self.resource.api_key.clone(), + access_token: None, + organization_id: None, + user: None, + region: self.resource.region.clone(), + aws_access_key_id: self.resource.aws_access_key_id.clone(), + aws_secret_access_key: self.resource.aws_secret_access_key.clone(), + aws_session_token: self.resource.aws_session_token.clone(), + platform: self.resource.platform.clone(), + enable_1m_context: self.resource.enable_1m_context, + custom_headers: self.resource.headers.clone(), + }) + } + #[cfg(feature = "bedrock")] pub fn get_region(&self) -> Option<&str> { self.resource.region.as_deref() diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index b9bc2e2417..734753a0e0 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -235,6 +235,246 @@ where Ok(()) } +/// Returns the caller's "real" scope restrictions: every scope other than +/// `if_jobs:filter_tags:` tag filters. `None` means the token is unscoped and +/// has the full privileges of its user; `Some` means it is restricted to the +/// returned scopes. An empty or filter-tags-only scope list is treated as +/// unscoped, mirroring `check_scopes`/`check_route_access`. +fn scope_restrictions(scopes: Option<&[String]>) -> Option> { + let restrictions: Vec<&String> = scopes? + .iter() + .filter(|s| !s.starts_with("if_jobs:filter_tags:")) + .collect(); + (!restrictions.is_empty()).then_some(restrictions) +} + +/// Enforce monotonic privilege when a token lifecycle endpoint mints or rescopes +/// a credential on behalf of `authed`: the resulting credential must never be +/// more privileged than the caller's own token. +/// +/// - An unscoped caller may grant any scopes (this is the existing UI/CLI flow). +/// - A scope-restricted caller may only grant scopes that are a subset of its +/// own, and may never produce an unscoped credential. +/// +/// Without this, a `users:write` token could create or rescope a token to be +/// unscoped, and a `users:read` token could refresh into an unscoped session — +/// escaping its own restrictions. +pub fn ensure_scopes_within_caller( + authed: &ApiAuthed, + requested_scopes: Option<&[String]>, +) -> error::Result<()> { + if let Some(caller_restrictions) = scope_restrictions(authed.scopes.as_deref()) { + let Some(requested_restrictions) = scope_restrictions(requested_scopes) else { + return Err(Error::PermissionDenied( + "A scope-restricted token cannot create or update a token with broader (unscoped) \ + privileges" + .to_string(), + )); + }; + + // MCP scopes (`mcp:all`, `mcp:favorites`, `mcp:scripts:*`, etc.) use a + // custom format that ScopeDefinition::from_scope_string parses + // permissively but the MCP runtime interprets via its own parser + // (parse_mcp_scopes). The two views disagree — e.g. the generic parser + // accepts `mcp:scripts` as an unrestricted-resource scope, while the + // MCP runtime ignores it as unrecognized but interprets `mcp:scripts:*` + // as granting all scripts. So generic containment would silently allow + // `mcp:scripts` → `mcp:scripts:*` (a widening). Legitimate MCP token + // issuance goes through the OAuth gateway (mcp/oauth_server.rs), not + // these user-token endpoints, so require byte-identical match for MCP + // scopes here rather than trying to mirror MCP semantics in two places. + // Unparseable non-MCP caller scopes are intentionally dropped + // (fail-closed): a caller scope that fails to parse can only narrow + // the set of requested scopes that get covered, never widen it. + // Unparseable requested scopes surface as `BadRequest`, which is what + // we want — the client is sending garbage. + let parsed_caller: Vec = caller_restrictions + .iter() + .filter(|s| !s.starts_with("mcp:")) + .filter_map(|s| ScopeDefinition::from_scope_string(s).ok()) + .collect(); + let caller_mcp: std::collections::HashSet<&str> = caller_restrictions + .iter() + .filter(|s| s.starts_with("mcp:")) + .map(|s| s.as_str()) + .collect(); + + for requested in requested_restrictions { + if requested.starts_with("mcp:") { + if !caller_mcp.contains(requested.as_str()) { + return Err(Error::PermissionDenied(format!( + "A scope-restricted token cannot grant MCP scope '{requested}' unless the \ + caller holds the same scope verbatim" + ))); + } + continue; + } + let requested_scope = ScopeDefinition::from_scope_string(requested)?; + let covered = parsed_caller + .iter() + .any(|caller_scope| scope_contains(caller_scope, &requested_scope)); + if !covered { + return Err(Error::PermissionDenied(format!( + "A scope-restricted token cannot grant scope '{requested}' which exceeds its \ + own scopes" + ))); + } + } + } + + // `if_jobs:filter_tags:` fences which job tags a token can run on (enforced + // at job operations as `v2_job.tag = ANY(...)`), and is checked independently + // of domain/action/resource subset. A caller restricted by filter_tags must + // not be able to mint or rescope a credential that drops or widens the fence + // — even if the caller has no other scope restrictions (filter_tags-only + // tokens otherwise look "unscoped" to `scope_restrictions`). + if let Some(caller_tags) = first_filter_tags(authed.scopes.as_deref()) { + let Some(requested_tags) = first_filter_tags(requested_scopes) else { + return Err(Error::PermissionDenied( + "A token restricted by if_jobs:filter_tags cannot mint or rescope a token that \ + drops the tag restriction" + .to_string(), + )); + }; + let caller_set: std::collections::HashSet<&str> = caller_tags.iter().copied().collect(); + for tag in &requested_tags { + if !caller_set.contains(tag) { + return Err(Error::PermissionDenied(format!( + "A token restricted by if_jobs:filter_tags cannot grant tag '{tag}' which is \ + not within its own filter_tags" + ))); + } + } + } + + Ok(()) +} + +/// Tags from the first `if_jobs:filter_tags:` scope, matching the +/// semantics of [`get_scope_tags`] (which is what the job runtime consults). +/// Returns `None` if no such scope is present. +fn first_filter_tags(scopes: Option<&[String]>) -> Option> { + scopes?.iter().find_map(|s| { + s.strip_prefix("if_jobs:filter_tags:") + .map(|tags| tags.split(',').collect()) + }) +} + +/// Whether `caller` grants at least everything `requested` grants (directional +/// containment). +/// +/// This is intentionally NOT `ScopeDefinition::includes`: that method answers +/// "does this scope grant access to a required action" using OR semantics over +/// resources (any overlap counts, and a `*` on either side matches), which is +/// correct for access checks but unsafe for subset checks — it would let a +/// token scoped to `scripts:read:f/team/a` mint `scripts:read:*` or +/// `scripts:read:f/team/a,f/other/b`. Subset containment instead requires that +/// EVERY requested resource is covered by SOME caller resource. +fn scope_contains(caller: &ScopeDefinition, requested: &ScopeDefinition) -> bool { + if caller.domain != requested.domain { + return false; + } + + // write subsumes read; otherwise the action must match exactly. + match (caller.action.as_str(), requested.action.as_str()) { + (c, r) if c == r || (c == "write" && r == "read") => {} + _ => return false, + } + + if caller.domain == "jobs" && caller.action == "run" { + match (&caller.kind, &requested.kind) { + (Some(caller_kind), Some(requested_kind)) if caller_kind != requested_kind => { + return false + } + // Caller pinned to a kind, but the request covers any kind. + (Some(_), None) => return false, + _ => {} + } + } + + match (&caller.resource, &requested.resource) { + // Caller is unrestricted on resources: covers everything. + (None, _) => true, + // Caller is resource-restricted but the request is not: broader. + (Some(_), None) => false, + (Some(caller_resources), Some(requested_resources)) => { + resource_set_contains(caller_resources, requested_resources) + } + } +} + +/// Every resource in `requested` must be covered by some resource in `caller`. +fn resource_set_contains(caller: &[String], requested: &[String]) -> bool { + if caller.iter().any(|r| r == "*") { + return true; + } + requested + .iter() + .all(|req| req != "*" && caller.iter().any(|c| resource_covers(c, req))) +} + +/// Directional: does the single caller resource pattern cover `requested`? +/// `caller` may be an exact path or a `/*` subtree wildcard; `requested` +/// may itself be a subtree wildcard, in which case the whole requested subtree +/// must fall within the caller's subtree. +fn resource_covers(caller: &str, requested: &str) -> bool { + if caller == requested { + return true; + } + let Some(prefix) = caller.strip_suffix("/*") else { + // An exact caller resource only covers itself (handled above). + return false; + }; + let requested_base = requested.strip_suffix("/*").unwrap_or(requested); + requested_base == prefix + || (requested_base.starts_with(prefix) + && requested_base.as_bytes().get(prefix.len()) == Some(&b'/')) +} + +/// Returns a predicate that checks whether `path` is within the token's +/// scope for `{domain}:{action}:{path}`. For tokens without scope +/// restrictions (no scopes at all, or only `if_jobs:filter_tags:*` scopes), +/// the predicate always returns `true`. +/// +/// Pre-parses the token's scopes once so the returned closure can cheaply +/// filter large listings without re-parsing on each call. +pub fn build_scope_path_predicate( + authed: &ApiAuthed, + domain: &str, + action: &str, +) -> impl Fn(&str) -> bool { + // Mirror check_scopes semantics: a token is "scope-restricted" iff it has + // at least one non-`if_jobs:filter_tags:` scope. Unparseable scopes still + // count as restrictive — they just match nothing. + let (is_scoped_token, parsed): (bool, Vec) = match authed.scopes.as_ref() { + Some(scopes) => { + let mut is_scoped = false; + let parsed = scopes + .iter() + .filter(|s| !s.starts_with("if_jobs:filter_tags:")) + .inspect(|_| is_scoped = true) + .filter_map(|s| ScopeDefinition::from_scope_string(s).ok()) + .collect(); + (is_scoped, parsed) + } + None => (false, Vec::new()), + }; + let domain = domain.to_string(); + let action = action.to_string(); + + move |path: &str| -> bool { + if !is_scoped_token { + return true; + } + let required = + match ScopeDefinition::from_scope_string(&format!("{}:{}:{}", domain, action, path)) { + Ok(r) => r, + Err(_) => return false, + }; + parsed.iter().any(|s| s.includes(&required)) + } +} + pub async fn require_devops_role(db: &DB, email: &str) -> error::Result<()> { let is_devops = is_devops_email(db, email).await?; @@ -530,6 +770,13 @@ impl NewToken { } } +/// Low-level token mint shared by trusted callers (the user-facing +/// `tokens/create` handler and internal mints such as native-trigger webhook +/// tokens). It does NOT enforce that `token_config.scopes` is within the +/// caller's own scopes — callers exposed to untrusted input must call +/// [`ensure_scopes_within_caller`] first (internal narrowing mints intentionally +/// skip it, since their scopes derive from the action being authorized, not the +/// caller's token). pub async fn create_token_internal( tx: &mut sqlx::PgConnection, db: &DB, @@ -803,3 +1050,339 @@ pub fn require_path_read_access_for_preview( ))), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn authed_with_scopes(scopes: Option>) -> ApiAuthed { + ApiAuthed { + scopes: scopes.map(|v| v.into_iter().map(String::from).collect()), + ..Default::default() + } + } + + #[test] + fn predicate_no_scopes_allows_all() { + let authed = authed_with_scopes(None); + let allowed = build_scope_path_predicate(&authed, "resources", "read"); + assert!(allowed("u/alice/anything")); + assert!(allowed("u/bob/other")); + } + + #[test] + fn predicate_tag_filter_only_allows_all() { + let authed = authed_with_scopes(Some(vec!["if_jobs:filter_tags:default"])); + let allowed = build_scope_path_predicate(&authed, "resources", "read"); + assert!(allowed("u/alice/foo")); + } + + #[test] + fn predicate_single_resource_scope_filters_others() { + // Regression test for WIN-1981: a token scoped to one resource must + // not match unrelated paths in listings (e.g. /resources/list_search). + let authed = authed_with_scopes(Some(vec!["resources:read:u/alice/allowed_resource"])); + let allowed = build_scope_path_predicate(&authed, "resources", "read"); + assert!(allowed("u/alice/allowed_resource")); + assert!(!allowed("u/alice/other_resource")); + assert!(!allowed("u/bob/foo")); + } + + #[test] + fn predicate_wildcard_scope_matches_subtree() { + let authed = authed_with_scopes(Some(vec!["resources:read:f/team/*"])); + let allowed = build_scope_path_predicate(&authed, "resources", "read"); + assert!(allowed("f/team/db")); + assert!(allowed("f/team/sub/nested")); + assert!(!allowed("f/other/db")); + } + + #[test] + fn predicate_wrong_domain_is_rejected() { + let authed = authed_with_scopes(Some(vec!["variables:read:u/alice/secret"])); + let allowed = build_scope_path_predicate(&authed, "resources", "read"); + assert!(!allowed("u/alice/secret")); + } + + #[test] + fn predicate_write_implies_read() { + let authed = authed_with_scopes(Some(vec!["resources:write:u/alice/foo"])); + let allowed = build_scope_path_predicate(&authed, "resources", "read"); + assert!(allowed("u/alice/foo")); + assert!(!allowed("u/alice/bar")); + } + + fn opt_scopes(scopes: Option>) -> Option> { + scopes.map(|v| v.into_iter().map(String::from).collect()) + } + + // Regression tests for WIN-1999: scoped user tokens must not be able to + // mint or rescope credentials with broader privileges than themselves. + + #[test] + fn unscoped_caller_can_grant_anything() { + let authed = authed_with_scopes(None); + assert!(ensure_scopes_within_caller(&authed, None).is_ok()); + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref() + ) + .is_ok()); + } + + #[test] + fn filter_tags_only_caller_is_unrestricted_on_domain_action_dimension() { + // The domain/action/resource subset check treats filter-tags-only as + // unrestricted, mirroring check_scopes/check_route_access. The tag + // dimension is checked separately (see filter_tags_dimension_is_monotonic). + let authed = authed_with_scopes(Some(vec!["if_jobs:filter_tags:default"])); + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["users:write", "if_jobs:filter_tags:default"])).as_deref() + ) + .is_ok()); + } + + #[test] + fn filter_tags_dimension_is_monotonic() { + // Caller restricted to tag fence "a" cannot drop the fence … + let single = authed_with_scopes(Some(vec!["if_jobs:filter_tags:a"])); + assert!(ensure_scopes_within_caller(&single, None).is_err()); + assert!( + ensure_scopes_within_caller(&single, opt_scopes(Some(vec!["users:read"])).as_deref()) + .is_err(), + "minting a token without filter_tags must be rejected" + ); + // … cannot widen to a tag it lacks … + assert!(ensure_scopes_within_caller( + &single, + opt_scopes(Some(vec!["if_jobs:filter_tags:a,b"])).as_deref() + ) + .is_err()); + // … and cannot mint a token fenced on a disjoint tag. + assert!(ensure_scopes_within_caller( + &single, + opt_scopes(Some(vec!["if_jobs:filter_tags:b"])).as_deref() + ) + .is_err()); + // Narrowing or matching the tag fence is allowed. + let multi = authed_with_scopes(Some(vec!["if_jobs:filter_tags:a,b"])); + assert!(ensure_scopes_within_caller( + &multi, + opt_scopes(Some(vec!["if_jobs:filter_tags:a"])).as_deref() + ) + .is_ok()); + assert!(ensure_scopes_within_caller( + &multi, + opt_scopes(Some(vec!["if_jobs:filter_tags:a,b"])).as_deref() + ) + .is_ok()); + // A caller with a real scope plus a tag fence cannot drop just the fence. + let mixed = authed_with_scopes(Some(vec!["jobs:run:scripts", "if_jobs:filter_tags:a"])); + assert!(ensure_scopes_within_caller( + &mixed, + opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref() + ) + .is_err()); + assert!(ensure_scopes_within_caller( + &mixed, + opt_scopes(Some(vec!["jobs:run:scripts", "if_jobs:filter_tags:a"])).as_deref() + ) + .is_ok()); + // An unrestricted caller may grant filter_tags freely. + let unscoped = authed_with_scopes(None); + assert!(ensure_scopes_within_caller( + &unscoped, + opt_scopes(Some(vec!["if_jobs:filter_tags:x"])).as_deref() + ) + .is_ok()); + } + + #[test] + fn scoped_caller_cannot_mint_unscoped_token() { + // Primitive 2 in the report: a users:write token minting an unscoped token. + let authed = authed_with_scopes(Some(vec!["users:write"])); + assert!(ensure_scopes_within_caller(&authed, None).is_err()); + // Empty scope list is effectively unscoped and must also be rejected. + assert!(ensure_scopes_within_caller(&authed, Some(&[])).is_err()); + // A scope list of only tag filters is effectively unscoped too. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["if_jobs:filter_tags:default"])).as_deref() + ) + .is_err()); + } + + #[test] + fn scoped_caller_cannot_remove_its_own_scopes() { + // Primitive 3 in the report: a users:write token setting its scopes to null. + let authed = authed_with_scopes(Some(vec!["users:write"])); + assert!(ensure_scopes_within_caller(&authed, None).is_err()); + } + + #[test] + fn scoped_caller_cannot_grant_scope_it_lacks() { + let authed = authed_with_scopes(Some(vec!["users:write"])); + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref() + ) + .is_err()); + } + + #[test] + fn scoped_caller_can_grant_subset_of_own_scopes() { + let authed = authed_with_scopes(Some(vec!["users:write", "jobs:run:scripts"])); + // Equal scope is allowed. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["jobs:run:scripts"])).as_deref() + ) + .is_ok()); + // write implies read, so a narrower read scope is allowed. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["users:read"])).as_deref() + ) + .is_ok()); + // Tag filters narrow further and are always permitted. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["users:read", "if_jobs:filter_tags:default"])).as_deref() + ) + .is_ok()); + } + + #[test] + fn scoped_caller_cannot_broaden_resource_scope() { + let authed = authed_with_scopes(Some(vec!["scripts:read:f/team/*"])); + // Narrower resource within the subtree is allowed. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["scripts:read:f/team/sub"])).as_deref() + ) + .is_ok()); + // A nested subtree within the caller's subtree is allowed. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["scripts:read:f/team/sub/*"])).as_deref() + ) + .is_ok()); + // The subtree root itself is allowed. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["scripts:read:f/team"])).as_deref() + ) + .is_ok()); + // A path outside the subtree is rejected. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["scripts:read:f/other/x"])).as_deref() + ) + .is_err()); + // read caller cannot grant write. + assert!(ensure_scopes_within_caller( + &authed, + opt_scopes(Some(vec!["scripts:write:f/team/db"])).as_deref() + ) + .is_err()); + } + + #[test] + fn mcp_scopes_require_byte_identical_match() { + // Regression for the access-grant-OR vs runtime-MCP-parser confusion: + // ScopeDefinition treats `mcp:scripts` as an unrestricted-resource scope + // and `mcp:scripts:*` as a strictly narrower one, so generic containment + // would silently allow widening. The MCP runtime however ignores + // `mcp:scripts` (unrecognized) while `mcp:scripts:*` grants all scripts. + // Legitimate MCP token issuance is the OAuth gateway, not these + // user-token endpoints, so MCP scopes must match the caller verbatim. + + // The bypass the reviewer flagged: malformed `mcp:scripts` would widen + // into the real `mcp:scripts:*` under generic containment. + let bypass = authed_with_scopes(Some(vec!["users:write", "mcp:scripts"])); + assert!(ensure_scopes_within_caller( + &bypass, + opt_scopes(Some(vec!["users:write", "mcp:scripts:*"])).as_deref() + ) + .is_err()); + + // A caller without any MCP scope cannot grant one (widening on the MCP + // dimension), even if the rest of the requested scopes are within reach. + let no_mcp = authed_with_scopes(Some(vec!["users:write"])); + assert!(ensure_scopes_within_caller( + &no_mcp, + opt_scopes(Some(vec!["users:write", "mcp:scripts:*"])).as_deref() + ) + .is_err()); + + // Byte-identical MCP scope passes; an additional non-matching MCP scope + // alongside it does not. + let mcp_caller = authed_with_scopes(Some(vec!["mcp:scripts:*"])); + assert!(ensure_scopes_within_caller( + &mcp_caller, + opt_scopes(Some(vec!["mcp:scripts:*"])).as_deref() + ) + .is_ok()); + assert!(ensure_scopes_within_caller( + &mcp_caller, + opt_scopes(Some(vec!["mcp:scripts:*", "mcp:flows:*"])).as_deref() + ) + .is_err()); + + // Even a narrowing within MCP semantics (`mcp:all` → `mcp:scripts:*`) + // is rejected by the byte-identical rule. This is intentional — these + // endpoints are not the legitimate path for narrowing MCP tokens. + let mcp_all = authed_with_scopes(Some(vec!["mcp:all"])); + assert!(ensure_scopes_within_caller( + &mcp_all, + opt_scopes(Some(vec!["mcp:scripts:*"])).as_deref() + ) + .is_err()); + } + + #[test] + fn scoped_caller_cannot_escalate_to_wildcard_or_superset() { + // Regression for the access-grant-OR vs subset-containment confusion: + // ScopeDefinition::includes would (incorrectly) allow all of these. + let star = authed_with_scopes(Some(vec!["scripts:read:f/team/a"])); + // Minting `*` from a single-path scope must be rejected. + assert!(ensure_scopes_within_caller( + &star, + opt_scopes(Some(vec!["scripts:read:*"])).as_deref() + ) + .is_err()); + // Minting a broader subtree must be rejected. + assert!(ensure_scopes_within_caller( + &star, + opt_scopes(Some(vec!["scripts:read:f/team/*"])).as_deref() + ) + .is_err()); + + // A comma-separated list that adds an uncovered resource must be rejected, + // even though one element overlaps the caller's scope. + let list = authed_with_scopes(Some(vec!["scripts:read:f/team/a"])); + assert!(ensure_scopes_within_caller( + &list, + opt_scopes(Some(vec!["scripts:read:f/team/a,f/other/b"])).as_deref() + ) + .is_err()); + + // A subset of a multi-resource caller scope is allowed. + let multi = authed_with_scopes(Some(vec!["scripts:read:f/team/a,f/team/b"])); + assert!(ensure_scopes_within_caller( + &multi, + opt_scopes(Some(vec!["scripts:read:f/team/a"])).as_deref() + ) + .is_ok()); + + // A wildcard caller covers any subset, but not `*`-less escalation rules apply + // only when the caller itself lacks `*`. + let wildcard = authed_with_scopes(Some(vec!["scripts:read:*"])); + assert!(ensure_scopes_within_caller( + &wildcard, + opt_scopes(Some(vec!["scripts:read:f/team/a"])).as_deref() + ) + .is_ok()); + } +} diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 8a42bce88e..c63d53171c 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -16,7 +16,8 @@ use axum::{ }; use windmill_api_auth::{ auth::{list_tokens_internal, TruncatedTokenWithEmail}, - check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed, + build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path, + ApiAuthed, }; use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; use windmill_common::{ @@ -108,9 +109,10 @@ async fn list_search_flows( let n = 3; let mut tx = user_db.begin(&authed).await?; + let allowed = build_scope_path_predicate(&authed, "flows", "read"); let rows = sqlx::query_as::<_, SearchFlow>( "SELECT flow.path, flow_version.value - FROM flow + FROM flow LEFT JOIN flow_version ON flow_version.id = flow.versions[array_upper(flow.versions, 1)] WHERE flow.workspace_id = $1 LIMIT $2", ) @@ -119,6 +121,7 @@ async fn list_search_flows( .fetch_all(&mut *tx) .await? .into_iter() + .filter(|r| allowed(&r.path)) .collect::>(); tx.commit().await?; Ok(Json(rows)) @@ -212,9 +215,13 @@ async fn list_flows( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; + let allowed = build_scope_path_predicate(&authed, "flows", "read"); let rows = sqlx::query_as::<_, ListableFlow>(&sql) .fetch_all(&mut *tx) - .await?; + .await? + .into_iter() + .filter(|r| allowed(&r.path)) + .collect::>(); tx.commit().await?; Ok(Json(rows)) } @@ -558,13 +565,17 @@ async fn create_flow( w_id ).execute(&mut *tx).await?; - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", - nf.path, - &w_id - ) - .execute(&mut *tx) - .await?; + // CLI / git-sync deploys ask us to preserve any existing user draft at this + // path instead of wiping it as part of the deploy. + if !nf.skip_draft_deletion.unwrap_or(false) { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + nf.path, + &w_id + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, @@ -1157,13 +1168,17 @@ async fn update_flow( })?; } - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", - flow_path, - &w_id - ) - .execute(&mut *tx) - .await?; + // CLI / git-sync deploys ask us to preserve any existing user draft at this + // path instead of wiping it as part of the deploy. + if !nf.skip_draft_deletion.unwrap_or(false) { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'", + flow_path, + &w_id + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, @@ -2031,6 +2046,7 @@ mod tests { })), preprocessor_module: None, same_worker: false, + preserve_step_tags: false, skip_expr: None, cache_ttl: None, cache_ignore_s3_path: None, diff --git a/backend/windmill-api-groups/src/granular_acls.rs b/backend/windmill-api-groups/src/granular_acls.rs index 3f0549f4e8..1f88c33e9f 100644 --- a/backend/windmill-api-groups/src/granular_acls.rs +++ b/backend/windmill-api-groups/src/granular_acls.rs @@ -383,6 +383,8 @@ async fn remove_granular_acl( // workspace export. let table = if kind == "raw_app" { "app" } else { kind }; // SAFETY: `kind` has been validated against the `KINDS` allowlist before reaching this function. + // LIMIT 1: `script` shares (workspace_id, path) across versions, so `old` can + // return >1 row, which would break the scalar subquery in RETURNING. let obj_o = sqlx::query_scalar::<_, bool>(&format!( "WITH old AS ( SELECT extra_perms->$1 as old_write FROM {table} @@ -390,7 +392,7 @@ async fn remove_granular_acl( ) UPDATE {table} SET extra_perms = extra_perms - $1 WHERE {identifier} = $2 AND workspace_id = $3 AND extra_perms ? $1 - RETURNING (SELECT old_write FROM old)::bool" + RETURNING (SELECT old_write FROM old LIMIT 1)::bool" )) .bind(&owner) .bind(path) diff --git a/backend/windmill-api-integration-tests/Cargo.toml b/backend/windmill-api-integration-tests/Cargo.toml index a73d3d3c99..3aee7e766c 100644 --- a/backend/windmill-api-integration-tests/Cargo.toml +++ b/backend/windmill-api-integration-tests/Cargo.toml @@ -35,6 +35,9 @@ anyhow.workspace = true uuid.workspace = true futures.workspace = true rand.workspace = true +hmac.workspace = true +sha2.workspace = true +hex.workspace = true rumqttc.workspace = true rdkafka.workspace = true async-nats.workspace = true diff --git a/backend/windmill-api-integration-tests/tests/fixtures/resource_cache_rls.sql b/backend/windmill-api-integration-tests/tests/fixtures/resource_cache_rls.sql new file mode 100644 index 0000000000..8c30eab5b2 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/fixtures/resource_cache_rls.sql @@ -0,0 +1,23 @@ +-- Fixture for the resource-value interpolation cache RLS regression test. +-- Extends base.sql (which defines test-user [admin], test-user-2, test-user-3 +-- and their tokens). +-- +-- A folder `secret` is readable ONLY by test-user-2 (via extra_perms). It holds a +-- variable and a resource that interpolates it. test-user-3 has no access to the +-- folder, so a cache entry warmed by test-user-2 with allow_cache=true must never +-- be served back to test-user-3. + +INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by) +VALUES ('test-workspace', 'secret', 'Secret Folder', '{}', + '{"u/test-user-2": true}', 'test-user'); + +-- A (non-secret) variable gated to the `secret` folder; its value gets interpolated +-- into the resource value below and ends up in the cached, already-resolved blob. +INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms) +VALUES ('test-workspace', 'f/secret/db_password', 'LEAKED_FOLDER_SECRET', false, + 'Folder-gated secret', '{}'); + +INSERT INTO resource (workspace_id, path, value, description, resource_type, extra_perms, created_by) +VALUES ('test-workspace', 'f/secret/cache_target', + '{"host": "db.internal", "password": "$var:f/secret/db_password"}', + 'Folder-gated resource referencing a folder-gated variable', 'object', '{}', 'test-user'); diff --git a/backend/windmill-api-integration-tests/tests/fixtures/variable_cache_rls.sql b/backend/windmill-api-integration-tests/tests/fixtures/variable_cache_rls.sql new file mode 100644 index 0000000000..69a6810b7b --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/fixtures/variable_cache_rls.sql @@ -0,0 +1,15 @@ +-- Fixture for the variable-value cache RLS regression test. +-- Extends base.sql (which defines test-user [admin], test-user-2, test-user-3 +-- and their tokens). +-- +-- A folder `secret` is readable ONLY by test-user-2 (via extra_perms). It holds a +-- variable that test-user-2 can read but test-user-3 cannot. A cache entry warmed +-- by test-user-2 with allow_cache=true must never be served back to test-user-3. + +INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by) +VALUES ('test-workspace', 'secret', 'Secret Folder', '{}', + '{"u/test-user-2": true}', 'test-user'); + +INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms) +VALUES ('test-workspace', 'f/secret/cache_target_var', 'LEAKED_VAR_SECRET', false, + 'Folder-gated variable', '{}'); diff --git a/backend/windmill-api-integration-tests/tests/flows.rs b/backend/windmill-api-integration-tests/tests/flows.rs index ff3f86bf2d..b6075c8e69 100644 --- a/backend/windmill-api-integration-tests/tests/flows.rs +++ b/backend/windmill-api-integration-tests/tests/flows.rs @@ -259,12 +259,10 @@ async fn test_flow_endpoints(db: Pool) -> anyhow::Result<()> { // ===== Hub endpoints (require external network, expect 500 or 200) ===== // --- hub/list --- - let resp = authed(client().get(format!( - "http://localhost:{port}/api/flows/hub/list" - ))) - .send() - .await - .unwrap(); + let resp = authed(client().get(format!("http://localhost:{port}/api/flows/hub/list"))) + .send() + .await + .unwrap(); assert!( resp.status() == 200 || resp.status() == 500, "hub/list: unexpected status {}", @@ -272,12 +270,10 @@ async fn test_flow_endpoints(db: Pool) -> anyhow::Result<()> { ); // --- hub/get --- - let resp = authed(client().get(format!( - "http://localhost:{port}/api/flows/hub/get/1" - ))) - .send() - .await - .unwrap(); + let resp = authed(client().get(format!("http://localhost:{port}/api/flows/hub/get/1"))) + .send() + .await + .unwrap(); assert!( resp.status() == 200 || resp.status() == 500, "hub/get: unexpected status {}", @@ -286,3 +282,98 @@ async fn test_flow_endpoints(db: Pool) -> anyhow::Result<()> { Ok(()) } + +/// Regression test for GHSA-2ppx-66jv-wpw5: a path-scoped token must only see +/// the flows within its scope when listing, even though the route-level scope +/// check only validates `domain:action`. Before the fix, `list_search` returned +/// `path` + the full flow `value` for every flow the underlying user could see, +/// leaking out-of-scope flow definitions to narrowly-scoped tokens. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_list_search_scope_filtering(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/flows"); + + // Create two folders and one flow in each, as the (super-admin) test user. + for folder in ["allowed", "private"] { + let resp = authed(client().post(format!( + "http://localhost:{port}/api/w/test-workspace/folders/create" + ))) + .json(&json!({ "name": folder })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "create folder: {}", resp.text().await?); + } + + for path in ["f/allowed/foo", "f/private/bar"] { + let resp = authed(client().post(format!("{base}/create"))) + .json(&new_flow(path, "summary")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201, "create {path}: {}", resp.text().await?); + } + + // Helper: GET /list_search with an arbitrary bearer token, returning the set + // of flow paths visible to that token. + async fn list_search_paths(port: u16, token: &str) -> Vec { + let resp = client() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/flows/list_search" + )) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + resp.json::>() + .await + .unwrap() + .into_iter() + .map(|s| s["path"].as_str().unwrap().to_string()) + .collect() + } + + // Insert three tokens for the same super-admin user, differing only by scope. + sqlx::query( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES + (encode(sha256('SCOPED_TOKEN'::bytea), 'hex'), 'SCOPED_TOK', 'SCOPED_TOKEN', 'test@windmill.dev', 'scoped', true, ARRAY['flows:read:f/allowed/*']), + (encode(sha256('BROAD_TOKEN'::bytea), 'hex'), 'BROAD_TOK', 'BROAD_TOKEN', 'test@windmill.dev', 'broad', true, ARRAY['flows:read']), + (encode(sha256('TAG_TOKEN'::bytea), 'hex'), 'TAG_TOK', 'TAG_TOKEN', 'test@windmill.dev', 'tag-only', true, ARRAY['if_jobs:filter_tags:default'])", + ) + .execute(&db) + .await?; + + // Path-scoped token: only sees flows within `f/allowed/*`. + let scoped = list_search_paths(port, "SCOPED_TOKEN").await; + assert!( + scoped.contains(&"f/allowed/foo".to_string()), + "scoped token should see f/allowed/foo, got: {scoped:?}" + ); + assert!( + !scoped.contains(&"f/private/bar".to_string()), + "scoped token must NOT see f/private/bar, got: {scoped:?}" + ); + + // Broad `flows:read` token: still sees every RLS-visible flow. + let broad = list_search_paths(port, "BROAD_TOKEN").await; + assert!(broad.contains(&"f/allowed/foo".to_string())); + assert!( + broad.contains(&"f/private/bar".to_string()), + "broad flows:read token should see all flows, got: {broad:?}" + ); + + // Tag-filter-only token is not scope-restricted: unchanged, sees all. + let tag_only = list_search_paths(port, "TAG_TOKEN").await; + assert!(tag_only.contains(&"f/allowed/foo".to_string())); + assert!(tag_only.contains(&"f/private/bar".to_string())); + + // Unscoped token (no scopes column set): unchanged, sees all. + let unscoped = list_search_paths(port, "SECRET_TOKEN").await; + assert!(unscoped.contains(&"f/allowed/foo".to_string())); + assert!(unscoped.contains(&"f/private/bar".to_string())); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/native_triggers.rs b/backend/windmill-api-integration-tests/tests/native_triggers.rs index 159236ed4b..1d809ae526 100644 --- a/backend/windmill-api-integration-tests/tests/native_triggers.rs +++ b/backend/windmill-api-integration-tests/tests/native_triggers.rs @@ -17,8 +17,8 @@ use windmill_native_triggers::{ decrypt_oauth_data, delete_native_trigger, delete_workspace_integration, get_workspace_integration, google::{parse_stop_channel_params, should_renew_channel}, - store_native_trigger, store_workspace_integration, NativeTriggerConfig, OAuthConfig, - ServiceName, + require_native_integration_use, store_native_trigger, store_workspace_integration, + NativeTriggerConfig, OAuthConfig, ServiceName, }; // ============================================================================ @@ -329,6 +329,26 @@ async fn test_token_update_persists(db: Pool) -> anyhow::Result<()> { // 3. Channel Expiration Renewal — should_renew_channel // ============================================================================ +#[test] +fn test_require_native_integration_use_blocks_operators() { + // Regression: the integration *use* routes (calendar/drive/repo/event pickers) + // must reject read-only operators, who cannot create native triggers and so + // must not be able to drive the admin-configured integration's upstream API. + let mut operator = test_authed(); + operator.is_admin = false; + operator.is_operator = true; + assert!(require_native_integration_use(&operator).is_err()); + + // A regular non-admin author (the population that configures triggers) is allowed. + let mut author = test_authed(); + author.is_admin = false; + author.is_operator = false; + assert!(require_native_integration_use(&author).is_ok()); + + // Admins are allowed. + assert!(require_native_integration_use(&test_authed()).is_ok()); +} + #[test] fn test_should_renew_drive_channel_expired() { let config = json!({ diff --git a/backend/windmill-api-integration-tests/tests/resources.rs b/backend/windmill-api-integration-tests/tests/resources.rs index 363217712f..cc78056176 100644 --- a/backend/windmill-api-integration-tests/tests/resources.rs +++ b/backend/windmill-api-integration-tests/tests/resources.rs @@ -477,6 +477,117 @@ async fn test_resource_endpoints(db: Pool) -> anyhow::Result<()> { Ok(()) } +/// Regression test: the resource-value interpolation cache +/// (`get_value_interpolated?allow_cache=true`) must be identity-scoped. test-user-2 +/// (folder access) warms the cache; test-user-3 (no access) must then be denied rather +/// than served the cached, already-decrypted value. Pre-fix the unscoped key returned +/// a 200 with the secret here. +#[sqlx::test(migrations = "../migrations", fixtures("base", "resource_cache_rls"))] +async fn test_resource_value_cache_is_identity_scoped(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let url = format!( + "{}?allow_cache=true", + resource_url(port, "get_value_interpolated", "f/secret/cache_target") + ); + let get = |token: &str| { + client() + .get(url.as_str()) + .header("Authorization", format!("Bearer {token}")) + }; + + // test-user-2 has folder access and WARMS the cache. + let resp = get("SECRET_TOKEN_2").send().await?; + assert_eq!(resp.status(), 200); + assert!(resp.text().await?.contains("LEAKED_FOLDER_SECRET")); + + // test-user-3 has no folder access: must miss the cache and be denied (401), not leak. + let resp = get("SECRET_TOKEN_3").send().await?; + assert_eq!(resp.status(), 401); + assert!(!resp.text().await?.contains("LEAKED_FOLDER_SECRET")); + + Ok(()) +} + +/// A resource whose value contains a `$WM_*` contextual variable (e.g. `$WM_TOKEN`) is +/// job-dependent and must NEVER be cached — even when first read WITHOUT a `job_id`, where the +/// placeholder is left unresolved (caching that would serve a stale placeholder to a later job +/// read). Any other value — plain, or a non-`$WM_` `$`-string like `$HOME` (which is NOT +/// interpolated, so it's constant) — is job-independent and IS cached, with the entry shared +/// across job contexts (a read carrying a `job_id` still hits it, keeping the hit ratio up). +/// We prove all three by warming each (no job_id), deleting the row directly (cache survives), +/// then re-reading: the job-independent ones are still served from cache — even under a +/// `job_id` — while the `$WM_*` one was never cached and 404s. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_resource_cache_handles_job_context(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/resources"); + + let plain = "u/test-user/plain_res"; + let dollar = "u/test-user/dollar_res"; // non-$WM_ `$`-string: not interpolated, cacheable + let jobctx = "u/test-user/jobctx_res"; + for (path, value) in [ + (plain, json!({"v": 1})), + (dollar, json!({"d": "$HOME"})), + (jobctx, json!({"j": "$WM_JOB_ID"})), + ] { + let resp = authed(client().post(format!("{base}/create"))) + .json( + &json!({ "path": path, "value": value, "description": "", "resource_type": "object" }), + ) + .send() + .await?; + assert_eq!(resp.status(), 201); + } + + let get = |path: &str, query: &str| { + let url = format!("{base}/get_value_interpolated/{path}?{query}"); + async move { authed(client().get(url)).send().await.unwrap() } + }; + + // Warm all three WITHOUT a job context (the placeholder is left unresolved for `jobctx`). + for path in [plain, dollar, jobctx] { + assert_eq!(get(path, "allow_cache=true").await.status(), 200); + } + + // Delete the rows directly — bypasses the API/NOTIFY, so the in-memory cache survives. + for path in [plain, dollar, jobctx] { + sqlx::query("DELETE FROM resource WHERE workspace_id = 'test-workspace' AND path = $1") + .bind(path) + .execute(&db) + .await?; + } + + // Job-independent values are cached and still served even under a job_id (a random uuid is + // fine: a cache hit short-circuits before any job lookup). `$HOME` is a non-`$WM_` string, + // so it's not interpolated and stays cacheable. + for path in [plain, dollar] { + let resp = get( + path, + "allow_cache=true&job_id=11111111-1111-4111-8111-111111111111", + ) + .await; + assert_eq!( + resp.status(), + 200, + "job-independent resource ({path}) must stay cached and be served under a job_id" + ); + } + + // The `$WM_*` resource was never cached → the (now deleted) row is not found. + let resp = get(jobctx, "allow_cache=true").await; + assert_ne!( + resp.status(), + 200, + "resource with a $WM_* contextual variable must not be cached" + ); + + Ok(()) +} + #[cfg(feature = "mcp")] #[sqlx::test(migrations = "../migrations", fixtures("base", "resources_test"))] async fn test_mcp_tools(db: Pool) -> anyhow::Result<()> { diff --git a/backend/windmill-api-integration-tests/tests/scripts.rs b/backend/windmill-api-integration-tests/tests/scripts.rs index f5e78f880f..c374b757a4 100644 --- a/backend/windmill-api-integration-tests/tests/scripts.rs +++ b/backend/windmill-api-integration-tests/tests/scripts.rs @@ -463,3 +463,107 @@ async fn test_auto_parent_resolves_parent_hash(db: Pool) -> anyhow::Re Ok(()) } + +/// Regression test for GHSA-2ppx-66jv-wpw5: a path-scoped token must only see +/// the scripts within its scope when listing, even though the route-level scope +/// check only validates `domain:action`. Before the fix, `list_search` (and +/// `list`) returned `path` + full `content` for every script the underlying +/// user could see, leaking out-of-scope script source to narrowly-scoped tokens. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_list_search_scope_filtering(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/scripts"); + + // Create two folders and one script in each, as the (super-admin) test user. + for folder in ["allowed", "private"] { + let resp = authed(client().post(format!( + "http://localhost:{port}/api/w/test-workspace/folders/create" + ))) + .json(&json!({ "name": folder })) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200, "create folder: {}", resp.text().await?); + } + + for (path, content) in [ + ( + "f/allowed/foo", + "export async function main() { return 'allowed'; }", + ), + ( + "f/private/bar", + "export async function main() { return 'secret'; }", + ), + ] { + let resp = authed(client().post(format!("{base}/create"))) + .json(&new_script(path, "summary", content)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 201, "create {path}: {}", resp.text().await?); + } + + // Helper: GET /list_search with an arbitrary bearer token, returning the set + // of script paths visible to that token. + async fn list_search_paths(port: u16, token: &str) -> Vec { + let resp = client() + .get(format!( + "http://localhost:{port}/api/w/test-workspace/scripts/list_search" + )) + .header("Authorization", format!("Bearer {token}")) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + resp.json::>() + .await + .unwrap() + .into_iter() + .map(|s| s["path"].as_str().unwrap().to_string()) + .collect() + } + + // Insert three tokens for the same super-admin user, differing only by scope. + sqlx::query( + "INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes) VALUES + (encode(sha256('SCOPED_TOKEN'::bytea), 'hex'), 'SCOPED_TOK', 'SCOPED_TOKEN', 'test@windmill.dev', 'scoped', true, ARRAY['scripts:read:f/allowed/*']), + (encode(sha256('BROAD_TOKEN'::bytea), 'hex'), 'BROAD_TOK', 'BROAD_TOKEN', 'test@windmill.dev', 'broad', true, ARRAY['scripts:read']), + (encode(sha256('TAG_TOKEN'::bytea), 'hex'), 'TAG_TOK', 'TAG_TOKEN', 'test@windmill.dev', 'tag-only', true, ARRAY['if_jobs:filter_tags:default'])", + ) + .execute(&db) + .await?; + + // Path-scoped token: only sees scripts within `f/allowed/*`. + let scoped = list_search_paths(port, "SCOPED_TOKEN").await; + assert!( + scoped.contains(&"f/allowed/foo".to_string()), + "scoped token should see f/allowed/foo, got: {scoped:?}" + ); + assert!( + !scoped.contains(&"f/private/bar".to_string()), + "scoped token must NOT see f/private/bar, got: {scoped:?}" + ); + + // Broad `scripts:read` token: still sees every RLS-visible script. + let broad = list_search_paths(port, "BROAD_TOKEN").await; + assert!(broad.contains(&"f/allowed/foo".to_string())); + assert!( + broad.contains(&"f/private/bar".to_string()), + "broad scripts:read token should see all scripts, got: {broad:?}" + ); + + // Tag-filter-only token is not scope-restricted: unchanged, sees all. + let tag_only = list_search_paths(port, "TAG_TOKEN").await; + assert!(tag_only.contains(&"f/allowed/foo".to_string())); + assert!(tag_only.contains(&"f/private/bar".to_string())); + + // Unscoped token (no scopes column set): unchanged, sees all. + let unscoped = list_search_paths(port, "SECRET_TOKEN").await; + assert!(unscoped.contains(&"f/allowed/foo".to_string())); + assert!(unscoped.contains(&"f/private/bar".to_string())); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/slack_approvals_unauthed.rs b/backend/windmill-api-integration-tests/tests/slack_approvals_unauthed.rs new file mode 100644 index 0000000000..f07e8b81e1 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/slack_approvals_unauthed.rs @@ -0,0 +1,230 @@ +//! Regression tests for GHSA-vm75-gmpw-rvp9: the unauthenticated `/api/slack` callback must +//! not be drivable into decrypting arbitrary workspace variables. +//! +//! The OpenModal branch reaches `get_slack_token` (a privileged, RLS-bypassing variable +//! decryption). It is now gated by a per-workspace HMAC over (w_id, job_id, path) — the same +//! workspace key used to sign resume URLs. Without a valid signature the request is rejected +//! with 401 before any decryption, even when `SLACK_SIGNING_SECRET` is unset (the default). + +use hmac::{Hmac, Mac}; +use serde_json::json; +use sha2::Sha256; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +/// Re-implementation of the server's `sign_slack_payload` for the positive-control test. +/// The fixture sets `workspace_key.key = 'test-key'` for `test-workspace`. +fn sign(w_id: &str, parts: &[&[u8]]) -> String { + let mut mac = Hmac::::new_from_slice(b"test-key").unwrap(); + mac.update(b"slack_payload_v1\0"); // SLACK_PAYLOAD_HMAC_DOMAIN + mac.update(w_id.as_bytes()); + for p in parts { + mac.update(b"\0"); + mac.update(p); + } + hex::encode(mac.finalize().into_bytes()) +} + +/// POST an `open_modal` block action to the unauthenticated `/api/slack` callback. +async fn post_open_modal(port: u16, value: serde_json::Value) -> reqwest::Response { + let payload = json!({ + "type": "block_actions", + "trigger_id": "trigger-123", + "container": { "message_ts": "0", "channel_id": "C1" }, + "actions": [ { "action_id": "open_modal", "value": value.to_string() } ], + }); + client() + .post(format!("http://localhost:{port}/api/slack")) + .form(&[("payload", payload.to_string())]) + .send() + .await + .unwrap() +} + +/// POST a `view_submission` to the unauthenticated `/api/slack` callback with the given +/// private_metadata. +async fn post_view_submission(port: u16, private_metadata: serde_json::Value) -> reqwest::Response { + let payload = json!({ + "type": "view_submission", + "view": { + "state": { "values": {} }, + "private_metadata": private_metadata.to_string(), + }, + }); + client() + .post(format!("http://localhost:{port}/api/slack")) + .form(&[("payload", payload.to_string())]) + .send() + .await + .unwrap() +} + +/// A submission with an unsigned (or tampered) `private_metadata` must be rejected with 401 +/// BEFORE the resume/cancel action runs — the signature gate is checked first. The resume_url +/// here is well-formed (so it parses) but never acted upon. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_view_submission_without_signature_is_rejected( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let job_id = Uuid::new_v4(); + let resume_url = format!("/api/w/test-workspace/jobs_u/resume/{job_id}/1/deadbeef"); + + let resp = post_view_submission( + port, + json!({ + "resume_url": resume_url, + "resource_path": "u/admin/secret", + "container": { "message_ts": "0", "channel_id": "C1" }, + "hide_cancel": false, + }), + ) + .await; + assert_eq!( + resp.status(), + 401, + "unsigned submission must be rejected before the resume action" + ); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_open_modal_without_signature_is_rejected(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let job_id = Uuid::new_v4(); + + // No signature → must be rejected with 401 before any variable lookup. Before the fix + // this reached `get_slack_token` and forced decryption of `u/admin/secret`. + let resp = post_open_modal( + port, + json!({ + "w_id": "test-workspace", + "job_id": job_id.to_string(), + "path": "u/admin/secret", + "flow_step_id": "a", + }), + ) + .await; + assert_eq!( + resp.status(), + 401, + "unsigned OpenModal callback must be rejected" + ); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_open_modal_with_wrong_signature_is_rejected( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let job_id = Uuid::new_v4(); + + let resp = post_open_modal( + port, + json!({ + "w_id": "test-workspace", + "job_id": job_id.to_string(), + "path": "u/admin/secret", + "flow_step_id": "a", + "signature": "deadbeef", + }), + ) + .await; + assert_eq!( + resp.status(), + 401, + "OpenModal callback with an invalid signature must be rejected" + ); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_open_modal_with_tampered_path_is_rejected(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let job_id = Uuid::new_v4(); + + // A signature legitimately minted for one path cannot be reused to decrypt another: the + // path is bound into the HMAC. + let signature = sign( + "test-workspace", + &[job_id.to_string().as_bytes(), b"u/admin/legit_resource"], + ); + let resp = post_open_modal( + port, + json!({ + "w_id": "test-workspace", + "job_id": job_id.to_string(), + "path": "u/admin/some_other_secret", + "flow_step_id": "a", + "signature": signature, + }), + ) + .await; + assert_eq!( + resp.status(), + 401, + "a signature bound to a different path must not authorize decryption" + ); + + Ok(()) +} + +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_open_modal_with_valid_signature_passes_the_gate( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let job_id = Uuid::new_v4(); + let path = "u/admin/nonexistent_resource"; + + // A correctly signed payload passes the authorization gate and proceeds to resolve the + // slack resource. The resource does not exist, so the handler returns a generic 400 + // ("Invalid Slack callback request") rather than 401 — proving the gate accepted the + // signature (so the fix does not simply reject everything) without echoing the path. + let signature = sign( + "test-workspace", + &[job_id.to_string().as_bytes(), path.as_bytes()], + ); + let resp = post_open_modal( + port, + json!({ + "w_id": "test-workspace", + "job_id": job_id.to_string(), + "path": path, + "flow_step_id": "a", + "signature": signature, + }), + ) + .await; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 400, + "validly signed callback should pass the gate and 400 on the missing resource, got {status}: {body}" + ); + assert!( + !body.contains("nonexistent_resource"), + "error must not echo the probed path: {body}" + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/token_label_idor.rs b/backend/windmill-api-integration-tests/tests/token_label_idor.rs new file mode 100644 index 0000000000..92dada92c2 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/token_label_idor.rs @@ -0,0 +1,192 @@ +//! Regression tests for GHSA-8x8x-88qc-qp4r: token label collision bypassing job read +//! access control (IDOR). +//! +//! `username_override` is derived from a fully user-controlled token label, so a bare +//! `username_override == created_by` match in `require_job_read_access` is forgeable. The fix +//! binds that fast path to a non-forgeable attribute — the job's `permissioned_as_email` (the +//! token owner's email) must equal the caller's email. This: +//! - denies a colliding-label token created by a different principal, while +//! - still allowing a principal to re-read its own labeled-token jobs (incl. when RLS would +//! otherwise hide them), and +//! - leaving user-facing webhook/http/email trigger token creation untouched (those labels +//! are created through the public token API by design). + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use uuid::Uuid; +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn bearer(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + builder.header("Authorization", format!("Bearer {token}")) +} + +async fn create_token_with_label(port: u16, caller_token: &str, label: &str) -> reqwest::Response { + bearer( + client().post(format!("http://localhost:{port}/api/users/tokens/create")), + caller_token, + ) + .json(&json!({ "label": label })) + .send() + .await + .unwrap() +} + +/// Insert a completed job with a labeled-token `created_by`, running as `permissioned_as` +/// (email `permissioned_as_email`) with the given `runnable_path` (which governs RLS). +async fn insert_labeled_job( + db: &Pool, + created_by: &str, + runnable_path: &str, + permissioned_as: &str, + permissioned_as_email: &str, +) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + "INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email, runnable_path, kind, tag, args, visible_to_owner) + VALUES ($1, 'test-workspace', $2, $3, $4, $5, 'script', 'deno', '{}'::jsonb, true)", + ) + .bind(id) + .bind(created_by) + .bind(permissioned_as) + .bind(permissioned_as_email) + .bind(runnable_path) + .execute(db) + .await + .unwrap(); + sqlx::query( + "INSERT INTO v2_job_completed (id, workspace_id, duration_ms, result, status) + VALUES ($1, 'test-workspace', 100, '{\"secret\":\"super-secret-value\"}'::jsonb, 'success')", + ) + .bind(id) + .execute(db) + .await + .unwrap(); + id +} + +/// The core IDOR: an operator who mints a token whose label collides with another +/// principal's labeled-token identity must NOT be able to read that principal's job — the +/// `permissioned_as_email` of that job is the victim's, not the attacker's. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_label_collision_does_not_grant_job_read(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/jobs"); + + // A job submitted with a token labeled "collide", running as the admin (test-user). + let job_id = insert_labeled_job( + &db, + "label-collide", + "u/test-user/secret_script", + "u/test-user", + "test@windmill.dev", + ) + .await; + + // Sanity: the admin can read it, so the job exists and is otherwise readable. + let resp = bearer( + client().get(format!("{base}/completed/get/{job_id}")), + "SECRET_TOKEN", + ) + .send() + .await?; + assert_eq!(resp.status(), 200, "admin must still read the job"); + + // The attacker (a different member, test-user-2) mints a colliding-label token. + let resp = create_token_with_label(port, "SECRET_TOKEN_2", "collide").await; + assert_eq!(resp.status(), 201); + let attacker_token = resp.text().await?; + + // Reading the admin's job with the colliding token must be denied. Before the fix the + // `username_override == created_by` fast path returned the full result here. + let resp = bearer( + client().get(format!("{base}/completed/get/{job_id}")), + &attacker_token, + ) + .send() + .await?; + assert!( + !resp.status().is_success(), + "colliding-label token must not read another principal's job (got {})", + resp.status() + ); + let body = resp.text().await?; + assert!( + !body.contains("super-secret-value"), + "job result must not leak to the colliding-label token" + ); + + Ok(()) +} + +/// The fix must not regress the legitimate case: a principal re-reading its own +/// labeled-token job is granted via the email-bound fast path, even when RLS would hide the +/// job (the runnable lives in another user's space the caller has no RLS path to). +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_legit_labeled_self_read_still_works(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/jobs"); + + // Created by test-user-2's labeled token, running as test-user-2, but the runnable lives + // under u/test-user so RLS alone would not reveal it to test-user-2 — the grant must come + // from the email-bound fast path. + let job_id = insert_labeled_job( + &db, + "label-mine", + "u/test-user/shared_script", + "u/test-user-2", + "test2@windmill.dev", + ) + .await; + + let resp = create_token_with_label(port, "SECRET_TOKEN_2", "mine").await; + assert_eq!(resp.status(), 201); + let token = resp.text().await?; + + let resp = bearer( + client().get(format!("{base}/completed/get/{job_id}")), + &token, + ) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "owner must still read their own labeled-token job via the email-bound fast path" + ); + + Ok(()) +} + +/// P1 regression guard: the user-facing token API must keep accepting the labels that the +/// webhook / http-route / email trigger panels mint (e.g. `webhook--`). The fix +/// must not reserve those prefixes. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_trigger_token_labels_still_creatable(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + for label in [ + "webhook-test-user-2-ab12", + "http-test-user-2-cd34", + "email-test-user-2-ef56", + "my-ci-token", + ] { + let resp = create_token_with_label(port, "SECRET_TOKEN_2", label).await; + assert_eq!( + resp.status(), + 201, + "creating a token with label {label:?} must succeed" + ); + } + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/variables.rs b/backend/windmill-api-integration-tests/tests/variables.rs index 0d4edaff91..e5018f4f97 100644 --- a/backend/windmill-api-integration-tests/tests/variables.rs +++ b/backend/windmill-api-integration-tests/tests/variables.rs @@ -108,12 +108,10 @@ async fn test_variable_endpoints(db: Pool) -> anyhow::Result<()> { assert_eq!(secret["value"], serde_json::Value::Null); // list with path_start filter - let resp = authed(client().get(format!( - "{base}/list?path_start=u/test-user/plain" - ))) - .send() - .await - .unwrap(); + let resp = authed(client().get(format!("{base}/list?path_start=u/test-user/plain"))) + .send() + .await + .unwrap(); assert_eq!(resp.status(), 200); let list = resp.json::>().await?; assert_eq!(list.len(), 1); @@ -252,3 +250,91 @@ async fn test_variable_endpoints(db: Pool) -> anyhow::Result<()> { Ok(()) } + +/// Regression test: the variable-value cache (`get_value?allow_cache=true`) must be +/// identity-scoped. test-user-2 (folder access) warms the cache; test-user-3 (no access) +/// must then be denied rather than served the cached value. +#[sqlx::test(migrations = "../migrations", fixtures("base", "variable_cache_rls"))] +async fn test_variable_value_cache_is_identity_scoped(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let url = format!( + "{}?allow_cache=true", + variable_url(port, "get_value", "f/secret/cache_target_var") + ); + let get = |token: &str| { + client() + .get(url.as_str()) + .header("Authorization", format!("Bearer {token}")) + }; + + // test-user-2 has folder access and WARMS the cache. + let resp = get("SECRET_TOKEN_2").send().await?; + assert_eq!(resp.status(), 200); + assert!(resp.text().await?.contains("LEAKED_VAR_SECRET")); + + // test-user-3 has no folder access: must miss the cache and be denied (401), not leak. + let resp = get("SECRET_TOKEN_3").send().await?; + assert_eq!(resp.status(), 401); + assert!(!resp.text().await?.contains("LEAKED_VAR_SECRET")); + + Ok(()) +} + +/// Secret variables ARE cached (with their per-read side effects — the EE +/// `variables.decrypt_secret` audit and running-job secret registration — re-run on every +/// hit; that re-emission is not observable in the OSS build since `audit_log` is a no-op). +/// We assert the caching itself: warm the cache, delete the row directly (no API/NOTIFY, so +/// the in-memory cache survives), and re-read with `allow_cache=true` — the value is still +/// returned from cache. A non-secret variable behaves identically (control). +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_variables_are_cached(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/variables"); + + let plain = "u/test-user/cache_plain_probe"; + let secret = "u/test-user/cache_secret_probe"; + + // Create one non-secret and one secret variable (the secret is stored encrypted). + for (path, value, is_secret) in [ + (plain, "PLAIN_PROBE", false), + (secret, "SECRET_PROBE", true), + ] { + let resp = authed(client().post(format!("{base}/create"))) + .json( + &json!({ "path": path, "value": value, "is_secret": is_secret, "description": "" }), + ) + .send() + .await?; + assert_eq!(resp.status(), 201); + } + + let read = |path: &str| { + let url = format!("{base}/get_value/{path}?allow_cache=true"); + async move { authed(client().get(url)).send().await.unwrap() } + }; + + // Warm the cache for both. + assert_eq!(read(plain).await.json::().await?, "PLAIN_PROBE"); + assert_eq!(read(secret).await.json::().await?, "SECRET_PROBE"); + + // Delete both rows directly — bypasses the API and its NOTIFY-based invalidation, so + // the in-memory cache survives. A subsequent read can only succeed from cache. + for path in [plain, secret] { + sqlx::query("DELETE FROM variable WHERE workspace_id = 'test-workspace' AND path = $1") + .bind(path) + .execute(&db) + .await?; + } + + // Both (secret included) are still served from the cache. + assert_eq!(read(plain).await.json::().await?, "PLAIN_PROBE"); + let resp = read(secret).await; + assert_eq!(resp.status(), 200, "secret must still be served from cache"); + assert_eq!(resp.json::().await?, "SECRET_PROBE"); + + 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 new file mode 100644 index 0000000000..8edfd39d61 --- /dev/null +++ b/backend/windmill-api-integration-tests/tests/workspace_encryption_key_git_sync.rs @@ -0,0 +1,358 @@ +/*! + * Integration test for workspace encryption key rotation triggering git sync. + * + * Regression test for windmill-labs/windmill#9344 — re-encrypting all secret + * variables on workspace key change must dispatch a git-sync job that carries + * every re-encrypted variable plus the encryption_key entry, so repos with + * Secrets sync enabled receive the new ciphertexts in one commit. + * + * Run with enterprise features: + * ```bash + * cargo test --test workspace_encryption_key_git_sync --features enterprise,private + * ``` + */ + +use serde_json::json; +use sqlx::{Pool, Postgres}; +use std::time::Duration; + +#[allow(unused_imports)] +use windmill_test_utils::*; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + builder.header("Authorization", "Bearer SECRET_TOKEN") +} + +#[allow(dead_code)] +async fn create_git_repo_resource(db: &Pool) -> anyhow::Result<()> { + sqlx::query( + r#" + INSERT INTO resource (workspace_id, path, value, resource_type, extra_perms, created_by) + VALUES ('test-workspace', 'u/test-user/test_git_repo', $1::jsonb, 'git_repository', '{}'::jsonb, 'test-user') + ON CONFLICT (workspace_id, path) DO NOTHING + "#, + ) + .bind(json!({ + "url": "https://github.com/test/test.git", + "branch": "main", + "token": "test-token" + })) + .execute(db) + .await?; + Ok(()) +} + +#[allow(dead_code)] +async fn create_folder(db: &Pool, name: &str) -> anyhow::Result<()> { + sqlx::query( + r#" + INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms, created_by) + VALUES ('test-workspace', $1, $1, ARRAY['u/test-user'], '{}'::jsonb, 'test-user') + ON CONFLICT (workspace_id, name) DO NOTHING + "#, + ) + .bind(name) + .execute(db) + .await?; + Ok(()) +} + +#[allow(dead_code)] +async fn create_sync_script(db: &Pool, path: &str) -> anyhow::Result { + let hash: i64 = rand::random::().unsigned_abs() as i64; + sqlx::query( + r#" + INSERT INTO script (workspace_id, hash, path, summary, description, content, + created_by, language, kind, lock) + VALUES ('test-workspace', $1, $2, 'sync script', '', + 'export function main(items: any[]) { return { synced: items.length }; }', + 'test-user', 'bun', 'script', '') + "#, + ) + .bind(hash) + .bind(path) + .execute(db) + .await?; + Ok(hash) +} + +#[allow(dead_code)] +async fn setup_git_sync_config(db: &Pool, sync_script_path: &str) -> anyhow::Result<()> { + // Include Variable + Secret + Key so the encryption rotation has a reason + // to push every re-encrypted variable. Anchor include_path to root so all + // u/... and f/... paths pass the regex filter. + let git_sync_config = json!({ + "include_type": ["variable", "secret", "key"], + "include_path": ["**"], + "repositories": [{ + "script_path": sync_script_path, + "git_repo_resource_path": "$res:u/test-user/test_git_repo", + "use_individual_branch": false, + "group_by_folder": false + }] + }); + sqlx::query!( + "UPDATE workspace_settings SET git_sync = $1 WHERE workspace_id = $2", + git_sync_config, + "test-workspace" + ) + .execute(db) + .await?; + Ok(()) +} + +/// Insert N secret variables, encrypting their values with the workspace's +/// current key so the re-encryption path can decrypt them. +#[allow(dead_code)] +async fn insert_secret_variables(db: &Pool, paths: &[&str]) -> anyhow::Result<()> { + use windmill_common::variables::{build_crypt, encrypt}; + let mc = build_crypt(db, "test-workspace").await?; + for path in paths { + let plaintext = format!("secret-value-for-{path}"); + let encrypted = encrypt(&mc, &plaintext); + sqlx::query!( + r#" + INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account) + VALUES ($1, $2, $3, true, '', '{}'::jsonb, NULL) + ON CONFLICT (workspace_id, path) DO UPDATE SET value = EXCLUDED.value + "#, + "test-workspace", + path, + encrypted, + ) + .execute(db) + .await?; + } + Ok(()) +} + +#[derive(Debug)] +#[allow(dead_code)] +struct DeploymentCallbackJob { + id: uuid::Uuid, + args: Option, +} + +/// Poll until at least `min_count` deployment-callback jobs exist for the +/// script path, or the timeout elapses. Returns whatever was found. +#[allow(dead_code)] +async fn wait_for_deployment_callbacks( + db: &Pool, + script_path: &str, + min_count: usize, + timeout: Duration, +) -> anyhow::Result> { + let deadline = tokio::time::Instant::now() + timeout; + loop { + let rows = sqlx::query_as!( + DeploymentCallbackJob, + r#" + SELECT j.id, j.args + FROM v2_job j + JOIN v2_job_queue q ON j.id = q.id + WHERE j.runnable_path = $1 + AND j.kind = 'deploymentcallback' + AND j.workspace_id = 'test-workspace' + ORDER BY j.created_at DESC + "#, + script_path, + ) + .fetch_all(db) + .await?; + if rows.len() >= min_count || tokio::time::Instant::now() >= deadline { + return Ok(rows); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +#[cfg(all(feature = "enterprise", feature = "private"))] +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_encryption_key_rotation_dispatches_batched_git_sync( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + // Setup git sync repo + sync script (folder/path encodes the hub min-version) + create_folder(&db, "28103").await?; + create_git_repo_resource(&db).await?; + let sync_script_path = "f/28103/test_sync_script_encryption"; + create_sync_script(&db, sync_script_path).await?; + setup_git_sync_config(&db, sync_script_path).await?; + + let secret_paths = [ + "u/test-user/secret_a", + "u/test-user/secret_b", + "u/test-user/secret_c", + ]; + insert_secret_variables(&db, &secret_paths).await?; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces"); + + // 64-char alphanumeric per the route's WORKSPACE_KEY_REGEXP + let new_key = "a".repeat(64); + let resp = authed(client().post(format!("{base}/encryption_key"))) + .json(&json!({"new_key": new_key, "skip_reencrypt": false})) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "set_encryption_key failed: {}", + resp.text().await? + ); + + // The git-sync dispatch runs in a tokio::spawn'd task. Poll up to a few + // seconds for the deployment callback to land in the queue. + let jobs = + wait_for_deployment_callbacks(&db, sync_script_path, 1, Duration::from_secs(5)).await?; + assert_eq!( + jobs.len(), + 1, + "expected exactly one batched deployment callback job, got {}", + jobs.len() + ); + let job = &jobs[0]; + + let args = job.args.as_ref().expect("job should have args"); + let items = args + .get("items") + .and_then(|v| v.as_array()) + .expect("args.items should be a JSON array"); + + // Expect exactly one job carrying the Key entry + every re-encrypted variable + assert_eq!( + items.len(), + secret_paths.len() + 1, + "expected {} items (key + {} variables) in a single sync job, got {} — items: {:#?}", + secret_paths.len() + 1, + secret_paths.len(), + items.len(), + items + ); + + let mut variable_paths: Vec = Vec::new(); + let mut saw_key = false; + for item in items { + let path_type = item.get("path_type").and_then(|v| v.as_str()).unwrap_or(""); + let path = item.get("path").and_then(|v| v.as_str()).unwrap_or(""); + match path_type { + "variable" => variable_paths.push(path.to_string()), + "key" => saw_key = true, + other => panic!("unexpected path_type in batch: {other}"), + } + } + assert!( + saw_key, + "expected a path_type=key entry in items: {:#?}", + items + ); + variable_paths.sort(); + let mut expected: Vec = secret_paths.iter().map(|s| s.to_string()).collect(); + expected.sort(); + assert_eq!( + variable_paths, expected, + "items array should contain every re-encrypted secret variable" + ); + + // Secrets sync is enabled (ObjectType::Secret in include_type), so the sync + // script should be invoked with skip_secret=false. + let skip_secret = args + .get("skip_secret") + .and_then(|v| v.as_bool()) + .expect("args.skip_secret should be set when batch carries variables"); + assert!( + !skip_secret, + "skip_secret should be false when Secret is included in the repo's types" + ); + + 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 +/// added, the batch path `continue`d past such repos and queued nothing, +/// silently leaving the repo stale after a key rotation. +#[cfg(all(feature = "enterprise", feature = "private"))] +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_encryption_key_rotation_falls_back_without_debouncing( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + + // Folder/path encodes a hub version BELOW 28103, so + // is_script_meets_min_version(28103) is false → debouncing unsupported. + create_folder(&db, "28000").await?; + create_git_repo_resource(&db).await?; + let sync_script_path = "f/28000/test_sync_script_legacy"; + create_sync_script(&db, sync_script_path).await?; + setup_git_sync_config(&db, sync_script_path).await?; + + let secret_paths = ["u/test-user/secret_a", "u/test-user/secret_b"]; + insert_secret_variables(&db, &secret_paths).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 = "b".repeat(64); + let resp = authed(client().post(format!("{base}/encryption_key"))) + .json(&json!({"new_key": new_key, "skip_reencrypt": false})) + .send() + .await?; + assert_eq!( + resp.status(), + 200, + "set_encryption_key failed: {}", + resp.text().await? + ); + + // Legacy fallback pushes one job per item (flat args, no `items` array): + // the Key entry + one per re-encrypted variable. + let expected = secret_paths.len() + 1; + let jobs = + wait_for_deployment_callbacks(&db, sync_script_path, expected, Duration::from_secs(5)) + .await?; + assert_eq!( + jobs.len(), + expected, + "expected {expected} legacy deployment-callback jobs (key + {} variables), got {} — a repo on an old sync script must not be silently skipped", + secret_paths.len(), + jobs.len() + ); + + let mut variable_paths: Vec = Vec::new(); + let mut saw_key = false; + for job in &jobs { + let args = job.args.as_ref().expect("job should have args"); + // Legacy format: flat fields, never an `items` array. + assert!( + args.get("items").is_none(), + "fallback jobs must use the flat legacy format, not an items array: {args:#?}" + ); + let path_type = args.get("path_type").and_then(|v| v.as_str()).unwrap_or(""); + let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(""); + match path_type { + "variable" => variable_paths.push(path.to_string()), + "key" => saw_key = true, + other => panic!("unexpected path_type in fallback job: {other}"), + } + } + assert!(saw_key, "expected a path_type=key fallback job"); + variable_paths.sort(); + let mut expected_paths: Vec = secret_paths.iter().map(|s| s.to_string()).collect(); + expected_paths.sort(); + assert_eq!( + variable_paths, expected_paths, + "fallback must queue a job for every re-encrypted secret variable" + ); + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/workspaces.rs b/backend/windmill-api-integration-tests/tests/workspaces.rs index 131cfbbae5..3d208d4e25 100644 --- a/backend/windmill-api-integration-tests/tests/workspaces.rs +++ b/backend/windmill-api-integration-tests/tests/workspaces.rs @@ -709,7 +709,9 @@ async fn test_get_copilot_settings_state_reports_instance_ai_fallback_flags( "resource_path": "u/test-user/openai_instance", "models": ["gpt-4o-mini"] } - } + }, + "default_model": { "provider": "openai", "model": "gpt-4o-mini" }, + "metadata_model": { "provider": "openai", "model": "gpt-4o-mini" } }); let workspace_ai_config = json!({ "providers": { @@ -749,6 +751,10 @@ async fn test_get_copilot_settings_state_reports_instance_ai_fallback_flags( settings["instance_ai_summary"]["providers"][0]["models"][0], "gpt-4o-mini" ); + assert_eq!( + settings["instance_ai_summary"]["metadata_model"]["model"], + "gpt-4o-mini" + ); sqlx::query("UPDATE workspace_settings SET ai_config = $1 WHERE workspace_id = $2") .bind(workspace_ai_config) diff --git a/backend/windmill-api-jobs/src/concurrency_groups.rs b/backend/windmill-api-jobs/src/concurrency_groups.rs index d8e7a54112..8303788b8a 100644 --- a/backend/windmill-api-jobs/src/concurrency_groups.rs +++ b/backend/windmill-api-jobs/src/concurrency_groups.rs @@ -191,6 +191,7 @@ async fn get_concurrent_intervals( script_path_exact: None, script_hash: None, created_by: None, + status: None, success: None, running: None, parent_job: None, diff --git a/backend/windmill-api-jobs/src/query.rs b/backend/windmill-api-jobs/src/query.rs index e6c57e6c5b..a95a12ee6a 100644 --- a/backend/windmill-api-jobs/src/query.rs +++ b/backend/windmill-api-jobs/src/query.rs @@ -430,7 +430,15 @@ pub fn filter_list_completed_query( sqlb.and_where_in("created_by", "ed); } } - if let Some(r) = &lq.success { + if let Some(status) = &lq.status { + let status = match status { + windmill_common::jobs::JobStatus::Success => "success", + windmill_common::jobs::JobStatus::Failure => "failure", + windmill_common::jobs::JobStatus::Canceled => "canceled", + windmill_common::jobs::JobStatus::Skipped => "skipped", + }; + sqlb.and_where_eq("v2_job_completed.status", quote(status)); + } else if let Some(r) = &lq.success { if *r { sqlb.and_where_eq("status", "'success'") .or_where_eq("status", "'skipped'"); @@ -572,6 +580,11 @@ pub fn list_completed_jobs_query( if lq.completed_before.is_some() || lq.completed_after.is_some() || lq.success == Some(false) + || matches!( + lq.status, + Some(windmill_common::jobs::JobStatus::Failure) + | Some(windmill_common::jobs::JobStatus::Canceled) + ) { "v2_job_completed.completed_at" } else { @@ -653,6 +666,7 @@ mod tests { created_after_queue: None, completed_after: None, completed_before: None, + status: None, success: None, running: None, parent_job: None, @@ -928,6 +942,23 @@ mod tests { assert!(sql.contains("'failure'")); } + #[test] + fn test_completed_filter_status_canceled() { + let lq = ListCompletedQuery { + status: Some(windmill_common::jobs::JobStatus::Canceled), + ..empty_completed_query() + }; + let sqlb = filter_list_completed_query( + SqlBuilder::select_from("v2_job_completed").clone(), + &lq, + "ws", + false, + ); + let sql = build_sql(sqlb); + assert!(sql.contains("v2_job_completed.status")); + assert!(sql.contains("'canceled'")); + } + #[test] fn test_completed_order_by_completed_at() { let lq = ListCompletedQuery { @@ -939,6 +970,25 @@ mod tests { assert!(sql.contains("completed_at")); } + #[test] + fn test_completed_order_by_completed_at_status_failure_canceled() { + // status=failure|canceled must order by v2_job_completed.completed_at so the + // partial index ix_v2_job_completed_failure_workspace serves both filtering + // and ordering in a single scan. + for status in [ + windmill_common::jobs::JobStatus::Failure, + windmill_common::jobs::JobStatus::Canceled, + ] { + let lq = ListCompletedQuery { status: Some(status), ..empty_completed_query() }; + let sqlb = list_completed_jobs_query("ws", Some(10), 0, &lq, &["id"], false, None); + let sql = build_sql(sqlb); + assert!( + sql.contains("ORDER BY v2_job_completed.completed_at"), + "expected order by completed_at, got: {sql}" + ); + } + } + #[test] fn test_completed_filter_label() { let lq = ListCompletedQuery { diff --git a/backend/windmill-api-jobs/src/types.rs b/backend/windmill-api-jobs/src/types.rs index bc03a04289..6252cef9b7 100644 --- a/backend/windmill-api-jobs/src/types.rs +++ b/backend/windmill-api-jobs/src/types.rs @@ -19,7 +19,7 @@ use std::collections::HashMap; use uuid::Uuid; use windmill_common::{ error, - jobs::{CompletedJob, JobKind, JobTriggerKind, QueuedJob}, + jobs::{CompletedJob, JobKind, JobStatus, JobTriggerKind, QueuedJob}, scripts::{ScriptHash, ScriptLang}, utils::now_from_db, DB, @@ -142,6 +142,7 @@ pub struct ListCompletedQuery { pub created_after_queue: Option>, pub completed_after: Option>, pub completed_before: Option>, + pub status: Option, pub success: Option, pub running: Option, pub parent_job: Option, @@ -680,6 +681,7 @@ mod tests { created_after_queue: None, completed_after: None, completed_before: None, + status: None, success: None, running: Some(true), parent_job: None, @@ -752,6 +754,7 @@ mod tests { created_after_queue: Some(specific_time), completed_after: None, completed_before: None, + status: None, success: None, running: None, parent_job: None, diff --git a/backend/windmill-api-scripts/Cargo.toml b/backend/windmill-api-scripts/Cargo.toml index 200a2aef64..5ea3088837 100644 --- a/backend/windmill-api-scripts/Cargo.toml +++ b/backend/windmill-api-scripts/Cargo.toml @@ -13,6 +13,7 @@ default = [] enterprise = ["windmill-common/enterprise"] private = ["windmill-common/private", "windmill-dep-map/private"] python = ["dep:windmill-parser-py"] +prometheus = ["dep:prometheus", "windmill-common/prometheus"] [dependencies] windmill-common = { workspace = true, default-features = false } windmill-object-store.workspace = true @@ -38,4 +39,5 @@ tracing.workspace = true chrono.workspace = true lazy_static.workspace = true tokio.workspace = true +prometheus = { workspace = true, optional = true } windmill-parser-py = { workspace = true, optional = true } diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index 19fbaa2148..c91a45ddfa 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -9,7 +9,8 @@ use axum::extract::Multipart; use windmill_api_auth::{ auth::{list_tokens_internal, AuthCache, TruncatedTokenWithEmail}, - check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed, + build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path, + ApiAuthed, }; use windmill_common::{ utils::{BulkDeleteRequest, WithStarredInfoQuery, HTTP_CLIENT}, @@ -275,6 +276,7 @@ async fn list_search_scripts( #[cfg(not(feature = "enterprise"))] let n = 10; + let allowed = build_scope_path_predicate(&authed, "scripts", "read"); let rows = sqlx::query_as!( SearchScript, "SELECT path, content from script WHERE workspace_id = $1 AND archived = false LIMIT $2", @@ -284,6 +286,7 @@ async fn list_search_scripts( .fetch_all(&mut *tx) .await? .into_iter() + .filter(|r| allowed(&r.path)) .collect::>(); tx.commit().await?; Ok(Json(rows)) @@ -438,9 +441,13 @@ async fn list_scripts( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; + let allowed = build_scope_path_predicate(&authed, "scripts", "read"); let rows = sqlx::query_as::<_, ListableScript>(&sql) .fetch_all(&mut *tx) - .await?; + .await? + .into_iter() + .filter(|r| allowed(&r.path)) + .collect::>(); tx.commit().await?; Ok(Json(rows)) } @@ -737,6 +744,9 @@ async fn is_noop_deploy_against_parent( // caller-intent flag (auto-resolve parent), not script state auto_parent: _, labels, + // caller-intent flag (preserve user drafts on CLI/git-sync deploys); + // transient, never persisted, does not change what the script *is* + skip_draft_deletion: _, } = ns; if path != &parent.path { @@ -925,6 +935,9 @@ async fn create_script_internal<'c>( } } let script_path = ns.path.clone(); + // Caller-intent: CLI / git-sync deploys ask us to preserve any existing + // user draft at this path instead of wiping it as part of the deploy. + let skip_draft_deletion = ns.skip_draft_deletion.unwrap_or(false); let hash = ScriptHash(hash_script(&ns)); let authed = maybe_refresh_folders(&ns.path, &w_id, authed, &db).await; @@ -1357,13 +1370,15 @@ async fn create_script_internal<'c>( let p_path_opt = parent_hashes_and_perms.as_ref().map(|x| x.p_path.clone()); if let Some(ref p_path) = p_path_opt { - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", - p_path, - &w_id - ) - .execute(&mut *tx) - .await?; + if !skip_draft_deletion { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", + p_path, + &w_id + ) + .execute(&mut *tx) + .await?; + } sqlx::query!( "UPDATE capture_config SET path = $1 WHERE path = $2 AND workspace_id = $3 AND is_flow IS FALSE", @@ -1442,7 +1457,7 @@ async fn create_script_internal<'c>( tx = push_scheduled_job(&db, tx, &schedule, None, None).await?; } } - } else { + } else if !skip_draft_deletion { sqlx::query!( "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'", ns.path, @@ -2084,14 +2099,64 @@ async fn raw_script_by_path_unpinned( lazy_static::lazy_static! { static ref DEBUG_RAW_SCRIPT_ENDPOINTS: bool = std::env::var("DEBUG_RAW_SCRIPT_ENDPOINTS").is_ok(); + + /// Fallback freshness window (seconds) for [`RAW_SCRIPT_LATEST_HASH_CACHE`]. + /// Primary invalidation is event-driven: deploying a script writes a + /// `notify_runnable_version_change` row, and the server's polling-events handler + /// evicts the entry across all replicas (see `main.rs`). This TTL only bounds + /// staleness if that event is missed. Defaults to 60s (matches + /// `DEPLOYED_SCRIPT_HASH_CACHE`). Override with `RAW_SCRIPT_CACHE_TTL_SECONDS`. + static ref RAW_SCRIPT_CACHE_TTL_S: i64 = std::env::var("RAW_SCRIPT_CACHE_TTL_SECONDS") + .ok() + .and_then(|s| s.parse::().ok()) + .filter(|s| *s >= 0) + .unwrap_or(60); } lazy_static::lazy_static! { + // Imported-script content, keyed by + // `{ws}:{path}:{importer_cache_key}[:unpinned]:{latest_hash}`. Including the + // imported script's own latest hash makes each entry immutable, so no + // per-entry TTL is needed; staleness is bounded by RAW_SCRIPT_LATEST_HASH_CACHE. pub static ref RAW_SCRIPT_CACHE: Cache = Cache::new(1000); + // `{ws}:{path}` (bare path) -> (latest non-archived hash, unix_ts cached). + // Resolving the imported script's own hash and keying content by it is what + // fixes relative-import staleness for deployed scripts, whose importer hash + // never moves (see #6769). Evicted on deploy by the `notify_runnable_version_change` + // handler in main.rs (cross-replica, within a poll interval); RAW_SCRIPT_CACHE_TTL_S + // is a fallback bound. + pub static ref RAW_SCRIPT_LATEST_HASH_CACHE: Cache = Cache::new(1000); pub static ref CACHE_FOLDERS_PATH: Cache = Cache::new(1000); } +/// Records a [`RAW_SCRIPT_CACHE`] lookup outcome (`hit` / `expired` / `miss`) to +/// the `raw_script_cache_total` counter when the prometheus feature is enabled. +#[cfg(feature = "prometheus")] +fn record_raw_script_cache(result: &str) { + if let Some(c) = RAW_SCRIPT_CACHE_METRIC.as_ref() { + c.with_label_values(&[result]).inc(); + } +} + +#[cfg(not(feature = "prometheus"))] +fn record_raw_script_cache(_result: &str) {} + +#[cfg(feature = "prometheus")] +lazy_static::lazy_static! { + /// Raw relative-import cache lookups, labeled by `result` (hit/expired/miss). + static ref RAW_SCRIPT_CACHE_METRIC: Option = + if windmill_common::METRICS_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { + Some(prometheus::register_int_counter_vec!( + "raw_script_cache_total", + "Raw script relative-import cache lookups by result (hit/expired/miss)", + &["result"] + ).unwrap()) + } else { + None + }; +} + async fn raw_script_by_path_internal( path: StripPath, user_db: UserDB, @@ -2113,23 +2178,10 @@ async fn raw_script_by_path_internal( } } - let cache_path = query - .cache_key - .map(|x| format!("{w_id}:{path}:{x}{}", if unpin { ":unpinned" } else { "" })); - if let Some(cache_path) = cache_path.clone() { - let cached_content = RAW_SCRIPT_CACHE.get(&cache_path); - if let Some(cached_content) = cached_content { - if *DEBUG_RAW_SCRIPT_ENDPOINTS { - tracing::warn!("Raw script by path request: {} (cached)", path); - } - return Ok(cached_content); - } - } - - if *DEBUG_RAW_SCRIPT_ENDPOINTS { - tracing::warn!("Raw script by path request: {} (not cached)", path); - } - + // Validate + strip the language extension up front so cache keys use the bare + // script path. This matches the `notify_runnable_version_change` event payload + // (which carries the bare path), so a deploy can evict RAW_SCRIPT_LATEST_HASH_CACHE + // by key from the polling-events handler in the server binary. if !path.ends_with(".py") && !path.ends_with(".ts") && !path.ends_with(".go") @@ -2148,6 +2200,52 @@ async fn raw_script_by_path_internal( .trim_end_matches(".go") .trim_end_matches(".sh"); + // Content cache is keyed by the IMPORTED script's own latest hash, not by the + // importer's runnable hash (`query.cache_key`). The importer hash never moves + // when only an imported script's content changes (relock is in-place — see + // #6769), so keying solely on it served stale content indefinitely. The + // importer + unpin dimensions are kept to preserve per-runnable authorization + // scoping (a content-cache hit skips the authed RLS query, so an entry must + // stay scoped to the runnable that fetched it); the imported latest hash is + // appended for content correctness. + let cache_path_base = query + .cache_key + .as_ref() + .map(|x| format!("{w_id}:{path}:{x}{}", if unpin { ":unpinned" } else { "" })); + + // Resolve the imported script's latest hash from RAW_SCRIPT_LATEST_HASH_CACHE + // (keyed by the bare path so the deploy event can evict it). A fresh entry + // serves from the immutable content cache with no DB hit; a stale/absent entry + // falls through to the query below, which refreshes both caches. + let hash_cache_key = format!("{w_id}:{path}"); + let (fresh_hash, had_stale_hash) = match RAW_SCRIPT_LATEST_HASH_CACHE.get(&hash_cache_key) { + Some((hash, cached_at)) + if chrono::Utc::now().timestamp() - cached_at <= *RAW_SCRIPT_CACHE_TTL_S => + { + (Some(hash), false) + } + Some(_) => (None, true), + None => (None, false), + }; + + if let (Some(base), Some(latest_hash)) = (cache_path_base.as_ref(), fresh_hash) { + let content_key = format!("{base}:{latest_hash}"); + if let Some(cached_content) = RAW_SCRIPT_CACHE.get(&content_key) { + if *DEBUG_RAW_SCRIPT_ENDPOINTS { + tracing::warn!("Raw script by path request: {path} (cached, key={content_key})"); + } + record_raw_script_cache("hit"); + return Ok(cached_content); + } + } + if cache_path_base.is_some() { + record_raw_script_cache(if had_stale_hash { "expired" } else { "miss" }); + } + + if *DEBUG_RAW_SCRIPT_ENDPOINTS { + tracing::warn!("Raw script by path request: {} (not cached)", path); + } + // folder cache is only useful for python given it needs to recuse over all intermediate folders to find the package. // When a script exists in a folder, we can cache the fact that the folder exists to avoid extra db calls. let mut split_path = path.split("/").collect::>(); @@ -2180,8 +2278,10 @@ async fn raw_script_by_path_internal( let mut tx = user_db.begin(&authed).await?; - let content_o = sqlx::query_scalar!( - "SELECT content FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1", + // Fetch the latest non-archived row's hash AND content in one query: the hash + // keys the (immutable) content cache and refreshes RAW_SCRIPT_LATEST_HASH_CACHE. + let row_o = sqlx::query!( + "SELECT hash, content FROM script WHERE path = $1 AND workspace_id = $2 AND archived = false ORDER BY created_at DESC LIMIT 1", path, w_id ) @@ -2189,6 +2289,10 @@ async fn raw_script_by_path_internal( .warn_after_seconds(5) .await?; tx.commit().await?; + let (db_hash, content_o) = match row_o { + Some(r) => (Some(r.hash), Some(r.content)), + None => (None, None), + }; if *DEBUG_RAW_SCRIPT_ENDPOINTS { tracing::warn!( "Raw script by path request: {} (content: {:?})", @@ -2252,8 +2356,14 @@ async fn raw_script_by_path_internal( } } - if let Some(cache_path) = cache_path { - RAW_SCRIPT_CACHE.insert(cache_path, content.clone()); + // content_o was Some, so db_hash is Some too (same row). Refresh the latest-hash + // cache and store the content under the hash-qualified key. + if let Some(db_hash) = db_hash { + RAW_SCRIPT_LATEST_HASH_CACHE + .insert(hash_cache_key, (db_hash, chrono::Utc::now().timestamp())); + if let Some(base) = cache_path_base { + RAW_SCRIPT_CACHE.insert(format!("{base}:{db_hash}"), content.clone()); + } } if *DEBUG_RAW_SCRIPT_ENDPOINTS { tracing::warn!("Raw script by path request: {} (content response)", path); diff --git a/backend/windmill-api-settings/src/lib.rs b/backend/windmill-api-settings/src/lib.rs index 3078be9818..9fd1091d66 100644 --- a/backend/windmill-api-settings/src/lib.rs +++ b/backend/windmill-api-settings/src/lib.rs @@ -6,7 +6,10 @@ * LICENSE-AGPL for a copy of the license. */ -use std::{collections::HashMap, time::Duration}; +use std::{ + collections::{BTreeSet, HashMap}, + time::Duration, +}; #[cfg(feature = "parquet")] mod audit_logs_s3; @@ -47,6 +50,8 @@ use windmill_common::secret_backend::{ AwsSecretsManagerSettings, AzureKeyVaultSettings, SecretMigrationReport, VaultSettings, }; use windmill_common::{ + auth::is_super_admin_email, + ee_oss::{get_license_plan, LicensePlan}, email_oss::send_email_plain_text, error::{self, JsonResult, Result}, get_database_url, @@ -61,9 +66,8 @@ use windmill_common::{ }, instance_config::{self, ApplyMode, InstanceConfig}, server::Smtp, - worker::is_cloud_production_host, }; -use windmill_common::{error::to_anyhow, PgDatabase}; +use windmill_common::{error::to_anyhow, worker::CLOUD_HOSTED, PgDatabase}; /// Unauthenticated settings routes. /// @@ -462,10 +466,13 @@ fn is_workspace_fairness_setting(key: &str) -> bool { ) } -/// Cloud-and-app.windmill.dev gate for workspace fairness. Must hold to persist -/// the setting; the runtime path additionally verifies before applying the cap. -fn workspace_fairness_settings_allowed() -> bool { - is_cloud_production_host() +/// Enterprise gate for the workspace-fairness settings. Workspace fairness is +/// only useful on multi-tenant clusters where one workspace can starve other +/// workspaces sharing the same worker pool, and the feature is licensed as +/// part of Enterprise. Non-EE installs are rejected at write time; the runtime +/// dispatch additionally honours the `WORKSPACE_FAIRNESS_ENABLED` toggle. +async fn workspace_fairness_settings_allowed() -> bool { + matches!(get_license_plan().await, LicensePlan::Enterprise) } pub async fn set_global_setting( @@ -490,23 +497,27 @@ pub async fn set_global_setting_internal( value }; - // Hard-gate the cloud-only workspace fairness settings: refuse to persist - // them on any instance that is not CLOUD_HOSTED + app.windmill.dev. This is - // belt-and-suspenders alongside the frontend `{#if isCloudHosted()}` wrap - // and the runtime check in `workspace_fairness::fairness_active`. - // - // Deletes (Null / empty-string) are *allowed* on non-cloud so admins can clear - // stale rows that ended up in `global_settings` via a cloned cloud DB. Without - // this exception a self-hosted instance would be stuck with cloud-only rows - // showing up in its instance-config YAML export. + // EE gate for workspace-fairness settings. Workspace fairness only matters + // on multi-tenant clusters; it is licensed as an Enterprise feature so the + // setter rejects writes from non-EE builds. Disabling/clearing writes are + // *always* allowed regardless of license, so an admin who downgrades from + // EE (or imports a row from a cloned EE DB) can always turn the cap off: + // - `Null` / empty-string → row delete + // - `Bool(false)` on `workspace_fairness_enabled` → explicit disable + // Without the `Bool(false)` carve-out, a stale `enabled=true` row from a + // downgrade would be impossible to flip off through the normal API/UI + // and the runtime path (which only checks the toggle) would keep + // throttling. let is_clearing_value = matches!(&value, serde_json::Value::Null) - || matches!(&value, serde_json::Value::String(s) if s.trim().is_empty()); + || matches!(&value, serde_json::Value::String(s) if s.trim().is_empty()) + || (key == WORKSPACE_FAIRNESS_ENABLED_SETTING + && matches!(&value, serde_json::Value::Bool(false))); if is_workspace_fairness_setting(&key) && !is_clearing_value - && !workspace_fairness_settings_allowed() + && !workspace_fairness_settings_allowed().await { return Err(error::Error::BadRequest(format!( - "{} is only configurable on app.windmill.dev cloud (CLOUD_HOSTED + BASE_URL match required)", + "{} requires an Enterprise license", key ))); } @@ -588,7 +599,10 @@ async fn run_setting_pre_write_hook( ))); }; - if !*workspaced_route { + // Cloud always scopes app custom paths by workspace_id (see + // `custom_path_exists` in apps.rs), so duplicates across workspaces + // are expected and this setting has no runtime effect on cloud. + if !*workspaced_route && !*CLOUD_HOSTED { #[derive(Debug, Deserialize, Serialize)] #[allow(unused)] struct DuplicateApp { @@ -651,7 +665,11 @@ async fn run_setting_pre_write_hook( ))); }; - if !*workspaced_route { + // Cloud always scopes routes by workspace_id (see + // `route_path_key_exists` in windmill-trigger-http), so duplicates + // across workspaces are expected and this setting has no runtime + // effect on cloud. + if !*workspaced_route && !*CLOUD_HOSTED { #[derive(Debug, Deserialize, Serialize)] #[allow(unused)] struct DuplicateRoute { @@ -769,22 +787,24 @@ async fn set_instance_config( .iter() .any(|(key, _)| key == AI_CONFIG_SETTING); - // Mirror the per-key cloud gate in `set_global_setting_internal`. Without this, the - // bulk endpoint would let a self-hosted superadmin persist `workspace_fairness_*` rows - // even though the per-key API rejects them. The runtime check in - // `workspace_fairness::fairness_active` still keeps the cap inert there, but persisting - // the rows would be a leak of cloud-only config into non-cloud DBs and would advertise - // the feature in the YAML export. - // - // Only block *upserts*; deletes are allowed everywhere so admins can clean up stale - // rows (e.g. from a cloned cloud DB) without flipping `CLOUD_HOSTED` on temporarily. - let upserts_touch_fairness = settings_diff - .upserts - .keys() - .any(|k| is_workspace_fairness_setting(k)); - if upserts_touch_fairness && !workspace_fairness_settings_allowed() { + // Mirror the per-key EE gate in `set_global_setting_internal`. Without + // this, the bulk endpoint would let a non-EE superadmin persist + // `workspace_fairness_*` rows even though the per-key API rejects them. + // Only block *non-disabling* upserts; deletes are allowed everywhere + // (already filtered into `settings_diff.removals`) and a + // `workspace_fairness_enabled=false` upsert is treated as a disable, + // so a downgraded instance can always turn the cap off via the bulk + // YAML endpoint too. + let upserts_touch_fairness_non_disable = settings_diff.upserts.iter().any(|(k, v)| { + if !is_workspace_fairness_setting(k) { + return false; + } + !(k == WORKSPACE_FAIRNESS_ENABLED_SETTING + && matches!(v, serde_json::Value::Bool(false))) + }); + if upserts_touch_fairness_non_disable && !workspace_fairness_settings_allowed().await { return Err(error::Error::BadRequest( - "Workspace fairness settings are only configurable on app.windmill.dev cloud (CLOUD_HOSTED + BASE_URL match required)".to_string(), + "Workspace fairness settings require an Enterprise license".to_string(), )); } @@ -1119,6 +1139,8 @@ struct CustomInstanceDb { success: bool, error: Option, tag: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + used_by_workspaces: Vec, } #[derive(Deserialize, Debug, Serialize, Default)] @@ -1138,7 +1160,7 @@ struct CustomInstanceDbLogs { } async fn list_custom_instance_pg_databases( - _authed: ApiAuthed, + authed: ApiAuthed, Extension(db): Extension, ) -> JsonResult> { let result = sqlx::query_scalar!( @@ -1147,12 +1169,57 @@ async fn list_custom_instance_pg_databases( .fetch_one(&db) .await? .ok_or_else(|| error::Error::ExecutionErr("Couldn't find custom_instance_pg_databases".to_string()))?; - let result = serde_json::from_value(result).map_err(|e| { - error::Error::ExecutionErr(format!( - "couldn't parse custom_instance_pg_databases.databases : {}", - e.to_string() - )) - })?; + let mut result: HashMap = + serde_json::from_value(result).map_err(|e| { + error::Error::ExecutionErr(format!( + "couldn't parse custom_instance_pg_databases.databases : {}", + e.to_string() + )) + })?; + + if is_super_admin_email(&db, &authed.email).await? { + // Enrich each database with the list of workspaces referencing it through + // either a ducklake catalog or a datatable database whose resource_type is + // 'instance'. Not stored in DB to avoid drift. + let usages = sqlx::query!( + r#" + SELECT ws.workspace_id AS "workspace_id!", entry->'catalog'->>'resource_path' AS dbname + FROM workspace_settings ws + CROSS JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(ws.ducklake->'ducklakes') = 'object' + THEN ws.ducklake->'ducklakes' + ELSE '{}'::jsonb END + ) AS dl(k, entry) + WHERE entry->'catalog'->>'resource_type' = 'instance' + AND entry->'catalog'->>'resource_path' IS NOT NULL + UNION ALL + SELECT ws.workspace_id AS "workspace_id!", entry->'database'->>'resource_path' AS dbname + FROM workspace_settings ws + CROSS JOIN LATERAL jsonb_each( + CASE WHEN jsonb_typeof(ws.datatable->'datatables') = 'object' + THEN ws.datatable->'datatables' + ELSE '{}'::jsonb END + ) AS dt(k, entry) + WHERE entry->'database'->>'resource_type' = 'instance' + AND entry->'database'->>'resource_path' IS NOT NULL + "#, + ) + .fetch_all(&db) + .await?; + + let mut by_db: HashMap> = HashMap::new(); + for row in usages { + if let Some(dbname) = row.dbname { + by_db.entry(dbname).or_default().insert(row.workspace_id); + } + } + for (dbname, entry) in result.iter_mut() { + if let Some(workspaces) = by_db.remove(dbname) { + entry.used_by_workspaces = workspaces.into_iter().collect(); + } + } + } + return Ok(Json(result)); } @@ -1180,7 +1247,8 @@ async fn setup_custom_instance_pg_database( let result = setup_custom_instance_pg_database_inner(authed, &db, &dbname, &mut logs).await; let success = result.is_ok(); let error = result.err().map(|e| e.to_string()); - let status = CustomInstanceDb { logs, success, error, tag: body.tag }; + let status = + CustomInstanceDb { logs, success, error, tag: body.tag, used_by_workspaces: vec![] }; let status_json = serde_json::to_value(&status).map_err(to_anyhow)?; // Save that the database was setup successfully sqlx::query!( diff --git a/backend/windmill-api-users/src/users.rs b/backend/windmill-api-users/src/users.rs index 0eb9d26b5d..95b9527a8e 100644 --- a/backend/windmill-api-users/src/users.rs +++ b/backend/windmill-api-users/src/users.rs @@ -207,6 +207,11 @@ pub struct GlobalUserInfo { username: Option, #[serde(skip_serializing_if = "Option::is_none")] operator_only: Option, + /// Populated only for service-account rows (which are workspace-scoped). + /// `None` for password users since their admin status varies per workspace + /// and is not surfaced by this aggregation. + #[serde(skip_serializing_if = "Option::is_none")] + is_workspace_admin: Option, first_time_user: bool, role_source: String, disabled: bool, @@ -455,11 +460,11 @@ async fn list_users_as_super_admin( 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')), 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, 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 + 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 WHERE email IN (SELECT email FROM active_users) UNION ALL - SELECT email as "email!", true as operator_only, '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 + 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 FROM usr WHERE is_service_account IS true ORDER BY "super_admin!" DESC, "devops!" DESC @@ -472,9 +477,9 @@ async fn list_users_as_super_admin( } else { sqlx::query_as!( GlobalUserInfo, - r#"SELECT email as "email!", login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, NULL::bool as operator_only, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id FROM password + r#"SELECT email as "email!", login_type::text, verified as "verified!", super_admin as "super_admin!", devops as "devops!", name, company, username, NULL::bool as operator_only, NULL::bool as is_workspace_admin, first_time_user as "first_time_user!", role_source as "role_source!", disabled as "disabled!", NULL::text as workspace_id FROM password UNION ALL - SELECT email as "email!", '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, true as operator_only, false as "first_time_user!", 'service_account'::text as "role_source!", disabled as "disabled!", workspace_id + SELECT email as "email!", '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, operator as operator_only, is_admin as is_workspace_admin, false as "first_time_user!", 'service_account'::text as "role_source!", disabled as "disabled!", workspace_id FROM usr WHERE is_service_account IS true ORDER BY "super_admin!" DESC, "devops!" DESC, "email!" @@ -727,7 +732,7 @@ async fn global_whoami( ) -> JsonResult { let user = sqlx::query_as!( GlobalUserInfo, - "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE \ + "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, NULL::bool as is_workspace_admin, first_time_user, role_source, disabled, NULL::text as workspace_id FROM password WHERE \ email = $1", email ) @@ -748,13 +753,24 @@ async fn global_whoami( company: None, username: None, operator_only: None, + is_workspace_admin: None, first_time_user: false, role_source: "manual".to_string(), disabled: false, workspace_id: None, })) } else { - // Service accounts don't have a password row + // Service accounts don't have a password row. The SA email is unique + // per (workspace, username) and pinpoints a single usr row, so we can + // surface its real role rather than pinning to operator. + let sa_role = sqlx::query!( + "SELECT operator, is_admin FROM usr WHERE email = $1 AND is_service_account IS true LIMIT 1", + email + ) + .fetch_optional(&db) + .await + .map_err(|e| Error::internal_err(format!("fetching service-account role: {e:#}")))?; + Ok(Json(GlobalUserInfo { email: email.clone(), login_type: Some("service_account".to_string()), @@ -764,7 +780,8 @@ async fn global_whoami( name: None, company: None, username: None, - operator_only: Some(true), + operator_only: sa_role.as_ref().map(|r| r.operator).or(Some(true)), + is_workspace_admin: sa_role.as_ref().map(|r| r.is_admin), first_time_user: false, role_source: "service_account".to_string(), disabled: false, @@ -1947,7 +1964,8 @@ async fn login( windmill_common::login_rate_limit::record_login_failure(&email); Err(Error::BadRequest("Invalid login".to_string())) } else { - let token = create_session_token(&email, super_admin, &mut tx, cookies).await?; + let token = + create_session_token(&email, super_admin, None, false, &mut tx, cookies).await?; let audit_author = AuditAuthor { email: email.clone(), @@ -2019,7 +2037,15 @@ async fn refresh_token( .await? .unwrap_or(false); - let new_token = create_session_token(&authed.email, super_admin, &mut tx, cookies).await?; + let new_token = create_session_token( + &authed.email, + super_admin, + authed.scopes.as_deref(), + authed.read_only, + &mut tx, + cookies, + ) + .await?; audit_log( &mut *tx, @@ -2049,6 +2075,8 @@ lazy_static::lazy_static! { pub async fn create_session_token<'c>( email: &str, super_admin: bool, + scopes: Option<&[String]>, + read_only: bool, tx: &mut sqlx::Transaction<'c, sqlx::Postgres>, cookies: Cookies, ) -> Result { @@ -2091,15 +2119,17 @@ pub async fn create_session_token<'c>( sqlx::query!( "INSERT INTO token - (token_hash, token_prefix, token, email, label, expiration, super_admin) - VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7)", + (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, read_only) + VALUES ($1, $2, $3, $4, $5, now() + ($6 || ' seconds')::interval, $7, $8, $9)", t_hash, t_prefix, plaintext as Option<&str>, email, "session", &MAX_SESSION_VALIDITY_SECONDS.to_string(), - super_admin + super_admin, + scopes, + read_only, ) .execute(&mut **tx) .await?; @@ -2129,6 +2159,8 @@ async fn create_token( ) -> Result<(StatusCode, String)> { check_token_create_rate_limit(&authed.username)?; + windmill_api_auth::ensure_scopes_within_caller(&authed, token_config.scopes.as_deref())?; + let mut tx = db.begin().await?; let token = create_token_internal(&mut *tx, &db, &authed, token_config).await?; @@ -2336,6 +2368,8 @@ async fn update_token_scopes( Path(token_prefix): Path, Json(req): Json, ) -> Result { + windmill_api_auth::ensure_scopes_within_caller(&authed, req.scopes.as_deref())?; + let mut tx = db.begin().await?; let updated: Option = sqlx::query_scalar!( diff --git a/backend/windmill-api-workers/src/lib.rs b/backend/windmill-api-workers/src/lib.rs index dc6b08db08..d65316555e 100644 --- a/backend/windmill-api-workers/src/lib.rs +++ b/backend/windmill-api-workers/src/lib.rs @@ -39,6 +39,10 @@ pub fn global_service() -> Router { .route("/queue_metrics", get(get_queue_metrics)) .route("/queue_counts", get(get_queue_counts)) .route("/queue_running_counts", get(get_queue_running_counts)) + .route( + "/workspace_fairness_events", + get(get_workspace_fairness_events), + ) } pub fn workspaced_service() -> Router { @@ -283,3 +287,77 @@ async fn get_queue_running_counts( let queue_running_counts = windmill_common::queue::get_queue_running_counts(&db).await; Ok(Json(queue_running_counts)) } + +#[derive(Serialize)] +pub struct WorkspaceFairnessEvent { + pub timestamp: chrono::DateTime, + pub operation: String, + /// Affected workspace (stored in audit log `resource`). `None` only for very + /// old rows pre-dating the resource convention — UI should treat as "unknown". + pub workspace_id: Option, + /// Snapshot of the relevant fairness settings at the time of the transition + /// (`max_percent`, `window_secs`, `total_overloaded`). `None` for uncap rows. + pub parameters: Option, +} + +/// Return the most recent ~200 cap and ~200 uncap transitions (merged into +/// at most 400 rows) written by `workspace_fairness::emit_transition_audit`. +/// Workspace fairness is an Enterprise feature; on non-EE / non-enabled +/// instances the table is naturally empty. +async fn get_workspace_fairness_events( + authed: ApiAuthed, + Extension(db): Extension, +) -> JsonResult> { + require_devops_role(&db, &authed.email).await?; + + // No cloud-host gate — workspace fairness is an Enterprise feature + // available on any multi-tenant EE deployment. Non-EE / non-enabled + // instances will simply have no audit rows of these operation types, + // so the table is naturally empty. + // + // Return the most recent 200 cap **and** the most recent 200 uncap + // events separately, then merge — without this, a long stretch of caps + // can push every uncap off the unified `LIMIT 200` window and the UI + // appears to "never record uncaps". (The unified ordered limit was a + // real footgun in production audit drawers.) + let events = sqlx::query_as!( + WorkspaceFairnessEvent, + r#" + 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 + LIMIT 200 + ), 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 + LIMIT 200 + ) + SELECT timestamp AS "timestamp!", + operation::text AS "operation!", + resource AS workspace_id, + parameters + FROM (SELECT * FROM capped UNION ALL SELECT * FROM uncapped) e + ORDER BY timestamp DESC + "#, + ) + .fetch_all(&db) + .await?; + + Ok(Json(events)) +} diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index 3c99548628..00cebc32ff 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -55,7 +55,10 @@ use windmill_common::{ use windmill_dep_map::scoped_dependency_map::{ DependencyDependent, DependencyMap, ScopedDependencyMap, }; -use windmill_git_sync::{handle_deployment_metadata, handle_fork_branch_creation, DeployedObject}; +use windmill_git_sync::{ + handle_deployment_metadata, handle_deployment_metadata_batch, handle_fork_branch_creation, + DeployedObject, +}; use windmill_types::s3::LargeFileStorage; use hyper::StatusCode; @@ -337,6 +340,8 @@ pub struct InstanceAISummary { #[serde(skip_serializing_if = "Option::is_none")] pub default_model: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub metadata_model: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub code_completion_model: Option, } @@ -822,6 +827,7 @@ pub fn build_instance_ai_summary(config: Option<&serde_json::Value>) -> Option = 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. @@ -3448,6 +3455,7 @@ async fn set_encryption_key( ) .execute(&mut *tx) .await?; + reencrypted_secret_paths.push(variable.path); } } @@ -3456,16 +3464,23 @@ async fn set_encryption_key( // Invalidate the cache only after the transaction has committed WORKSPACE_CRYPT_CACHE.remove(w_id.as_str()); - // Trigger git sync for encryption key changes - handle_deployment_metadata( + // Build the batch: one event for the encryption key itself plus one per + // re-encrypted secret variable. The batch entrypoint dispatches a single + // git-sync job per repo carrying all items, so repos with Secrets sync + // enabled receive the new ciphertexts in one commit. + let mut batch: Vec = Vec::with_capacity(reencrypted_secret_paths.len() + 1); + batch.push(DeployedObject::Key { key_type: "encryption_key".to_string() }); + for path in reencrypted_secret_paths { + batch.push(DeployedObject::Variable { path: path.clone(), parent_path: Some(path) }); + } + + handle_deployment_metadata_batch( &authed.email, &authed.username, &db, &w_id, - windmill_git_sync::DeployedObject::Key { key_type: "encryption_key".to_string() }, + batch, Some("Encryption key updated".to_string()), - false, - None, ) .await?; @@ -5451,6 +5466,16 @@ If you do not have an account on {}, login with SSO or ask an admin to create an #[derive(Deserialize)] pub struct NewServiceAccount { pub username: String, + #[serde(default)] + pub is_admin: bool, + #[serde(default = "default_true")] + pub operator: bool, + #[serde(default)] + pub add_to_deployers: bool, +} + +fn default_true() -> bool { + true } async fn create_service_account( diff --git a/backend/windmill-api/Cargo.toml b/backend/windmill-api/Cargo.toml index 2af5e17406..94e72e7c4a 100644 --- a/backend/windmill-api/Cargo.toml +++ b/backend/windmill-api/Cargo.toml @@ -19,7 +19,7 @@ enterprise_saml = ["dep:samael", "dep:libxml"] benchmark = [] embedding = ["windmill-api-embeddings/embedding"] parquet = ["dep:datafusion", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker?/parquet", "windmill-api-users/parquet", "windmill-api-settings/parquet", "windmill-api-workspaces/parquet", "dep:aws-sigv4", "dep:aws-sdk-config"] -prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker?/prometheus"] +prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker?/prometheus", "windmill-api-scripts/prometheus"] openidconnect = ["dep:openidconnect", "windmill-common/openidconnect", "windmill-store/openidconnect"] tantivy = ["dep:windmill-indexer"] kafka = ["dep:windmill-trigger-kafka", "windmill-store/kafka"] @@ -40,7 +40,7 @@ gcp_trigger = ["dep:windmill-trigger-gcp", "windmill-store/gcp_trigger"] azure_trigger = ["dep:windmill-trigger-azure", "windmill-store/azure_trigger"] cloud = ["windmill-common/cloud", "windmill-api-auth/cloud", "windmill-store/cloud", "windmill-api-workspaces/cloud"] mcp = ["dep:windmill-mcp", "windmill-mcp/server", "windmill-mcp/auth", "windmill-api-auth/mcp", "windmill-store/mcp"] -bedrock = ["windmill-ai/bedrock", "dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types"] +bedrock = ["windmill-ai/bedrock"] python = ["windmill-dep-map/python", "dep:windmill-parser-py", "dep:windmill-parser-py-imports", "windmill-api-scripts/python", "windmill-api-configs/python", "windmill-api-agent-workers?/python", "windmill-trigger/python", "windmill-common/python"] no_auth = ["windmill-api-auth/no_auth", "windmill-store/no_auth", "windmill-api-users/no_auth"] quickjs = ["windmill-jseval/quickjs"] @@ -172,11 +172,6 @@ rustls = { workspace = true } aws-sigv4 = { workspace = true, optional = true } aws-sdk-config = { workspace = true, optional = true } -aws-config = { workspace = true, optional = true } -aws-credential-types = { workspace = true, optional = true } -aws-sdk-bedrock = { workspace = true, optional = true } -aws-sdk-bedrockruntime = { workspace = true, optional = true } -aws-smithy-types = { workspace = true, optional = true } async-trait.workspace = true eventsource-stream.workspace = true windmill-jseval.workspace = true diff --git a/backend/windmill-api/openapi-deref.json b/backend/windmill-api/openapi-deref.json index 5bd7f5c21e..00727e626f 100644 --- a/backend/windmill-api/openapi-deref.json +++ b/backend/windmill-api/openapi-deref.json @@ -1,7 +1,7 @@ { "openapi": "3.0.3", "info": { - "version": "1.689.0", + "version": "1.713.1", "title": "Windmill API", "contact": { "name": "Windmill Team", @@ -2529,6 +2529,70 @@ } } }, + "/settings/audit_logs_s3_status": { + "get": { + "summary": "get status of the audit-log object-store export cursor", + "operationId": "getAuditLogsS3Status", + "tags": [ + "setting" + ], + "responses": { + "200": { + "description": "current export status (null if the feature was never enabled)", + "content": { + "application/json": { + "schema": { + "nullable": true, + "type": "object", + "properties": { + "last_xmin": { + "type": "integer", + "format": "int64" + }, + "last_ts": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "bootstrapping": { + "type": "boolean" + }, + "last_exported_audit_ts": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "last_run_at": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "last_run_exported": { + "type": "integer", + "format": "int64" + }, + "updated_at": { + "type": "string", + "format": "date-time" + }, + "owner": { + "type": "string", + "nullable": true + } + }, + "required": [ + "last_xmin", + "bootstrapping", + "last_run_exported", + "updated_at" + ] + } + } + } + } + } + } + }, "/settings/send_stats": { "post": { "summary": "send stats", @@ -2678,6 +2742,82 @@ } } }, + "/settings/offline_license_status": { + "get": { + "summary": "get cap-usage status for the currently-loaded offline license", + "description": "Returns the live cap status (seats used vs cap, current CU vs cap) for\nthe offline license key currently in use. Returns `null` if no offline\nlicense is loaded. Super-admin only.\n", + "operationId": "getOfflineLicenseStatus", + "tags": [ + "setting" + ], + "responses": { + "200": { + "description": "cap status (or null when no offline license)", + "content": { + "application/json": { + "schema": { + "type": "object", + "nullable": true, + "properties": { + "seats_used": { + "type": "number", + "description": "Author-equivalent seats consumed (authors + 0.5 × operators)" + }, + "seats_cap": { + "type": "integer" + }, + "author_count": { + "type": "integer" + }, + "operator_count": { + "type": "integer" + }, + "current_cu": { + "type": "number", + "description": "Sum of CU rate across workers that pinged in the last 2 minutes." + }, + "cu_cap": { + "type": "number" + }, + "cu_over_cap": { + "type": "boolean" + } + } + } + } + } + } + } + } + }, + "/settings/instance_hash": { + "get": { + "summary": "per-instance binding hash for offline license issuance", + "description": "Returns the hash a superadmin shares with Windmill support when\nrequesting an offline license. Super-admin only.\n", + "operationId": "getInstanceHash", + "tags": [ + "setting" + ], + "responses": { + "200": { + "description": "instance hash", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "instance_hash": { + "type": "string", + "nullable": true + } + } + } + } + } + } + } + } + }, "/settings/customer_portal": { "post": { "summary": "create customer portal session", @@ -3733,6 +3873,136 @@ } } }, + "/github_app/ghes/discover": { + "get": { + "summary": "Discover GHES App installations", + "description": "Lists every installation the configured self-managed GitHub App can see,\nannotated with the workspaces in this Windmill instance the\ninstallation is currently assigned to. Super-admin only.\n", + "operationId": "discoverGhesInstallations", + "tags": [ + "Git Sync" + ], + "responses": { + "200": { + "description": "Discovered installations", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "required": [ + "installation_id", + "account_id", + "assigned_workspaces" + ], + "properties": { + "installation_id": { + "type": "integer", + "format": "int64" + }, + "account_id": { + "type": "string", + "description": "GitHub login of the installation's account (org or user)" + }, + "assigned_workspaces": { + "type": "array", + "items": { + "type": "object", + "required": [ + "workspace_id", + "provisioned_by_admin" + ], + "properties": { + "workspace_id": { + "type": "string" + }, + "provisioned_by_admin": { + "type": "boolean" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/github_app/ghes/assign": { + "post": { + "summary": "Assign GHES installation to a workspace", + "description": "Assigns a discovered GHES App installation to a workspace. The resulting\ninstallation is marked as admin-provisioned, so workspace admins cannot\nremove it. Super-admin only.\n", + "operationId": "assignGhesInstallation", + "tags": [ + "Git Sync" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "workspace_id", + "installation_id" + ], + "properties": { + "workspace_id": { + "type": "string" + }, + "installation_id": { + "type": "integer", + "format": "int64" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Installation assigned" + } + } + } + }, + "/github_app/ghes/assign/{workspace_id}/{installation_id}": { + "delete": { + "summary": "Unassign GHES installation from a workspace", + "description": "Removes an installation (admin-provisioned or otherwise) from a\nworkspace. Super-admin only. Does not affect the installation on the\nGitHub side.\n", + "operationId": "unassignGhesInstallation", + "tags": [ + "Git Sync" + ], + "parameters": [ + { + "name": "workspace_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "installation_id", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "Installation unassigned" + } + } + } + }, "/users/accept_invite": { "post": { "summary": "accept invite to workspace", @@ -3950,6 +4220,18 @@ "properties": { "username": { "type": "string" + }, + "is_admin": { + "type": "boolean", + "description": "Grant the service account workspace admin. Defaults to false. Cannot be combined with operator=true." + }, + "operator": { + "type": "boolean", + "description": "Make the service account an operator. Defaults to true for backward compatibility. Set to false to count as a developer (1 seat) instead of 0.5 seat." + }, + "add_to_deployers": { + "type": "boolean", + "description": "Add the service account to the workspace `wm_deployers` group on creation. Recommended when the account will be used as a CLI sync / CI deploy identity so it can deploy on behalf of other users." } }, "required": [ @@ -4622,9 +4904,72 @@ } } }, + "/w/{workspace}/workspaces/get_public_settings": { + "get": { + "summary": "get public settings", + "description": "Returns the subset of workspace settings safe to expose to any workspace member. The full settings struct is admin-only via `getSettings`.", + "operationId": "getPublicSettings", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + } + ], + "responses": { + "200": { + "description": "status", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "workspace_id": { + "type": "string" + }, + "slack_name": { + "type": "string" + }, + "slack_team_id": { + "type": "string" + }, + "teams_team_id": { + "type": "string" + }, + "teams_team_name": { + "type": "string" + }, + "teams_team_guid": { + "type": "string" + }, + "large_file_storage": { + "$ref": "#/components/schemas/LargeFileStorage" + }, + "datatable": { + "$ref": "#/components/schemas/DataTableSettings" + }, + "deploy_ui": { + "$ref": "#/components/schemas/WorkspaceDeployUISettings" + }, + "mute_critical_alerts": { + "type": "boolean" + } + }, + "required": [ + "workspace_id" + ] + } + } + } + } + } + } + }, "/w/{workspace}/workspaces/get_settings": { "get": { - "summary": "get settings", + "summary": "get settings (admin only)", + "description": "Returns the full workspace settings including admin-managed integration credentials. Admin-only — non-admin callers should use `getPublicSettings`.", "operationId": "getSettings", "tags": [ "workspace" @@ -5467,6 +5812,53 @@ } } }, + "/w/{workspace}/workspaces/connect_slack": { + "post": { + "summary": "connect slack (non-interactive; pre-minted bot token)", + "operationId": "connectSlack", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + } + ], + "requestBody": { + "description": "connect slack with a pre-minted bot token", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "bot_token", + "team_id", + "team_name" + ], + "properties": { + "bot_token": { + "type": "string", + "description": "xoxb-... bot token obtained at api.slack.com/apps" + }, + "team_id": { + "type": "string" + }, + "team_name": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "status" + } + } + } + }, "/w/{workspace}/workspaces/run_slack_message_test_job": { "post": { "summary": "run a job that sends a message to Slack", @@ -6099,6 +6491,85 @@ } } }, + "/w/{workspace}/workspaces/list_datatable_tables": { + "get": { + "summary": "list tables of all connected Datatables", + "operationId": "listDataTableTables", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + } + ], + "responses": { + "200": { + "description": "table metadata of all datatables", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DataTableTables" + } + } + } + } + } + } + } + }, + "/w/{workspace}/workspaces/get_datatable_table_schema": { + "get": { + "summary": "get one Datatable table schema", + "operationId": "getDataTableTableSchema", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "datatable_name", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "schema_name", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "table_name", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "schema of one datatable table", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DataTableTableSchema" + } + } + } + } + } + } + }, "/w/{workspace}/workspaces/edit_ducklake_config": { "post": { "summary": "edit ducklake settings", @@ -7357,6 +7828,57 @@ } } }, + "/users/tokens/update_scopes/{token_prefix}": { + "post": { + "summary": "update scopes of an existing token (owner only)", + "operationId": "updateTokenScopes", + "tags": [ + "user" + ], + "parameters": [ + { + "name": "token_prefix", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "new scopes (null or omitted = full access)", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "scopes": { + "type": "array", + "items": { + "type": "string" + }, + "nullable": true + } + } + } + } + } + }, + "responses": { + "200": { + "description": "scopes updated", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/users/tokens/list": { "get": { "summary": "list token", @@ -8696,6 +9218,96 @@ } } }, + "/w/{workspace}/workspaces/list_ws_specific": { + "get": { + "summary": "list all workspace-specific items", + "operationId": "listWsSpecific", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + } + ], + "responses": { + "200": { + "description": "list of workspace-specific items", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "item_kind": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "item_kind", + "path" + ] + } + } + } + } + } + } + } + }, + "/w/{workspace}/workspaces/list_ws_specific_versions": { + "get": { + "summary": "list workspace ids that have a version of the given item", + "operationId": "listWsSpecificVersions", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "kind", + "in": "query", + "required": true, + "schema": { + "type": "string", + "enum": [ + "resource", + "variable" + ] + } + }, + { + "name": "path", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "list of workspace ids that have a version of the item", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + } + } + } + }, "/w/{workspace}/workspaces/public_app_rate_limit": { "post": { "summary": "Set public app rate limit for this workspace", @@ -8888,6 +9500,48 @@ } } }, + "/oauth/connect_slack_instance": { + "post": { + "summary": "connect slack instance (non-interactive; pre-minted bot token)", + "operationId": "connectSlackInstance", + "tags": [ + "oauth" + ], + "requestBody": { + "description": "connect slack at the instance level with a pre-minted bot token", + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "bot_token", + "team_id", + "team_name" + ], + "properties": { + "bot_token": { + "type": "string", + "description": "xoxb-... bot token obtained at api.slack.com/apps" + }, + "team_id": { + "type": "string" + }, + "team_name": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "status" + } + } + } + }, "/oauth/connect_callback/{client_name}": { "post": { "summary": "connect callback", @@ -9248,6 +9902,10 @@ }, "saml": { "type": "string" + }, + "auto_login": { + "type": "string", + "description": "provider type to auto-redirect to on login (oauth key or \"saml\")" } }, "required": [ @@ -11485,6 +12143,7 @@ "/w/{workspace}/scripts/create": { "post": { "summary": "create script", + "description": "Creates a new script when the path does not already exist.\nCreates a new version of an existing script when called with the same path and the current `parent_hash`.\n", "operationId": "createScript", "x-mcp-tool": true, "x-mcp-instructions": "To create a script, specify the path (e.g., 'f/my_folder/my_script'), the content (source code), and the language. For TypeScript, use 'bun' unless deno-specific APIs are needed.", @@ -14481,6 +15140,11 @@ "properties": { "draft": { "$ref": "#/components/schemas/Flow" + }, + "draft_created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check." } } } @@ -14565,6 +15229,10 @@ }, "deployment_message": { "type": "string" + }, + "skip_draft_deletion": { + "type": "boolean", + "description": "When true (set by the CLI / git sync), deploying this flow does not delete an existing user draft at the same path." } } } @@ -14631,6 +15299,10 @@ "properties": { "deployment_message": { "type": "string" + }, + "skip_draft_deletion": { + "type": "boolean", + "description": "When true (set by the CLI / git sync), deploying this flow does not delete an existing user draft at the same path." } } } @@ -14843,13 +15515,13 @@ } }, { - "name": "after_id", - "description": "id to fetch only the messages after that id", + "name": "after_seq", + "description": "Message sequence cursor to fetch only the messages after that cursor", "in": "query", "required": false, "schema": { - "type": "string", - "format": "uuid" + "type": "integer", + "format": "int64" } } ], @@ -14881,6 +15553,14 @@ "parameters": [ { "$ref": "#/components/parameters/WorkspaceId" + }, + { + "name": "force", + "description": "bypass the server-side cache and re-query the DB, refreshing the\ncache. Used right after a deploy so the new path appears immediately.\n", + "in": "query", + "schema": { + "type": "boolean" + } } ], "responses": { @@ -14982,6 +15662,198 @@ } } }, + "/w/{workspace}/shared_ui/get": { + "get": { + "summary": "get the workspace shared UI folder (full content)", + "operationId": "getSharedUi", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + } + ], + "responses": { + "200": { + "description": "shared UI content", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "files", + "version", + "edited_at", + "edited_by" + ], + "properties": { + "files": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "version": { + "type": "integer", + "format": "int64" + }, + "edited_at": { + "type": "string", + "format": "date-time" + }, + "edited_by": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/w/{workspace}/shared_ui/list": { + "get": { + "summary": "list paths/sizes of the workspace shared UI folder", + "operationId": "listSharedUi", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + } + ], + "responses": { + "200": { + "description": "shared UI listing", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "paths", + "sizes", + "version", + "edited_at", + "edited_by" + ], + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "sizes": { + "type": "object", + "additionalProperties": { + "type": "integer", + "format": "int64" + } + }, + "version": { + "type": "integer", + "format": "int64" + }, + "edited_at": { + "type": "string", + "format": "date-time" + }, + "edited_by": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/w/{workspace}/shared_ui/version": { + "get": { + "summary": "get the current version of the workspace shared UI folder", + "operationId": "getSharedUiVersion", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + } + ], + "responses": { + "200": { + "description": "shared UI version", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "version" + ], + "properties": { + "version": { + "type": "integer", + "format": "int64" + } + } + } + } + } + } + } + } + }, + "/w/{workspace}/shared_ui": { + "put": { + "summary": "replace the workspace shared UI folder (admin only)", + "operationId": "updateSharedUi", + "tags": [ + "workspace" + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceId" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "files" + ], + "properties": { + "files": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "updated", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/w/{workspace}/apps/get_data/v/{secretWithExtension}": { "get": { "summary": "get raw app data by", @@ -15205,6 +16077,10 @@ "items": { "type": "string" } + }, + "skip_draft_deletion": { + "type": "boolean", + "description": "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." } }, "required": [ @@ -15282,6 +16158,10 @@ "items": { "type": "string" } + }, + "skip_draft_deletion": { + "type": "boolean", + "description": "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." } }, "required": [ @@ -15815,6 +16695,10 @@ "items": { "type": "string" } + }, + "skip_draft_deletion": { + "type": "boolean", + "description": "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." } } } @@ -15886,6 +16770,10 @@ "items": { "type": "string" } + }, + "skip_draft_deletion": { + "type": "boolean", + "description": "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." } } }, @@ -16044,6 +16932,9 @@ }, "cache_ttl": { "type": "integer" + }, + "tag": { + "type": "string" } }, "required": [ @@ -16066,9 +16957,26 @@ "type": "string" } }, + "force_viewer_sensitive_inputs": { + "type": "array", + "items": { + "type": "string" + } + }, + "force_viewer_delete_after_secs": { + "type": "integer" + }, "run_query_params": { "type": "object", "description": "Runnable query parameters" + }, + "temp_script_refs": { + "type": "object", + "nullable": true, + "description": "Map of relative-import script path -> temp storage hash. Only honored for inline-script (raw_code) execution so app dev resolves those imports from not-yet-deployed local content.", + "additionalProperties": { + "type": "string" + } } }, "required": [ @@ -16572,15 +17480,35 @@ "properties": { "step_id": { "type": "string", - "description": "step id to restart the flow from" + "description": "top-level step id to restart the flow from (or the outermost container when restarting at a nested step)" }, "branch_or_iteration_n": { "type": "integer", - "description": "for branchall or loop, the iteration at which the flow should restart (optional)" + "description": "for branchall or loop at the top level, the iteration at which the flow should restart (optional)" }, "flow_version": { "type": "integer", "description": "specific flow version to use for restart (optional, uses current version if not specified)" + }, + "nested_path": { + "type": "array", + "description": "path of additional steps to descend into AFTER `step_id`. Each entry represents one level of nesting inside the spawned child of the previous level's container (BranchOne / sequential ForLoop iteration / Subflow). When non-empty, the actual restart point is the LAST entry's step_id.", + "items": { + "type": "object", + "required": [ + "step_id" + ], + "properties": { + "step_id": { + "type": "string", + "description": "step id at this nesting level" + }, + "branch_or_iteration_n": { + "type": "integer", + "description": "for ForLoop containers, the iteration to restart at (0-based; iterations 0..n-1 are preserved)" + } + } + } } } } @@ -18433,6 +19361,14 @@ "type": "boolean" } }, + { + "name": "excludes_entrypoint_override", + "description": "exclude jobs that were started with a `_ENTRYPOINT_OVERRIDE` arg (e.g. dynamic-select helper runs and preprocessor previews)", + "in": "query", + "schema": { + "type": "boolean" + } + }, { "name": "broad_filter", "description": "broad search across multiple fields (case-insensitive substring match on path, tag, schedule path, trigger kind, label)", @@ -18564,6 +19500,14 @@ "schema": { "type": "boolean" } + }, + { + "name": "approval_token", + "in": "query", + "description": "Approval token granting read access to the job when not logged in. The token must be the one issued for this job's flow (i.e. the flow id used when generating the approval URL).", + "schema": { + "type": "string" + } } ], "responses": { @@ -20473,6 +21417,10 @@ "properties": { "enabled": { "type": "boolean" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a schedule in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -21209,6 +22157,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -21488,6 +22440,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -21819,6 +22775,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -22216,6 +23176,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -22540,6 +23504,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -23789,6 +24757,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -24113,6 +25085,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -24539,6 +25515,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -25459,6 +26439,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -25833,6 +26817,10 @@ "properties": { "mode": { "$ref": "#/components/schemas/TriggerMode" + }, + "force": { + "type": "boolean", + "description": "Bypass the parent-state conflict warning when enabling a trigger in a fork whose parent has the same path enabled.\n" } }, "required": [ @@ -27246,6 +28234,52 @@ } } }, + "/workers/workspace_fairness_events": { + "get": { + "summary": "list last 100 workspace-fairness cap/uncap events (cloud-only)", + "operationId": "getWorkspaceFairnessEvents", + "tags": [ + "worker" + ], + "responses": { + "200": { + "description": "workspace fairness events (empty on non-cloud)", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "timestamp": { + "type": "string", + "format": "date-time" + }, + "operation": { + "type": "string" + }, + "workspace_id": { + "type": "string", + "nullable": true + }, + "parameters": { + "type": "object", + "nullable": true, + "additionalProperties": true + } + }, + "required": [ + "timestamp", + "operation" + ] + } + } + } + } + } + } + } + }, "/configs/list_worker_groups": { "get": { "summary": "list worker groups", @@ -29364,6 +30398,15 @@ "schema": { "type": "string" } + }, + { + "name": "marker_file", + "description": "If provided, the folder is only considered to exist when this exact\nsentinel file is present under file_key. Lets callers distinguish a\nfully populated folder from a partial upload.\n", + "in": "query", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -30850,6 +31893,14 @@ "is_alive": { "type": "boolean" }, + "state": { + "type": "string", + "enum": [ + "running", + "stale", + "never_started" + ] + }, "last_locked_at": { "type": "string", "format": "date-time", @@ -30880,6 +31931,14 @@ "is_alive": { "type": "boolean" }, + "state": { + "type": "string", + "enum": [ + "running", + "stale", + "never_started" + ] + }, "last_locked_at": { "type": "string", "format": "date-time", @@ -32141,6 +33200,10 @@ "type": "boolean", "description": "If true, all steps run on the same worker for better performance" }, + "preserve_step_tags": { + "type": "boolean", + "description": "If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag." + }, "concurrent_limit": { "type": "number", "description": "Maximum number of concurrent executions of this flow" @@ -33082,6 +34145,14 @@ } ], "description": "Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n" + }, + "max_iterations": { + "allOf": [ + { + "$ref": "#/components/schemas/schemas-InputTransform" + } + ], + "description": "Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n" } }, "required": [ @@ -33103,6 +34174,11 @@ "aiagent" ] }, + "omit_output_from_conversation": { + "type": "boolean", + "default": false, + "description": "If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled." + }, "parallel": { "type": "boolean", "description": "If true, the agent can execute multiple tool calls in parallel" @@ -33921,10 +34997,18 @@ "type": "string", "description": "KV v2 secrets engine mount path (e.g., windmill)" }, + "kv_secret_path_prefix": { + "type": "string", + "description": "Optional path prefix inserted between the KV data/metadata segment and the workspace id (e.g., \"apps/windmill\"). When set, secrets are stored at `/data///`, allowing a Vault policy scoped to exactly `/data//*`." + }, "jwt_role": { "type": "string", "description": "Vault JWT auth role name for Windmill (optional, if not provided token auth is used)" }, + "jwt_mount_path": { + "type": "string", + "description": "Mount path for the JWT auth method in Vault (optional, defaults to \"jwt\"). Set this when the JWT auth method is mounted at a non-default path, e.g. via `vault auth enable -path= jwt`." + }, "namespace": { "type": "string", "description": "Vault Enterprise namespace (optional)" @@ -34120,7 +35204,8 @@ "conversation_id", "message_type", "content", - "created_at" + "created_at", + "created_seq" ], "properties": { "id": { @@ -34158,6 +35243,11 @@ "format": "date-time", "description": "When the message was created" }, + "created_seq": { + "type": "integer", + "format": "int64", + "description": "Monotonic cursor assigned when the message is inserted" + }, "step_name": { "type": "string", "description": "The step name that produced that message" @@ -34296,6 +35386,9 @@ "default_model": { "$ref": "#/components/schemas/AIProviderModel" }, + "metadata_model": { + "$ref": "#/components/schemas/AIProviderModel" + }, "code_completion_model": { "$ref": "#/components/schemas/AIProviderModel" }, @@ -34345,6 +35438,9 @@ "default_model": { "$ref": "#/components/schemas/AIProviderModel" }, + "metadata_model": { + "$ref": "#/components/schemas/AIProviderModel" + }, "code_completion_model": { "$ref": "#/components/schemas/AIProviderModel" } @@ -34821,6 +35917,10 @@ "items": { "type": "string" } + }, + "skip_draft_deletion": { + "type": "boolean", + "description": "When true (set by the CLI / git sync), deploying this script does not delete an existing user draft at the same path." } }, "required": [ @@ -34841,6 +35941,11 @@ "draft": { "$ref": "#/components/schemas/NewScript" }, + "draft_created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check." + }, "hash": { "type": "string" } @@ -36128,12 +37233,19 @@ }, "email": { "type": "string" + }, + "workspace_id": { + "type": "string" + }, + "read_only": { + "type": "boolean" } }, "required": [ "token_prefix", "created_at", - "last_used_at" + "last_used_at", + "read_only" ] }, "ExternalJwtToken": { @@ -36199,6 +37311,10 @@ }, "workspace_id": { "type": "string" + }, + "read_only": { + "type": "boolean", + "description": "If true, the token is restricted to read-only HTTP methods\n(GET/HEAD/OPTIONS). Mutating endpoints and job-run actions are\nrejected with 403, regardless of the scopes attached.\n" } } }, @@ -36274,6 +37390,16 @@ "items": { "type": "string" } + }, + "ws_specific": { + "type": "boolean" + }, + "edited_at": { + "type": "string", + "format": "date-time" + }, + "edited_by": { + "type": "string" } }, "required": [ @@ -36343,6 +37469,9 @@ "items": { "type": "string" } + }, + "ws_specific": { + "type": "boolean" } }, "required": [ @@ -36376,6 +37505,9 @@ "items": { "type": "string" } + }, + "ws_specific": { + "type": "boolean" } } }, @@ -36808,6 +37940,14 @@ "additionalProperties": { "$ref": "#/components/schemas/ScriptModule" } + }, + "temp_script_refs": { + "type": "object", + "nullable": true, + "description": "Map of relative-import script path -> temp storage hash so the preview job resolves those imports from not-yet-deployed local content instead of the deployed script", + "additionalProperties": { + "type": "string" + } } }, "required": [ @@ -36899,6 +38039,9 @@ "items": { "type": "string" } + }, + "ws_specific": { + "type": "boolean" } }, "required": [ @@ -36928,6 +38071,9 @@ "items": { "type": "string" } + }, + "ws_specific": { + "type": "boolean" } } }, @@ -36968,6 +38114,9 @@ "items": { "type": "string" } + }, + "ws_specific": { + "type": "boolean" } }, "required": [ @@ -37028,6 +38177,9 @@ "items": { "type": "string" } + }, + "ws_specific": { + "type": "boolean" } }, "required": [ @@ -37086,7 +38238,7 @@ "properties": { "path": { "type": "string", - "description": "The unique path identifier for this schedule" + "description": "The unique Windmill path for this schedule. Must be of the form `u//` or `f//`." }, "edited_by": { "type": "string", @@ -37296,7 +38448,7 @@ "properties": { "path": { "type": "string", - "description": "The unique path identifier for this schedule" + "description": "The unique Windmill path for this schedule. Must be of the form `u//` or `f//`." }, "schedule": { "type": "string", @@ -37589,7 +38741,7 @@ "properties": { "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -37921,7 +39073,7 @@ "properties": { "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -38049,7 +39201,7 @@ "properties": { "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -38354,7 +39506,7 @@ "properties": { "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -38469,7 +39621,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -38768,7 +39920,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -38852,7 +40004,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -39032,7 +40184,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger." + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -39261,7 +40413,8 @@ } }, "path": { - "type": "string" + "type": "string", + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string" @@ -39469,6 +40622,13 @@ }, "tag": { "$ref": "#/components/schemas/CustomInstanceDbTag" + }, + "used_by_workspaces": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workspaces that reference this database via a ducklake catalog or datatable database with resource_type 'instance'. Computed at request time, not persisted." } } }, @@ -39497,7 +40657,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -39571,7 +40731,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -39791,7 +40951,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -39860,7 +41020,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -40018,7 +41178,7 @@ "properties": { "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -40181,7 +41341,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -40298,7 +41458,7 @@ "properties": { "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -40402,7 +41562,7 @@ }, "path": { "type": "string", - "description": "The unique path identifier for this trigger" + "description": "The unique Windmill path for this trigger. Must be of the form `u//` or `f//`. This is the trigger object path, not the HTTP route path." }, "script_path": { "type": "string", @@ -41070,7 +42230,8 @@ "type": "string", "enum": [ "password", - "github" + "github", + "service_account" ] }, "super_admin": { @@ -41094,6 +42255,10 @@ "operator_only": { "type": "boolean" }, + "is_workspace_admin": { + "type": "boolean", + "description": "Populated only for service accounts. True if the service account has workspace admin in its (single) workspace." + }, "first_time_user": { "type": "boolean" }, @@ -41101,11 +42266,15 @@ "type": "string", "enum": [ "manual", - "instance_group" + "instance_group", + "service_account" ] }, "disabled": { "type": "boolean" + }, + "workspace_id": { + "type": "string" } }, "required": [ @@ -41276,6 +42445,14 @@ }, "restarted_from": { "$ref": "#/components/schemas/RestartedFrom" + }, + "temp_script_refs": { + "type": "object", + "nullable": true, + "description": "Map of relative-import script path -> temp storage hash, propagated to each flow step so inline-script relative imports resolve from not-yet-deployed local content instead of the deployed script", + "additionalProperties": { + "type": "string" + } } }, "required": [ @@ -41295,10 +42472,31 @@ "type": "string" }, "branch_or_iteration_n": { - "type": "integer" + "type": "integer", + "description": "0-based iteration index for ForLoop / branch index for BranchAll. Iterations 0..n-1 are preserved; iteration n is restarted." }, "flow_version": { "type": "integer" + }, + "branch_chosen": { + "description": "For BranchOne nested restart — the branch that was originally chosen, used to lock branch evaluation.", + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "default", + "branch" + ] + }, + "branch": { + "type": "integer" + } + } + }, + "nested": { + "$ref": "#/components/schemas/RestartedFrom", + "description": "When set, the worker spawns the child for `step_id` as a `RestartedFlow` against `nested.flow_job_id` instead of fresh-launching it." } } }, @@ -41593,7 +42791,12 @@ "draft_only": { "type": "boolean" }, - "draft": {} + "draft": {}, + "draft_created_at": { + "type": "string", + "format": "date-time", + "description": "Timestamp at which the most recent DB draft was created. Used by the frontend's UserDraft staleness check." + } } } ] @@ -41901,6 +43104,59 @@ } } }, + "DataTableTables": { + "type": "object", + "required": [ + "datatable_name", + "schemas" + ], + "properties": { + "datatable_name": { + "type": "string" + }, + "schemas": { + "type": "object", + "description": "Hierarchical metadata: schema_name -> table_names", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "error": { + "type": "string" + } + } + }, + "DataTableTableSchema": { + "type": "object", + "required": [ + "datatable_name", + "schema_name", + "table_name", + "columns" + ], + "properties": { + "datatable_name": { + "type": "string" + }, + "schema_name": { + "type": "string" + }, + "table_name": { + "type": "string" + }, + "columns": { + "type": "object", + "description": "Columns in this table: column_name -> compact_type", + "additionalProperties": { + "type": "string", + "description": "Compact type: 'type[?][=default]' where ? means nullable" + } + } + } + }, "DynamicInputData": { "type": "object", "properties": { @@ -42667,7 +43923,19 @@ "raw_app", "resource", "variable", - "resource_type" + "resource_type", + "folder", + "schedule", + "http_trigger", + "websocket_trigger", + "kafka_trigger", + "nats_trigger", + "postgres_trigger", + "mqtt_trigger", + "sqs_trigger", + "gcp_trigger", + "azure_trigger", + "email_trigger" ], "description": "Type of the item" }, @@ -42710,6 +43978,8 @@ "variables_changed", "resource_types_changed", "folders_changed", + "schedules_changed", + "triggers_changed", "conflicts" ], "properties": { @@ -42753,6 +44023,14 @@ "type": "integer", "description": "Number of folders with differences" }, + "schedules_changed": { + "type": "integer", + "description": "Number of schedules with differences" + }, + "triggers_changed": { + "type": "integer", + "description": "Number of triggers with differences (sum across all trigger kinds)" + }, "conflicts": { "type": "integer", "description": "Number of items that are both ahead and behind (conflicts)" @@ -42860,6 +44138,15 @@ "error": { "type": "string", "description": "Error message if token retrieval failed" + }, + "github_base_url": { + "type": "string", + "nullable": true, + "description": "Set for self-managed (GHES) installs. Cloud installs omit this field." + }, + "provisioned_by_admin": { + "type": "boolean", + "description": "True when the installation was assigned by the instance super-admin from instance settings. Workspace admins cannot remove these." } }, "required": [ @@ -44714,6 +46001,14 @@ } ], "description": "Float. Controls randomness/creativity of responses.\nRange: 0.0 to 2.0 (provider-dependent)\n- 0.0 = deterministic, focused responses\n- 0.7 = balanced (common default)\n- 1.0+ = more creative/random\n" + }, + "max_iterations": { + "allOf": [ + { + "$ref": "#/components/schemas/schemas-InputTransform" + } + ], + "description": "Number. Limits how many times the agent can loop through reasoning and tool use.\nRange: 1-1000.\n" } }, "required": [ @@ -44735,6 +46030,11 @@ "aiagent" ] }, + "omit_output_from_conversation": { + "type": "boolean", + "default": false, + "description": "If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled." + }, "parallel": { "type": "boolean", "description": "If true, the agent can execute multiple tool calls in parallel" @@ -44967,6 +46267,10 @@ "type": "boolean", "description": "If true, all steps run on the same worker for better performance" }, + "preserve_step_tags": { + "type": "boolean", + "description": "If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag." + }, "concurrent_limit": { "type": "number", "description": "Maximum number of concurrent executions of this flow" diff --git a/backend/windmill-api/openapi-deref.yaml b/backend/windmill-api/openapi-deref.yaml index 367b570b29..4ced5ef265 100644 --- a/backend/windmill-api/openapi-deref.yaml +++ b/backend/windmill-api/openapi-deref.yaml @@ -1,6 +1,6 @@ openapi: 3.0.3 info: - version: 1.689.0 + version: 1.713.1 title: Windmill API contact: name: Windmill Team @@ -146,18 +146,18 @@ paths: checks: type: object description: Detailed health checks - required: &ref_346 + required: &ref_347 - database - readiness - properties: &ref_347 + properties: &ref_348 database: type: object description: Database health status - required: &ref_348 + required: &ref_349 - healthy - latency_ms - pool - properties: &ref_349 + properties: &ref_350 healthy: type: boolean description: Whether the database is reachable @@ -168,11 +168,11 @@ paths: pool: type: object description: Database connection pool statistics - required: &ref_350 + required: &ref_351 - size - idle - max_connections - properties: &ref_351 + properties: &ref_352 size: type: integer description: Current number of connections in the pool @@ -186,13 +186,13 @@ paths: description: Workers health status nullable: true type: object - required: &ref_352 + required: &ref_353 - healthy - active_count - worker_groups - min_version - versions - properties: &ref_353 + properties: &ref_354 healthy: type: boolean description: Whether any workers are active @@ -219,10 +219,10 @@ paths: description: Job queue status nullable: true type: object - required: &ref_354 + required: &ref_355 - pending_jobs - running_jobs - properties: &ref_355 + properties: &ref_356 pending_jobs: type: integer format: int64 @@ -234,9 +234,9 @@ paths: readiness: type: object description: Server readiness status - required: &ref_356 + required: &ref_357 - healthy - properties: &ref_357 + properties: &ref_358 healthy: type: boolean description: Whether the server is ready to accept requests @@ -488,24 +488,24 @@ paths: - name: before description: filter on started before (inclusive) timestamp in: query - schema: &ref_298 + schema: &ref_299 type: string format: date-time - name: after description: filter on created after (exclusive) timestamp in: query - schema: &ref_299 + schema: &ref_300 type: string format: date-time - name: username description: filter on exact username of user in: query - schema: &ref_307 + schema: &ref_308 type: string - name: operation description: filter on exact or prefix name of operation in: query - schema: &ref_308 + schema: &ref_309 type: string - name: operations in: query @@ -520,12 +520,12 @@ paths: - name: resource description: filter on exact or prefix name of resource in: query - schema: &ref_309 + schema: &ref_310 type: string - name: action_kind description: filter on type of operation in: query - schema: &ref_310 + schema: &ref_311 type: string enum: - Create @@ -562,12 +562,12 @@ paths: application/json: schema: type: object - properties: &ref_396 + properties: &ref_397 email: type: string password: type: string - required: &ref_397 + required: &ref_398 - email - password responses: @@ -757,7 +757,7 @@ paths: nullable: true allOf: - type: object - properties: &ref_393 + properties: &ref_394 source: type: string enum: @@ -775,7 +775,7 @@ paths: description: >- The instance group name (when source is 'instance_group') - required: &ref_394 + required: &ref_395 - source is_service_account: type: boolean @@ -813,7 +813,7 @@ paths: application/json: schema: type: object - properties: &ref_398 + properties: &ref_399 is_admin: type: boolean operator: @@ -1187,7 +1187,7 @@ paths: type: array items: type: object - properties: &ref_412 + properties: &ref_413 jwt_hash: type: integer format: int64 @@ -1210,7 +1210,7 @@ paths: last_used_at: type: string format: date-time - required: &ref_413 + required: &ref_414 - jwt_hash - email - username @@ -1342,7 +1342,7 @@ paths: type: array items: type: object - properties: &ref_399 + properties: &ref_400 label: type: string scopes: @@ -1351,7 +1351,7 @@ paths: type: string expiration: type: string - required: &ref_400 + required: &ref_401 - label - scopes description: Tokens owned by this user (will be deleted) @@ -1395,7 +1395,7 @@ paths: application/json: schema: type: object - properties: &ref_401 + properties: &ref_402 reassign_to: type: string description: 'Target for reassignment: ''u/{username}'' or ''f/{folder}''' @@ -1409,7 +1409,7 @@ paths: type: boolean default: true description: Whether to also remove the user from the workspace - required: &ref_402 + required: &ref_403 - reassign_to responses: '200': @@ -1428,7 +1428,7 @@ paths: on success. summary: type: object - properties: &ref_403 + properties: &ref_404 scripts_reassigned: type: integer flows_reassigned: @@ -1445,7 +1445,7 @@ paths: type: integer drafts_deleted: type: integer - required: &ref_404 + required: &ref_405 - scripts_reassigned - flows_reassigned - apps_reassigned @@ -1475,12 +1475,12 @@ paths: application/json: schema: type: object - properties: &ref_405 + properties: &ref_406 workspaces: type: array items: type: object - properties: &ref_407 + properties: &ref_408 workspace_id: type: string username: @@ -1489,11 +1489,11 @@ paths: type: object properties: *ref_12 required: *ref_13 - required: &ref_408 + required: &ref_409 - workspace_id - username - preview - required: &ref_406 + required: &ref_407 - workspaces /users/offboard/{email}: post: @@ -1515,12 +1515,12 @@ paths: application/json: schema: type: object - properties: &ref_409 + properties: &ref_410 reassignments: type: object additionalProperties: type: object - properties: &ref_410 + properties: &ref_411 reassign_to: type: string description: 'Target: ''u/{username}'' or ''f/{folder}''' @@ -1529,7 +1529,7 @@ paths: description: >- Required when reassign_to is a folder. Username to use as permissioned_as. - required: &ref_411 + required: &ref_412 - reassign_to description: Map of workspace_id to reassignment config delete_user: @@ -1589,7 +1589,7 @@ paths: application/json: schema: type: array - items: &ref_558 + items: &ref_562 type: object properties: workspace_id: @@ -1621,6 +1621,18 @@ paths: error: type: string description: Error message if token retrieval failed + github_base_url: + type: string + nullable: true + description: >- + Set for self-managed (GHES) installs. Cloud installs + omit this field. + provisioned_by_admin: + type: boolean + description: >- + True when the installation was assigned by the instance + super-admin from instance settings. Workspace admins + cannot remove these. required: - installation_id - account_id @@ -1687,7 +1699,7 @@ paths: application/json: schema: type: object - properties: &ref_501 + properties: &ref_502 email: type: string workspaces: @@ -1762,7 +1774,7 @@ paths: - username - color - disabled - required: &ref_502 + required: &ref_503 - email - workspaces /w/{workspace}/workspaces/get_as_superadmin: @@ -1824,7 +1836,7 @@ paths: application/json: schema: type: object - properties: &ref_503 + properties: &ref_504 id: type: string name: @@ -1833,7 +1845,7 @@ paths: type: string color: type: string - required: &ref_504 + required: &ref_505 - id - name responses: @@ -2009,7 +2021,7 @@ paths: properties: &ref_24 logs: type: object - properties: &ref_469 + properties: &ref_470 super_admin: type: string enum: &ref_21 @@ -2045,6 +2057,14 @@ paths: enum: &ref_22 - ducklake - datatable + used_by_workspaces: + type: array + items: + type: string + description: >- + Workspaces that reference this database via a ducklake + catalog or datatable database with resource_type + 'instance'. Computed at request time, not persisted. /settings/setup_custom_instance_pg_database/{name}: post: summary: >- @@ -2552,6 +2572,52 @@ paths: - orphans_scanned - orphans_deleted - errors + /settings/audit_logs_s3_status: + get: + summary: get status of the audit-log object-store export cursor + operationId: getAuditLogsS3Status + tags: + - setting + responses: + '200': + description: current export status (null if the feature was never enabled) + content: + application/json: + schema: + nullable: true + type: object + properties: + last_xmin: + type: integer + format: int64 + last_ts: + type: string + format: date-time + nullable: true + bootstrapping: + type: boolean + last_exported_audit_ts: + type: string + format: date-time + nullable: true + last_run_at: + type: string + format: date-time + nullable: true + last_run_exported: + type: integer + format: int64 + updated_at: + type: string + format: date-time + owner: + type: string + nullable: true + required: + - last_xmin + - bootstrapping + - last_run_exported + - updated_at /settings/send_stats: post: summary: send stats @@ -2649,6 +2715,65 @@ paths: text/plain: schema: type: string + /settings/offline_license_status: + get: + summary: get cap-usage status for the currently-loaded offline license + description: | + Returns the live cap status (seats used vs cap, current CU vs cap) for + the offline license key currently in use. Returns `null` if no offline + license is loaded. Super-admin only. + operationId: getOfflineLicenseStatus + tags: + - setting + responses: + '200': + description: cap status (or null when no offline license) + content: + application/json: + schema: + type: object + nullable: true + properties: + seats_used: + type: number + description: >- + Author-equivalent seats consumed (authors + 0.5 × + operators) + seats_cap: + type: integer + author_count: + type: integer + operator_count: + type: integer + current_cu: + type: number + description: >- + Sum of CU rate across workers that pinged in the last 2 + minutes. + cu_cap: + type: number + cu_over_cap: + type: boolean + /settings/instance_hash: + get: + summary: per-instance binding hash for offline license issuance + description: | + Returns the hash a superadmin shares with Windmill support when + requesting an offline license. Super-admin only. + operationId: getInstanceHash + tags: + - setting + responses: + '200': + description: instance hash + content: + application/json: + schema: + type: object + properties: + instance_hash: + type: string + nullable: true /settings/customer_portal: post: summary: create customer portal session @@ -2703,11 +2828,11 @@ paths: type: array items: type: object - properties: &ref_541 + properties: &ref_545 name: type: string value: {} - required: &ref_542 + required: &ref_546 - name - value /settings/instance_config: @@ -2805,9 +2930,9 @@ paths: application/json: schema: type: object - required: &ref_369 + required: &ref_370 - keys - properties: &ref_370 + properties: &ref_371 keys: type: array items: @@ -2839,11 +2964,26 @@ paths: mount_path: type: string description: KV v2 secrets engine mount path (e.g., windmill) + kv_secret_path_prefix: + type: string + description: >- + Optional path prefix inserted between the KV data/metadata + segment and the workspace id (e.g., "apps/windmill"). When + set, secrets are stored at + `/data///`, allowing a + Vault policy scoped to exactly `/data//*`. jwt_role: type: string description: >- Vault JWT auth role name for Windmill (optional, if not provided token auth is used) + jwt_mount_path: + type: string + description: >- + Mount path for the JWT auth method in Vault (optional, + defaults to "jwt"). Set this when the JWT auth method is + mounted at a non-default path, e.g. via `vault auth enable + -path= jwt`. namespace: type: string description: Vault Enterprise namespace (optional) @@ -2909,11 +3049,11 @@ paths: type: array items: type: object - required: &ref_367 + required: &ref_368 - workspace_id - path - error - properties: &ref_368 + properties: &ref_369 workspace_id: type: string description: Workspace ID where the secret is located @@ -2979,10 +3119,10 @@ paths: type: string description: >- Azure AD client secret. Optional — when omitted, the - integration falls back to Azure Workload Identity Federation, - exchanging the Kubernetes-projected service-account JWT at - AZURE_FEDERATED_TOKEN_FILE for an access token (no long-lived - secret stored). + integration falls back to Azure Workload Identity + Federation, exchanging the Kubernetes-projected + service-account JWT at AZURE_FEDERATED_TOKEN_FILE for an + access token (no long-lived secret stored). token: type: string description: >- @@ -3281,6 +3421,7 @@ paths: enum: - password - github + - service_account super_admin: type: boolean devops: @@ -3295,6 +3436,11 @@ paths: type: string operator_only: type: boolean + is_workspace_admin: + type: boolean + description: >- + Populated only for service accounts. True if the service + account has workspace admin in its (single) workspace. first_time_user: type: boolean role_source: @@ -3302,8 +3448,11 @@ paths: enum: - manual - instance_group + - service_account disabled: type: boolean + workspace_id: + type: string required: &ref_40 - email - login_type @@ -3572,6 +3721,101 @@ paths: - base_url - app_slug - client_id + /github_app/ghes/discover: + get: + summary: Discover GHES App installations + description: | + Lists every installation the configured self-managed GitHub App can see, + annotated with the workspaces in this Windmill instance the + installation is currently assigned to. Super-admin only. + operationId: discoverGhesInstallations + tags: + - Git Sync + responses: + '200': + description: Discovered installations + content: + application/json: + schema: + type: array + items: + type: object + required: + - installation_id + - account_id + - assigned_workspaces + properties: + installation_id: + type: integer + format: int64 + account_id: + type: string + description: GitHub login of the installation's account (org or user) + assigned_workspaces: + type: array + items: + type: object + required: + - workspace_id + - provisioned_by_admin + properties: + workspace_id: + type: string + provisioned_by_admin: + type: boolean + /github_app/ghes/assign: + post: + summary: Assign GHES installation to a workspace + description: | + Assigns a discovered GHES App installation to a workspace. The resulting + installation is marked as admin-provisioned, so workspace admins cannot + remove it. Super-admin only. + operationId: assignGhesInstallation + tags: + - Git Sync + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - workspace_id + - installation_id + properties: + workspace_id: + type: string + installation_id: + type: integer + format: int64 + responses: + '200': + description: Installation assigned + /github_app/ghes/assign/{workspace_id}/{installation_id}: + delete: + summary: Unassign GHES installation from a workspace + description: | + Removes an installation (admin-provisioned or otherwise) from a + workspace. Super-admin only. Does not affect the installation on the + GitHub side. + operationId: unassignGhesInstallation + tags: + - Git Sync + parameters: + - name: workspace_id + in: path + required: true + schema: + type: string + - name: installation_id + in: path + required: true + schema: + type: integer + format: int64 + responses: + '200': + description: Installation unassigned /users/accept_invite: post: summary: accept invite to workspace @@ -3721,6 +3965,24 @@ paths: properties: username: type: string + is_admin: + type: boolean + description: >- + Grant the service account workspace admin. Defaults to + false. Cannot be combined with operator=true. + operator: + type: boolean + description: >- + Make the service account an operator. Defaults to true for + backward compatibility. Set to false to count as a developer + (1 seat) instead of 0.5 seat. + add_to_deployers: + type: boolean + description: >- + Add the service account to the workspace `wm_deployers` + group on creation. Recommended when the account will be used + as a CLI sync / CI deploy identity so it can deploy on + behalf of other users. required: - username responses: @@ -4086,13 +4348,13 @@ paths: application/json: schema: type: object - required: &ref_550 + required: &ref_554 - all_ahead_items_visible - all_behind_items_visible - skipped_comparison - diffs - summary - properties: &ref_551 + properties: &ref_555 all_ahead_items_visible: type: boolean description: >- @@ -4113,7 +4375,7 @@ paths: description: List of differences found between workspaces items: type: object - required: &ref_552 + required: &ref_556 - kind - path - ahead @@ -4121,7 +4383,7 @@ paths: - has_changes - exists_in_source - exists_in_fork - properties: &ref_553 + properties: &ref_557 kind: type: string enum: @@ -4132,6 +4394,18 @@ paths: - resource - variable - resource_type + - folder + - schedule + - http_trigger + - websocket_trigger + - kafka_trigger + - nats_trigger + - postgres_trigger + - mqtt_trigger + - sqs_trigger + - gcp_trigger + - azure_trigger + - email_trigger description: Type of the item path: type: string @@ -4154,7 +4428,7 @@ paths: summary: description: Summary statistics of the comparison type: object - required: &ref_554 + required: &ref_558 - total_diffs - total_ahead - total_behind @@ -4165,8 +4439,10 @@ paths: - variables_changed - resource_types_changed - folders_changed + - schedules_changed + - triggers_changed - conflicts - properties: &ref_555 + properties: &ref_559 total_diffs: type: integer description: Total number of items with differences @@ -4197,6 +4473,14 @@ paths: folders_changed: type: integer description: Number of folders with differences + schedules_changed: + type: integer + description: Number of schedules with differences + triggers_changed: + type: integer + description: >- + Number of triggers with differences (sum across all + trigger kinds) conflicts: type: integer description: >- @@ -4298,9 +4582,162 @@ paths: type: object properties: *ref_41 required: *ref_42 + /w/{workspace}/workspaces/get_public_settings: + get: + summary: get public settings + description: >- + Returns the subset of workspace settings safe to expose to any workspace + member. The full settings struct is admin-only via `getSettings`. + operationId: getPublicSettings + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + responses: + '200': + description: status + content: + application/json: + schema: + type: object + properties: + workspace_id: + type: string + slack_name: + type: string + slack_team_id: + type: string + teams_team_id: + type: string + teams_team_name: + type: string + teams_team_guid: + type: string + large_file_storage: + type: object + properties: &ref_45 + type: + type: string + enum: + - S3Storage + - AzureBlobStorage + - AzureWorkloadIdentity + - S3AwsOidc + - GoogleCloudStorage + s3_resource_path: + type: string + azure_blob_resource_path: + type: string + gcs_resource_path: + type: string + public_resource: + type: boolean + advanced_permissions: + type: array + items: + type: object + properties: &ref_531 + pattern: + type: string + allow: + type: string + required: &ref_532 + - pattern + - allow + secondary_storage: + type: object + additionalProperties: + type: object + properties: + type: + type: string + enum: + - S3Storage + - AzureBlobStorage + - AzureWorkloadIdentity + - S3AwsOidc + - GoogleCloudStorage + s3_resource_path: + type: string + azure_blob_resource_path: + type: string + gcs_resource_path: + type: string + public_resource: + type: boolean + datatable: + type: object + required: &ref_46 + - datatables + properties: &ref_47 + datatables: + type: object + additionalProperties: + type: object + required: + - database + properties: + database: + type: object + properties: + resource_type: + type: string + enum: + - postgresql + - instance + resource_path: + type: string + required: + - resource_type + forked_from: + type: object + description: Fork origin info with schema snapshot + properties: + schema: + type: object + description: Schema snapshot at fork time + additionalProperties: true + deploy_ui: + type: object + properties: &ref_49 + include_path: + type: array + items: + type: string + include_type: + type: array + items: + type: string + enum: &ref_48 + - script + - flow + - app + - folder + - resource + - variable + - secret + - resourcetype + - schedule + - user + - group + - trigger + - settings + - key + - workspacedependencies + mute_critical_alerts: + type: boolean + required: + - workspace_id /w/{workspace}/workspaces/get_settings: get: - summary: get settings + summary: get settings (admin only) + description: >- + Returns the full workspace settings including admin-managed integration + credentials. Admin-only — non-admin callers should use + `getPublicSettings`. operationId: getSettings tags: - workspace @@ -4340,7 +4777,7 @@ paths: auto_invite: type: object description: Configuration for auto-inviting users to the workspace - properties: &ref_358 + properties: &ref_359 enabled: type: boolean default: false @@ -4376,19 +4813,19 @@ paths: type: string ai_config: type: object - properties: &ref_46 + properties: &ref_50 providers: type: object additionalProperties: type: object - properties: &ref_377 + properties: &ref_378 resource_path: type: string models: type: array items: type: string - required: &ref_378 + required: &ref_379 - resource_path - models default_model: @@ -4398,7 +4835,7 @@ paths: type: string provider: type: string - enum: &ref_47 + enum: &ref_51 - openai - azure_openai - anthropic @@ -4413,6 +4850,10 @@ paths: required: &ref_44 - model - provider + metadata_model: + type: object + properties: *ref_43 + required: *ref_44 code_completion_model: type: object properties: *ref_43 @@ -4430,7 +4871,7 @@ paths: error_handler: type: object description: Configuration for the workspace error handler - properties: &ref_359 + properties: &ref_360 path: type: string description: Path to the error handler script or flow @@ -4447,7 +4888,7 @@ paths: success_handler: type: object description: Configuration for the workspace success handler - properties: &ref_360 + properties: &ref_361 path: type: string description: Path to the success handler script or flow @@ -4457,61 +4898,12 @@ paths: additionalProperties: true large_file_storage: type: object - properties: &ref_50 - type: - type: string - enum: - - S3Storage - - AzureBlobStorage - - AzureWorkloadIdentity - - S3AwsOidc - - GoogleCloudStorage - s3_resource_path: - type: string - azure_blob_resource_path: - type: string - gcs_resource_path: - type: string - public_resource: - type: boolean - advanced_permissions: - type: array - items: - type: object - properties: &ref_527 - pattern: - type: string - allow: - type: string - required: &ref_528 - - pattern - - allow - secondary_storage: - type: object - additionalProperties: - type: object - properties: - type: - type: string - enum: - - S3Storage - - AzureBlobStorage - - AzureWorkloadIdentity - - S3AwsOidc - - GoogleCloudStorage - s3_resource_path: - type: string - azure_blob_resource_path: - type: string - gcs_resource_path: - type: string - public_resource: - type: boolean + properties: *ref_45 ducklake: type: object - required: &ref_51 + required: &ref_54 - ducklakes - properties: &ref_52 + properties: &ref_55 ducklakes: type: object additionalProperties: @@ -4546,44 +4938,16 @@ paths: type: string datatable: type: object - required: &ref_53 - - datatables - properties: &ref_54 - datatables: - type: object - additionalProperties: - type: object - required: - - database - properties: - database: - type: object - properties: - resource_type: - type: string - enum: - - postgresql - - instance - resource_path: - type: string - required: - - resource_type - forked_from: - type: object - description: Fork origin info with schema snapshot - properties: - schema: - type: object - description: Schema snapshot at fork time - additionalProperties: true + required: *ref_46 + properties: *ref_47 git_sync: type: object - properties: &ref_55 + properties: &ref_56 repositories: type: array items: type: object - properties: &ref_56 + properties: &ref_57 script_path: type: string git_repo_resource_path: @@ -4605,22 +4969,7 @@ paths: type: array items: type: string - enum: &ref_45 - - script - - flow - - app - - folder - - resource - - variable - - secret - - resourcetype - - schedule - - user - - group - - trigger - - settings - - key - - workspacedependencies + enum: *ref_48 exclude_path: type: array items: @@ -4633,21 +4982,12 @@ paths: type: array items: type: string - enum: *ref_45 - required: &ref_57 + enum: *ref_48 + required: &ref_58 - git_repo_resource_path deploy_ui: type: object - properties: &ref_58 - include_path: - type: array - items: - type: string - include_type: - type: array - items: - type: string - enum: *ref_45 + properties: *ref_49 default_app: type: string default_scripts: @@ -4852,7 +5192,7 @@ paths: type: array items: type: object - properties: &ref_506 + properties: &ref_507 importer_path: type: string importer_kind: @@ -4866,7 +5206,7 @@ paths: items: type: string nullable: true - required: &ref_507 + required: &ref_508 - importer_path - importer_kind /w/{workspace}/workspaces/get_imports/{importer_path}: @@ -4924,13 +5264,13 @@ paths: type: array items: type: object - properties: &ref_508 + properties: &ref_509 imported_path: type: string count: type: integer format: int64 - required: &ref_509 + required: &ref_510 - imported_path - count /w/{workspace}/workspaces/get_dependency_map: @@ -4953,7 +5293,7 @@ paths: type: array items: type: object - properties: &ref_505 + properties: &ref_506 workspace_id: type: string nullable: true @@ -5220,6 +5560,39 @@ paths: text/plain: schema: type: string + /w/{workspace}/workspaces/connect_slack: + post: + summary: connect slack (non-interactive; pre-minted bot token) + operationId: connectSlack + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + requestBody: + description: connect slack with a pre-minted bot token + required: true + content: + application/json: + schema: + type: object + required: + - bot_token + - team_id + - team_name + properties: + bot_token: + type: string + description: xoxb-... bot token obtained at api.slack.com/apps + team_id: + type: string + team_name: + type: string + responses: + '200': + description: status /w/{workspace}/workspaces/run_slack_message_test_job: post: summary: run a job that sends a message to Slack @@ -5429,7 +5802,7 @@ paths: application/json: schema: type: object - properties: *ref_46 + properties: *ref_50 responses: '200': description: status @@ -5440,38 +5813,42 @@ paths: properties: effective_ai_config: type: object - properties: *ref_46 + properties: *ref_50 has_instance_ai_config: type: boolean uses_instance_ai_config: type: boolean instance_ai_summary: type: object - properties: &ref_48 + properties: &ref_52 providers: type: array items: type: object - properties: &ref_379 + properties: &ref_380 provider: type: string - enum: *ref_47 + enum: *ref_51 models: type: array items: type: string - required: &ref_380 + required: &ref_381 - provider - models default_model: type: object properties: *ref_43 required: *ref_44 + metadata_model: + type: object + properties: *ref_43 + required: *ref_44 code_completion_model: type: object properties: *ref_43 required: *ref_44 - required: &ref_49 + required: &ref_53 - providers required: - effective_ai_config @@ -5502,8 +5879,8 @@ paths: type: boolean instance_ai_summary: type: object - properties: *ref_48 - required: *ref_49 + properties: *ref_52 + required: *ref_53 required: - has_instance_ai_config - uses_instance_ai_config @@ -5525,7 +5902,7 @@ paths: application/json: schema: type: object - properties: *ref_46 + properties: *ref_50 /w/{workspace}/workspaces/edit_error_handler: post: summary: edit error handler @@ -5547,10 +5924,10 @@ paths: Request body for editing the workspace error handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: &ref_361 + oneOf: &ref_362 - type: object description: New grouped format for editing error handler - properties: &ref_362 + properties: &ref_363 path: type: string description: Path to the error handler script or flow @@ -5568,7 +5945,7 @@ paths: description: >- Legacy flat format for editing error handler (deprecated, use new format) - properties: &ref_363 + properties: &ref_364 error_handler: type: string description: Path to the error handler script or flow @@ -5607,10 +5984,10 @@ paths: Request body for editing the workspace success handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: &ref_364 + oneOf: &ref_365 - type: object description: New grouped format for editing success handler - properties: &ref_365 + properties: &ref_366 path: type: string description: Path to the success handler script or flow @@ -5622,7 +5999,7 @@ paths: description: >- Legacy flat format for editing success handler (deprecated, use new format) - properties: &ref_366 + properties: &ref_367 success_handler: type: string description: Path to the success handler script or flow @@ -5658,7 +6035,7 @@ paths: properties: large_file_storage: type: object - properties: *ref_50 + properties: *ref_45 responses: '200': description: status @@ -5764,6 +6141,92 @@ paths: nullable error: type: string + /w/{workspace}/workspaces/list_datatable_tables: + get: + summary: list tables of all connected Datatables + operationId: listDataTableTables + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + responses: + '200': + description: table metadata of all datatables + content: + application/json: + schema: + type: array + items: + type: object + required: &ref_525 + - datatable_name + - schemas + properties: &ref_526 + datatable_name: + type: string + schemas: + type: object + description: 'Hierarchical metadata: schema_name -> table_names' + additionalProperties: + type: array + items: + type: string + error: + type: string + /w/{workspace}/workspaces/get_datatable_table_schema: + get: + summary: get one Datatable table schema + operationId: getDataTableTableSchema + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + - name: datatable_name + in: query + required: true + schema: + type: string + - name: schema_name + in: query + required: true + schema: + type: string + - name: table_name + in: query + required: true + schema: + type: string + responses: + '200': + description: schema of one datatable table + content: + application/json: + schema: + type: object + required: &ref_527 + - datatable_name + - schema_name + - table_name + - columns + properties: &ref_528 + datatable_name: + type: string + schema_name: + type: string + table_name: + type: string + columns: + type: object + description: 'Columns in this table: column_name -> compact_type' + additionalProperties: + type: string + description: 'Compact type: ''type[?][=default]'' where ? means nullable' /w/{workspace}/workspaces/edit_ducklake_config: post: summary: edit ducklake settings @@ -5787,8 +6250,8 @@ paths: properties: settings: type: object - required: *ref_51 - properties: *ref_52 + required: *ref_54 + properties: *ref_55 responses: '200': description: status @@ -5818,8 +6281,8 @@ paths: properties: settings: type: object - required: *ref_53 - properties: *ref_54 + required: *ref_46 + properties: *ref_47 responses: '200': description: status @@ -6113,7 +6576,7 @@ paths: properties: git_sync_settings: type: object - properties: *ref_55 + properties: *ref_56 responses: '200': description: status @@ -6144,8 +6607,8 @@ paths: description: The resource path of the git repository to update repository: type: object - properties: *ref_56 - required: *ref_57 + properties: *ref_57 + required: *ref_58 required: - git_repo_resource_path - repository @@ -6206,7 +6669,7 @@ paths: properties: deploy_ui_settings: type: object - properties: *ref_58 + properties: *ref_49 responses: '200': description: status @@ -6512,7 +6975,7 @@ paths: type: array items: type: object - properties: &ref_395 + properties: &ref_396 email: type: string executions: @@ -6615,7 +7078,7 @@ paths: application/json: schema: type: object - properties: &ref_414 + properties: &ref_415 label: type: string expiration: @@ -6627,6 +7090,15 @@ paths: type: string workspace_id: type: string + read_only: + type: boolean + description: > + If true, the token is restricted to read-only HTTP methods + + (GET/HEAD/OPTIONS). Mutating endpoints and job-run actions + are + + rejected with 403, regardless of the scopes attached. responses: '201': description: token created @@ -6647,7 +7119,7 @@ paths: application/json: schema: type: object - properties: &ref_415 + properties: &ref_416 label: type: string expiration: @@ -6657,7 +7129,7 @@ paths: type: string workspace_id: type: string - required: &ref_416 + required: &ref_417 - impersonate_email responses: '201': @@ -6685,6 +7157,38 @@ paths: text/plain: schema: type: string + /users/tokens/update_scopes/{token_prefix}: + post: + summary: update scopes of an existing token (owner only) + operationId: updateTokenScopes + tags: + - user + parameters: + - name: token_prefix + in: path + required: true + schema: + type: string + requestBody: + description: new scopes (null or omitted = full access) + required: true + content: + application/json: + schema: + type: object + properties: + scopes: + type: array + items: + type: string + nullable: true + responses: + '200': + description: scopes updated + content: + text/plain: + schema: + type: string /users/tokens/list: get: summary: list token @@ -6733,10 +7237,15 @@ paths: type: string email: type: string + workspace_id: + type: string + read_only: + type: boolean required: &ref_103 - token_prefix - created_at - last_used_at + - read_only /w/{workspace}/oidc/token/{audience}: post: summary: get OIDC token (ee only) @@ -6788,7 +7297,7 @@ paths: application/json: schema: type: object - properties: &ref_419 + properties: &ref_420 path: type: string description: The path to the variable @@ -6815,7 +7324,9 @@ paths: type: array items: type: string - required: &ref_420 + ws_specific: + type: boolean + required: &ref_421 - path - value - is_secret @@ -6937,7 +7448,7 @@ paths: application/json: schema: type: object - properties: &ref_421 + properties: &ref_422 path: type: string description: The path to the variable @@ -6954,6 +7465,8 @@ paths: type: array items: type: string + ws_specific: + type: boolean responses: '200': description: variable updated @@ -7032,6 +7545,13 @@ paths: type: array items: type: string + ws_specific: + type: boolean + edited_at: + type: string + format: date-time + edited_by: + type: string required: &ref_62 - workspace_id - path @@ -7173,7 +7693,7 @@ paths: type: array items: type: object - properties: &ref_417 + properties: &ref_418 name: type: string value: @@ -7182,7 +7702,7 @@ paths: type: string is_custom: type: boolean - required: &ref_418 + required: &ref_419 - name - value - description @@ -7369,12 +7889,12 @@ paths: description: >- A workspace protection rule defining restrictions and bypass permissions - required: &ref_561 + required: &ref_565 - name - rules - bypass_groups - bypass_users - properties: &ref_562 + properties: &ref_566 name: type: string description: Unique name for the protection rule @@ -7386,7 +7906,7 @@ paths: description: Configuration of protection restrictions items: &ref_64 type: string - enum: &ref_563 + enum: &ref_567 - DisableDirectDeployment - DisableWorkspaceForking - RestrictDeployToDeployers @@ -7546,11 +8066,11 @@ paths: type: array items: type: object - required: &ref_564 + required: &ref_568 - username - email - is_admin - properties: &ref_565 + properties: &ref_569 username: type: string email: @@ -7605,10 +8125,10 @@ paths: type: array items: type: object - required: &ref_566 + required: &ref_570 - username - email - properties: &ref_567 + properties: &ref_571 username: type: string email: @@ -7915,6 +8435,67 @@ paths: type: integer required: - pruned + /w/{workspace}/workspaces/list_ws_specific: + get: + summary: list all workspace-specific items + operationId: listWsSpecific + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + responses: + '200': + description: list of workspace-specific items + content: + application/json: + schema: + type: array + items: + type: object + properties: + item_kind: + type: string + path: + type: string + required: + - item_kind + - path + /w/{workspace}/workspaces/list_ws_specific_versions: + get: + summary: list workspace ids that have a version of the given item + operationId: listWsSpecificVersions + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + - name: kind + in: query + required: true + schema: + type: string + enum: + - resource + - variable + - name: path + in: query + required: true + schema: + type: string + responses: + '200': + description: list of workspace ids that have a version of the item + content: + application/json: + schema: + type: array + items: + type: string /w/{workspace}/workspaces/public_app_rate_limit: post: summary: Set public app rate limit for this workspace @@ -8050,6 +8631,34 @@ paths: text/plain: schema: type: string + /oauth/connect_slack_instance: + post: + summary: connect slack instance (non-interactive; pre-minted bot token) + operationId: connectSlackInstance + tags: + - oauth + requestBody: + description: connect slack at the instance level with a pre-minted bot token + required: true + content: + application/json: + schema: + type: object + required: + - bot_token + - team_id + - team_name + properties: + bot_token: + type: string + description: xoxb-... bot token obtained at api.slack.com/apps + team_id: + type: string + team_name: + type: string + responses: + '200': + description: status /oauth/connect_callback/{client_name}: post: summary: connect callback @@ -8332,6 +8941,11 @@ paths: - type saml: type: string + auto_login: + type: string + description: >- + provider type to auto-redirect to on login (oauth key or + "saml") required: - oauth /oauth/list_connects: @@ -8437,7 +9051,7 @@ paths: application/json: schema: type: object - properties: &ref_426 + properties: &ref_427 path: type: string description: The path to the resource @@ -8452,7 +9066,9 @@ paths: type: array items: type: string - required: &ref_427 + ws_specific: + type: boolean + required: &ref_428 - path - value - resource_type @@ -8543,7 +9159,7 @@ paths: application/json: schema: type: object - properties: &ref_428 + properties: &ref_429 path: type: string description: The path to the resource @@ -8558,6 +9174,8 @@ paths: type: array items: type: string + ws_specific: + type: boolean responses: '200': description: resource updated @@ -8619,7 +9237,7 @@ paths: application/json: schema: type: object - properties: &ref_429 + properties: &ref_430 workspace_id: type: string path: @@ -8644,7 +9262,9 @@ paths: type: array items: type: string - required: &ref_430 + ws_specific: + type: boolean + required: &ref_431 - path - resource_type - is_oauth @@ -8827,7 +9447,7 @@ paths: type: array items: type: object - properties: &ref_431 + properties: &ref_432 workspace_id: type: string path: @@ -8862,7 +9482,9 @@ paths: type: array items: type: string - required: &ref_432 + ws_specific: + type: boolean + required: &ref_433 - path - resource_type - is_oauth @@ -8943,7 +9565,7 @@ paths: - name: name in: path required: true - schema: &ref_272 + schema: &ref_273 type: string responses: '200': @@ -9076,7 +9698,7 @@ paths: application/json: schema: type: object - properties: &ref_433 + properties: &ref_434 schema: {} description: type: string @@ -9465,7 +10087,7 @@ paths: description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: &ref_606 + properties: &ref_610 modules: type: array description: >- @@ -9497,7 +10119,7 @@ paths: in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms - properties: &ref_322 + properties: &ref_323 input_transforms: type: object description: >- @@ -9675,7 +10297,7 @@ paths: - r - w - rw - required: &ref_323 + required: &ref_324 - type - content - language @@ -9685,7 +10307,7 @@ paths: Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code - properties: &ref_324 + properties: &ref_325 input_transforms: type: object description: >- @@ -9725,7 +10347,7 @@ paths: description: >- If true, this script is a trigger that can start the flow - required: &ref_325 + required: &ref_326 - type - path - input_transforms @@ -9734,7 +10356,7 @@ paths: Reference to an existing flow by path. Use this to call another flow as a subflow - properties: &ref_326 + properties: &ref_327 input_transforms: type: object description: >- @@ -9759,7 +10381,7 @@ paths: type: string enum: - flow - required: &ref_327 + required: &ref_328 - type - path - input_transforms @@ -9772,7 +10394,7 @@ paths: 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations - properties: &ref_328 + properties: &ref_329 modules: type: array description: >- @@ -9821,7 +10443,7 @@ paths: discriminator: *ref_81 squash: type: boolean - required: &ref_329 + required: &ref_330 - modules - iterator - skip_failures @@ -9833,7 +10455,7 @@ paths: condition after each iteration. Use stop_after_if on modules to control loop termination - properties: &ref_330 + properties: &ref_331 modules: type: array description: >- @@ -9871,7 +10493,7 @@ paths: discriminator: *ref_81 squash: type: boolean - required: &ref_331 + required: &ref_332 - modules - skip_failures - type @@ -9883,7 +10505,7 @@ paths: one with a true expression runs. If no branches match, the default branch executes - properties: &ref_332 + properties: &ref_333 branches: type: array description: >- @@ -9935,7 +10557,7 @@ paths: type: string enum: - branchone - required: &ref_333 + required: &ref_334 - branches - default - type @@ -9946,7 +10568,7 @@ paths: BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently - properties: &ref_334 + properties: &ref_335 branches: type: array description: >- @@ -9987,7 +10609,7 @@ paths: If true, all branches execute concurrently. If false, they execute sequentially - required: &ref_335 + required: &ref_336 - branches - type - type: object @@ -9995,7 +10617,7 @@ paths: Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder - properties: &ref_336 + properties: &ref_337 type: type: string enum: @@ -10005,7 +10627,7 @@ paths: description: >- If true, marks this as a flow identity (special handling) - required: &ref_337 + required: &ref_338 - type - type: object description: >- @@ -10013,7 +10635,7 @@ paths: accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task - properties: &ref_338 + properties: &ref_339 input_transforms: type: object description: >- @@ -10025,22 +10647,22 @@ paths: Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined - oneOf: &ref_340 + oneOf: &ref_341 - type: object description: >- Static provider configuration passed directly to the AI agent - properties: &ref_591 + properties: &ref_595 value: type: object description: >- Complete AI provider configuration with resource reference and model selection - properties: &ref_589 + properties: &ref_593 kind: type: string description: Supported AI provider types - enum: &ref_315 + enum: &ref_316 - openai - azure_openai - anthropic @@ -10063,7 +10685,7 @@ paths: description: >- Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro') - required: &ref_590 + required: &ref_594 - kind - resource - model @@ -10071,7 +10693,7 @@ paths: type: string enum: - static - required: &ref_592 + required: &ref_596 - type - value - type: object @@ -10091,7 +10713,7 @@ paths: satisfy the parameter. properties: *ref_86 required: *ref_87 - discriminator: &ref_341 + discriminator: &ref_342 propertyName: type mapping: static: >- @@ -10161,27 +10783,27 @@ paths: Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined - oneOf: &ref_342 + oneOf: &ref_343 - type: object description: >- Static memory configuration passed directly to the AI agent - properties: &ref_597 + properties: &ref_601 value: description: Conversation memory configuration - oneOf: &ref_595 + oneOf: &ref_599 - type: object description: No conversation memory/context - properties: &ref_316 + properties: &ref_317 kind: type: string enum: - 'off' - required: &ref_317 + required: &ref_318 - kind - type: object description: Automatic context management - properties: &ref_318 + properties: &ref_319 kind: type: string enum: @@ -10196,11 +10818,11 @@ paths: description: >- Identifier for persistent memory across agent invocations - required: &ref_319 + required: &ref_320 - kind - type: object description: Explicit message history - properties: &ref_320 + properties: &ref_321 kind: type: string enum: @@ -10210,7 +10832,7 @@ paths: items: type: object description: A single message in conversation history - properties: &ref_593 + properties: &ref_597 role: type: string enum: @@ -10219,13 +10841,13 @@ paths: - system content: type: string - required: &ref_594 + required: &ref_598 - role - content - required: &ref_321 + required: &ref_322 - kind - messages - discriminator: &ref_596 + discriminator: &ref_600 propertyName: kind mapping: 'off': '#/components/schemas/MemoryOff' @@ -10235,7 +10857,7 @@ paths: type: string enum: - static - required: &ref_598 + required: &ref_602 - type - value - type: object @@ -10255,7 +10877,7 @@ paths: satisfy the parameter. properties: *ref_86 required: *ref_87 - discriminator: &ref_343 + discriminator: &ref_344 propertyName: type mapping: static: >- @@ -10338,6 +10960,20 @@ paths: - 0.7 = balanced (common default) - 1.0+ = more creative/random + max_iterations: + allOf: + - description: >- + Maps input parameters for a step. Can be + a static value or a JavaScript + expression that references previous + results or flow inputs + oneOf: *ref_80 + discriminator: *ref_81 + description: > + Number. Limits how many times the agent + can loop through reasoning and tool use. + + Range: 1-1000. required: - provider - user_message @@ -10354,7 +10990,7 @@ paths: A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool - properties: &ref_344 + properties: &ref_345 id: type: string description: >- @@ -10372,12 +11008,12 @@ paths: The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference - oneOf: &ref_604 + oneOf: &ref_608 - description: >- A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module - allOf: &ref_599 + allOf: &ref_603 - type: object properties: tool_type: @@ -10410,7 +11046,7 @@ paths: Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers - properties: &ref_600 + properties: &ref_604 tool_type: type: string enum: @@ -10434,7 +11070,7 @@ paths: MCP server items: type: string - required: &ref_601 + required: &ref_605 - tool_type - resource_path - type: object @@ -10442,32 +11078,40 @@ paths: A tool implemented as a websearch tool. The AI can call this like any other websearch tool - properties: &ref_602 + properties: &ref_606 tool_type: type: string enum: - websearch - required: &ref_603 + required: &ref_607 - tool_type - discriminator: &ref_605 + discriminator: &ref_609 propertyName: tool_type mapping: flowmodule: '#/components/schemas/FlowModuleTool' mcp: '#/components/schemas/McpToolValue' websearch: '#/components/schemas/WebsearchToolValue' - required: &ref_345 + required: &ref_346 - id - value type: type: string enum: - aiagent + omit_output_from_conversation: + type: boolean + default: false + description: >- + If true, this AI agent step does not + persist its assistant or tool messages + to the flow conversation when chat mode + is enabled. parallel: type: boolean description: >- If true, the agent can execute multiple tool calls in parallel - required: &ref_339 + required: &ref_340 - tools - type - input_transforms @@ -10631,7 +11275,7 @@ paths: Retry configuration for failed module executions type: object - properties: &ref_314 + properties: &ref_315 constant: type: object description: >- @@ -10672,14 +11316,14 @@ paths: description: >- Conditional retry based on error or result - properties: &ref_194 + properties: &ref_195 expr: type: string description: >- JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables - required: &ref_195 + required: &ref_196 - expr debouncing: description: >- @@ -10736,6 +11380,13 @@ paths: description: >- If true, all steps run on the same worker for better performance + preserve_step_tags: + type: boolean + description: >- + If true and the flow runs on a custom worker tag, + steps that declare their own non-empty tag run on + it instead of inheriting the flow tag. Steps + without their own tag still inherit the flow tag. concurrent_limit: type: number description: >- @@ -10921,7 +11572,7 @@ paths: required: &ref_148 - start_id - end_id - required: &ref_607 + required: &ref_611 - modules schema: type: object @@ -11822,6 +12473,11 @@ paths: /w/{workspace}/scripts/create: post: summary: create script + description: > + Creates a new script when the path does not already exist. + + Creates a new version of an existing script when called with the same + path and the current `parent_hash`. operationId: createScript x-mcp-tool: true x-mcp-instructions: >- @@ -11955,7 +12611,7 @@ paths: type: string kind: type: string - enum: &ref_300 + enum: &ref_301 - s3object - resource - ducklake @@ -11986,6 +12642,11 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: >- + When true (set by the CLI / git sync), deploying this script + does not delete an existing user draft at the same path. required: &ref_105 - path - summary @@ -12119,7 +12780,7 @@ paths: application/json: schema: type: object - properties: &ref_384 + properties: &ref_385 workspace_id: type: string language: @@ -12131,7 +12792,7 @@ paths: type: string content: type: string - required: &ref_385 + required: &ref_386 - workspace_id - language - content @@ -12545,7 +13206,7 @@ paths: content: application/json: schema: - allOf: &ref_386 + allOf: &ref_387 - type: object properties: *ref_104 required: *ref_105 @@ -12555,6 +13216,13 @@ paths: type: object properties: *ref_104 required: *ref_105 + draft_created_at: + type: string + format: date-time + description: >- + Timestamp at which the most recent DB draft was + created. Used by the frontend's UserDraft staleness + check. hash: type: string required: @@ -12768,7 +13436,7 @@ paths: - name: token in: path required: true - schema: &ref_304 + schema: &ref_305 type: string - name: path in: path @@ -14346,7 +15014,7 @@ paths: properties: *ref_120 required: *ref_121 - type: object - properties: &ref_511 + properties: &ref_512 workspace_id: type: string path: @@ -14360,7 +15028,7 @@ paths: type: boolean extra_perms: type: object - additionalProperties: &ref_510 + additionalProperties: &ref_511 type: boolean starred: type: boolean @@ -14385,7 +15053,7 @@ paths: items: type: string default: [] - required: &ref_512 + required: &ref_513 - path - edited_by - edited_at @@ -14719,6 +15387,13 @@ paths: properties: draft: allOf: *ref_124 + draft_created_at: + type: string + format: date-time + description: >- + Timestamp at which the most recent DB draft was + created. Used by the frontend's UserDraft staleness + check. /w/{workspace}/flows/exists/{path}: get: summary: exists flow by path @@ -14814,6 +15489,12 @@ paths: type: boolean deployment_message: type: string + skip_draft_deletion: + type: boolean + description: >- + When true (set by the CLI / git sync), deploying this + flow does not delete an existing user draft at the same + path. responses: '201': description: flow created @@ -14860,6 +15541,12 @@ paths: properties: deployment_message: type: string + skip_draft_deletion: + type: boolean + description: >- + When true (set by the CLI / git sync), deploying this + flow does not delete an existing user draft at the same + path. responses: '200': description: flow updated @@ -14960,14 +15647,14 @@ paths: type: array items: type: object - required: &ref_371 + required: &ref_372 - id - workspace_id - flow_path - created_at - updated_at - created_by - properties: &ref_372 + properties: &ref_373 id: type: string format: uuid @@ -15044,13 +15731,13 @@ paths: schema: type: string format: uuid - - name: after_id - description: id to fetch only the messages after that id + - name: after_seq + description: Message sequence cursor to fetch only the messages after that cursor in: query required: false schema: - type: string - format: uuid + type: integer + format: int64 responses: '200': description: conversation messages @@ -15060,13 +15747,14 @@ paths: type: array items: type: object - required: &ref_373 + required: &ref_374 - id - conversation_id - message_type - content - created_at - properties: &ref_374 + - created_seq + properties: &ref_375 id: type: string format: uuid @@ -15095,6 +15783,10 @@ paths: type: string format: date-time description: When the message was created + created_seq: + type: integer + format: int64 + description: Monotonic cursor assigned when the message is inserted step_name: type: string description: The step name that produced that message @@ -15123,6 +15815,15 @@ paths: in: path required: true schema: *ref_4 + - name: force + description: > + bypass the server-side cache and re-query the DB, refreshing the + + cache. Used right after a deploy so the new path appears + immediately. + in: query + schema: + type: boolean responses: '200': description: deduplicated path list, sorted lexicographically @@ -15229,6 +15930,139 @@ paths: - extra_perms - version - edited_at + /w/{workspace}/shared_ui/get: + get: + summary: get the workspace shared UI folder (full content) + operationId: getSharedUi + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + responses: + '200': + description: shared UI content + content: + application/json: + schema: + type: object + required: + - files + - version + - edited_at + - edited_by + properties: + files: + type: object + additionalProperties: + type: string + version: + type: integer + format: int64 + edited_at: + type: string + format: date-time + edited_by: + type: string + /w/{workspace}/shared_ui/list: + get: + summary: list paths/sizes of the workspace shared UI folder + operationId: listSharedUi + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + responses: + '200': + description: shared UI listing + content: + application/json: + schema: + type: object + required: + - paths + - sizes + - version + - edited_at + - edited_by + properties: + paths: + type: array + items: + type: string + sizes: + type: object + additionalProperties: + type: integer + format: int64 + version: + type: integer + format: int64 + edited_at: + type: string + format: date-time + edited_by: + type: string + /w/{workspace}/shared_ui/version: + get: + summary: get the current version of the workspace shared UI folder + operationId: getSharedUiVersion + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + responses: + '200': + description: shared UI version + content: + application/json: + schema: + type: object + required: + - version + properties: + version: + type: integer + format: int64 + /w/{workspace}/shared_ui: + put: + summary: replace the workspace shared UI folder (admin only) + operationId: updateSharedUi + tags: + - workspace + parameters: + - name: workspace + in: path + required: true + schema: *ref_4 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - files + properties: + files: + type: object + additionalProperties: + type: string + responses: + '200': + description: updated + content: + text/plain: + schema: + type: string /w/{workspace}/apps/get_data/v/{secretWithExtension}: get: summary: get raw app data by @@ -15450,6 +16284,11 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: >- + When true (set by the CLI / git sync), deploying this app + does not delete an existing user draft at the same path. required: - path - value @@ -15509,6 +16348,12 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: >- + When true (set by the CLI / git sync), deploying this + app does not delete an existing user draft at the same + path. required: - path - value @@ -15629,6 +16474,13 @@ paths: draft_only: type: boolean draft: {} + draft_created_at: + type: string + format: date-time + description: >- + Timestamp at which the most recent DB draft was + created. Used by the frontend's UserDraft staleness + check. /w/{workspace}/apps/history/p/{path}: get: summary: get app history by path @@ -15730,7 +16582,7 @@ paths: - name: version in: path required: true - schema: &ref_305 + schema: &ref_306 type: integer requestBody: description: App deployment message @@ -15939,6 +16791,11 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: >- + When true (set by the CLI / git sync), deploying this app + does not delete an existing user draft at the same path. responses: '200': description: app updated @@ -15995,6 +16852,12 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: >- + When true (set by the CLI / git sync), deploying this + app does not delete an existing user draft at the same + path. js: type: string css: @@ -16118,6 +16981,8 @@ paths: type: string cache_ttl: type: integer + tag: + type: string required: - content - language @@ -16131,9 +16996,25 @@ paths: type: array items: type: string + force_viewer_sensitive_inputs: + type: array + items: + type: string + force_viewer_delete_after_secs: + type: integer run_query_params: type: object description: Runnable query parameters + temp_script_refs: + type: object + nullable: true + description: >- + Map of relative-import script path -> temp storage hash. + Only honored for inline-script (raw_code) execution so app + dev resolves those imports from not-yet-deployed local + content. + additionalProperties: + type: string required: - args - component @@ -16526,7 +17407,7 @@ paths: - name: id in: path required: true - schema: &ref_171 + schema: &ref_172 type: string format: uuid - name: scheduled_for @@ -16584,17 +17465,40 @@ paths: properties: step_id: type: string - description: step id to restart the flow from + description: >- + top-level step id to restart the flow from (or the outermost + container when restarting at a nested step) branch_or_iteration_n: type: integer description: >- - for branchall or loop, the iteration at which the flow - should restart (optional) + for branchall or loop at the top level, the iteration at + which the flow should restart (optional) flow_version: type: integer description: >- specific flow version to use for restart (optional, uses current version if not specified) + nested_path: + type: array + description: >- + path of additional steps to descend into AFTER `step_id`. + Each entry represents one level of nesting inside the + spawned child of the previous level's container (BranchOne / + sequential ForLoop iteration / Subflow). When non-empty, the + actual restart point is the LAST entry's step_id. + items: + type: object + required: + - step_id + properties: + step_id: + type: string + description: step id at this nesting level + branch_or_iteration_n: + type: integer + description: >- + for ForLoop containers, the iteration to restart at + (0-based; iterations 0..n-1 are preserved) responses: '201': description: job created @@ -16766,6 +17670,15 @@ paths: description: An additional module file associated with a script properties: *ref_95 required: *ref_96 + temp_script_refs: + type: object + nullable: true + description: >- + Map of relative-import script path -> temp storage hash so + the preview job resolves those imports from not-yet-deployed + local content instead of the deployed script + additionalProperties: + type: string required: &ref_142 - args responses: @@ -16794,7 +17707,7 @@ paths: application/json: schema: type: object - properties: &ref_422 + properties: &ref_423 content: type: string description: The code to run @@ -16805,7 +17718,7 @@ paths: language: type: string enum: *ref_94 - required: &ref_423 + required: &ref_424 - content - args - language @@ -16940,12 +17853,12 @@ paths: application/json: schema: type: object - properties: &ref_424 + properties: &ref_425 args: type: object description: The arguments to pass to the script or flow additionalProperties: true - required: &ref_425 + required: &ref_426 - args responses: '201': @@ -17103,6 +18016,13 @@ paths: description: >- If true, all steps run on the same worker for better performance + preserve_step_tags: + type: boolean + description: >- + If true and the flow runs on a custom worker tag, steps + that declare their own non-empty tag run on it instead + of inheriting the flow tag. Steps without their own tag + still inherit the flow tag. concurrent_limit: type: number description: Maximum number of concurrent executions of this flow @@ -17248,7 +18168,7 @@ paths: application/json: schema: type: object - properties: &ref_151 + properties: &ref_152 value: type: object description: >- @@ -17266,7 +18186,7 @@ paths: type: string restarted_from: type: object - properties: &ref_513 + properties: &ref_151 flow_job_id: type: string format: uuid @@ -17274,9 +18194,43 @@ paths: type: string branch_or_iteration_n: type: integer + description: >- + 0-based iteration index for ForLoop / branch index for + BranchAll. Iterations 0..n-1 are preserved; iteration n + is restarted. flow_version: type: integer - required: &ref_152 + branch_chosen: + description: >- + For BranchOne nested restart — the branch that was + originally chosen, used to lock branch evaluation. + type: object + properties: + type: + type: string + enum: + - default + - branch + branch: + type: integer + nested: + description: >- + When set, the worker spawns the child for `step_id` as a + `RestartedFlow` against `nested.flow_job_id` instead of + fresh-launching it. + type: object + properties: *ref_151 + temp_script_refs: + type: object + nullable: true + description: >- + Map of relative-import script path -> temp storage hash, + propagated to each flow step so inline-script relative + imports resolve from not-yet-deployed local content instead + of the deployed script + additionalProperties: + type: string + required: &ref_153 - value - content - args @@ -17312,8 +18266,8 @@ paths: application/json: schema: type: object - properties: *ref_151 - required: *ref_152 + properties: *ref_152 + required: *ref_153 responses: '200': description: job result @@ -17338,7 +18292,7 @@ paths: application/json: schema: type: object - properties: &ref_525 + properties: &ref_529 entrypoint_function: type: string description: Name of the function to execute for dynamic select @@ -17359,7 +18313,7 @@ paths: description: Path to the deployed script or flow runnable_kind: type: string - enum: &ref_199 + enum: &ref_200 - script - flow required: @@ -17381,7 +18335,7 @@ paths: required: - source - code - required: &ref_526 + required: &ref_530 - entrypoint_function - runnable_ref responses: @@ -17427,7 +18381,7 @@ paths: (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: &ref_155 + schema: &ref_156 type: string - name: script_path_exact description: >- @@ -17435,7 +18389,7 @@ paths: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: &ref_156 + schema: &ref_157 type: string - name: script_path_start description: >- @@ -17443,12 +18397,12 @@ paths: 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: &ref_157 + schema: &ref_158 type: string - name: schedule_path description: mask to filter by schedule path in: query - schema: &ref_158 + schema: &ref_159 type: string - name: trigger_path description: >- @@ -17456,7 +18410,7 @@ paths: 'f/trigger1,f/trigger2') and negation by prefixing all values with '!' (e.g. '!f/trigger1,!f/trigger2') in: query - schema: &ref_306 + schema: &ref_307 type: string - name: trigger_kind description: >- @@ -17465,34 +18419,34 @@ paths: (e.g. '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: &ref_187 + schema: &ref_188 type: string - name: script_hash description: mask to filter exact matching path in: query - schema: &ref_159 + schema: &ref_160 type: string - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: &ref_160 + schema: &ref_161 type: string format: date-time - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: &ref_161 + schema: &ref_162 type: string format: date-time - name: success description: filter on successful jobs in: query - schema: &ref_169 + schema: &ref_170 type: boolean - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: &ref_163 + schema: &ref_164 type: boolean - name: job_kinds description: >- @@ -17500,36 +18454,36 @@ paths: ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: &ref_164 + schema: &ref_165 type: string - name: suspended description: filter on suspended jobs in: query - schema: &ref_165 + schema: &ref_166 type: boolean - name: running description: filter on running jobs in: query - schema: &ref_162 + schema: &ref_163 type: boolean - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: &ref_166 + schema: &ref_167 type: string - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: &ref_168 + schema: &ref_169 type: string - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: &ref_170 + schema: &ref_171 type: boolean - name: tag description: >- @@ -17537,7 +18491,7 @@ paths: 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: &ref_167 + schema: &ref_168 type: string - name: page description: which page to return (start at 1, default 1) @@ -17568,7 +18522,7 @@ paths: type: array items: type: object - properties: &ref_190 + properties: &ref_191 workspace_id: type: string id: @@ -17643,14 +18597,14 @@ paths: by extension its DT_TOKEN. flow_status: type: object - properties: &ref_174 + properties: &ref_175 step: type: integer modules: type: array items: type: object - properties: &ref_153 + properties: &ref_154 type: type: string enum: @@ -17804,20 +18758,20 @@ paths: type: array items: type: boolean - required: &ref_154 + required: &ref_155 - type user_states: additionalProperties: true preprocessor_module: allOf: - type: object - properties: *ref_153 - required: *ref_154 + properties: *ref_154 + required: *ref_155 failure_module: allOf: - type: object - properties: *ref_153 - required: *ref_154 + properties: *ref_154 + required: *ref_155 - type: object properties: parent_module: @@ -17832,13 +18786,13 @@ paths: items: type: string format: uuid - required: &ref_175 + required: &ref_176 - step - modules - failure_module workflow_as_code_status: type: object - properties: &ref_176 + properties: &ref_177 scheduled_for: type: string format: date-time @@ -17881,7 +18835,7 @@ paths: type: boolean worker: type: string - required: &ref_191 + required: &ref_192 - id - running - canceled @@ -18004,7 +18958,7 @@ paths: (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: &ref_173 + schema: &ref_174 type: string - name: worker description: >- @@ -18012,7 +18966,7 @@ paths: (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_155 + schema: *ref_156 - name: parent_job description: >- The parent job that is at the origin and responsible for the @@ -18025,104 +18979,104 @@ paths: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_156 + schema: *ref_157 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_157 + schema: *ref_158 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_158 + schema: *ref_159 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_159 + schema: *ref_160 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_160 + schema: *ref_161 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_161 + schema: *ref_162 - name: created_before description: filter on created before (inclusive) timestamp in: query - schema: &ref_181 + schema: &ref_182 type: string format: date-time - name: created_after description: filter on created after (exclusive) timestamp in: query - schema: &ref_182 + schema: &ref_183 type: string format: date-time - name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: &ref_183 + schema: &ref_184 type: string format: date-time - name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: &ref_184 + schema: &ref_185 type: string format: date-time - name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: &ref_185 + schema: &ref_186 type: string format: date-time - name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: &ref_186 + schema: &ref_187 type: string format: date-time - name: running description: filter on running jobs in: query - schema: *ref_162 + schema: *ref_163 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_163 + schema: *ref_164 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_164 + schema: *ref_165 - name: suspended description: filter on suspended jobs in: query - schema: *ref_165 + schema: *ref_166 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_166 + schema: *ref_167 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_167 + schema: *ref_168 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_168 + schema: *ref_169 - name: page description: which page to return (start at 1, default 1) in: query @@ -18206,76 +19160,76 @@ paths: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_156 + schema: *ref_157 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_157 + schema: *ref_158 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_158 + schema: *ref_159 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_159 + schema: *ref_160 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_160 + schema: *ref_161 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_161 + schema: *ref_162 - name: success description: filter on successful jobs in: query - schema: *ref_169 + schema: *ref_170 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_163 + schema: *ref_164 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_164 + schema: *ref_165 - name: suspended description: filter on suspended jobs in: query - schema: *ref_165 + schema: *ref_166 - name: running description: filter on running jobs in: query - schema: *ref_162 + schema: *ref_163 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_166 + schema: *ref_167 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_168 + schema: *ref_169 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_170 + schema: *ref_171 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_167 + schema: *ref_168 - name: page description: which page to return (start at 1, default 1) in: query @@ -18361,7 +19315,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: list of OTEL Span objects (compatible with OpenTelemetry Span proto) @@ -18389,7 +19343,7 @@ paths: schema: description: job trigger kind (schedule, http, websocket...) type: string - enum: &ref_172 + enum: &ref_173 - webhook - default_email - email @@ -18455,7 +19409,7 @@ paths: schema: description: job trigger kind (schedule, http, websocket...) type: string - enum: *ref_172 + enum: *ref_173 - name: trigger_path description: The path of the trigger (can contain forward slashes) in: path @@ -18516,14 +19470,14 @@ paths: (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_173 + schema: *ref_174 - name: worker description: >- filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_155 + schema: *ref_156 - name: parent_job description: >- The parent job that is at the origin and responsible for the @@ -18536,64 +19490,64 @@ paths: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_156 + schema: *ref_157 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_157 + schema: *ref_158 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_158 + schema: *ref_159 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_159 + schema: *ref_160 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_160 + schema: *ref_161 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_161 + schema: *ref_162 - name: success description: filter on successful jobs in: query - schema: *ref_169 + schema: *ref_170 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_164 + schema: *ref_165 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_166 + schema: *ref_167 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_168 + schema: *ref_169 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_170 + schema: *ref_171 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_167 + schema: *ref_168 - name: page description: which page to return (start at 1, default 1) in: query @@ -18631,7 +19585,7 @@ paths: type: array items: type: object - properties: &ref_188 + properties: &ref_189 workspace_id: type: string id: @@ -18708,11 +19662,11 @@ paths: by extension its DT_TOKEN. flow_status: type: object - properties: *ref_174 - required: *ref_175 + properties: *ref_175 + required: *ref_176 workflow_as_code_status: type: object - properties: *ref_176 + properties: *ref_177 raw_flow: type: object description: >- @@ -18749,7 +19703,7 @@ paths: type: boolean worker: type: string - required: &ref_189 + required: &ref_190 - id - created_by - duration_ms @@ -18793,7 +19747,7 @@ paths: items: type: object description: Completed job with full data for export/import operations - properties: &ref_177 + properties: &ref_178 id: type: string format: uuid @@ -18931,7 +19885,7 @@ paths: status: type: string description: Actual job status from database - required: &ref_178 + required: &ref_179 - id - created_by - created_at @@ -18959,8 +19913,8 @@ paths: items: type: object description: Completed job with full data for export/import operations - properties: *ref_177 - required: *ref_178 + properties: *ref_178 + required: *ref_179 responses: '200': description: Successfully imported completed jobs @@ -18997,7 +19951,7 @@ paths: items: type: object description: Queued job with full data for export/import operations - properties: &ref_179 + properties: &ref_180 id: type: string format: uuid @@ -19128,7 +20082,7 @@ paths: suspend_until: type: string format: date-time - required: &ref_180 + required: &ref_181 - id - created_by - created_at @@ -19156,8 +20110,8 @@ paths: items: type: object description: Queued job with full data for export/import operations - properties: *ref_179 - required: *ref_180 + properties: *ref_180 + required: *ref_181 responses: '200': description: Successfully imported queued jobs @@ -19219,14 +20173,14 @@ paths: (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_173 + schema: *ref_174 - name: worker description: >- filter by worker this job ran on. Supports comma-separated list (e.g. 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_155 + schema: *ref_156 - name: parent_job description: >- The parent job that is at the origin and responsible for the @@ -19239,96 +20193,96 @@ paths: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_156 + schema: *ref_157 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_157 + schema: *ref_158 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_158 + schema: *ref_159 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_159 + schema: *ref_160 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_160 + schema: *ref_161 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_161 + schema: *ref_162 - name: created_before description: filter on created before (inclusive) timestamp in: query - schema: *ref_181 + schema: *ref_182 - name: created_after description: filter on created after (exclusive) timestamp in: query - schema: *ref_182 + schema: *ref_183 - name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_183 + schema: *ref_184 - name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_184 + schema: *ref_185 - name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: *ref_185 + schema: *ref_186 - name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: *ref_186 + schema: *ref_187 - name: running description: filter on running jobs in: query - schema: *ref_162 + schema: *ref_163 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_163 + schema: *ref_164 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_164 + schema: *ref_165 - name: suspended description: filter on suspended jobs in: query - schema: *ref_165 + schema: *ref_166 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_166 + schema: *ref_167 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_167 + schema: *ref_168 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_168 + schema: *ref_169 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_170 + schema: *ref_171 - name: per_page description: number of items to return for a given page (default 30, max 100) in: query @@ -19340,7 +20294,7 @@ paths: (e.g. '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: *ref_187 + schema: *ref_188 - name: is_skipped description: is the job skipped in: query @@ -19373,6 +20327,13 @@ paths: in: query schema: type: boolean + - name: excludes_entrypoint_override + description: >- + exclude jobs that were started with a `_ENTRYPOINT_OVERRIDE` arg + (e.g. dynamic-select helper runs and preprocessor previews) + in: query + schema: + type: boolean - name: broad_filter description: >- broad search across multiple fields (case-insensitive substring @@ -19388,11 +20349,11 @@ paths: schema: type: array items: - oneOf: &ref_192 + oneOf: &ref_193 - allOf: - type: object - properties: *ref_188 - required: *ref_189 + properties: *ref_189 + required: *ref_190 - type: object properties: type: @@ -19401,15 +20362,15 @@ paths: - CompletedJob - allOf: - type: object - properties: *ref_190 - required: *ref_191 + properties: *ref_191 + required: *ref_192 - type: object properties: type: type: string enum: - QueuedJob - discriminator: &ref_193 + discriminator: &ref_194 propertyName: type /jobs/db_clock: get: @@ -19477,7 +20438,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: no_logs in: query schema: @@ -19486,14 +20447,22 @@ paths: in: query schema: type: boolean + - name: approval_token + in: query + description: >- + Approval token granting read access to the job when not logged in. + The token must be the one issued for this job's flow (i.e. the flow + id used when generating the approval URL). + schema: + type: string responses: '200': description: job details content: application/json: schema: - oneOf: *ref_192 - discriminator: *ref_193 + oneOf: *ref_193 + discriminator: *ref_194 /w/{workspace}/jobs_u/get_root_job_id/{id}: get: summary: get root job id @@ -19508,7 +20477,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: get root job id @@ -19531,7 +20500,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: remove_ansi_warnings in: query schema: @@ -19557,7 +20526,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: concatenated logs of all flow steps @@ -19579,7 +20548,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: completed job logs tail @@ -19601,7 +20570,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: job args @@ -19652,7 +20621,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: running in: query schema: @@ -19699,11 +20668,11 @@ paths: type: string flow_status: type: object - properties: *ref_174 - required: *ref_175 + properties: *ref_175 + required: *ref_176 workflow_as_code_status: type: object - properties: *ref_176 + properties: *ref_177 /w/{workspace}/jobs_u/getupdate_sse/{id}: get: summary: get job updates via server-sent events @@ -19718,7 +20687,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: running in: query schema: @@ -19791,7 +20760,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: flow debug info details @@ -19812,7 +20781,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: job details @@ -19820,8 +20789,8 @@ paths: application/json: schema: type: object - properties: *ref_188 - required: *ref_189 + properties: *ref_189 + required: *ref_190 /w/{workspace}/jobs_u/completed/get_result/{id}: get: summary: get completed job result @@ -19836,7 +20805,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: suspended_job in: query schema: @@ -19873,10 +20842,10 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: get_started in: query - schema: &ref_312 + schema: &ref_313 type: boolean responses: '200': @@ -19910,7 +20879,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: job timing details @@ -19943,7 +20912,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: job details @@ -19951,8 +20920,8 @@ paths: application/json: schema: type: object - properties: *ref_188 - required: *ref_189 + properties: *ref_189 + required: *ref_190 /w/{workspace}/jobs_u/queue/cancel/{id}: post: summary: cancel queued or running job @@ -19967,7 +20936,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 requestBody: description: reason required: true @@ -20031,7 +21000,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 requestBody: description: reason required: true @@ -20093,7 +21062,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: scheduled for timestamp @@ -20115,7 +21084,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: resume_id in: path required: true @@ -20146,7 +21115,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: resume_id in: path required: true @@ -20196,7 +21165,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: approver in: query schema: @@ -20257,7 +21226,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: approver in: query schema: @@ -20445,7 +21414,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: payload description: > The base64 encoded payload that has been encoded as a JSON. e.g how @@ -20488,7 +21457,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: resume_id in: path required: true @@ -20530,7 +21499,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: key in: path required: true @@ -20562,7 +21531,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: key in: path required: true @@ -20588,7 +21557,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 requestBody: required: true content: @@ -20616,7 +21585,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: resume_id in: path required: true @@ -20651,7 +21620,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: resume_id in: path required: true @@ -20693,7 +21662,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 - name: resume_id in: path required: true @@ -20717,8 +21686,8 @@ paths: type: object properties: job: - oneOf: *ref_192 - discriminator: *ref_193 + oneOf: *ref_193 + discriminator: *ref_194 approvers: type: array items: @@ -20793,10 +21762,12 @@ paths: application/json: schema: type: object - properties: &ref_435 + properties: &ref_436 path: type: string - description: The unique path identifier for this schedule + description: >- + The unique Windmill path for this schedule. Must be of the + form `u//` or `f//`. schedule: type: string description: >- @@ -20884,7 +21855,7 @@ paths: nullable: true type: object description: Retry configuration for failed module executions - properties: &ref_196 + properties: &ref_197 constant: type: object description: Retry with constant delay between attempts @@ -20919,8 +21890,8 @@ paths: retry_if: type: object description: Conditional retry based on error or result - properties: *ref_194 - required: *ref_195 + properties: *ref_195 + required: *ref_196 no_flow_overlap: type: boolean description: >- @@ -20973,7 +21944,7 @@ paths: type: array items: type: string - required: &ref_436 + required: &ref_437 - path - schedule - timezone @@ -21017,7 +21988,7 @@ paths: application/json: schema: type: object - properties: &ref_437 + properties: &ref_438 schedule: type: string description: >- @@ -21092,7 +22063,7 @@ paths: nullable: true type: object description: Retry configuration for failed module executions - properties: *ref_196 + properties: *ref_197 no_flow_overlap: type: boolean description: >- @@ -21148,7 +22119,7 @@ paths: type: array items: type: string - required: &ref_438 + required: &ref_439 - schedule - timezone - args @@ -21184,6 +22155,11 @@ paths: properties: enabled: type: boolean + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + schedule in a fork whose parent has the same path enabled. required: - enabled responses: @@ -21239,10 +22215,12 @@ paths: application/json: schema: type: object - properties: &ref_197 + properties: &ref_198 path: type: string - description: The unique path identifier for this schedule + description: >- + The unique Windmill path for this schedule. Must be of the + form `u//` or `f//`. edited_by: type: string description: Username of the last person who edited this schedule @@ -21358,7 +22336,7 @@ paths: nullable: true type: object description: Retry configuration for failed module executions - properties: *ref_196 + properties: *ref_197 summary: type: string nullable: true @@ -21401,7 +22379,7 @@ paths: items: type: string default: [] - required: &ref_198 + required: &ref_199 - path - edited_by - edited_at @@ -21460,7 +22438,7 @@ paths: filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_166 + schema: *ref_167 - name: path description: filter by path (script path) in: query @@ -21513,8 +22491,8 @@ paths: type: array items: type: object - properties: *ref_197 - required: *ref_198 + properties: *ref_198 + required: *ref_199 /w/{workspace}/schedules/list_with_jobs: get: summary: list schedules with last 20 jobs @@ -21542,10 +22520,10 @@ paths: schema: type: array items: - allOf: &ref_434 + allOf: &ref_435 - type: object - properties: *ref_197 - required: *ref_198 + properties: *ref_198 + required: *ref_199 - type: object properties: jobs: @@ -21623,10 +22601,10 @@ paths: application/json: schema: type: object - properties: &ref_200 + properties: &ref_201 info: type: object - properties: &ref_444 + properties: &ref_445 title: type: string version: @@ -21655,28 +22633,28 @@ paths: type: string required: - name - required: &ref_445 + required: &ref_446 - title - version url: type: string openapi_spec_format: type: string - enum: &ref_439 + enum: &ref_440 - yaml - json http_route_filters: type: array items: type: object - properties: &ref_440 + properties: &ref_441 folder_regex: type: string path_regex: type: string route_path_regex: type: string - required: &ref_441 + required: &ref_442 - folder_regex - path_regex - route_path_regex @@ -21684,7 +22662,7 @@ paths: type: array items: type: object - properties: &ref_442 + properties: &ref_443 user_or_folder_regex: type: string enum: @@ -21697,8 +22675,8 @@ paths: type: string runnable_kind: type: string - enum: *ref_199 - required: &ref_443 + enum: *ref_200 + required: &ref_444 - user_or_folder_regex - user_or_folder_regex_value - path @@ -21727,7 +22705,7 @@ paths: application/json: schema: type: object - properties: *ref_200 + properties: *ref_201 responses: '200': description: Downloaded OpenAPI spec @@ -21756,10 +22734,13 @@ paths: type: array items: type: object - properties: &ref_201 + properties: &ref_202 path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: Path to the script or flow to execute when triggered @@ -21807,7 +22788,7 @@ paths: HTTP method (get, post, put, delete, patch) that triggers this endpoint type: string - enum: &ref_203 + enum: &ref_204 - get - post - put @@ -21829,7 +22810,7 @@ paths: 'async' returns job ID immediately, 'sync_sse' streams results via Server-Sent Events type: string - enum: &ref_204 + enum: &ref_205 - sync - async - sync_sse @@ -21839,7 +22820,7 @@ paths: 'windmill' (Windmill token), 'api_key', 'basic_http', 'custom_script', 'signature' type: string - enum: &ref_205 + enum: &ref_206 - none - windmill - api_key @@ -21857,7 +22838,7 @@ paths: mode: description: job trigger mode type: string - enum: &ref_206 + enum: &ref_207 - enabled - disabled - suspended @@ -21878,7 +22859,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -21894,7 +22875,7 @@ paths: type: array items: type: string - required: &ref_202 + required: &ref_203 - path - script_path - route_path @@ -21927,8 +22908,8 @@ paths: application/json: schema: type: object - properties: *ref_201 - required: *ref_202 + properties: *ref_202 + required: *ref_203 responses: '201': description: http trigger created @@ -21958,10 +22939,13 @@ paths: application/json: schema: type: object - properties: &ref_446 + properties: &ref_447 path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: Path to the script or flow to execute when triggered @@ -22015,7 +22999,7 @@ paths: HTTP method (get, post, put, delete, patch) that triggers this endpoint type: string - enum: *ref_203 + enum: *ref_204 is_async: type: boolean description: Deprecated, use request_type instead @@ -22025,14 +23009,14 @@ paths: 'async' returns job ID immediately, 'sync_sse' streams results via Server-Sent Events type: string - enum: *ref_204 + enum: *ref_205 authentication_method: description: >- How requests are authenticated - 'none' (public), 'windmill' (Windmill token), 'api_key', 'basic_http', 'custom_script', 'signature' type: string - enum: *ref_205 + enum: *ref_206 is_static_website: type: boolean description: >- @@ -22056,7 +23040,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -22072,7 +23056,7 @@ paths: type: array items: type: string - required: &ref_447 + required: &ref_448 - path - script_path - is_flow @@ -22130,12 +23114,16 @@ paths: content: application/json: schema: - allOf: &ref_207 + allOf: &ref_208 - type: object - properties: &ref_213 + properties: &ref_214 path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of + the form `u//` or `f//`. + This is the trigger object path, not the HTTP route + path. script_path: type: string description: Path to the script or flow to execute when triggered @@ -22167,13 +23155,13 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 labels: type: array items: type: string default: [] - required: &ref_214 + required: &ref_215 - path - script_path - permissioned_as @@ -22184,7 +23172,7 @@ paths: - is_flow - mode type: object - properties: &ref_208 + properties: &ref_209 route_path: type: string description: >- @@ -22213,7 +23201,7 @@ paths: HTTP method (get, post, put, delete, patch) that triggers this endpoint type: string - enum: *ref_203 + enum: *ref_204 authentication_resource_path: type: string nullable: true @@ -22235,14 +23223,14 @@ paths: 'async' returns job ID immediately, 'sync_sse' streams results via Server-Sent Events type: string - enum: *ref_204 + enum: *ref_205 authentication_method: description: >- How requests are authenticated - 'none' (public), 'windmill' (Windmill token), 'api_key', 'basic_http', 'custom_script', 'signature' type: string - enum: *ref_205 + enum: *ref_206 is_static_website: type: boolean description: >- @@ -22271,8 +23259,8 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 - required: &ref_209 + properties: *ref_197 + required: &ref_210 - route_path - request_type - authentication_method @@ -22327,10 +23315,10 @@ paths: schema: type: array items: - allOf: *ref_207 + allOf: *ref_208 type: object - properties: *ref_208 - required: *ref_209 + properties: *ref_209 + required: *ref_210 /w/{workspace}/http_triggers/exists/{path}: get: summary: does http trigger exists @@ -22376,7 +23364,7 @@ paths: type: string http_method: type: string - enum: *ref_203 + enum: *ref_204 trigger_path: type: string workspaced_route: @@ -22416,7 +23404,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -22444,10 +23437,13 @@ paths: application/json: schema: type: object - properties: &ref_448 + properties: &ref_449 path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -22466,7 +23462,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 filters: type: array description: >- @@ -22497,7 +23493,7 @@ paths: Messages to send immediately after connecting (can be raw strings or computed by runnables) items: - anyOf: &ref_210 + anyOf: &ref_211 - type: object properties: raw_message: @@ -22540,7 +23536,7 @@ paths: nullable: true description: Optional periodic heartbeat message configuration type: object - properties: &ref_211 + properties: &ref_212 interval_secs: type: integer minimum: 1 @@ -22557,7 +23553,7 @@ paths: Optional. Top-level JSON field to extract from incoming messages. The extracted value replaces {{state}} in the heartbeat message. - required: &ref_212 + required: &ref_213 - interval_secs - message error_handler_path: @@ -22570,7 +23566,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -22586,7 +23582,7 @@ paths: type: array items: type: string - required: &ref_449 + required: &ref_450 - path - script_path - url @@ -22623,7 +23619,7 @@ paths: application/json: schema: type: object - properties: &ref_450 + properties: &ref_451 url: type: string description: >- @@ -22631,7 +23627,10 @@ paths: computed by a runnable) path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -22672,7 +23671,7 @@ paths: Messages to send immediately after connecting (can be raw strings or computed by runnables) items: - anyOf: *ref_210 + anyOf: *ref_211 url_runnable_args: description: The arguments to pass to the script or flow nullable: true @@ -22690,8 +23689,8 @@ paths: nullable: true description: Optional periodic heartbeat message configuration type: object - properties: *ref_211 - required: *ref_212 + properties: *ref_212 + required: *ref_213 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -22702,7 +23701,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -22718,7 +23717,7 @@ paths: type: array items: type: string - required: &ref_451 + required: &ref_452 - path - script_path - url @@ -22776,12 +23775,12 @@ paths: content: application/json: schema: - allOf: &ref_215 + allOf: &ref_216 - type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 type: object - properties: &ref_216 + properties: &ref_217 url: type: string description: >- @@ -22829,7 +23828,7 @@ paths: Messages to send immediately after connecting (can be raw strings or computed by runnables) items: - anyOf: *ref_210 + anyOf: *ref_211 url_runnable_args: description: The arguments to pass to the script or flow nullable: true @@ -22847,8 +23846,8 @@ paths: nullable: true description: Optional periodic heartbeat message configuration type: object - properties: *ref_211 - required: *ref_212 + properties: *ref_212 + required: *ref_213 error_handler_path: type: string description: >- @@ -22861,8 +23860,8 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 - required: &ref_217 + properties: *ref_197 + required: &ref_218 - url - filters - can_return_message @@ -22913,10 +23912,10 @@ paths: schema: type: array items: - allOf: *ref_215 + allOf: *ref_216 type: object - properties: *ref_216 - required: *ref_217 + properties: *ref_217 + required: *ref_218 /w/{workspace}/websocket_triggers/exists/{path}: get: summary: does websocket trigger exists @@ -22965,7 +23964,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -23030,10 +24034,13 @@ paths: application/json: schema: type: object - properties: &ref_483 + properties: &ref_484 path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -23096,7 +24103,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -23107,7 +24114,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -23123,7 +24130,7 @@ paths: type: array items: type: string - required: &ref_484 + required: &ref_485 - path - script_path - is_flow @@ -23160,7 +24167,7 @@ paths: application/json: schema: type: object - properties: &ref_485 + properties: &ref_486 kafka_resource_path: type: string description: >- @@ -23212,7 +24219,10 @@ paths: commit offsets using the commit_offsets endpoint. path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -23233,7 +24243,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -23249,7 +24259,7 @@ paths: type: array items: type: string - required: &ref_486 + required: &ref_487 - path - script_path - kafka_resource_path @@ -23307,12 +24317,12 @@ paths: content: application/json: schema: - allOf: &ref_218 + allOf: &ref_219 - type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 type: object - properties: &ref_219 + properties: &ref_220 kafka_resource_path: type: string description: >- @@ -23387,8 +24397,8 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 - required: &ref_220 + properties: *ref_197 + required: &ref_221 - kafka_resource_path - group_id - topics @@ -23439,10 +24449,10 @@ paths: schema: type: array items: - allOf: *ref_218 + allOf: *ref_219 type: object - properties: *ref_219 - required: *ref_220 + properties: *ref_220 + required: *ref_221 /w/{workspace}/kafka_triggers/exists/{path}: get: summary: does kafka trigger exists @@ -23491,7 +24501,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -23605,10 +24620,13 @@ paths: application/json: schema: type: object - properties: &ref_487 + properties: &ref_488 path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -23645,7 +24663,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -23656,7 +24674,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -23672,7 +24690,7 @@ paths: type: array items: type: string - required: &ref_488 + required: &ref_489 - path - script_path - is_flow @@ -23708,7 +24726,7 @@ paths: application/json: schema: type: object - properties: &ref_489 + properties: &ref_490 nats_resource_path: type: string description: >- @@ -23734,7 +24752,10 @@ paths: description: Array of NATS subjects to subscribe to path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -23755,7 +24776,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -23771,7 +24792,7 @@ paths: type: array items: type: string - required: &ref_490 + required: &ref_491 - path - script_path - nats_resource_path @@ -23828,12 +24849,12 @@ paths: content: application/json: schema: - allOf: &ref_221 + allOf: &ref_222 - type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 type: object - properties: &ref_222 + properties: &ref_223 nats_resource_path: type: string description: >- @@ -23883,8 +24904,8 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 - required: &ref_223 + properties: *ref_197 + required: &ref_224 - nats_resource_path - use_jetstream - subjects @@ -23934,10 +24955,10 @@ paths: schema: type: array items: - allOf: *ref_221 + allOf: *ref_222 type: object - properties: *ref_222 - required: *ref_223 + properties: *ref_223 + required: *ref_224 /w/{workspace}/nats_triggers/exists/{path}: get: summary: does nats trigger exists @@ -23986,7 +25007,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -24044,7 +25070,7 @@ paths: application/json: schema: type: object - properties: &ref_470 + properties: &ref_471 queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -24053,7 +25079,7 @@ paths: Authentication type - 'credentials' for access key/secret, 'oidc' for OpenID Connect type: string - enum: &ref_224 + enum: &ref_225 - oidc - credentials aws_resource_path: @@ -24071,7 +25097,10 @@ paths: message path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -24085,7 +25114,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -24096,7 +25125,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -24112,7 +25141,7 @@ paths: type: array items: type: string - required: &ref_471 + required: &ref_472 - queue_url - aws_resource_path - path @@ -24148,7 +25177,7 @@ paths: application/json: schema: type: object - properties: &ref_472 + properties: &ref_473 queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -24157,7 +25186,7 @@ paths: Authentication type - 'credentials' for access key/secret, 'oidc' for OpenID Connect type: string - enum: *ref_224 + enum: *ref_225 aws_resource_path: type: string description: >- @@ -24173,7 +25202,10 @@ paths: message path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -24187,7 +25219,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -24198,7 +25230,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -24214,7 +25246,7 @@ paths: type: array items: type: string - required: &ref_473 + required: &ref_474 - queue_url - aws_resource_path - path @@ -24272,12 +25304,12 @@ paths: content: application/json: schema: - allOf: &ref_225 + allOf: &ref_226 - type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 type: object - properties: &ref_226 + properties: &ref_227 queue_url: type: string description: The full URL of the AWS SQS queue to poll for messages @@ -24286,7 +25318,7 @@ paths: Authentication type - 'credentials' for access key/secret, 'oidc' for OpenID Connect type: string - enum: *ref_224 + enum: *ref_225 aws_resource_path: type: string description: >- @@ -24324,8 +25356,8 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 - required: &ref_227 + properties: *ref_197 + required: &ref_228 - queue_url - aws_resource_path - aws_auth_resource_type @@ -24375,10 +25407,10 @@ paths: schema: type: array items: - allOf: *ref_225 + allOf: *ref_226 type: object - properties: *ref_226 - required: *ref_227 + properties: *ref_227 + required: *ref_228 /w/{workspace}/sqs_triggers/exists/{path}: get: summary: does sqs trigger exists @@ -24427,7 +25459,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -24487,17 +25524,17 @@ paths: type: array items: type: object - properties: &ref_572 + properties: &ref_576 service_name: type: string - enum: &ref_228 + enum: &ref_229 - nextcloud - google - github oauth_data: nullable: true type: object - properties: &ref_229 + properties: &ref_230 client_id: type: string description: The OAuth client ID for the workspace @@ -24512,7 +25549,7 @@ paths: type: string format: uri description: The OAuth redirect URI - required: &ref_230 + required: &ref_231 - client_id - client_secret - base_url @@ -24521,7 +25558,7 @@ paths: type: string nullable: true description: Path to the resource storing the OAuth token - required: &ref_573 + required: &ref_577 - service_name /w/{workspace}/native_triggers/integrations/{service_name}/exists: get: @@ -24539,7 +25576,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 responses: '200': description: integration exists @@ -24563,7 +25600,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 requestBody: description: new native trigger service required: true @@ -24571,8 +25608,8 @@ paths: application/json: schema: type: object - properties: *ref_229 - required: *ref_230 + properties: *ref_230 + required: *ref_231 responses: '201': description: native trigger service created @@ -24596,7 +25633,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 requestBody: description: redirect_uri required: true @@ -24604,10 +25641,10 @@ paths: application/json: schema: type: object - properties: &ref_231 + properties: &ref_232 redirect_uri: type: string - required: &ref_232 + required: &ref_233 - redirect_uri responses: '200': @@ -24632,7 +25669,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 responses: '200': description: whether instance sharing is available @@ -24656,7 +25693,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 requestBody: description: redirect_uri required: true @@ -24664,8 +25701,8 @@ paths: application/json: schema: type: object - properties: *ref_231 - required: *ref_232 + properties: *ref_232 + required: *ref_233 responses: '200': description: authorization URL using instance credentials @@ -24689,7 +25726,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 responses: '200': description: native trigger service deleted @@ -24713,7 +25750,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 requestBody: description: OAuth callback data required: true @@ -24762,7 +25799,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 requestBody: description: new native trigger configuration required: true @@ -24771,7 +25808,7 @@ paths: schema: type: object description: Data for creating or updating a native trigger - properties: &ref_233 + properties: &ref_234 script_path: type: string description: The path to the script or flow that will be triggered @@ -24788,7 +25825,7 @@ paths: type: string nullable: true description: Short summary to be displayed when listed - required: &ref_234 + required: &ref_235 - script_path - is_flow - service_config @@ -24800,13 +25837,13 @@ paths: schema: type: object description: Response returned when a native trigger is created - properties: &ref_575 + properties: &ref_579 external_id: type: string description: >- The external ID of the created trigger from the external service - required: &ref_576 + required: &ref_580 - external_id /w/{workspace}/native_triggers/{service_name}/update/{external_id}: post: @@ -24829,7 +25866,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 - name: external_id in: path required: true @@ -24844,8 +25881,8 @@ paths: schema: type: object description: Data for creating or updating a native trigger - properties: *ref_233 - required: *ref_234 + properties: *ref_234 + required: *ref_235 responses: '200': description: native trigger updated @@ -24874,7 +25911,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 - name: external_id in: path required: true @@ -24891,7 +25928,7 @@ paths: description: >- Full trigger response containing both Windmill data and external service data - properties: &ref_570 + properties: &ref_574 external_id: type: string description: The unique identifier from the external service @@ -24900,7 +25937,7 @@ paths: description: The workspace this trigger belongs to service_name: type: string - enum: *ref_228 + enum: *ref_229 script_path: type: string description: The path to the script or flow that will be triggered @@ -24927,7 +25964,7 @@ paths: type: object description: Configuration data from the external service additionalProperties: true - required: &ref_571 + required: &ref_575 - external_id - workspace_id - service_name @@ -24956,7 +25993,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 - name: external_id in: path required: true @@ -24987,7 +26024,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 - name: page description: which page to return (start at 1, default 1) in: query @@ -25022,7 +26059,7 @@ paths: items: type: object description: A native trigger stored in Windmill - properties: &ref_568 + properties: &ref_572 external_id: type: string description: The unique identifier from the external service @@ -25031,7 +26068,7 @@ paths: description: The workspace this trigger belongs to service_name: type: string - enum: *ref_228 + enum: *ref_229 script_path: type: string description: The path to the script or flow that will be triggered @@ -25054,7 +26091,7 @@ paths: type: string nullable: true description: Short summary to be displayed when listed - required: &ref_569 + required: &ref_573 - external_id - workspace_id - service_name @@ -25078,7 +26115,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 - name: external_id in: path required: true @@ -25108,7 +26145,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 responses: '200': description: sync completed successfully @@ -25133,7 +26170,7 @@ paths: type: array items: type: object - properties: &ref_577 + properties: &ref_581 id: type: string name: @@ -25144,7 +26181,7 @@ paths: type: string path: type: string - required: &ref_578 + required: &ref_582 - id - name - path @@ -25169,7 +26206,7 @@ paths: type: array items: type: object - properties: &ref_579 + properties: &ref_583 id: type: string summary: @@ -25177,7 +26214,7 @@ paths: primary: type: boolean default: false - required: &ref_580 + required: &ref_584 - id - summary /w/{workspace}/native_triggers/google/drive/files: @@ -25220,12 +26257,12 @@ paths: application/json: schema: type: object - properties: &ref_583 + properties: &ref_587 files: type: array items: type: object - properties: &ref_581 + properties: &ref_585 id: type: string name: @@ -25235,13 +26272,13 @@ paths: is_folder: type: boolean default: false - required: &ref_582 + required: &ref_586 - id - name - mime_type next_page_token: type: string - required: &ref_584 + required: &ref_588 - files /w/{workspace}/native_triggers/google/drive/shared_drives: get: @@ -25264,12 +26301,12 @@ paths: type: array items: type: object - properties: &ref_585 + properties: &ref_589 id: type: string name: type: string - required: &ref_586 + required: &ref_590 - id - name /w/{workspace}/native_triggers/github/repos: @@ -25293,7 +26330,7 @@ paths: type: array items: type: object - properties: &ref_587 + properties: &ref_591 full_name: type: string name: @@ -25302,7 +26339,7 @@ paths: type: string private: type: boolean - required: &ref_588 + required: &ref_592 - full_name - name - owner @@ -25319,7 +26356,7 @@ paths: required: true schema: type: string - enum: *ref_228 + enum: *ref_229 - name: workspace_id in: path required: true @@ -25368,7 +26405,7 @@ paths: application/json: schema: type: object - properties: &ref_453 + properties: &ref_454 mqtt_resource_path: type: string description: >- @@ -25378,16 +26415,16 @@ paths: type: array items: type: object - properties: &ref_235 + properties: &ref_236 qos: type: string - enum: &ref_452 + enum: &ref_453 - qos0 - qos1 - qos2 topic: type: string - required: &ref_236 + required: &ref_237 - qos - topic description: >- @@ -25401,7 +26438,7 @@ paths: nullable: true description: MQTT v3 specific configuration (clean_session) type: object - properties: &ref_237 + properties: &ref_238 clean_session: type: boolean v5_config: @@ -25410,7 +26447,7 @@ paths: MQTT v5 specific configuration (clean_start, topic_alias_maximum, session_expiry_interval) type: object - properties: &ref_238 + properties: &ref_239 clean_start: type: boolean topic_alias_maximum: @@ -25421,12 +26458,15 @@ paths: nullable: true description: MQTT protocol version ('v3' or 'v5') type: string - enum: &ref_239 + enum: &ref_240 - v3 - v5 path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -25440,7 +26480,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -25451,7 +26491,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -25467,7 +26507,7 @@ paths: type: array items: type: string - required: &ref_454 + required: &ref_455 - path - script_path - is_flow @@ -25502,7 +26542,7 @@ paths: application/json: schema: type: object - properties: &ref_455 + properties: &ref_456 mqtt_resource_path: type: string description: >- @@ -25512,8 +26552,8 @@ paths: type: array items: type: object - properties: *ref_235 - required: *ref_236 + properties: *ref_236 + required: *ref_237 description: >- Array of MQTT topics to subscribe to, each with topic name and QoS level @@ -25525,22 +26565,25 @@ paths: nullable: true description: MQTT v3 specific configuration (clean_session) type: object - properties: *ref_237 + properties: *ref_238 v5_config: nullable: true description: >- MQTT v5 specific configuration (clean_start, topic_alias_maximum, session_expiry_interval) type: object - properties: *ref_238 + properties: *ref_239 client_version: nullable: true description: MQTT protocol version ('v3' or 'v5') type: string - enum: *ref_239 + enum: *ref_240 path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -25554,7 +26597,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -25565,7 +26608,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -25581,7 +26624,7 @@ paths: type: array items: type: string - required: &ref_456 + required: &ref_457 - path - script_path - is_flow @@ -25638,12 +26681,12 @@ paths: content: application/json: schema: - allOf: &ref_240 + allOf: &ref_241 - type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 type: object - properties: &ref_241 + properties: &ref_242 mqtt_resource_path: type: string description: >- @@ -25653,8 +26696,8 @@ paths: type: array items: type: object - properties: *ref_235 - required: *ref_236 + properties: *ref_236 + required: *ref_237 description: >- Array of MQTT topics to subscribe to, each with topic name and QoS level @@ -25662,14 +26705,14 @@ paths: nullable: true description: MQTT v3 specific configuration (clean_session) type: object - properties: *ref_237 + properties: *ref_238 v5_config: nullable: true description: >- MQTT v5 specific configuration (clean_start, topic_alias_maximum, session_expiry_interval) type: object - properties: *ref_238 + properties: *ref_239 client_id: type: string nullable: true @@ -25678,7 +26721,7 @@ paths: nullable: true description: MQTT protocol version ('v3' or 'v5') type: string - enum: *ref_239 + enum: *ref_240 server_id: type: string description: >- @@ -25703,8 +26746,8 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 - required: &ref_242 + properties: *ref_197 + required: &ref_243 - subscribe_topics - mqtt_resource_path /w/{workspace}/mqtt_triggers/list: @@ -25753,10 +26796,10 @@ paths: schema: type: array items: - allOf: *ref_240 + allOf: *ref_241 type: object - properties: *ref_241 - required: *ref_242 + properties: *ref_242 + required: *ref_243 /w/{workspace}/mqtt_triggers/exists/{path}: get: summary: does mqtt trigger exists @@ -25805,7 +26848,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -25864,7 +26912,7 @@ paths: schema: type: object description: Data for creating or updating a Google Cloud Pub/Sub trigger. - properties: &ref_243 + properties: &ref_244 gcp_resource_path: type: string description: >- @@ -25872,7 +26920,7 @@ paths: credentials for authentication. subscription_mode: type: string - enum: &ref_248 + enum: &ref_249 - existing - create_update description: >- @@ -25890,7 +26938,7 @@ paths: description: Base URL for push delivery endpoint. delivery_type: type: string - enum: &ref_245 + enum: &ref_246 - push - pull description: >- @@ -25901,7 +26949,7 @@ paths: nullable: true type: object description: Configuration for push delivery mode. - properties: &ref_246 + properties: &ref_247 audience: type: string description: >- @@ -25912,12 +26960,15 @@ paths: description: >- If true, push messages will include OIDC authentication tokens. - required: &ref_247 + required: &ref_248 - authenticate - base_endpoint path: type: string - description: The unique path identifier for this trigger. + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -25931,7 +26982,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 auto_acknowledge_msg: type: boolean description: >- @@ -25958,7 +27009,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -25974,7 +27025,7 @@ paths: type: array items: type: string - required: &ref_244 + required: &ref_245 - path - script_path - is_flow @@ -26011,8 +27062,8 @@ paths: schema: type: object description: Data for creating or updating a Google Cloud Pub/Sub trigger. - properties: *ref_243 - required: *ref_244 + properties: *ref_244 + required: *ref_245 responses: '200': description: gcp trigger updated @@ -26063,15 +27114,15 @@ paths: content: application/json: schema: - allOf: &ref_249 + allOf: &ref_250 - type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 type: object description: >- A Google Cloud Pub/Sub trigger that executes a script or flow when messages are received. - properties: &ref_250 + properties: &ref_251 gcp_resource_path: type: string description: >- @@ -26090,7 +27141,7 @@ paths: use). delivery_type: type: string - enum: *ref_245 + enum: *ref_246 description: >- Delivery mode for messages. 'push' for HTTP push delivery where messages are sent to a webhook endpoint, 'pull' for @@ -26099,11 +27150,11 @@ paths: nullable: true type: object description: Configuration for push delivery mode. - properties: *ref_246 - required: *ref_247 + properties: *ref_247 + required: *ref_248 subscription_mode: type: string - enum: *ref_248 + enum: *ref_249 description: >- The mode of subscription. 'existing' means using an existing GCP subscription, while 'create_update' involves @@ -26127,8 +27178,8 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 - required: &ref_251 + properties: *ref_197 + required: &ref_252 - gcp_resource_path - topic_id - subscription_id @@ -26180,13 +27231,13 @@ paths: schema: type: array items: - allOf: *ref_249 + allOf: *ref_250 type: object description: >- A Google Cloud Pub/Sub trigger that executes a script or flow when messages are received. - properties: *ref_250 - required: *ref_251 + properties: *ref_251 + required: *ref_252 /w/{workspace}/gcp_triggers/exists/{path}: get: summary: does gcp trigger exists @@ -26235,7 +27286,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -26297,10 +27353,10 @@ paths: application/json: schema: type: object - properties: &ref_459 + properties: &ref_460 subscription_id: type: string - required: &ref_460 + required: &ref_461 - subscription_id responses: '200': @@ -26355,10 +27411,10 @@ paths: application/json: schema: type: object - properties: &ref_457 + properties: &ref_458 topic_id: type: string - required: &ref_458 + required: &ref_459 - topic_id responses: '200': @@ -26387,12 +27443,12 @@ paths: schema: type: object description: Data for creating or updating an Azure Event Grid trigger. - properties: &ref_252 + properties: &ref_253 azure_resource_path: type: string azure_mode: type: string - enum: &ref_254 + enum: &ref_255 - basic_push - namespace_push - namespace_pull @@ -26413,6 +27469,10 @@ paths: type: string path: type: string + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string is_flow: @@ -26420,7 +27480,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 error_handler_path: type: string error_handler_args: @@ -26430,7 +27490,7 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string preserve_permissioned_as: @@ -26439,7 +27499,7 @@ paths: type: array items: type: string - required: &ref_253 + required: &ref_254 - path - script_path - is_flow @@ -26476,8 +27536,8 @@ paths: schema: type: object description: Data for creating or updating an Azure Event Grid trigger. - properties: *ref_252 - required: *ref_253 + properties: *ref_253 + required: *ref_254 responses: '200': description: azure trigger updated @@ -26528,20 +27588,20 @@ paths: content: application/json: schema: - allOf: &ref_255 + allOf: &ref_256 - type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 type: object description: >- An Azure Event Grid trigger that executes a script or flow when events arrive. - properties: &ref_256 + properties: &ref_257 azure_resource_path: type: string azure_mode: type: string - enum: *ref_254 + enum: *ref_255 description: Azure Event Grid trigger mode. scope_resource_id: type: string @@ -26575,8 +27635,8 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_196 - required: &ref_257 + properties: *ref_197 + required: &ref_258 - azure_resource_path - azure_mode - scope_resource_id @@ -26621,13 +27681,13 @@ paths: schema: type: array items: - allOf: *ref_255 + allOf: *ref_256 type: object description: >- An Azure Event Grid trigger that executes a script or flow when events arrive. - properties: *ref_256 - required: *ref_257 + properties: *ref_257 + required: *ref_258 /w/{workspace}/azure_triggers/exists/{path}: get: summary: check whether an azure trigger exists @@ -26675,7 +27735,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -26702,10 +27767,10 @@ paths: application/json: schema: type: object - properties: &ref_463 + properties: &ref_464 azure_resource_path: type: string - required: &ref_464 + required: &ref_465 - azure_resource_path responses: '200': @@ -26735,10 +27800,10 @@ paths: application/json: schema: type: object - properties: &ref_465 + properties: &ref_466 scope_resource_id: type: string - required: &ref_466 + required: &ref_467 - scope_resource_id responses: '200': @@ -26770,12 +27835,12 @@ paths: application/json: schema: type: object - properties: &ref_467 + properties: &ref_468 scope_resource_id: type: string topic_name: type: string - required: &ref_468 + required: &ref_469 - scope_resource_id - topic_name responses: @@ -26808,10 +27873,10 @@ paths: application/json: schema: type: object - properties: &ref_461 + properties: &ref_462 azure_mode: type: string - enum: *ref_254 + enum: *ref_255 description: Azure Event Grid trigger mode. scope_resource_id: type: string @@ -26820,7 +27885,7 @@ paths: nullable: true subscription_name: type: string - required: &ref_462 + required: &ref_463 - azure_mode - scope_resource_id - subscription_name @@ -26856,7 +27921,7 @@ paths: items: type: object description: An ARM resource the service principal can see. - properties: &ref_258 + properties: &ref_259 id: type: string name: @@ -26865,7 +27930,7 @@ paths: type: string type: type: string - required: &ref_259 + required: &ref_260 - id - name - type @@ -26896,8 +27961,8 @@ paths: items: type: object description: An ARM resource the service principal can see. - properties: *ref_258 - required: *ref_259 + properties: *ref_259 + required: *ref_260 /w/{workspace}/postgres_triggers/postgres/version/{path}: get: summary: get postgres version @@ -26960,19 +28025,19 @@ paths: application/json: schema: type: object - properties: &ref_477 + properties: &ref_478 postgres_resource_path: type: string relations: type: array items: type: object - properties: &ref_261 + properties: &ref_262 schema_name: type: string table_to_track: type: array - items: &ref_475 + items: &ref_476 type: object properties: table_name: @@ -26985,14 +28050,14 @@ paths: type: string required: - table_name - required: &ref_262 + required: &ref_263 - schema_name - table_to_track language: type: string - enum: &ref_476 + enum: &ref_477 - Typescript - required: &ref_478 + required: &ref_479 - postgres_resource_path - relations - language @@ -27017,7 +28082,7 @@ paths: - name: id in: path required: true - schema: &ref_303 + schema: &ref_304 type: string responses: '200': @@ -27050,7 +28115,7 @@ paths: type: array items: type: object - properties: &ref_474 + properties: &ref_475 slot_name: type: string active: @@ -27077,7 +28142,7 @@ paths: application/json: schema: type: object - properties: &ref_260 + properties: &ref_261 name: type: string responses: @@ -27109,7 +28174,7 @@ paths: application/json: schema: type: object - properties: *ref_260 + properties: *ref_261 responses: '200': description: postgres replication slot deleted @@ -27160,7 +28225,7 @@ paths: in: path required: true description: The name of the publication - schema: &ref_263 + schema: &ref_264 type: string responses: '200': @@ -27169,18 +28234,18 @@ paths: application/json: schema: type: object - properties: &ref_264 + properties: &ref_265 table_to_track: type: array items: type: object - properties: *ref_261 - required: *ref_262 + properties: *ref_262 + required: *ref_263 transaction_to_track: type: array items: type: string - required: &ref_265 + required: &ref_266 - transaction_to_track /w/{workspace}/postgres_triggers/publication/create/{publication}/{path}: post: @@ -27201,7 +28266,7 @@ paths: in: path required: true description: The name of the publication - schema: *ref_263 + schema: *ref_264 requestBody: description: new publication for postgres required: true @@ -27209,8 +28274,8 @@ paths: application/json: schema: type: object - properties: *ref_264 - required: *ref_265 + properties: *ref_265 + required: *ref_266 responses: '201': description: publication created @@ -27237,7 +28302,7 @@ paths: in: path required: true description: The name of the publication - schema: *ref_263 + schema: *ref_264 requestBody: description: update publication for postgres required: true @@ -27245,8 +28310,8 @@ paths: application/json: schema: type: object - properties: *ref_264 - required: *ref_265 + properties: *ref_265 + required: *ref_266 responses: '201': description: publication updated @@ -27273,7 +28338,7 @@ paths: in: path required: true description: The name of the publication - schema: *ref_263 + schema: *ref_264 responses: '200': description: postgres publication deleted @@ -27299,7 +28364,7 @@ paths: application/json: schema: type: object - properties: &ref_479 + properties: &ref_480 replication_slot_name: type: string description: Name of the PostgreSQL logical replication slot to use @@ -27310,7 +28375,10 @@ paths: change data capture path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -27324,7 +28392,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 postgres_resource_path: type: string description: >- @@ -27335,8 +28403,8 @@ paths: Configuration for creating/managing the publication (tables, operations) type: object - properties: *ref_264 - required: *ref_265 + properties: *ref_265 + required: *ref_266 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -27347,7 +28415,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -27363,7 +28431,7 @@ paths: type: array items: type: string - required: &ref_480 + required: &ref_481 - path - script_path - is_flow @@ -27398,7 +28466,7 @@ paths: application/json: schema: type: object - properties: &ref_481 + properties: &ref_482 replication_slot_name: type: string description: Name of the PostgreSQL logical replication slot to use @@ -27409,7 +28477,10 @@ paths: change data capture path: type: string - description: The unique path identifier for this trigger + description: >- + The unique Windmill path for this trigger. Must be of the + form `u//` or `f//`. This is the + trigger object path, not the HTTP route path. script_path: type: string description: >- @@ -27423,7 +28494,7 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 postgres_resource_path: type: string description: >- @@ -27434,8 +28505,8 @@ paths: Configuration for creating/managing the publication (tables, operations) type: object - properties: *ref_264 - required: *ref_265 + properties: *ref_265 + required: *ref_266 error_handler_path: type: string description: Path to a script or flow to run when the triggered job fails @@ -27446,7 +28517,7 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -27462,7 +28533,7 @@ paths: type: array items: type: string - required: &ref_482 + required: &ref_483 - path - script_path - is_flow @@ -27520,12 +28591,12 @@ paths: content: application/json: schema: - allOf: &ref_266 + allOf: &ref_267 - type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 type: object - properties: &ref_267 + properties: &ref_268 postgres_resource_path: type: string description: >- @@ -27563,8 +28634,8 @@ paths: retry: description: Retry configuration for failed module executions type: object - properties: *ref_196 - required: &ref_268 + properties: *ref_197 + required: &ref_269 - postgres_resource_path - replication_slot_name - publication_name @@ -27614,10 +28685,10 @@ paths: schema: type: array items: - allOf: *ref_266 + allOf: *ref_267 type: object - properties: *ref_267 - required: *ref_268 + properties: *ref_268 + required: *ref_269 /w/{workspace}/postgres_triggers/exists/{path}: get: summary: does postgres trigger exists @@ -27666,7 +28737,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -27724,7 +28800,7 @@ paths: application/json: schema: type: object - properties: &ref_491 + properties: &ref_492 path: type: string script_path: @@ -27744,11 +28820,11 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_196 + properties: *ref_197 mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 permissioned_as: type: string description: >- @@ -27764,7 +28840,7 @@ paths: type: array items: type: string - required: &ref_492 + required: &ref_493 - path - script_path - local_part @@ -27798,7 +28874,7 @@ paths: application/json: schema: type: object - properties: &ref_493 + properties: &ref_494 path: type: string script_path: @@ -27818,7 +28894,7 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_196 + properties: *ref_197 permissioned_as: type: string description: >- @@ -27834,7 +28910,7 @@ paths: type: array items: type: string - required: &ref_494 + required: &ref_495 - path - script_path - is_flow @@ -27888,12 +28964,12 @@ paths: content: application/json: schema: - allOf: &ref_269 + allOf: &ref_270 - type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 type: object - properties: &ref_270 + properties: &ref_271 local_part: type: string workspaced_local_part: @@ -27907,8 +28983,8 @@ paths: retry: type: object description: Retry configuration for failed module executions - properties: *ref_196 - required: &ref_271 + properties: *ref_197 + required: &ref_272 - local_part /w/{workspace}/email_triggers/list: get: @@ -27956,10 +29032,10 @@ paths: schema: type: array items: - allOf: *ref_269 + allOf: *ref_270 type: object - properties: *ref_270 - required: *ref_271 + properties: *ref_271 + required: *ref_272 /w/{workspace}/email_triggers/exists/{path}: get: summary: does email trigger exists @@ -28041,7 +29117,12 @@ paths: mode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 + force: + type: boolean + description: > + Bypass the parent-state conflict warning when enabling a + trigger in a fork whose parent has the same path enabled. required: - mode responses: @@ -28066,9 +29147,9 @@ paths: type: array items: type: object - required: &ref_495 + required: &ref_496 - name - properties: &ref_496 + properties: &ref_497 name: type: string summary: @@ -28098,9 +29179,9 @@ paths: type: array items: type: object - required: &ref_273 + required: &ref_274 - name - properties: &ref_274 + properties: &ref_275 name: type: string summary: @@ -28119,14 +29200,14 @@ paths: type: array items: type: object - properties: &ref_497 + properties: &ref_498 workspace_id: type: string workspace_name: type: string role: type: string - required: &ref_498 + required: &ref_499 - name /groups/get/{name}: get: @@ -28138,7 +29219,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: instance group @@ -28146,8 +29227,8 @@ paths: application/json: schema: type: object - required: *ref_273 - properties: *ref_274 + required: *ref_274 + properties: *ref_275 /groups/create: post: summary: create instance group @@ -28185,7 +29266,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: update instance group required: true @@ -28221,7 +29302,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: instance group deleted @@ -28239,7 +29320,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: user to add to instance group required: true @@ -28269,7 +29350,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: user to remove from instance group required: true @@ -28304,7 +29385,7 @@ paths: type: array items: type: object - properties: &ref_275 + properties: &ref_276 name: type: string summary: @@ -28325,7 +29406,7 @@ paths: enum: - superadmin - devops - required: &ref_276 + required: &ref_277 - name /groups/overwrite: post: @@ -28342,8 +29423,8 @@ paths: type: array items: type: object - properties: *ref_275 - required: *ref_276 + properties: *ref_276 + required: *ref_277 responses: '200': description: success message @@ -28379,7 +29460,7 @@ paths: type: array items: type: object - properties: &ref_277 + properties: &ref_278 name: type: string summary: @@ -28392,7 +29473,7 @@ paths: type: object additionalProperties: type: boolean - required: &ref_278 + required: &ref_279 - name /w/{workspace}/groups/listnames: get: @@ -28465,7 +29546,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: updated group required: true @@ -28497,7 +29578,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: group deleted @@ -28519,7 +29600,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: group @@ -28527,8 +29608,8 @@ paths: application/json: schema: type: object - properties: *ref_277 - required: *ref_278 + properties: *ref_278 + required: *ref_279 /w/{workspace}/groups/adduser/{name}: post: summary: add user to group @@ -28543,7 +29624,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: added user to group required: true @@ -28575,7 +29656,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: added user to group required: true @@ -28607,7 +29688,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 - name: page description: which page to return (start at 1, default 1) in: query @@ -28666,7 +29747,7 @@ paths: type: array items: type: object - properties: &ref_280 + properties: &ref_281 name: type: string owners: @@ -28692,7 +29773,7 @@ paths: (relative to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: &ref_279 + items: &ref_280 type: object required: - path_glob @@ -28713,7 +29794,7 @@ paths: permissioned as. Must be `u/`, `g/`, or an email that exists in this workspace. - required: &ref_281 + required: &ref_282 - name - owners - extra_perms @@ -28780,7 +29861,7 @@ paths: to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: *ref_279 + items: *ref_280 required: - name responses: @@ -28804,7 +29885,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: update folder required: true @@ -28830,7 +29911,7 @@ paths: to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: *ref_279 + items: *ref_280 responses: '200': description: folder updated @@ -28852,7 +29933,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: folder deleted @@ -28874,7 +29955,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: folder @@ -28882,8 +29963,8 @@ paths: application/json: schema: type: object - properties: *ref_280 - required: *ref_281 + properties: *ref_281 + required: *ref_282 /w/{workspace}/folders/exists/{name}: get: summary: exists folder @@ -28898,7 +29979,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: folder exists @@ -28920,7 +30001,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: folder @@ -28962,7 +30043,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: owner user to folder required: true @@ -28996,7 +30077,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: added owner to folder required: true @@ -29032,7 +30113,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 - name: page description: which page to return (start at 1, default 1) in: query @@ -29096,7 +30177,7 @@ paths: type: array items: type: object - properties: &ref_499 + properties: &ref_500 worker: type: string worker_instance: @@ -29142,7 +30223,7 @@ paths: type: string native_mode: type: boolean - required: &ref_500 + required: &ref_501 - worker - worker_instance - ping_at @@ -29244,6 +30325,37 @@ paths: type: object additionalProperties: type: integer + /workers/workspace_fairness_events: + get: + summary: list last 100 workspace-fairness cap/uncap events (cloud-only) + operationId: getWorkspaceFairnessEvents + tags: + - worker + responses: + '200': + description: workspace fairness events (empty on non-cloud) + content: + application/json: + schema: + type: array + items: + type: object + properties: + timestamp: + type: string + format: date-time + operation: + type: string + workspace_id: + type: string + nullable: true + parameters: + type: object + nullable: true + additionalProperties: true + required: + - timestamp + - operation /configs/list_worker_groups: get: summary: list worker groups @@ -29276,7 +30388,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: a config @@ -29285,12 +30397,12 @@ paths: schema: type: object nullable: true - properties: &ref_383 + properties: &ref_384 alerts: type: array items: type: object - properties: &ref_381 + properties: &ref_382 name: type: string tags_to_monitor: @@ -29303,7 +30415,7 @@ paths: type: integer alert_time_threshold_seconds: type: integer - required: &ref_382 + required: &ref_383 - name - tags_to_monitor - jobs_num_threshold @@ -29319,7 +30431,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 requestBody: description: worker group required: true @@ -29342,7 +30454,7 @@ paths: - name: name in: path required: true - schema: *ref_272 + schema: *ref_273 responses: '200': description: Delete config @@ -29365,12 +30477,12 @@ paths: type: array items: type: object - properties: &ref_543 + properties: &ref_547 name: type: string config: type: object - required: &ref_544 + required: &ref_548 - name /configs/list_autoscaling_events/{worker_group}: get: @@ -29401,7 +30513,7 @@ paths: type: array items: type: object - properties: &ref_547 + properties: &ref_551 id: type: integer format: int64 @@ -29834,7 +30946,7 @@ paths: properties: trigger_kind: type: string - enum: &ref_282 + enum: &ref_283 - webhook - http - websocket @@ -29880,7 +30992,7 @@ paths: required: true schema: type: string - enum: *ref_282 + enum: *ref_283 - name: runnable_kind in: path required: true @@ -29920,17 +31032,17 @@ paths: type: array items: type: object - properties: &ref_548 + properties: &ref_552 trigger_config: {} trigger_kind: type: string - enum: *ref_282 + enum: *ref_283 error: type: string last_server_ping: type: string format: date-time - required: &ref_549 + required: &ref_553 - trigger_kind /w/{workspace}/capture/list/{runnable_kind}/{path}: get: @@ -29955,7 +31067,7 @@ paths: in: query schema: type: string - enum: *ref_282 + enum: *ref_283 - name: page description: which page to return (start at 1, default 1) in: query @@ -29973,10 +31085,10 @@ paths: type: array items: type: object - properties: &ref_283 + properties: &ref_284 trigger_kind: type: string - enum: *ref_282 + enum: *ref_283 main_args: {} preprocessor_args: {} id: @@ -29984,7 +31096,7 @@ paths: created_at: type: string format: date-time - required: &ref_284 + required: &ref_285 - trigger_kind - main_args - preprocessor_args @@ -30049,8 +31161,8 @@ paths: application/json: schema: type: object - properties: *ref_283 - required: *ref_284 + properties: *ref_284 + required: *ref_285 delete: summary: delete a capture operationId: deleteCapture @@ -30142,13 +31254,13 @@ paths: schema: *ref_4 - name: runnable_id in: query - schema: &ref_285 + schema: &ref_286 type: string - name: runnable_type in: query - schema: &ref_286 + schema: &ref_287 type: string - enum: &ref_391 + enum: &ref_392 - ScriptHash - ScriptPath - FlowPath @@ -30165,7 +31277,7 @@ paths: filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_166 + schema: *ref_167 - name: include_preview in: query schema: @@ -30179,7 +31291,7 @@ paths: type: array items: type: object - properties: &ref_287 + properties: &ref_288 id: type: string name: @@ -30193,7 +31305,7 @@ paths: type: boolean success: type: boolean - required: &ref_288 + required: &ref_289 - id - name - args @@ -30243,10 +31355,10 @@ paths: schema: *ref_4 - name: runnable_id in: query - schema: *ref_285 + schema: *ref_286 - name: runnable_type in: query - schema: *ref_286 + schema: *ref_287 - name: page description: which page to return (start at 1, default 1) in: query @@ -30264,8 +31376,8 @@ paths: type: array items: type: object - properties: *ref_287 - required: *ref_288 + properties: *ref_288 + required: *ref_289 /w/{workspace}/inputs/create: post: summary: Create an Input for future use in a script or flow @@ -30279,10 +31391,10 @@ paths: schema: *ref_4 - name: runnable_id in: query - schema: *ref_285 + schema: *ref_286 - name: runnable_type in: query - schema: *ref_286 + schema: *ref_287 requestBody: description: Input required: true @@ -30290,12 +31402,12 @@ paths: application/json: schema: type: object - properties: &ref_387 + properties: &ref_388 name: type: string args: type: object - required: &ref_388 + required: &ref_389 - name - args - created_by @@ -30325,14 +31437,14 @@ paths: application/json: schema: type: object - properties: &ref_389 + properties: &ref_390 id: type: string name: type: string is_public: type: boolean - required: &ref_390 + required: &ref_391 - id - name - is_public @@ -30358,7 +31470,7 @@ paths: - name: input in: path required: true - schema: &ref_311 + schema: &ref_312 type: string responses: '200': @@ -30391,7 +31503,7 @@ paths: properties: s3_resource: type: object - properties: &ref_289 + properties: &ref_290 bucket: type: string region: @@ -30406,7 +31518,7 @@ paths: type: string pathStyle: type: boolean - required: &ref_290 + required: &ref_291 - bucket - region - endPoint @@ -30484,8 +31596,8 @@ paths: properties: s3_resource: type: object - properties: *ref_289 - required: *ref_290 + properties: *ref_290 + required: *ref_291 responses: '200': description: Connection settings @@ -30506,10 +31618,10 @@ paths: type: boolean client_kwargs: type: object - properties: &ref_291 + properties: &ref_292 region_name: type: string - required: &ref_292 + required: &ref_293 - region_name required: - endpoint_url @@ -30564,8 +31676,8 @@ paths: type: boolean client_kwargs: type: object - properties: *ref_291 - required: *ref_292 + properties: *ref_292 + required: *ref_293 required: - endpoint_url - use_ssl @@ -30623,8 +31735,8 @@ paths: application/json: schema: type: object - properties: *ref_289 - required: *ref_290 + properties: *ref_290 + required: *ref_291 /w/{workspace}/job_helpers/test_connection: get: summary: Test connection to the workspace object storage @@ -30688,10 +31800,10 @@ paths: type: array items: type: object - properties: &ref_293 + properties: &ref_294 s3: type: string - required: &ref_294 + required: &ref_295 - s3 restricted_access: type: boolean @@ -30724,7 +31836,7 @@ paths: application/json: schema: type: object - properties: &ref_297 + properties: &ref_298 mime_type: type: string size_in_bytes: @@ -30788,7 +31900,7 @@ paths: application/json: schema: type: object - properties: &ref_295 + properties: &ref_296 msg: type: string content: @@ -30800,7 +31912,7 @@ paths: - Csv - Parquet - Unknown - required: &ref_296 + required: &ref_297 - content_type /w/{workspace}/job_helpers/list_git_repo_files: get: @@ -30848,8 +31960,8 @@ paths: type: array items: type: object - properties: *ref_293 - required: *ref_294 + properties: *ref_294 + required: *ref_295 restricted_access: type: boolean required: @@ -30908,8 +32020,8 @@ paths: application/json: schema: type: object - properties: *ref_295 - required: *ref_296 + properties: *ref_296 + required: *ref_297 /w/{workspace}/job_helpers/load_git_repo_file_metadata: get: summary: >- @@ -30940,7 +32052,7 @@ paths: application/json: schema: type: object - properties: *ref_297 + properties: *ref_298 /w/{workspace}/job_helpers/check_s3_folder_exists: get: summary: Check if S3 path exists and is a folder @@ -30961,7 +32073,7 @@ paths: schema: type: string - name: marker_file - description: >- + description: | If provided, the folder is only considered to exist when this exact sentinel file is present under file_key. Lets callers distinguish a fully populated folder from a partial upload. @@ -31391,7 +32503,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 requestBody: description: parameters for statistics retrieval required: true @@ -31420,46 +32532,46 @@ paths: type: array items: type: object - properties: &ref_529 + properties: &ref_533 id: type: string name: type: string - required: &ref_530 + required: &ref_534 - id scalar_metrics: type: array items: type: object - properties: &ref_531 + properties: &ref_535 metric_id: type: string value: type: number - required: &ref_532 + required: &ref_536 - id - value timeseries_metrics: type: array items: type: object - properties: &ref_533 + properties: &ref_537 metric_id: type: string values: type: array items: type: object - properties: &ref_535 + properties: &ref_539 timestamp: type: string format: date-time value: type: number - required: &ref_536 + required: &ref_540 - timestamp - value - required: &ref_534 + required: &ref_538 - id - values /w/{workspace}/job_metrics/set_progress/{id}: @@ -31476,7 +32588,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 requestBody: description: parameters for statistics retrieval required: true @@ -31510,7 +32622,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: job progress between 0 and 99 @@ -31528,11 +32640,11 @@ paths: - name: before description: filter on started before (inclusive) timestamp in: query - schema: *ref_298 + schema: *ref_299 - name: after description: filter on created after (exclusive) timestamp in: query - schema: *ref_299 + schema: *ref_300 - name: with_error in: query required: false @@ -31604,12 +32716,12 @@ paths: type: array items: type: object - properties: &ref_537 + properties: &ref_541 concurrency_key: type: string total_running: type: number - required: &ref_538 + required: &ref_542 - concurrency_key - total_running /concurrency_groups/prune/{concurrency_id}: @@ -31622,7 +32734,7 @@ paths: - name: concurrency_id in: path required: true - schema: &ref_313 + schema: &ref_314 type: string responses: '200': @@ -31642,7 +32754,7 @@ paths: - name: id in: path required: true - schema: *ref_171 + schema: *ref_172 responses: '200': description: concurrency key for given job @@ -31685,7 +32797,7 @@ paths: (e.g. 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_173 + schema: *ref_174 - name: parent_job description: >- The parent job that is at the origin and responsible for the @@ -31698,84 +32810,84 @@ paths: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_156 + schema: *ref_157 - name: script_path_start description: >- filter by script path prefix. Supports comma-separated list (e.g. 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_157 + schema: *ref_158 - name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_158 + schema: *ref_159 - name: script_hash description: mask to filter exact matching path in: query - schema: *ref_159 + schema: *ref_160 - name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_160 + schema: *ref_161 - name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_161 + schema: *ref_162 - name: running description: filter on running jobs in: query - schema: *ref_162 + schema: *ref_163 - name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_163 + schema: *ref_164 - name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_183 + schema: *ref_184 - name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_184 + schema: *ref_185 - name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: *ref_185 + schema: *ref_186 - name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: *ref_186 + schema: *ref_187 - name: job_kinds description: >- filter by job kind. Supports comma-separated list of values ('preview', 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_164 + schema: *ref_165 - name: args description: >- filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_166 + schema: *ref_167 - name: tag description: >- filter by tag/worker group. Supports comma-separated list (e.g. 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_167 + schema: *ref_168 - name: result description: >- filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_168 + schema: *ref_169 - name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_170 + schema: *ref_171 - name: page description: which page to return (start at 1, default 1) in: query @@ -31791,7 +32903,7 @@ paths: (e.g. '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: *ref_187 + schema: *ref_188 - name: is_skipped description: is the job skipped in: query @@ -31831,17 +32943,17 @@ paths: application/json: schema: type: object - properties: &ref_539 + properties: &ref_543 jobs: type: array items: - oneOf: *ref_192 - discriminator: *ref_193 + oneOf: *ref_193 + discriminator: *ref_194 obscured_jobs: type: array items: type: object - properties: &ref_392 + properties: &ref_393 typ: type: string started_at: @@ -31854,7 +32966,7 @@ paths: Obscured jobs omitted for security because of too specific filtering type: boolean - required: &ref_540 + required: &ref_544 - jobs - obscured_jobs /srch/w/{workspace}/index/search/job: @@ -31898,7 +33010,7 @@ paths: type: array items: type: object - properties: &ref_545 + properties: &ref_549 dancer: type: string hit_count: @@ -31977,7 +33089,7 @@ paths: type: array items: type: object - properties: &ref_546 + properties: &ref_550 dancer: type: string /srch/index/search/count_service_logs: @@ -32115,6 +33227,12 @@ paths: properties: is_alive: type: boolean + state: + type: string + enum: + - running + - stale + - never_started last_locked_at: type: string format: date-time @@ -32136,6 +33254,12 @@ paths: properties: is_alive: type: boolean + state: + type: string + enum: + - running + - stale + - never_started last_locked_at: type: string format: date-time @@ -32240,7 +33364,7 @@ paths: type: string kind: type: string - enum: *ref_300 + enum: *ref_301 usages: type: array items: @@ -32253,13 +33377,13 @@ paths: type: string kind: type: string - enum: &ref_302 + enum: &ref_303 - script - flow - job access_type: type: string - enum: &ref_301 + enum: &ref_302 - r - w - rw @@ -32269,7 +33393,7 @@ paths: description: The columns used (for tables) additionalProperties: type: string - enum: *ref_301 + enum: *ref_302 nullable: true created_at: type: string @@ -32342,7 +33466,7 @@ paths: type: string kind: type: string - enum: *ref_302 + enum: *ref_303 responses: '200': description: all assets used by the given usage paths, in the same order @@ -32362,10 +33486,10 @@ paths: type: string kind: type: string - enum: *ref_300 + enum: *ref_301 access_type: type: string - enum: *ref_301 + enum: *ref_302 nullable: true /w/{workspace}/assets/list_favorites: get: @@ -32413,13 +33537,13 @@ paths: type: array items: type: object - required: &ref_559 + required: &ref_563 - name - size_bytes - file_count - created_at - created_by - properties: &ref_560 + properties: &ref_564 name: type: string size_bytes: @@ -32534,13 +33658,13 @@ paths: type: array items: type: object - required: &ref_375 + required: &ref_376 - name - description - instructions - path - method - properties: &ref_376 + properties: &ref_377 name: type: string description: The tool name/operation ID @@ -32677,7 +33801,7 @@ components: name: id in: path required: true - schema: *ref_303 + schema: *ref_304 Key: name: key in: path @@ -32693,7 +33817,7 @@ components: in: path required: true description: The name of the publication - schema: *ref_263 + schema: *ref_264 VersionId: name: version in: path @@ -32704,7 +33828,7 @@ components: name: token in: path required: true - schema: *ref_304 + schema: *ref_305 AccountId: name: id in: path @@ -32729,7 +33853,7 @@ components: name: id in: path required: true - schema: *ref_171 + schema: *ref_172 Path: name: path in: path @@ -32749,12 +33873,12 @@ components: name: version in: path required: true - schema: *ref_305 + schema: *ref_306 Name: name: name in: path required: true - schema: *ref_272 + schema: *ref_273 Page: name: page description: which page to return (start at 1, default 1) @@ -32773,7 +33897,7 @@ components: '!schedule,!webhook') in: query x-go-name: JobTriggerKindParam - schema: *ref_187 + schema: *ref_188 OrderDesc: name: order_desc description: order by desc order (default true) @@ -32794,7 +33918,7 @@ components: 'deploy,release') and negation by prefixing all values with '!' (e.g. '!deploy,!release') in: query - schema: *ref_173 + schema: *ref_174 Worker: name: worker description: >- @@ -32802,7 +33926,7 @@ components: 'worker-1,worker-2') and negation by prefixing all values with '!' (e.g. '!worker-1,!worker-2') in: query - schema: *ref_155 + schema: *ref_156 ParentJob: name: parent_job description: >- @@ -32868,12 +33992,12 @@ components: 'f/folder1,f/folder2') and negation by prefixing all values with '!' (e.g. '!f/folder1,!f/folder2') in: query - schema: *ref_157 + schema: *ref_158 SchedulePath: name: schedule_path description: mask to filter by schedule path in: query - schema: *ref_158 + schema: *ref_159 TriggerPath: name: trigger_path description: >- @@ -32881,7 +34005,7 @@ components: 'f/trigger1,f/trigger2') and negation by prefixing all values with '!' (e.g. '!f/trigger1,!f/trigger2') in: query - schema: *ref_306 + schema: *ref_307 ScriptExactPath: name: script_path_exact description: >- @@ -32889,87 +34013,87 @@ components: (e.g. 'f/script1,f/script2') and negation by prefixing all values with '!' (e.g. '!f/script1,!f/script2') in: query - schema: *ref_156 + schema: *ref_157 ScriptExactHash: name: script_hash description: mask to filter exact matching path in: query - schema: *ref_159 + schema: *ref_160 CreatedBefore: name: created_before description: filter on created before (inclusive) timestamp in: query - schema: *ref_181 + schema: *ref_182 CreatedAfter: name: created_after description: filter on created after (exclusive) timestamp in: query - schema: *ref_182 + schema: *ref_183 StartedBefore: name: started_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_160 + schema: *ref_161 StartedAfter: name: started_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_161 + schema: *ref_162 Before: name: before description: filter on started before (inclusive) timestamp in: query - schema: *ref_298 + schema: *ref_299 CompletedBefore: name: completed_before description: filter on started before (inclusive) timestamp in: query - schema: *ref_183 + schema: *ref_184 CompletedAfter: name: completed_after description: filter on started after (exclusive) timestamp in: query - schema: *ref_184 + schema: *ref_185 CreatedAfterQueue: name: created_after_queue description: filter on jobs created after X for jobs in the queue only in: query - schema: *ref_186 + schema: *ref_187 CreatedBeforeQueue: name: created_before_queue description: filter on jobs created before X for jobs in the queue only in: query - schema: *ref_185 + schema: *ref_186 Success: name: success description: filter on successful jobs in: query - schema: *ref_169 + schema: *ref_170 ScheduledForBeforeNow: name: scheduled_for_before_now description: filter on jobs scheduled_for before now (hence waitinf for a worker) in: query - schema: *ref_163 + schema: *ref_164 Suspended: name: suspended description: filter on suspended jobs in: query - schema: *ref_165 + schema: *ref_166 Running: name: running description: filter on running jobs in: query - schema: *ref_162 + schema: *ref_163 AllowWildcards: name: allow_wildcards description: allow wildcards (*) in the filter of label, tag, worker in: query - schema: *ref_170 + schema: *ref_171 ArgsFilter: name: args description: filter on jobs containing those args as a json subset (@> in postgres) in: query - schema: *ref_166 + schema: *ref_167 Tag: name: tag description: >- @@ -32977,37 +34101,37 @@ components: 'gpu,highmem') and negation by prefixing all values with '!' (e.g. '!gpu,!highmem') in: query - schema: *ref_167 + schema: *ref_168 ResultFilter: name: result description: filter on jobs containing those result as a json subset (@> in postgres) in: query - schema: *ref_168 + schema: *ref_169 After: name: after description: filter on created after (exclusive) timestamp in: query - schema: *ref_299 + schema: *ref_300 Username: name: username description: filter on exact username of user in: query - schema: *ref_307 + schema: *ref_308 Operation: name: operation description: filter on exact or prefix name of operation in: query - schema: *ref_308 + schema: *ref_309 ResourceName: name: resource description: filter on exact or prefix name of resource in: query - schema: *ref_309 + schema: *ref_310 ActionKind: name: action_kind description: filter on type of operation in: query - schema: *ref_310 + schema: *ref_311 JobKinds: name: job_kinds description: >- @@ -33015,29 +34139,29 @@ components: 'script', 'dependencies', 'flow') and negation by prefixing all values with '!' (e.g. '!preview,!dependencies') in: query - schema: *ref_164 + schema: *ref_165 RunnableId: name: runnable_id in: query - schema: *ref_285 + schema: *ref_286 RunnableTypeQuery: name: runnable_type in: query - schema: *ref_286 + schema: *ref_287 InputId: name: input in: path required: true - schema: *ref_311 + schema: *ref_312 GetStarted: name: get_started in: query - schema: *ref_312 + schema: *ref_313 ConcurrencyId: name: concurrency_id in: path required: true - schema: *ref_313 + schema: *ref_314 RunnableKind: name: runnable_kind in: path @@ -33061,7 +34185,7 @@ components: Retry: type: object description: Retry configuration for failed module executions - properties: *ref_196 + properties: *ref_197 StopAfterIf: type: object description: Early termination condition for a module @@ -33204,7 +34328,7 @@ components: retry: description: Retry configuration for failed module executions type: object - properties: *ref_314 + properties: *ref_315 debouncing: description: Debounce configuration for this step (EE only) type: object @@ -33295,7 +34419,7 @@ components: kind: type: string description: Supported AI provider types - enum: *ref_315 + enum: *ref_316 resource: type: string description: >- @@ -33315,16 +34439,16 @@ components: oneOf: - type: object description: No conversation memory/context - properties: *ref_316 - required: *ref_317 + properties: *ref_317 + required: *ref_318 - type: object description: Automatic context management - properties: *ref_318 - required: *ref_319 + properties: *ref_319 + required: *ref_320 - type: object description: Explicit message history - properties: *ref_320 - required: *ref_321 + properties: *ref_321 + required: *ref_322 discriminator: propertyName: kind mapping: @@ -33341,62 +34465,62 @@ components: Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms - properties: *ref_322 - required: *ref_323 + properties: *ref_323 + required: *ref_324 - type: object description: >- Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code - properties: *ref_324 - required: *ref_325 + properties: *ref_325 + required: *ref_326 - type: object description: >- Reference to an existing flow by path. Use this to call another flow as a subflow - properties: *ref_326 - required: *ref_327 + properties: *ref_327 + required: *ref_328 - type: object description: >- Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations - properties: *ref_328 - required: *ref_329 + properties: *ref_329 + required: *ref_330 - type: object description: >- Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination - properties: *ref_330 - required: *ref_331 + properties: *ref_331 + required: *ref_332 - type: object description: >- Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes - properties: *ref_332 - required: *ref_333 + properties: *ref_333 + required: *ref_334 - type: object description: >- Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently - properties: *ref_334 - required: *ref_335 + properties: *ref_335 + required: *ref_336 - type: object description: >- Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder - properties: *ref_336 - required: *ref_337 + properties: *ref_337 + required: *ref_338 - type: object description: >- AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task - properties: *ref_338 - required: *ref_339 + properties: *ref_339 + required: *ref_340 discriminator: propertyName: type mapping: @@ -33802,8 +34926,8 @@ components: description: >- Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined - oneOf: *ref_340 - discriminator: *ref_341 + oneOf: *ref_341 + discriminator: *ref_342 output_type: allOf: - description: >- @@ -33856,8 +34980,8 @@ components: description: >- Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined - oneOf: *ref_342 - discriminator: *ref_343 + oneOf: *ref_343 + discriminator: *ref_344 output_schema: allOf: - description: >- @@ -33919,6 +35043,19 @@ components: - 0.0 = deterministic, focused responses - 0.7 = balanced (common default) - 1.0+ = more creative/random + max_iterations: + allOf: + - description: >- + Maps input parameters for a step. Can be a static value or a + JavaScript expression that references previous results or + flow inputs + oneOf: *ref_80 + discriminator: *ref_81 + description: > + Number. Limits how many times the agent can loop through + reasoning and tool use. + + Range: 1-1000. required: - provider - user_message @@ -33933,12 +35070,18 @@ components: description: >- A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool - properties: *ref_344 - required: *ref_345 + properties: *ref_345 + required: *ref_346 type: type: string enum: - aiagent + omit_output_from_conversation: + type: boolean + default: false + description: >- + If true, this AI agent step does not persist its assistant or tool + messages to the flow conversation when chat mode is enabled. parallel: type: boolean description: If true, the agent can execute multiple tool calls in parallel @@ -33963,8 +35106,8 @@ components: - type FlowStatus: type: object - properties: *ref_174 - required: *ref_175 + properties: *ref_175 + required: *ref_176 FlowStatusModule: type: object properties: @@ -34201,75 +35344,75 @@ components: HealthChecks: type: object description: Detailed health checks - required: *ref_346 - properties: *ref_347 + required: *ref_347 + properties: *ref_348 DatabaseHealth: type: object description: Database health status - required: *ref_348 - properties: *ref_349 + required: *ref_349 + properties: *ref_350 PoolStats: type: object description: Database connection pool statistics - required: *ref_350 - properties: *ref_351 + required: *ref_351 + properties: *ref_352 WorkersHealth: type: object description: Workers health status - required: *ref_352 - properties: *ref_353 + required: *ref_353 + properties: *ref_354 QueueHealth: type: object description: Job queue status - required: *ref_354 - properties: *ref_355 + required: *ref_355 + properties: *ref_356 ReadinessHealth: type: object description: Server readiness status - required: *ref_356 - properties: *ref_357 + required: *ref_357 + properties: *ref_358 AutoInviteConfig: type: object description: Configuration for auto-inviting users to the workspace - properties: *ref_358 + properties: *ref_359 ErrorHandlerConfig: type: object description: Configuration for the workspace error handler - properties: *ref_359 + properties: *ref_360 SuccessHandlerConfig: type: object description: Configuration for the workspace success handler - properties: *ref_360 + properties: *ref_361 EditErrorHandler: description: >- Request body for editing the workspace error handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: *ref_361 + oneOf: *ref_362 EditErrorHandlerNew: type: object description: New grouped format for editing error handler - properties: *ref_362 + properties: *ref_363 EditErrorHandlerLegacy: type: object description: >- Legacy flat format for editing error handler (deprecated, use new format) - properties: *ref_363 + properties: *ref_364 EditSuccessHandler: description: >- Request body for editing the workspace success handler. Accepts both new grouped format and legacy flat format for backward compatibility. - oneOf: *ref_364 + oneOf: *ref_365 EditSuccessHandlerNew: type: object description: New grouped format for editing success handler - properties: *ref_365 + properties: *ref_366 EditSuccessHandlerLegacy: type: object description: >- Legacy flat format for editing success handler (deprecated, use new format) - properties: *ref_366 + properties: *ref_367 VaultSettings: type: object required: *ref_27 @@ -34284,69 +35427,69 @@ components: properties: *ref_34 SecretMigrationFailure: type: object - required: *ref_367 - properties: *ref_368 + required: *ref_368 + properties: *ref_369 SecretMigrationReport: type: object required: *ref_29 properties: *ref_30 JwksResponse: type: object - required: *ref_369 - properties: *ref_370 + required: *ref_370 + properties: *ref_371 FlowConversation: type: object - required: *ref_371 - properties: *ref_372 + required: *ref_372 + properties: *ref_373 FlowConversationMessage: type: object - required: *ref_373 - properties: *ref_374 + required: *ref_374 + properties: *ref_375 EndpointTool: type: object - required: *ref_375 - properties: *ref_376 + required: *ref_376 + properties: *ref_377 AIProvider: type: string - enum: *ref_47 + enum: *ref_51 GitSyncObjectType: type: string - enum: *ref_45 + enum: *ref_48 AIProviderModel: type: object properties: *ref_43 required: *ref_44 AIProviderConfig: type: object - properties: *ref_377 - required: *ref_378 + properties: *ref_378 + required: *ref_379 AIConfig: type: object - properties: *ref_46 + properties: *ref_50 InstanceAIProviderSummary: type: object - properties: *ref_379 - required: *ref_380 + properties: *ref_380 + required: *ref_381 InstanceAISummary: type: object - properties: *ref_48 - required: *ref_49 + properties: *ref_52 + required: *ref_53 Alert: type: object - properties: *ref_381 - required: *ref_382 + properties: *ref_382 + required: *ref_383 Configs: type: object nullable: true - properties: *ref_383 + properties: *ref_384 WorkspaceDependencies: type: object properties: *ref_97 required: *ref_98 NewWorkspaceDependencies: type: object - properties: *ref_384 - required: *ref_385 + properties: *ref_385 + required: *ref_386 Script: type: object properties: *ref_99 @@ -34356,7 +35499,7 @@ components: properties: *ref_104 required: *ref_105 NewScriptWithDraft: - allOf: *ref_386 + allOf: *ref_387 ScriptHistory: type: object properties: *ref_106 @@ -34367,65 +35510,65 @@ components: additionalProperties: true Input: type: object - properties: *ref_287 - required: *ref_288 + properties: *ref_288 + required: *ref_289 CreateInput: type: object - properties: *ref_387 - required: *ref_388 + properties: *ref_388 + required: *ref_389 UpdateInput: type: object - properties: *ref_389 - required: *ref_390 + properties: *ref_390 + required: *ref_391 RunnableType: type: string - enum: *ref_391 + enum: *ref_392 QueuedJob: type: object - properties: *ref_190 - required: *ref_191 + properties: *ref_191 + required: *ref_192 CompletedJob: type: object - properties: *ref_188 - required: *ref_189 + properties: *ref_189 + required: *ref_190 ExportableCompletedJob: type: object description: Completed job with full data for export/import operations - properties: *ref_177 - required: *ref_178 + properties: *ref_178 + required: *ref_179 ExportableQueuedJob: type: object description: Queued job with full data for export/import operations - properties: *ref_179 - required: *ref_180 + properties: *ref_180 + required: *ref_181 ObscuredJob: type: object - properties: *ref_392 + properties: *ref_393 Job: - oneOf: *ref_192 - discriminator: *ref_193 + oneOf: *ref_193 + discriminator: *ref_194 User: type: object properties: *ref_35 required: *ref_36 UserSource: type: object - properties: *ref_393 - required: *ref_394 + properties: *ref_394 + required: *ref_395 UserUsage: type: object - properties: *ref_395 + properties: *ref_396 Login: type: object - properties: *ref_396 - required: *ref_397 + properties: *ref_397 + required: *ref_398 PasswordResetResponse: type: object properties: *ref_7 required: *ref_8 EditWorkspaceUser: type: object - properties: *ref_398 + properties: *ref_399 OffboardAffectedPaths: type: object properties: *ref_11 @@ -34435,64 +35578,64 @@ components: required: *ref_13 OffboardTokenInfo: type: object - properties: *ref_399 - required: *ref_400 + properties: *ref_400 + required: *ref_401 OffboardRequest: type: object - properties: *ref_401 - required: *ref_402 + properties: *ref_402 + required: *ref_403 OffboardResponse: type: object properties: *ref_14 OffboardSummary: type: object - properties: *ref_403 - required: *ref_404 + properties: *ref_404 + required: *ref_405 GlobalOffboardPreview: type: object - properties: *ref_405 - required: *ref_406 + properties: *ref_406 + required: *ref_407 WorkspaceOffboardPreview: type: object - properties: *ref_407 - required: *ref_408 + properties: *ref_408 + required: *ref_409 GlobalOffboardRequest: type: object - properties: *ref_409 + properties: *ref_410 WorkspaceReassignment: type: object - properties: *ref_410 - required: *ref_411 + properties: *ref_411 + required: *ref_412 TruncatedToken: type: object properties: *ref_102 required: *ref_103 ExternalJwtToken: type: object - properties: *ref_412 - required: *ref_413 + properties: *ref_413 + required: *ref_414 NewToken: type: object - properties: *ref_414 + properties: *ref_415 NewTokenImpersonate: type: object - properties: *ref_415 - required: *ref_416 + properties: *ref_416 + required: *ref_417 ListableVariable: type: object properties: *ref_61 required: *ref_62 ContextualVariable: type: object - properties: *ref_417 - required: *ref_418 + properties: *ref_418 + required: *ref_419 CreateVariable: type: object - properties: *ref_419 - required: *ref_420 + properties: *ref_420 + required: *ref_421 EditVariable: type: object - properties: *ref_421 + properties: *ref_422 AuditLog: type: object properties: *ref_5 @@ -34641,51 +35784,51 @@ components: required: *ref_142 PreviewInline: type: object - properties: *ref_422 - required: *ref_423 + properties: *ref_423 + required: *ref_424 InlineScriptArgs: type: object properties: *ref_140 WorkflowTask: type: object - properties: *ref_424 - required: *ref_425 + properties: *ref_425 + required: *ref_426 WorkflowStatusRecord: type: object additionalProperties: type: object - properties: *ref_176 + properties: *ref_177 WorkflowStatus: type: object - properties: *ref_176 + properties: *ref_177 CreateResource: type: object - properties: *ref_426 - required: *ref_427 + properties: *ref_427 + required: *ref_428 EditResource: type: object - properties: *ref_428 + properties: *ref_429 Resource: type: object - properties: *ref_429 - required: *ref_430 + properties: *ref_430 + required: *ref_431 ListableResource: type: object - properties: *ref_431 - required: *ref_432 + properties: *ref_432 + required: *ref_433 ResourceType: type: object properties: *ref_77 required: *ref_78 EditResourceType: type: object - properties: *ref_433 + properties: *ref_434 Schedule: type: object - properties: *ref_197 - required: *ref_198 + properties: *ref_198 + required: *ref_199 ScheduleWJobs: - allOf: *ref_434 + allOf: *ref_435 ErrorHandler: type: string enum: @@ -34695,121 +35838,121 @@ components: - email NewSchedule: type: object - properties: *ref_435 - required: *ref_436 + properties: *ref_436 + required: *ref_437 EditSchedule: type: object - properties: *ref_437 - required: *ref_438 + properties: *ref_438 + required: *ref_439 JobTriggerKind: description: job trigger kind (schedule, http, websocket...) type: string - enum: *ref_172 + enum: *ref_173 TriggerMode: description: job trigger mode type: string - enum: *ref_206 + enum: *ref_207 TriggerExtraProperty: type: object - properties: *ref_213 - required: *ref_214 + properties: *ref_214 + required: *ref_215 AuthenticationMethod: type: string - enum: *ref_205 + enum: *ref_206 RunnableKind: type: string - enum: *ref_199 + enum: *ref_200 OpenapiSpecFormat: type: string - enum: *ref_439 + enum: *ref_440 OpenapiHttpRouteFilters: type: object - properties: *ref_440 - required: *ref_441 + properties: *ref_441 + required: *ref_442 WebhookFilters: type: object - properties: *ref_442 - required: *ref_443 + properties: *ref_443 + required: *ref_444 OpenapiV3Info: type: object - properties: *ref_444 - required: *ref_445 + properties: *ref_445 + required: *ref_446 GenerateOpenapiSpec: type: object - properties: *ref_200 + properties: *ref_201 HttpMethod: type: string - enum: *ref_203 + enum: *ref_204 HttpRequestType: type: string - enum: *ref_204 + enum: *ref_205 HttpTrigger: - allOf: *ref_207 + allOf: *ref_208 type: object - properties: *ref_208 - required: *ref_209 + properties: *ref_209 + required: *ref_210 NewHttpTrigger: type: object - properties: *ref_201 - required: *ref_202 + properties: *ref_202 + required: *ref_203 EditHttpTrigger: type: object - properties: *ref_446 - required: *ref_447 + properties: *ref_447 + required: *ref_448 TriggersCount: type: object properties: *ref_125 WebsocketHeartbeat: type: object - properties: *ref_211 - required: *ref_212 + properties: *ref_212 + required: *ref_213 WebsocketTrigger: - allOf: *ref_215 + allOf: *ref_216 type: object - properties: *ref_216 - required: *ref_217 + properties: *ref_217 + required: *ref_218 NewWebsocketTrigger: type: object - properties: *ref_448 - required: *ref_449 + properties: *ref_449 + required: *ref_450 EditWebsocketTrigger: type: object - properties: *ref_450 - required: *ref_451 + properties: *ref_451 + required: *ref_452 WebsocketTriggerInitialMessage: - anyOf: *ref_210 + anyOf: *ref_211 MqttQoS: type: string - enum: *ref_452 + enum: *ref_453 MqttV3Config: type: object - properties: *ref_237 + properties: *ref_238 MqttV5Config: type: object - properties: *ref_238 + properties: *ref_239 MqttSubscribeTopic: type: object - properties: *ref_235 - required: *ref_236 + properties: *ref_236 + required: *ref_237 MqttClientVersion: type: string - enum: *ref_239 + enum: *ref_240 MqttTrigger: - allOf: *ref_240 + allOf: *ref_241 type: object - properties: *ref_241 - required: *ref_242 + properties: *ref_242 + required: *ref_243 NewMqttTrigger: type: object - properties: *ref_453 - required: *ref_454 + properties: *ref_454 + required: *ref_455 EditMqttTrigger: type: object - properties: *ref_455 - required: *ref_456 + properties: *ref_456 + required: *ref_457 DeliveryType: type: string - enum: *ref_245 + enum: *ref_246 description: >- Delivery mode for messages. 'push' for HTTP push delivery where messages are sent to a webhook endpoint, 'pull' for polling where the trigger @@ -34817,19 +35960,19 @@ components: PushConfig: type: object description: Configuration for push delivery mode. - properties: *ref_246 - required: *ref_247 + properties: *ref_247 + required: *ref_248 GcpTrigger: - allOf: *ref_249 + allOf: *ref_250 type: object description: >- A Google Cloud Pub/Sub trigger that executes a script or flow when messages are received. - properties: *ref_250 - required: *ref_251 + properties: *ref_251 + required: *ref_252 SubscriptionMode: type: string - enum: *ref_248 + enum: *ref_249 description: >- The mode of subscription. 'existing' means using an existing GCP subscription, while 'create_update' involves creating or updating a new @@ -34837,68 +35980,68 @@ components: GcpTriggerData: type: object description: Data for creating or updating a Google Cloud Pub/Sub trigger. - properties: *ref_243 - required: *ref_244 + properties: *ref_244 + required: *ref_245 GetAllTopicSubscription: type: object - properties: *ref_457 - required: *ref_458 + properties: *ref_458 + required: *ref_459 DeleteGcpSubscription: type: object - properties: *ref_459 - required: *ref_460 + properties: *ref_460 + required: *ref_461 AzureMode: type: string - enum: *ref_254 + enum: *ref_255 description: Azure Event Grid trigger mode. AzureArmResource: type: object description: An ARM resource the service principal can see. - properties: *ref_258 - required: *ref_259 + properties: *ref_259 + required: *ref_260 AzureDeleteSubscription: type: object - properties: *ref_461 - required: *ref_462 + properties: *ref_462 + required: *ref_463 AzureTrigger: - allOf: *ref_255 + allOf: *ref_256 type: object description: >- An Azure Event Grid trigger that executes a script or flow when events arrive. - properties: *ref_256 - required: *ref_257 + properties: *ref_257 + required: *ref_258 AzureTriggerData: type: object description: Data for creating or updating an Azure Event Grid trigger. - properties: *ref_252 - required: *ref_253 + properties: *ref_253 + required: *ref_254 TestAzureConnection: type: object - properties: *ref_463 - required: *ref_464 + properties: *ref_464 + required: *ref_465 AzureListTopics: type: object - properties: *ref_465 - required: *ref_466 + properties: *ref_466 + required: *ref_467 AzureListSubscriptions: type: object - properties: *ref_467 - required: *ref_468 + properties: *ref_468 + required: *ref_469 AwsAuthResourceType: type: string - enum: *ref_224 + enum: *ref_225 SqsTrigger: - allOf: *ref_225 + allOf: *ref_226 type: object - properties: *ref_226 - required: *ref_227 + properties: *ref_227 + required: *ref_228 LoggedWizardStatus: type: string enum: *ref_21 CustomInstanceDbLogs: type: object - properties: *ref_469 + properties: *ref_470 CustomInstanceDbTag: type: string enum: *ref_22 @@ -34908,108 +36051,108 @@ components: properties: *ref_24 NewSqsTrigger: type: object - properties: *ref_470 - required: *ref_471 + properties: *ref_471 + required: *ref_472 EditSqsTrigger: type: object - properties: *ref_472 - required: *ref_473 + properties: *ref_473 + required: *ref_474 Slot: type: object - properties: *ref_260 + properties: *ref_261 SlotList: type: object - properties: *ref_474 + properties: *ref_475 PublicationData: type: object - properties: *ref_264 - required: *ref_265 + properties: *ref_265 + required: *ref_266 TableToTrack: type: array - items: *ref_475 + items: *ref_476 Relations: type: object - properties: *ref_261 - required: *ref_262 + properties: *ref_262 + required: *ref_263 Language: type: string - enum: *ref_476 + enum: *ref_477 TemplateScript: type: object - properties: *ref_477 - required: *ref_478 + properties: *ref_478 + required: *ref_479 PostgresTrigger: - allOf: *ref_266 + allOf: *ref_267 type: object - properties: *ref_267 - required: *ref_268 + properties: *ref_268 + required: *ref_269 NewPostgresTrigger: type: object - properties: *ref_479 - required: *ref_480 + properties: *ref_480 + required: *ref_481 EditPostgresTrigger: type: object - properties: *ref_481 - required: *ref_482 + properties: *ref_482 + required: *ref_483 KafkaTrigger: - allOf: *ref_218 + allOf: *ref_219 type: object - properties: *ref_219 - required: *ref_220 + properties: *ref_220 + required: *ref_221 NewKafkaTrigger: type: object - properties: *ref_483 - required: *ref_484 + properties: *ref_484 + required: *ref_485 EditKafkaTrigger: type: object - properties: *ref_485 - required: *ref_486 + properties: *ref_486 + required: *ref_487 NatsTrigger: - allOf: *ref_221 + allOf: *ref_222 type: object - properties: *ref_222 - required: *ref_223 + properties: *ref_223 + required: *ref_224 NewNatsTrigger: type: object - properties: *ref_487 - required: *ref_488 + properties: *ref_488 + required: *ref_489 EditNatsTrigger: type: object - properties: *ref_489 - required: *ref_490 + properties: *ref_490 + required: *ref_491 EmailTrigger: - allOf: *ref_269 + allOf: *ref_270 type: object - properties: *ref_270 - required: *ref_271 + properties: *ref_271 + required: *ref_272 NewEmailTrigger: type: object - properties: *ref_491 - required: *ref_492 + properties: *ref_492 + required: *ref_493 EditEmailTrigger: type: object - properties: *ref_493 - required: *ref_494 + properties: *ref_494 + required: *ref_495 Group: type: object - properties: *ref_277 - required: *ref_278 + properties: *ref_278 + required: *ref_279 InstanceGroup: type: object - required: *ref_495 - properties: *ref_496 + required: *ref_496 + properties: *ref_497 InstanceGroupWithWorkspaces: type: object - required: *ref_273 - properties: *ref_274 + required: *ref_274 + properties: *ref_275 WorkspaceInfo: type: object - properties: *ref_497 - required: *ref_498 + properties: *ref_498 + required: *ref_499 Folder: type: object - properties: *ref_280 - required: *ref_281 + properties: *ref_281 + required: *ref_282 FolderDefaultPermissionedAs: description: > Ordered list of rules applied at create-time when admins or @@ -35017,19 +36160,19 @@ components: `path_glob` matches the item path (relative to the folder root) wins, and its `permissioned_as` is used as the default. type: array - items: *ref_279 + items: *ref_280 WorkerPing: type: object - properties: *ref_499 - required: *ref_500 + properties: *ref_500 + required: *ref_501 UserWorkspaceList: type: object - properties: *ref_501 - required: *ref_502 + properties: *ref_502 + required: *ref_503 CreateWorkspace: type: object - properties: *ref_503 - required: *ref_504 + properties: *ref_504 + required: *ref_505 CreateWorkspaceFork: type: object properties: *ref_19 @@ -35040,15 +36183,15 @@ components: required: *ref_16 DependencyMap: type: object - properties: *ref_505 + properties: *ref_506 DependencyDependent: type: object - properties: *ref_506 - required: *ref_507 + properties: *ref_507 + required: *ref_508 DependentsAmount: type: object - properties: *ref_508 - required: *ref_509 + properties: *ref_509 + required: *ref_510 WorkspaceInvite: type: object properties: *ref_41 @@ -35061,20 +36204,20 @@ components: allOf: *ref_124 ExtraPerms: type: object - additionalProperties: *ref_510 + additionalProperties: *ref_511 FlowMetadata: type: object - properties: *ref_511 - required: *ref_512 + properties: *ref_512 + required: *ref_513 OpenFlowWPath: allOf: *ref_126 FlowPreview: type: object - properties: *ref_151 - required: *ref_152 + properties: *ref_152 + required: *ref_153 RestartedFrom: type: object - properties: *ref_513 + properties: *ref_151 Policy: type: object properties: *ref_127 @@ -35134,95 +36277,103 @@ components: enum: *ref_93 PolarsClientKwargs: type: object - properties: *ref_291 - required: *ref_292 + properties: *ref_292 + required: *ref_293 LargeFileStorage: type: object - properties: *ref_50 + properties: *ref_45 DucklakeSettings: type: object - required: *ref_51 - properties: *ref_52 + required: *ref_54 + properties: *ref_55 DataTableSettings: type: object - required: *ref_53 - properties: *ref_54 + required: *ref_46 + properties: *ref_47 DataTableSchema: type: object required: *ref_523 properties: *ref_524 + DataTableTables: + type: object + required: *ref_525 + properties: *ref_526 + DataTableTableSchema: + type: object + required: *ref_527 + properties: *ref_528 DynamicInputData: type: object - properties: *ref_525 - required: *ref_526 + properties: *ref_529 + required: *ref_530 WindmillLargeFile: type: object - properties: *ref_293 - required: *ref_294 + properties: *ref_294 + required: *ref_295 WindmillFileMetadata: type: object - properties: *ref_297 + properties: *ref_298 WindmillFilePreview: type: object - properties: *ref_295 - required: *ref_296 + properties: *ref_296 + required: *ref_297 S3Resource: type: object - properties: *ref_289 - required: *ref_290 + properties: *ref_290 + required: *ref_291 WorkspaceGitSyncSettings: type: object - properties: *ref_55 + properties: *ref_56 WorkspaceDeployUISettings: type: object - properties: *ref_58 + properties: *ref_49 WorkspaceDefaultScripts: type: object properties: *ref_59 S3PermissionRule: - type: object - properties: *ref_527 - required: *ref_528 - GitRepositorySettings: - type: object - properties: *ref_56 - required: *ref_57 - MetricMetadata: - type: object - properties: *ref_529 - required: *ref_530 - ScalarMetric: type: object properties: *ref_531 required: *ref_532 - TimeseriesMetric: + GitRepositorySettings: + type: object + properties: *ref_57 + required: *ref_58 + MetricMetadata: type: object properties: *ref_533 required: *ref_534 - MetricDataPoint: + ScalarMetric: type: object properties: *ref_535 required: *ref_536 + TimeseriesMetric: + type: object + properties: *ref_537 + required: *ref_538 + MetricDataPoint: + type: object + properties: *ref_539 + required: *ref_540 RawScriptForDependencies: type: object properties: *ref_143 required: *ref_144 ConcurrencyGroup: type: object - properties: *ref_537 - required: *ref_538 + properties: *ref_541 + required: *ref_542 ExtendedJobs: type: object - properties: *ref_539 - required: *ref_540 + properties: *ref_543 + required: *ref_544 ExportedUser: type: object properties: *ref_9 required: *ref_10 GlobalSetting: type: object - properties: *ref_541 - required: *ref_542 + properties: *ref_545 + required: *ref_546 InstanceConfig: type: object description: >- @@ -35231,52 +36382,52 @@ components: properties: *ref_26 Config: type: object - properties: *ref_543 - required: *ref_544 + properties: *ref_547 + required: *ref_548 ExportedInstanceGroup: type: object - properties: *ref_275 - required: *ref_276 + properties: *ref_276 + required: *ref_277 JobSearchHit: type: object - properties: *ref_545 + properties: *ref_549 LogSearchHit: type: object - properties: *ref_546 + properties: *ref_550 AutoscalingEvent: type: object - properties: *ref_547 + properties: *ref_551 CriticalAlert: type: object properties: *ref_63 CaptureTriggerKind: type: string - enum: *ref_282 + enum: *ref_283 Capture: type: object - properties: *ref_283 - required: *ref_284 + properties: *ref_284 + required: *ref_285 CaptureConfig: type: object - properties: *ref_548 - required: *ref_549 + properties: *ref_552 + required: *ref_553 OperatorSettings: nullable: true type: object required: *ref_37 properties: *ref_38 WorkspaceComparison: - type: object - required: *ref_550 - properties: *ref_551 - WorkspaceItemDiff: - type: object - required: *ref_552 - properties: *ref_553 - CompareSummary: type: object required: *ref_554 properties: *ref_555 + WorkspaceItemDiff: + type: object + required: *ref_556 + properties: *ref_557 + CompareSummary: + type: object + required: *ref_558 + properties: *ref_559 TeamInfo: type: object required: @@ -35297,12 +36448,12 @@ components: description: List of channels within the team items: type: object - required: &ref_556 + required: &ref_560 - channel_id - channel_name - tenant_id - service_url - properties: &ref_557 + properties: &ref_561 channel_id: type: string description: The unique identifier of the channel @@ -35322,11 +36473,11 @@ components: https://smba.trafficmanager.net/amer/12345678-1234-1234-1234-123456789012/ ChannelInfo: type: object - required: *ref_556 - properties: *ref_557 + required: *ref_560 + properties: *ref_561 GithubInstallations: type: array - items: *ref_558 + items: *ref_562 WorkspaceGithubInstallation: type: object properties: @@ -35367,14 +36518,14 @@ components: minLength: 1 AssetUsageKind: type: string - enum: *ref_302 + enum: *ref_303 AssetUsageAccessType: type: string - enum: *ref_301 + enum: *ref_302 nullable: true AssetKind: type: string - enum: *ref_300 + enum: *ref_301 Asset: type: object properties: @@ -35382,26 +36533,26 @@ components: type: string kind: type: string - enum: *ref_300 + enum: *ref_301 required: - path - kind Volume: type: object - required: *ref_559 - properties: *ref_560 + required: *ref_563 + properties: *ref_564 ProtectionRuleset: type: object description: A workspace protection rule defining restrictions and bypass permissions - required: *ref_561 - properties: *ref_562 + required: *ref_565 + properties: *ref_566 ProtectionRules: type: array description: Configuration of protection restrictions items: *ref_64 ProtectionRuleKind: type: string - enum: *ref_563 + enum: *ref_567 RuleBypasserGroups: type: array description: Groups that can bypass this ruleset @@ -35412,12 +36563,12 @@ components: items: *ref_66 DeploymentRequestEligibleDeployer: type: object - required: *ref_564 - properties: *ref_565 + required: *ref_568 + properties: *ref_569 DeploymentRequestAssignee: type: object - required: *ref_566 - properties: *ref_567 + required: *ref_570 + properties: *ref_571 DeploymentRequestComment: type: object required: *ref_69 @@ -35432,27 +36583,27 @@ components: required: *ref_72 NativeServiceName: type: string - enum: *ref_228 + enum: *ref_229 NativeTrigger: type: object description: A native trigger stored in Windmill - properties: *ref_568 - required: *ref_569 + properties: *ref_572 + required: *ref_573 NativeTriggerWithExternal: type: object description: >- Full trigger response containing both Windmill data and external service data - properties: *ref_570 - required: *ref_571 + properties: *ref_574 + required: *ref_575 WorkspaceIntegrations: type: object - properties: *ref_572 - required: *ref_573 + properties: *ref_576 + required: *ref_577 WorkspaceOAuthConfig: type: object - properties: *ref_229 - required: *ref_230 + properties: *ref_230 + required: *ref_231 WebhookEvent: type: object properties: @@ -35463,7 +36614,7 @@ components: request_type: type: string description: The type of webhook request (define possible values here) - enum: &ref_574 + enum: &ref_578 - async - sync required: @@ -35472,21 +36623,21 @@ components: WebhookRequestType: type: string description: The type of webhook request (define possible values here) - enum: *ref_574 + enum: *ref_578 RedirectUri: type: object - properties: *ref_231 - required: *ref_232 + properties: *ref_232 + required: *ref_233 NativeTriggerData: type: object description: Data for creating or updating a native trigger - properties: *ref_233 - required: *ref_234 + properties: *ref_234 + required: *ref_235 CreateTriggerResponse: type: object description: Response returned when a native trigger is created - properties: *ref_575 - required: *ref_576 + properties: *ref_579 + required: *ref_580 SyncResult: type: object properties: @@ -35509,29 +36660,29 @@ components: - total_external - total_windmill NextCloudEventType: - type: object - properties: *ref_577 - required: *ref_578 - GoogleCalendarEntry: - type: object - properties: *ref_579 - required: *ref_580 - GoogleDriveFile: type: object properties: *ref_581 required: *ref_582 - GoogleDriveFilesResponse: + GoogleCalendarEntry: type: object properties: *ref_583 required: *ref_584 - SharedDriveEntry: + GoogleDriveFile: type: object properties: *ref_585 required: *ref_586 - GithubRepoEntry: + GoogleDriveFilesResponse: type: object properties: *ref_587 required: *ref_588 + SharedDriveEntry: + type: object + properties: *ref_589 + required: *ref_590 + GithubRepoEntry: + type: object + properties: *ref_591 + required: *ref_592 schemas-StaticTransform: type: object description: >- @@ -35567,22 +36718,22 @@ components: Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms - properties: *ref_322 - required: *ref_323 + properties: *ref_323 + required: *ref_324 schemas-PathScript: type: object description: >- Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code - properties: *ref_324 - required: *ref_325 + properties: *ref_325 + required: *ref_326 schemas-PathFlow: type: object description: >- Reference to an existing flow by path. Use this to call another flow as a subflow - properties: *ref_326 - required: *ref_327 + properties: *ref_327 + required: *ref_328 schemas-FlowModule: type: object description: A single step in a flow. Can be a script, subflow, loop, or branch @@ -35595,96 +36746,96 @@ components: 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations - properties: *ref_328 - required: *ref_329 + properties: *ref_329 + required: *ref_330 schemas-WhileloopFlow: type: object description: >- Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination - properties: *ref_330 - required: *ref_331 + properties: *ref_331 + required: *ref_332 schemas-BranchOne: type: object description: >- Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes - properties: *ref_332 - required: *ref_333 + properties: *ref_333 + required: *ref_334 schemas-BranchAll: type: object description: >- Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently - properties: *ref_334 - required: *ref_335 + properties: *ref_335 + required: *ref_336 schemas-Identity: type: object description: >- Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder - properties: *ref_336 - required: *ref_337 + properties: *ref_337 + required: *ref_338 AIProviderKind: type: string description: Supported AI provider types - enum: *ref_315 + enum: *ref_316 schemas-ProviderConfig: type: object description: >- Complete AI provider configuration with resource reference and model selection - properties: *ref_589 - required: *ref_590 + properties: *ref_593 + required: *ref_594 StaticProviderTransform: type: object description: Static provider configuration passed directly to the AI agent - properties: *ref_591 - required: *ref_592 + properties: *ref_595 + required: *ref_596 ProviderTransform: description: >- Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined - oneOf: *ref_340 - discriminator: *ref_341 + oneOf: *ref_341 + discriminator: *ref_342 MemoryOff: type: object description: No conversation memory/context - properties: *ref_316 - required: *ref_317 + properties: *ref_317 + required: *ref_318 MemoryAuto: type: object description: Automatic context management - properties: *ref_318 - required: *ref_319 + properties: *ref_319 + required: *ref_320 MemoryMessage: type: object description: A single message in conversation history - properties: *ref_593 - required: *ref_594 + properties: *ref_597 + required: *ref_598 MemoryManual: type: object description: Explicit message history - properties: *ref_320 - required: *ref_321 + properties: *ref_321 + required: *ref_322 schemas-MemoryConfig: description: Conversation memory configuration - oneOf: *ref_595 - discriminator: *ref_596 + oneOf: *ref_599 + discriminator: *ref_600 StaticMemoryTransform: type: object description: Static memory configuration passed directly to the AI agent - properties: *ref_597 - required: *ref_598 + properties: *ref_601 + required: *ref_602 MemoryTransform: description: >- Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined - oneOf: *ref_342 - discriminator: *ref_343 + oneOf: *ref_343 + discriminator: *ref_344 schemas-FlowModuleValue: description: >- The actual implementation of a flow step. Can be a script (inline or @@ -35695,41 +36846,41 @@ components: description: >- A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module - allOf: *ref_599 + allOf: *ref_603 McpToolValue: type: object description: >- Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers - properties: *ref_600 - required: *ref_601 + properties: *ref_604 + required: *ref_605 WebsearchToolValue: type: object description: >- A tool implemented as a websearch tool. The AI can call this like any other websearch tool - properties: *ref_602 - required: *ref_603 + properties: *ref_606 + required: *ref_607 ToolValue: description: >- The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference - oneOf: *ref_604 - discriminator: *ref_605 + oneOf: *ref_608 + discriminator: *ref_609 AgentTool: type: object description: >- A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool - properties: *ref_344 - required: *ref_345 + properties: *ref_345 + required: *ref_346 schemas-AiAgent: type: object description: >- AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task - properties: *ref_338 - required: *ref_339 + properties: *ref_339 + required: *ref_340 schemas-StopAfterIf: type: object description: Early termination condition for a module @@ -35738,12 +36889,12 @@ components: RetryIf: type: object description: Conditional retry based on error or result - properties: *ref_194 - required: *ref_195 + properties: *ref_195 + required: *ref_196 schemas-Retry: type: object description: Retry configuration for failed module executions - properties: *ref_314 + properties: *ref_315 schemas-FlowNote: type: object description: A sticky note attached to a flow for documentation and annotation @@ -35764,9 +36915,9 @@ components: description: >- The flow structure containing modules and optional preprocessor/failure handlers - properties: *ref_606 - required: *ref_607 + properties: *ref_610 + required: *ref_611 schemas-FlowStatusModule: type: object - properties: *ref_153 - required: *ref_154 + properties: *ref_154 + required: *ref_155 diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index a72a6e31dc..7a8c349178 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.708.0 + version: 1.719.0 title: Windmill API contact: @@ -2816,6 +2816,15 @@ paths: properties: username: type: string + is_admin: + type: boolean + description: Grant the service account workspace admin. Defaults to false. Cannot be combined with operator=true. + operator: + type: boolean + description: Make the service account an operator. Defaults to true for backward compatibility. Set to false to count as a developer (1 seat) instead of 0.5 seat. + add_to_deployers: + type: boolean + description: Add the service account to the workspace `wm_deployers` group on creation. Recommended when the account will be used as a CLI sync / CI deploy identity so it can deploy on behalf of other users. required: - username responses: @@ -9309,6 +9318,33 @@ paths: application/json: schema: {} + /w/{workspace}/jobs/job_view_token/{id}: + get: + summary: mint a read-only share token for a job + description: > + Returns a stateless `{job_id}.{hmac}` token that grants an authenticated + workspace member read access to this job (and its flow subtree) via a + `view_token` query param or `X-View-Token` header. Only callable by a user + who can already read the job. + operationId: getJobViewToken + tags: + - job + parameters: + - $ref: "#/components/parameters/WorkspaceId" + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: the share read token + content: + text/plain: + schema: + type: string + /w/{workspace}/flows/list_paths: get: summary: list all flow paths @@ -9742,6 +9778,9 @@ paths: type: boolean deployment_message: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this flow does not delete an existing user draft at the same path." responses: "201": description: flow created @@ -9783,6 +9822,9 @@ paths: properties: deployment_message: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this flow does not delete an existing user draft at the same path." responses: "200": @@ -10281,6 +10323,9 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." required: - path - value @@ -10333,6 +10378,9 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." required: - path - value @@ -10651,6 +10699,9 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." responses: "200": description: app updated @@ -10697,6 +10748,9 @@ paths: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this app does not delete an existing user draft at the same path." js: type: string css: @@ -11767,6 +11821,16 @@ paths: in: query schema: type: boolean + - name: status + description: filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: all_workspaces description: get jobs from all workspaces (only valid if request come from the `admins` workspace) in: query @@ -12002,6 +12066,16 @@ paths: - $ref: "#/components/parameters/StartedBefore" - $ref: "#/components/parameters/StartedAfter" - $ref: "#/components/parameters/Success" + - name: status + description: filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - $ref: "#/components/parameters/JobKinds" - $ref: "#/components/parameters/ArgsFilter" - $ref: "#/components/parameters/ResultFilter" @@ -12209,6 +12283,16 @@ paths: in: query schema: type: boolean + - name: status + description: filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: all_workspaces description: get jobs from all workspaces (only valid if request come from the `admins` workspace) in: query @@ -17682,6 +17766,38 @@ paths: additionalProperties: type: integer + /workers/workspace_fairness_events: + get: + summary: list last 100 workspace-fairness cap/uncap events (cloud-only) + operationId: getWorkspaceFairnessEvents + tags: + - worker + responses: + "200": + description: workspace fairness events (empty on non-cloud) + content: + application/json: + schema: + type: array + items: + type: object + properties: + timestamp: + type: string + format: date-time + operation: + type: string + workspace_id: + type: string + nullable: true + parameters: + type: object + nullable: true + additionalProperties: true + required: + - timestamp + - operation + /configs/list_worker_groups: get: summary: list worker groups @@ -19686,6 +19802,16 @@ paths: in: query schema: type: boolean + - name: status + description: filter on the exact completed job status. Unlike `success=true` (which also matches `skipped`), `status=success` matches only `success`. + in: query + schema: + type: string + enum: + - success + - failure + - canceled + - skipped - name: all_workspaces description: get jobs from all workspaces (only valid if request come from the `admins` workspace) in: query @@ -21483,6 +21609,8 @@ components: $ref: "#/components/schemas/AIProviderConfig" default_model: $ref: "#/components/schemas/AIProviderModel" + metadata_model: + $ref: "#/components/schemas/AIProviderModel" code_completion_model: $ref: "#/components/schemas/AIProviderModel" custom_prompts: @@ -21518,6 +21646,8 @@ components: $ref: "#/components/schemas/InstanceAIProviderSummary" default_model: $ref: "#/components/schemas/AIProviderModel" + metadata_model: + $ref: "#/components/schemas/AIProviderModel" code_completion_model: $ref: "#/components/schemas/AIProviderModel" required: @@ -21842,6 +21972,9 @@ components: type: array items: type: string + skip_draft_deletion: + type: boolean + description: "When true (set by the CLI / git sync), deploying this script does not delete an existing user draft at the same path." required: - path @@ -25364,6 +25497,11 @@ components: example: "Connection timeout" tag: $ref: "#/components/schemas/CustomInstanceDbTag" + used_by_workspaces: + type: array + items: + type: string + description: Workspaces that reference this database via a ducklake catalog or datatable database with resource_type 'instance'. Computed at request time, not persisted. NewSqsTrigger: type: object @@ -26570,6 +26708,9 @@ components: type: string operator_only: type: boolean + is_workspace_admin: + type: boolean + description: Populated only for service accounts. True if the service account has workspace admin in its (single) workspace. first_time_user: type: boolean role_source: diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index e7bc089d7c..fb92d28cb3 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -1,5 +1,3 @@ -#[cfg(feature = "bedrock")] -use crate::bedrock; use crate::db::{ApiAuthed, DB}; use crate::utils::check_scopes; @@ -13,25 +11,29 @@ use http::{HeaderMap, Method}; use quick_cache::sync::Cache; use reqwest::{Client, RequestBuilder}; use serde::{Deserialize, Serialize}; -use serde_json::{json, value::RawValue}; +use serde_json::value::RawValue; use std::collections::HashMap; use std::time::Duration; 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::credentials::ProviderCredentials; +#[cfg(feature = "bedrock")] +use windmill_ai::providers::bedrock::{ + handle_bedrock_proxy, BedrockProxyResponse, BedrockProxyResponseBody, +}; use windmill_ai::providers::{ - create_proxy_query_builder, + create_query_builder, google_ai::{ handle_google_ai_chat_proxy, handle_google_ai_models_proxy, GoogleAIProxyResponse, GoogleAIProxyResponseBody, }, }; use windmill_ai::proxy::{ - proxy_execution_mode, supports_query_builder_proxy, ProviderCredentials, ProxyBuildArgs, - ProxyExecutionMode, ProxyRequest, + fim::maybe_transform_fim_request, proxy_execution_mode, ProxyBuildArgs, ProxyExecutionMode, + ProxyRequest, }; -use windmill_ai::utils::AI_HTTP_HEADERS; use windmill_audit::{audit_oss::audit_log, ActionKind}; use windmill_common::db::UserDB; use windmill_common::error::{to_anyhow, Error, Result}; @@ -106,13 +108,20 @@ lazy_static::lazy_static! { .timeout(std::time::Duration::from_secs(*AI_TIMEOUT_SECS)) .pool_max_idle_per_host(HTTP_POOL_MAX_IDLE_PER_HOST) .pool_idle_timeout(Some(std::time::Duration::from_secs(HTTP_POOL_IDLE_TIMEOUT_SECS))) + // The SSRF check in `get_base_url` only validates the configured `base_url`. + // reqwest follows up to 10 redirects by default and does not revalidate the + // hops, so a public base_url could 3xx the server into a private/internal + // address. Disable redirect following so the validated host is the only one + // we ever connect to. AI APIs respond directly and do not rely on redirects, + // so this holds even for ALLOW_PRIVATE_AI_BASE_URLS deployments. + .redirect(reqwest::redirect::Policy::none()) .user_agent("windmill/beta")) .build() .expect("Failed to build AI HTTP client - check system TLS configuration"); static ref OPENAI_AZURE_BASE_PATH: Option = std::env::var("OPENAI_AZURE_BASE_PATH").ok(); - pub static ref AI_REQUEST_CACHE: Cache<(String, AIProvider), ExpiringAIRequestConfig> = Cache::new(500); + pub static ref AI_REQUEST_CACHE: Cache<(String, AIProvider), ExpiringProviderCredentials> = Cache::new(500); } @@ -179,26 +188,6 @@ enum AIResource { Standard(AIStandardResource), } -#[derive(Deserialize, Clone, Debug)] -struct AIRequestConfig { - pub base_url: String, - pub api_key: Option, - pub access_token: Option, - pub organization_id: Option, - pub user: Option, - #[allow(dead_code)] - pub region: Option, - #[allow(dead_code)] - pub aws_access_key_id: Option, - #[allow(dead_code)] - pub aws_secret_access_key: Option, - #[allow(dead_code)] - pub aws_session_token: Option, - pub platform: AIPlatform, - pub enable_1m_context: bool, - pub custom_headers: HashMap, -} - /// Resolve a `$var:` reference. When `user_db`/`authed` are provided the query /// goes through an RLS-scoped connection so the caller can only read variables /// they are authorised to access. Without auth context the raw pool is used @@ -216,196 +205,158 @@ async fn resolve_var( } } -impl AIRequestConfig { - pub async fn new( - provider: &AIProvider, - db: &DB, - w_id: &str, - resource: AIResource, - authed: Option<&ApiAuthed>, - ) -> Result { - // When authed is provided, resolve $var: references through RLS so that - // users can only read variables they have permission to access. - let user_db = authed.map(|_| UserDB::new(db.clone())); +async fn resolve_provider_credentials( + provider: &AIProvider, + db: &DB, + w_id: &str, + resource: AIResource, + authed: Option<&ApiAuthed>, +) -> Result { + // When authed is provided, resolve $var: references through RLS so that + // users can only read variables they have permission to access. + let user_db = authed.map(|_| UserDB::new(db.clone())); - let ( - api_key, - access_token, - organization_id, - base_url, - user, - region, - aws_access_key_id, - aws_secret_access_key, - aws_session_token, - platform, - enable_1m_context, - custom_headers, - ) = match resource { - AIResource::Standard(resource) => { - let region = resource.region.clone(); - let platform = resource.platform.clone(); - let enable_1m_context = resource.enable_1m_context; - let custom_headers = resource.headers.clone(); - // Skip get_base_url for Bedrock - it uses SDK directly, not HTTP - let base_url = if matches!(provider, AIProvider::AWSBedrock) { - String::new() - } else { - provider.get_base_url(resource.base_url, db).await? - }; - let api_key = if let Some(api_key) = resource.api_key { - Some(resolve_var(api_key, db, w_id, user_db.as_ref(), authed).await?) - } else { - None - }; - let organization_id = if let Some(organization_id) = resource.organization_id { - Some(resolve_var(organization_id, db, w_id, user_db.as_ref(), authed).await?) - } else { - None - }; - let aws_access_key_id = if let Some(access_key_id) = resource.aws_access_key_id { - Some(resolve_var(access_key_id, db, w_id, user_db.as_ref(), authed).await?) - } else { - None - }; - let aws_secret_access_key = if let Some(secret_access_key) = - resource.aws_secret_access_key - { + match resource { + AIResource::Standard(resource) => { + // Skip get_base_url for Bedrock - it uses SDK directly, not HTTP + let base_url = if matches!(provider, AIProvider::AWSBedrock) { + String::new() + } else { + provider.get_base_url(resource.base_url, db).await? + }; + let api_key = if let Some(api_key) = resource.api_key { + Some(resolve_var(api_key, db, w_id, user_db.as_ref(), authed).await?) + } else { + None + }; + let organization_id = if let Some(organization_id) = resource.organization_id { + Some(resolve_var(organization_id, db, w_id, user_db.as_ref(), authed).await?) + } else { + None + }; + let aws_access_key_id = if let Some(access_key_id) = resource.aws_access_key_id { + Some(resolve_var(access_key_id, db, w_id, user_db.as_ref(), authed).await?) + } else { + None + }; + let aws_secret_access_key = + if let Some(secret_access_key) = resource.aws_secret_access_key { Some(resolve_var(secret_access_key, db, w_id, user_db.as_ref(), authed).await?) } else { None }; - let aws_session_token = if let Some(session_token) = resource.aws_session_token { - Some(resolve_var(session_token, db, w_id, user_db.as_ref(), authed).await?) - } else { - None - }; + let aws_session_token = if let Some(session_token) = resource.aws_session_token { + Some(resolve_var(session_token, db, w_id, user_db.as_ref(), authed).await?) + } else { + None + }; - ( - api_key, - None, - organization_id, - base_url, - None, - region, - aws_access_key_id, - aws_secret_access_key, - aws_session_token, - platform, - enable_1m_context, - custom_headers, - ) - } - AIResource::OAuth(resource) => { - let user = if let Some(user) = resource.user.clone() { - Some(resolve_var(user, db, w_id, user_db.as_ref(), authed).await?) - } else { - None - }; - let token = - Self::get_token_using_oauth(resource, db, w_id, user_db.as_ref(), authed) - .await?; - let base_url = provider.get_base_url(None, db).await?; + Ok(ProviderCredentials { + provider: provider.clone(), + base_url, + api_key, + access_token: None, + organization_id, + user: None, + region: resource.region, + aws_access_key_id, + aws_secret_access_key, + aws_session_token, + platform: resource.platform, + enable_1m_context: resource.enable_1m_context, + custom_headers: resource.headers, + }) + } + AIResource::OAuth(resource) => { + let user = if let Some(user) = resource.user.clone() { + Some(resolve_var(user, db, w_id, user_db.as_ref(), authed).await?) + } else { + None + }; + let token = get_token_using_oauth(resource, db, w_id, user_db.as_ref(), authed).await?; + let base_url = provider.get_base_url(None, db).await?; - ( - None, - Some(token), - None, - base_url, - user, - None, - None, - None, - None, - AIPlatform::Standard, - false, - HashMap::new(), - ) - } - }; - - Ok(Self { - base_url, - organization_id, - api_key, - access_token, - user, - region, - aws_access_key_id, - aws_secret_access_key, - aws_session_token, - platform, - enable_1m_context, - custom_headers, - }) - } - - async fn get_token_using_oauth( - mut resource: AIOAuthResource, - db: &DB, - w_id: &str, - user_db: Option<&UserDB>, - authed: Option<&ApiAuthed>, - ) -> Result { - resource.client_id = resolve_var(resource.client_id, db, w_id, user_db, authed).await?; - resource.client_secret = - resolve_var(resource.client_secret, db, w_id, user_db, authed).await?; - resource.token_url = resolve_var(resource.token_url, db, w_id, user_db, authed).await?; - let mut params = HashMap::new(); - params.insert("grant_type", "client_credentials"); - params.insert("scope", "https://cognitiveservices.azure.com/.default"); - let response = HTTP_CLIENT - .post(resource.token_url) - .form(¶ms) - .basic_auth(resource.client_id, Some(resource.client_secret)) - .send() - .await - .and_then(|r| r.error_for_status()) - .map_err(|err| { - Error::internal_err(format!( - "Failed to get access token using credentials flow: {}", - err - )) - })?; - let response = response.json::().await.map_err(|err| { - Error::internal_err(format!( - "Failed to parse access token from credentials flow: {}", - err - )) - })?; - Ok(response.access_token) - } - - fn into_provider_credentials(self, provider: AIProvider) -> ProviderCredentials { - ProviderCredentials { - provider, - base_url: self.base_url, - api_key: self.api_key, - access_token: self.access_token, - organization_id: self.organization_id, - user: self.user, - region: self.region, - aws_access_key_id: self.aws_access_key_id, - aws_secret_access_key: self.aws_secret_access_key, - aws_session_token: self.aws_session_token, - platform: self.platform, - enable_1m_context: self.enable_1m_context, - custom_headers: self.custom_headers, + Ok(ProviderCredentials { + provider: provider.clone(), + base_url, + api_key: None, + access_token: Some(token), + organization_id: None, + user, + region: None, + aws_access_key_id: None, + aws_secret_access_key: None, + aws_session_token: None, + platform: AIPlatform::Standard, + enable_1m_context: false, + custom_headers: HashMap::new(), + }) } } } +async fn get_token_using_oauth( + mut resource: AIOAuthResource, + db: &DB, + w_id: &str, + user_db: Option<&UserDB>, + authed: Option<&ApiAuthed>, +) -> Result { + resource.client_id = resolve_var(resource.client_id, db, w_id, user_db, authed).await?; + resource.client_secret = resolve_var(resource.client_secret, db, w_id, user_db, authed).await?; + resource.token_url = resolve_var(resource.token_url, db, w_id, user_db, authed).await?; + // Validate the resolved token_url against SSRF rules before issuing the request, + // mirroring the protection applied to base_url in `get_base_url` (same + // ALLOW_PRIVATE_AI_BASE_URLS opt-in). Without this a workspace member could + // point token_url at an internal/metadata address. + if !*windmill_ai::ai_providers::ALLOW_PRIVATE_AI_BASE_URLS { + use windmill_common::ssrf::SsrfValidationError; + windmill_common::ssrf::validate_url_for_ssrf(&resource.token_url) + .await + .map_err(|e| match e { + e @ SsrfValidationError::Private { .. } => Error::BadRequest(format!( + "{e}. If you need to use private/internal AI endpoints, \ + set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable" + )), + e => Error::from(e), + })?; + } + let mut params = HashMap::new(); + params.insert("grant_type", "client_credentials"); + params.insert("scope", "https://cognitiveservices.azure.com/.default"); + let response = HTTP_CLIENT + .post(resource.token_url) + .form(¶ms) + .basic_auth(resource.client_id, Some(resource.client_secret)) + .send() + .await + .and_then(|r| r.error_for_status()) + .map_err(|err| { + Error::internal_err(format!( + "Failed to get access token using credentials flow: {}", + err + )) + })?; + let response = response.json::().await.map_err(|err| { + Error::internal_err(format!( + "Failed to parse access token from credentials flow: {}", + err + )) + })?; + Ok(response.access_token) +} + #[derive(Clone, Debug)] -pub struct ExpiringAIRequestConfig { - config: AIRequestConfig, +pub struct ExpiringProviderCredentials { + credentials: ProviderCredentials, expires_at: std::time::Instant, instance_ai_config_revision: Option, } -impl ExpiringAIRequestConfig { - fn new(config: AIRequestConfig, instance_ai_config_revision: Option) -> Self { +impl ExpiringProviderCredentials { + fn new(credentials: ProviderCredentials, instance_ai_config_revision: Option) -> Self { Self { - config, + credentials, expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60), instance_ai_config_revision, } @@ -426,6 +377,8 @@ pub struct AIConfig { #[serde(skip_serializing_if = "Option::is_none")] pub default_model: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub metadata_model: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub code_completion_model: Option, #[serde(skip_serializing_if = "Option::is_none")] pub custom_prompts: Option>, @@ -441,53 +394,6 @@ impl AIConfig { } } -// FIM (Fill-in-the-Middle) simulation for providers that don't support native FIM -#[derive(Deserialize, Debug)] -struct FimRequest { - model: String, - prompt: String, // code before cursor - suffix: Option, // code after cursor - temperature: Option, - max_tokens: Option, - stop: Option>, -} - -/// Checks if the AI provider supports native FIM (Fill-in-the-Middle) endpoint -fn supports_native_fim(provider: &AIProvider) -> bool { - matches!(provider, AIProvider::Mistral) -} - -/// Transforms a FIM request to chat/completions format for providers that don't support native FIM. -fn transform_fim_to_chat_completions(body: &Bytes) -> Result<(Bytes, String)> { - let fim_req: FimRequest = serde_json::from_slice(body) - .map_err(|e| Error::internal_err(format!("Failed to parse FIM request: {}", e)))?; - - let suffix = fim_req.suffix.unwrap_or_default(); - - let system_prompt = "You are a code completion assistant. Complete the code at the position between the given prefix and suffix. Output ONLY the code that goes at the cursor - no explanations, no markdown, no repeating the prefix or suffix."; - - let user_content = format!( - "\n{}\n\n\n{}", - fim_req.prompt, suffix - ); - - let chat_req = json!({ - "model": fim_req.model, - "messages": [ - {"role": "system", "content": system_prompt}, - {"role": "user", "content": user_content} - ], - "temperature": fim_req.temperature.unwrap_or(0.0), - "max_tokens": fim_req.max_tokens.unwrap_or(256), - "stop": fim_req.stop - }); - - let chat_body = serde_json::to_vec(&chat_req) - .map_err(|e| Error::internal_err(format!("Failed to serialize chat request: {}", e)))?; - - Ok((Bytes::from(chat_body), "chat/completions".to_string())) -} - pub fn global_service() -> Router { Router::new().route("/proxy/{*ai}", post(global_proxy).get(global_proxy)) } @@ -527,6 +433,24 @@ fn proxy_request_to_request_builder(proxy_request: ProxyRequest) -> RequestBuild request.body(proxy_request.body) } +async fn audit_global_ai_request(db: &DB, authed: &ApiAuthed) -> Result<()> { + let mut tx = db.begin().await?; + + audit_log( + &mut *tx, + authed, + "ai.global_request", + ActionKind::Execute, + "global", + Some(&authed.email), + None, + ) + .await?; + tx.commit().await?; + + Ok(()) +} + fn google_ai_proxy_response_to_body( response: GoogleAIProxyResponse, ) -> (http::StatusCode, HeaderMap, axum::body::Body) { @@ -540,6 +464,18 @@ fn google_ai_proxy_response_to_body( (response.status_code, response.headers, body) } +#[cfg(feature = "bedrock")] +fn bedrock_proxy_response_to_body( + response: BedrockProxyResponse, +) -> (http::StatusCode, HeaderMap, axum::body::Body) { + let body = match response.body { + BedrockProxyResponseBody::Fixed(body) => axum::body::Body::from(body), + BedrockProxyResponseBody::Stream(stream) => axum::body::Body::from_stream(stream), + }; + + (response.status_code, response.headers, body) +} + pub(crate) fn inject_keepalives( upstream: S, interval: Duration, @@ -590,63 +526,78 @@ async fn global_proxy( return Err(Error::BadRequest("API key is required".to_string())); }; - let base_url = provider.get_base_url(None, &db).await?; + let proxy_mode = proxy_execution_mode(&provider); - let request = if supports_query_builder_proxy(&provider) { - let credentials = ProviderCredentials { - provider: provider.clone(), - base_url, - api_key: Some(api_key.clone()), - access_token: None, - organization_id: None, - user: None, - region: None, - aws_access_key_id: None, - aws_secret_access_key: None, - aws_session_token: None, - platform: AIPlatform::Standard, - enable_1m_context: false, - custom_headers: HashMap::new(), - }; - let query_builder = create_proxy_query_builder(&credentials); - let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs { + if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) { + return Err(Error::BadRequest( + "AWS Bedrock global proxy is not supported; use a workspace AI resource with a region" + .to_string(), + )); + } + + let base_url = provider.get_base_url(None, &db).await?; + let credentials = ProviderCredentials { + provider: provider.clone(), + base_url, + api_key: Some(api_key.clone()), + access_token: None, + organization_id: None, + user: None, + region: None, + aws_access_key_id: None, + aws_secret_access_key: None, + aws_session_token: None, + platform: AIPlatform::Standard, + enable_1m_context: false, + custom_headers: HashMap::new(), + }; + + if matches!(proxy_mode, ProxyExecutionMode::NativeGoogleAi) { + let proxy_args = ProxyBuildArgs { method: &method, path: &ai_path, headers: &headers, body: &body, credentials: &credentials, - })?; - proxy_request_to_request_builder(proxy_request) - } else { - let url = format!("{}/{}", base_url, ai_path); - let mut request = HTTP_CLIENT - .request(method, url) - .header("content-type", "application/json") - .header("Authorization", format!("Bearer {}", &api_key)); + }; - // Apply custom headers from AI_HTTP_HEADERS environment variable - for (header_name, header_value) in AI_HTTP_HEADERS.iter() { - request = request.header(header_name.as_str(), header_value.as_str()); + audit_global_ai_request(&db, &authed).await?; + + let response = match ai_path.as_str() { + "chat/completions" => handle_google_ai_chat_proxy(&HTTP_CLIENT, &proxy_args).await, + "models" => handle_google_ai_models_proxy(&HTTP_CLIENT, &proxy_args).await, + _ => Err(Error::BadRequest(format!( + "Unsupported Google AI path: {}", + ai_path + ))), + }?; + + return Ok(google_ai_proxy_response_to_body(response)); + } + + let request = match proxy_mode { + ProxyExecutionMode::HttpForward => { + let query_builder = create_query_builder(&credentials); + let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs { + method: &method, + path: &ai_path, + headers: &headers, + body: &body, + credentials: &credentials, + })?; + proxy_request_to_request_builder(proxy_request) + } + ProxyExecutionMode::NativeGoogleAi | ProxyExecutionMode::NativeAwsBedrock => { + return Err(Error::BadRequest(format!( + "Unsupported global proxy mode for provider {:?}", + provider + ))) } - - request.body(body) }; let response = request.send().await.map_err(to_anyhow)?; - let mut tx = db.begin().await?; - - audit_log( - &mut *tx, - &authed, - "ai.global_request", - ActionKind::Execute, - "global", - Some(&authed.email), - None, - ) - .await?; - tx.commit().await?; + audit_global_ai_request(&db, &authed).await?; if response.error_for_status_ref().is_err() { let err_msg = response.text().await.unwrap_or("".to_string()); @@ -701,9 +652,9 @@ async fn proxy( check_scopes(&authed, || format!("resources:read:{}", resource_path))?; } - let request_config = match workspace_cache { + let mut credentials = match workspace_cache { Some(request_cache) if !request_cache.is_expired() && forced_resource_path.is_none() => { - request_cache.config + request_cache.credentials } _ => { let (resource_path, save_to_cache, resource_workspace, instance_ai_config_revision) = @@ -811,7 +762,7 @@ async fn proxy( } else { None }; - let request_config = AIRequestConfig::new( + let credentials = resolve_provider_credentials( &provider, &db, &resource_workspace, @@ -822,27 +773,35 @@ async fn proxy( if save_to_cache { AI_REQUEST_CACHE.insert( (w_id.clone(), provider.clone()), - ExpiringAIRequestConfig::new( - request_config.clone(), + ExpiringProviderCredentials::new( + credentials.clone(), instance_ai_config_revision, ), ); } - request_config + credentials } }; - // Check if this is a FIM request to a provider that doesn't support native FIM endpoint - // For such providers, transform to use FIM sentinel tokens with the chat/completions endpoint - let is_fim_request = ai_path.contains("fim/completions"); - if is_fim_request && !supports_native_fim(&provider) { - tracing::debug!( - "Transforming FIM request to chat/completions with FIM tokens for provider {:?}", - provider - ); - let (chat_body, chat_path) = transform_fim_to_chat_completions(&body)?; - body = chat_body; - ai_path = chat_path; + if let Some(fim_transform) = + maybe_transform_fim_request(&provider, &ai_path, &credentials.base_url, &body)? + { + if fim_transform.base_url.is_some() { + tracing::debug!( + "Routing native FIM request through provider-specific endpoint for {:?}", + provider + ); + } else { + tracing::debug!( + "Transforming FIM request to chat/completions with FIM tokens for provider {:?}", + provider + ); + } + if let Some(base_url) = fim_transform.base_url { + credentials.base_url = base_url; + } + body = fim_transform.body; + ai_path = fim_transform.path; } let proxy_mode = proxy_execution_mode(&provider); @@ -862,7 +821,6 @@ async fn proxy( .await?; tx.commit().await?; - let credentials = request_config.into_provider_credentials(provider.clone()); let proxy_args = ProxyBuildArgs { method: &method, path: &ai_path, @@ -885,95 +843,30 @@ async fn proxy( // Handle Bedrock-specific logic when the feature is enabled #[cfg(feature = "bedrock")] - { - // Extract model and streaming flag for Bedrock transformation (only for POST requests) - let (model, is_streaming) = if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) - && method == Method::POST - { - #[derive(Deserialize, Debug)] - struct BedrockRequest { - model: String, - #[serde(default)] - stream: bool, - } - let parsed: BedrockRequest = serde_json::from_slice(&body) - .map_err(|e| Error::internal_err(format!("Failed to parse request body: {}", e)))?; - (Some(parsed.model), parsed.stream) - } else { - (None, false) - }; + if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) { + let mut tx = db.begin().await?; + audit_log( + &mut *tx, + &authed, + "ai.request", + ActionKind::Execute, + &w_id, + Some(&authed.email), + Some([("ai_config_path", &format!("{:?}", ai_path)[..])].into()), + ) + .await?; + tx.commit().await?; - // For Bedrock requests, use the SDK-based approach - if matches!(proxy_mode, ProxyExecutionMode::NativeAwsBedrock) { - let region = request_config - .region - .as_deref() - .unwrap_or(windmill_ai::ai_providers::USE_ENV_REGION); + let response = handle_bedrock_proxy(&ProxyBuildArgs { + method: &method, + path: &ai_path, + headers: &headers, + body: &body, + credentials: &credentials, + }) + .await?; - // Audit log before making the SDK request - let mut tx = db.begin().await?; - audit_log( - &mut *tx, - &authed, - "ai.request", - ActionKind::Execute, - &w_id, - Some(&authed.email), - Some([("ai_config_path", &format!("{:?}", ai_path)[..])].into()), - ) - .await?; - tx.commit().await?; - - // Handle GET requests for control plane operations - if method == Method::GET { - if ai_path == "foundation-models" { - return bedrock::list_foundation_models( - request_config.api_key.as_deref(), - request_config.aws_access_key_id.as_deref(), - request_config.aws_secret_access_key.as_deref(), - request_config.aws_session_token.as_deref(), - region, - ) - .await; - } else if ai_path == "inference-profiles" { - return bedrock::list_inference_profiles( - request_config.api_key.as_deref(), - request_config.aws_access_key_id.as_deref(), - request_config.aws_secret_access_key.as_deref(), - request_config.aws_session_token.as_deref(), - region, - ) - .await; - } - } - - // Handle POST requests for inference - if method == Method::POST && model.is_some() { - if is_streaming { - return bedrock::handle_bedrock_sdk_streaming( - model.as_ref().unwrap(), - &body, - request_config.api_key.as_deref(), - request_config.aws_access_key_id.as_deref(), - request_config.aws_secret_access_key.as_deref(), - request_config.aws_session_token.as_deref(), - region, - ) - .await; - } else { - return bedrock::handle_bedrock_sdk_non_streaming( - model.as_ref().unwrap(), - &body, - request_config.api_key.as_deref(), - request_config.aws_access_key_id.as_deref(), - request_config.aws_secret_access_key.as_deref(), - request_config.aws_session_token.as_deref(), - region, - ) - .await; - } - } - } + return Ok(bedrock_proxy_response_to_body(response)); } // When bedrock feature is disabled, return error for Bedrock provider @@ -986,8 +879,7 @@ async fn proxy( let request = match proxy_mode { ProxyExecutionMode::HttpForward => { - let credentials = request_config.into_provider_credentials(provider.clone()); - let query_builder = create_proxy_query_builder(&credentials); + let query_builder = create_query_builder(&credentials); let proxy_request = query_builder.build_proxy_request(&ProxyBuildArgs { method: &method, path: &ai_path, @@ -1053,8 +945,9 @@ mod tests { static TEST_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); - fn sample_request_config() -> AIRequestConfig { - AIRequestConfig { + fn sample_provider_credentials() -> ProviderCredentials { + ProviderCredentials { + provider: AIProvider::OpenAI, base_url: "https://example.com".to_string(), api_key: None, access_token: None, @@ -1070,70 +963,21 @@ mod tests { } } - #[test] - fn maps_request_config_to_provider_credentials() { - let mut custom_headers = HashMap::new(); - custom_headers.insert("X-Test".to_string(), "yes".to_string()); - - let config = AIRequestConfig { - base_url: "https://example.com".to_string(), - api_key: Some("api-key".to_string()), - access_token: Some("access-token".to_string()), - organization_id: Some("org-id".to_string()), - user: Some("user-id".to_string()), - region: Some("us-east-1".to_string()), - aws_access_key_id: Some("aws-access-key".to_string()), - aws_secret_access_key: Some("aws-secret-key".to_string()), - aws_session_token: Some("aws-session-token".to_string()), - platform: AIPlatform::GoogleVertexAi, - enable_1m_context: true, - custom_headers, - }; - - let credentials = config.into_provider_credentials(AIProvider::Anthropic); - - assert_eq!(credentials.provider, AIProvider::Anthropic); - assert_eq!(credentials.base_url, "https://example.com"); - assert_eq!(credentials.api_key.as_deref(), Some("api-key")); - assert_eq!(credentials.access_token.as_deref(), Some("access-token")); - assert_eq!(credentials.organization_id.as_deref(), Some("org-id")); - assert_eq!(credentials.user.as_deref(), Some("user-id")); - assert_eq!(credentials.region.as_deref(), Some("us-east-1")); - assert_eq!( - credentials.aws_access_key_id.as_deref(), - Some("aws-access-key") - ); - assert_eq!( - credentials.aws_secret_access_key.as_deref(), - Some("aws-secret-key") - ); - assert_eq!( - credentials.aws_session_token.as_deref(), - Some("aws-session-token") - ); - assert_eq!(credentials.platform, AIPlatform::GoogleVertexAi); - assert!(credentials.enable_1m_context); - assert_eq!( - credentials.custom_headers.get("X-Test").map(String::as_str), - Some("yes") - ); - } - #[test] fn invalidates_all_cached_providers_for_workspace() { let _guard = TEST_LOCK.lock().unwrap(); AI_REQUEST_CACHE.clear(); AI_REQUEST_CACHE.insert( ("workspace-a".to_string(), AIProvider::OpenAI), - ExpiringAIRequestConfig::new(sample_request_config(), None), + ExpiringProviderCredentials::new(sample_provider_credentials(), None), ); AI_REQUEST_CACHE.insert( ("workspace-a".to_string(), AIProvider::Anthropic), - ExpiringAIRequestConfig::new(sample_request_config(), None), + ExpiringProviderCredentials::new(sample_provider_credentials(), None), ); AI_REQUEST_CACHE.insert( ("workspace-b".to_string(), AIProvider::OpenAI), - ExpiringAIRequestConfig::new(sample_request_config(), None), + ExpiringProviderCredentials::new(sample_provider_credentials(), None), ); invalidate_ai_request_cache_for_workspace("workspace-a"); @@ -1154,8 +998,8 @@ mod tests { let _guard = TEST_LOCK.lock().unwrap(); AI_REQUEST_CACHE.clear(); - let cached = ExpiringAIRequestConfig::new( - sample_request_config(), + let cached = ExpiringProviderCredentials::new( + sample_provider_credentials(), Some(current_instance_ai_config_revision()), ); assert!(!cached.is_expired()); diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index c377203f38..de55cb87ff 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -306,6 +306,11 @@ pub struct CreateApp { pub preserve_on_behalf_of: Option, #[serde(default)] pub labels: Option>, + /// Caller-intent flag (set by the CLI / git sync): when true, deploying + /// this app must NOT delete an existing user draft at the same path. + /// Transient — never persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_draft_deletion: Option, } #[derive(Serialize, Deserialize)] @@ -319,6 +324,11 @@ pub struct EditApp { pub preserve_on_behalf_of: Option, #[serde(default)] pub labels: Option>, + /// Caller-intent flag (set by the CLI / git sync): when true, deploying + /// this app must NOT delete an existing user draft at the same path. + /// Transient — never persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_draft_deletion: Option, } #[derive(Serialize, FromRow)] @@ -1338,13 +1348,17 @@ async fn create_app_internal<'a>( )); } } - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", - &app.path, - &w_id - ) - .execute(&mut *tx) - .await?; + // CLI / git-sync deploys ask us to preserve any existing user draft at this + // path instead of wiping it as part of the deploy. + if !app.skip_draft_deletion.unwrap_or(false) { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + &app.path, + &w_id + ) + .execute(&mut *tx) + .await?; + } let id = sqlx::query_scalar!( "INSERT INTO app (workspace_id, path, summary, policy, versions, draft_only, custom_path, labels) @@ -1943,13 +1957,17 @@ async fn update_app_internal<'a>( ))); } }; - sqlx::query!( - "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", - path, - &w_id - ) - .execute(&mut *tx) - .await?; + // CLI / git-sync deploys ask us to preserve any existing user draft at this + // path instead of wiping it as part of the deploy. + if !ns.skip_draft_deletion.unwrap_or(false) { + sqlx::query!( + "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'", + path, + &w_id + ) + .execute(&mut *tx) + .await?; + } audit_log( &mut *tx, &authed, diff --git a/backend/windmill-api/src/args.rs b/backend/windmill-api/src/args.rs index ece28e51de..3793e94b02 100644 --- a/backend/windmill-api/src/args.rs +++ b/backend/windmill-api/src/args.rs @@ -13,9 +13,10 @@ use serde_json::value::RawValue; use sqlx::types::JsonRawValue; use windmill_common::{ error::Error, + jobs::WM_TRACEPARENT, triggers::{RunnableFormat, RunnableFormatVersion, TriggerKind}, worker::to_raw_value, - DB, + DB, OTEL_TRACING_ENABLED, }; use windmill_queue::PushArgsOwned; @@ -280,6 +281,13 @@ impl WebhookArgs { self, runnable_format: RunnableFormat, ) -> Result { + // Capture the inbound W3C `traceparent` before `self.metadata` is + // consumed below. Read back at root-job completion to link the job's + // OTLP span to the originating distributed trace. Deliberately bypasses + // the header whitelist, and is gated to OTel-enabled instances so others + // don't get a stray `_wm_traceparent` arg key. + let trace_context = inbound_traceparent(&self.metadata.headers); + let headers = build_headers( &self.metadata.headers, self.metadata.query_include_header, @@ -292,7 +300,7 @@ impl WebhookArgs { runnable_format.has_preprocessor, ); - match runnable_format { + let mut push_args = match runnable_format { RunnableFormat { has_preprocessor: true, version: RunnableFormatVersion::V2 } => { let mut args = HashMap::new(); @@ -307,7 +315,7 @@ impl WebhookArgs { }), ); - Ok(PushArgsOwned { args, extra: None }) + PushArgsOwned { args, extra: None } } RunnableFormat { has_preprocessor, .. } => { let mut extra = HashMap::new(); @@ -343,16 +351,40 @@ impl WebhookArgs { if query_wrap_body { body = HashMap::from([("body".to_string(), to_raw_value(&body))]); } - Ok(PushArgsOwned { args: body, extra }) + PushArgsOwned { args: body, extra } } Body::NoHashMap(args) => { let mut hm = HashMap::new(); hm.insert("body".to_string(), args); - Ok(PushArgsOwned { args: hm, extra }) + PushArgsOwned { args: hm, extra } } } } + }; + + // `_wm_traceparent` is Windmill-controlled: strip any caller-supplied + // value (e.g. smuggled through the request body) so only the header we + // captured above can become the job's inbound trace context. Then stash + // the captured value as a reserved arg key — it rides the `args` jsonb + // like `_ENTRYPOINT_OVERRIDE`; normal scripts never see it (args are + // bound by declared parameter name). + push_args.args.remove(WM_TRACEPARENT); + if let Some(ref mut extra) = push_args.extra { + extra.remove(WM_TRACEPARENT); } + if let Some(trace_context) = trace_context { + let raw = to_raw_value(&trace_context); + match push_args.extra { + Some(ref mut extra) => { + extra.insert(WM_TRACEPARENT.to_string(), raw); + } + None => { + push_args.args.insert(WM_TRACEPARENT.to_string(), raw); + } + } + } + + Ok(push_args) } } @@ -487,6 +519,23 @@ lazy_static::lazy_static! { .collect()).unwrap_or_default(); } +/// Extract the inbound W3C `traceparent` header so the enqueued job can be +/// linked back to the originating distributed trace. Returns `None` when OTel +/// tracing is disabled (so non-tracing instances don't accumulate a stray +/// reserved arg key) or when no `traceparent` header is present. The W3C format +/// is not validated here — it is checked later at use time +/// (`valid_w3c_traceparent` for the env, EE `span_cx_from_traceparent` for the +/// span). +fn inbound_traceparent(headers: &HeaderMap) -> Option { + if !OTEL_TRACING_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { + return None; + } + headers + .get("traceparent") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) +} + pub fn build_headers( headers: &HeaderMap, include_header: Option, diff --git a/backend/windmill-api/src/bedrock.rs b/backend/windmill-api/src/bedrock.rs deleted file mode 100644 index cc84fda1e8..0000000000 --- a/backend/windmill-api/src/bedrock.rs +++ /dev/null @@ -1,873 +0,0 @@ -//! AWS Bedrock SDK-based operations for the AI chat proxy. -//! -//! This module provides SDK-based request handling for Bedrock: -//! -//! ## Inference (Runtime SDK): -//! - `handle_bedrock_sdk_streaming`: Uses BedrockClient for streaming requests -//! - `handle_bedrock_sdk_non_streaming`: Uses BedrockClient for non-streaming requests -//! - `sdk_stream_to_sse`: Converts SDK ConverseStream events to SSE format -//! -//! ## Control Plane (Bedrock SDK): -//! - `list_foundation_models`: Lists available foundation models -//! - `list_inference_profiles`: Lists inference profiles -//! -//! Shared AWS SDK code is available in `windmill_common::ai_bedrock`, including: -//! - `BedrockClient`: SDK wrapper with bearer token and IAM auth -//! - Stream event parsing functions -//! - Helper utilities - -use axum::body::Bytes; -use serde::Deserialize; -use windmill_ai::ai_bedrock::build_tool_config; -use windmill_ai::ai_bedrock::{ - bedrock_stream_event_is_block_stop, bedrock_stream_event_to_text, - bedrock_stream_event_to_tool_delta, bedrock_stream_event_to_tool_start, format_bedrock_error, - BedrockClient, -}; -use windmill_ai::ai_types::{ - OpenAIFunction, OpenAIMessage, OpenAIToolCall, ToolDef, ToolDefFunction, -}; -use windmill_common::error::{Error, Result}; - -// ============================================================================ -// Shared Request Types for SDK-Based Handlers -// ============================================================================ - -/// OpenAI-format request body for Bedrock SDK handlers -#[derive(Deserialize, Debug)] -struct OpenAIRequest { - messages: Vec, - #[serde(default)] - tools: Option>, - #[serde(default)] - tool_choice: Option, - #[serde(default)] - max_tokens: Option, - #[serde(default)] - temperature: Option, -} - -#[derive(Deserialize, Debug)] -struct OpenAIToolDef { - #[serde(default)] - #[allow(dead_code)] - r#type: Option, - function: OpenAIToolFunction, -} - -#[derive(Deserialize, Debug)] -struct OpenAIToolFunction { - name: String, - #[serde(default)] - description: Option, - #[serde(default)] - parameters: Option, -} - -// ============================================================================ -// Shared Helper Functions for SDK-Based Handlers -// ============================================================================ - -/// Authentication configuration for Bedrock clients -enum BedrockAuthConfig { - BearerToken(String), - IamCredentials { - access_key_id: String, - secret_access_key: String, - session_token: Option, - }, - Environment, -} - -/// Determine auth configuration with priority: bearer token → IAM credentials → environment -fn determine_auth_config( - api_key: Option<&str>, - aws_access_key_id: Option<&str>, - aws_secret_access_key: Option<&str>, - aws_session_token: Option<&str>, -) -> BedrockAuthConfig { - if let Some(key) = api_key.filter(|k| !k.is_empty()) { - BedrockAuthConfig::BearerToken(key.to_string()) - } else if let (Some(access_key_id), Some(secret_access_key)) = ( - aws_access_key_id.filter(|s| !s.is_empty()), - aws_secret_access_key.filter(|s| !s.is_empty()), - ) { - BedrockAuthConfig::IamCredentials { - access_key_id: access_key_id.to_string(), - secret_access_key: secret_access_key.to_string(), - session_token: aws_session_token - .filter(|token| !token.is_empty()) - .map(str::to_string), - } - } else { - BedrockAuthConfig::Environment - } -} - -/// Create a BedrockClient with auth priority: bearer token → IAM credentials → environment -async fn create_bedrock_client( - api_key: Option<&str>, - aws_access_key_id: Option<&str>, - aws_secret_access_key: Option<&str>, - aws_session_token: Option<&str>, - region: &str, -) -> Result { - match determine_auth_config( - api_key, - aws_access_key_id, - aws_secret_access_key, - aws_session_token, - ) { - BedrockAuthConfig::BearerToken(key) => BedrockClient::from_bearer_token(key, region).await, - BedrockAuthConfig::IamCredentials { access_key_id, secret_access_key, session_token } => { - BedrockClient::from_credentials(access_key_id, secret_access_key, session_token, region) - .await - } - BedrockAuthConfig::Environment => BedrockClient::from_env(region).await, - } -} - -/// Convert OpenAIToolDef array to tool configuration for Bedrock SDK -fn build_tool_config_from_request( - tools: Option<&[OpenAIToolDef]>, - tool_choice: Option<&serde_json::Value>, - enable_prompt_caching: bool, -) -> Result> { - if let Some(tools) = tools { - let tool_defs: Vec = tools - .iter() - .map(|t| ToolDef { - r#type: "function".to_string(), - function: ToolDefFunction { - name: t.function.name.clone(), - description: t.function.description.clone(), - parameters: Box::from( - serde_json::value::RawValue::from_string( - serde_json::to_string( - &t.function - .parameters - .clone() - .unwrap_or(serde_json::json!({})), - ) - .unwrap_or_default(), - ) - .unwrap_or_else(|_| { - serde_json::value::RawValue::from_string("{}".to_string()).unwrap() - }), - ), - }, - }) - .collect(); - - // Determine if we should force tool use based on tool_choice - let force_tool_use = tool_choice - .map(|tc| tc == "required" || tc.as_str() == Some("required")) - .unwrap_or(false); - - build_tool_config(Some(&tool_defs), force_tool_use, enable_prompt_caching) - } else { - Ok(None) - } -} - -// ============================================================================ -// Control Plane Operations (using aws-sdk-bedrock) -// ============================================================================ - -/// Create a Bedrock control plane client with auth priority: bearer token → IAM credentials → environment -async fn create_bedrock_control_client( - api_key: Option<&str>, - aws_access_key_id: Option<&str>, - aws_secret_access_key: Option<&str>, - aws_session_token: Option<&str>, - region: &str, -) -> Result { - use aws_config::BehaviorVersion; - use windmill_ai::ai_bedrock::BearerTokenProvider; - - let region_provider = aws_sdk_bedrock::config::Region::new(region.to_string()); - - match determine_auth_config( - api_key, - aws_access_key_id, - aws_secret_access_key, - aws_session_token, - ) { - BedrockAuthConfig::BearerToken(key) => { - let config = aws_sdk_bedrock::config::Builder::new() - .region(region_provider) - .behavior_version(BehaviorVersion::latest()) - .token_provider(BearerTokenProvider::new(key)) - .build(); - Ok(aws_sdk_bedrock::Client::from_conf(config)) - } - BedrockAuthConfig::IamCredentials { access_key_id, secret_access_key, session_token } => { - let credentials = aws_credential_types::Credentials::new( - access_key_id, - secret_access_key, - session_token, - None, - "windmill", - ); - let config = aws_sdk_bedrock::config::Builder::new() - .region(region_provider) - .behavior_version(BehaviorVersion::latest()) - .credentials_provider(credentials) - .build(); - Ok(aws_sdk_bedrock::Client::from_conf(config)) - } - BedrockAuthConfig::Environment => { - let config = aws_config::defaults(BehaviorVersion::latest()) - .region(region_provider) - .load() - .await; - Ok(aws_sdk_bedrock::Client::new(&config)) - } - } -} - -/// List foundation models using the Bedrock SDK -pub async fn list_foundation_models( - api_key: Option<&str>, - aws_access_key_id: Option<&str>, - aws_secret_access_key: Option<&str>, - aws_session_token: Option<&str>, - region: &str, -) -> Result<(http::StatusCode, http::HeaderMap, axum::body::Body)> { - let client = create_bedrock_control_client( - api_key, - aws_access_key_id, - aws_secret_access_key, - aws_session_token, - region, - ) - .await?; - - let response = client - .list_foundation_models() - .send() - .await - .map_err(|e| Error::internal_err(format!("Failed to list foundation models: {}", e)))?; - - // Convert to JSON response - let models: Vec = response - .model_summaries() - .iter() - .map(|m| { - serde_json::json!({ - "modelId": m.model_id(), - "modelName": m.model_name(), - "providerName": m.provider_name(), - "modelArn": m.model_arn(), - "inputModalities": m.input_modalities().iter().map(|i| i.as_str()).collect::>(), - "outputModalities": m.output_modalities().iter().map(|o| o.as_str()).collect::>(), - "responseStreamingSupported": m.response_streaming_supported(), - "inferenceTypesSupported": m.inference_types_supported().iter().map(|i| i.as_str()).collect::>(), - }) - }) - .collect(); - - let body = serde_json::json!({ "modelSummaries": models }); - let body_bytes = serde_json::to_vec(&body) - .map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?; - - let mut headers = http::HeaderMap::new(); - headers.insert("content-type", "application/json".parse().unwrap()); - - Ok(( - http::StatusCode::OK, - headers, - axum::body::Body::from(body_bytes), - )) -} - -/// List inference profiles using the Bedrock SDK -pub async fn list_inference_profiles( - api_key: Option<&str>, - aws_access_key_id: Option<&str>, - aws_secret_access_key: Option<&str>, - aws_session_token: Option<&str>, - region: &str, -) -> Result<(http::StatusCode, http::HeaderMap, axum::body::Body)> { - let client = create_bedrock_control_client( - api_key, - aws_access_key_id, - aws_secret_access_key, - aws_session_token, - region, - ) - .await?; - - let response = - client.list_inference_profiles().send().await.map_err(|e| { - Error::internal_err(format!("Failed to list inference profiles: {}", e)) - })?; - - // Convert to JSON response - let profiles: Vec = response - .inference_profile_summaries() - .iter() - .map(|p| { - serde_json::json!({ - "inferenceProfileId": p.inference_profile_id(), - "inferenceProfileName": p.inference_profile_name(), - "inferenceProfileArn": p.inference_profile_arn(), - "description": p.description(), - "status": p.status().as_str(), - "type": p.r#type().as_str(), - }) - }) - .collect(); - - let body = serde_json::json!({ "inferenceProfileSummaries": profiles }); - let body_bytes = serde_json::to_vec(&body) - .map_err(|e| Error::internal_err(format!("Failed to serialize response: {}", e)))?; - - let mut headers = http::HeaderMap::new(); - headers.insert("content-type", "application/json".parse().unwrap()); - - Ok(( - http::StatusCode::OK, - headers, - axum::body::Body::from(body_bytes), - )) -} - -// ============================================================================ -// Inference Operations (using aws-sdk-bedrockruntime) -// ============================================================================ - -/// Handle Bedrock streaming request using the AWS SDK. -/// -/// This function uses the shared BedrockClient to make streaming requests -/// and converts the SDK stream events to SSE format for the proxy response. -/// -/// Auth priority: bearer token → IAM credentials → environment credentials -pub async fn handle_bedrock_sdk_streaming( - model: &str, - body: &Bytes, - api_key: Option<&str>, - aws_access_key_id: Option<&str>, - aws_secret_access_key: Option<&str>, - aws_session_token: Option<&str>, - region: &str, -) -> Result<(http::StatusCode, http::HeaderMap, axum::body::Body)> { - let openai_req: OpenAIRequest = serde_json::from_slice(body) - .map_err(|e| Error::internal_err(format!("Failed to parse OpenAI request: {}", e)))?; - - // Create Bedrock client using shared helper - let bedrock_client = create_bedrock_client( - api_key, - aws_access_key_id, - aws_secret_access_key, - aws_session_token, - region, - ) - .await?; - - // Convert messages using shared conversion - let enable_prompt_caching = - windmill_ai::ai_bedrock::bedrock_model_supports_prompt_caching(model); - let (bedrock_messages, system_prompts) = - windmill_ai::ai_bedrock::openai_messages_to_bedrock( - &openai_req.messages, - enable_prompt_caching, - )?; - - // Build inference configuration - let inference_config = windmill_ai::ai_bedrock::create_inference_config( - openai_req.temperature, - openai_req.max_tokens, - ); - - // Convert tools using shared helper - let tool_config = build_tool_config_from_request( - openai_req.tools.as_deref(), - openai_req.tool_choice.as_ref(), - enable_prompt_caching, - )?; - - // Build the SDK request - let mut request_builder = bedrock_client - .client() - .converse_stream() - .model_id(model) - .set_messages(Some(bedrock_messages)); - - if !system_prompts.is_empty() { - request_builder = request_builder.set_system(Some(system_prompts)); - } - - if let Some(config) = inference_config { - request_builder = request_builder.inference_config(config); - } - - if let Some(config) = tool_config { - request_builder = request_builder.set_tool_config(Some(config)); - } - - // Send the request and get the stream - tracing::debug!("Bedrock SDK streaming: sending converse_stream request"); - let stream_output = request_builder.send().await.map_err(|e| { - let error_msg = format!("Bedrock SDK streaming error: {}", format_bedrock_error(&e)); - tracing::error!("Bedrock SDK streaming failed: {}", error_msg); - Error::internal_err(error_msg) - })?; - tracing::debug!("Bedrock SDK streaming: stream established successfully"); - - // Convert SDK stream to SSE (pass the inner stream, not the full output) - let sse_stream = sdk_stream_to_sse(stream_output.stream, model.to_string()); - - // Build response headers - let mut response_headers = http::HeaderMap::new(); - response_headers.insert("content-type", "text/event-stream".parse().unwrap()); - response_headers.insert("cache-control", "no-cache".parse().unwrap()); - response_headers.insert("connection", "keep-alive".parse().unwrap()); - - Ok(( - http::StatusCode::OK, - response_headers, - axum::body::Body::from_stream(sse_stream), - )) -} - -/// Convert AWS SDK ConverseStream events to SSE format. -/// -/// Uses shared stream parsing functions from windmill_common::ai_bedrock -/// to extract text deltas and tool calls from the SDK stream events. -pub fn sdk_stream_to_sse( - stream: aws_sdk_bedrockruntime::primitives::event_stream::EventReceiver< - aws_sdk_bedrockruntime::types::ConverseStreamOutput, - aws_sdk_bedrockruntime::types::error::ConverseStreamOutputError, - >, - model: String, -) -> impl futures::Stream> + Send { - use std::collections::HashMap; - - let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple()); - let created = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - // State to track partial tool calls - struct StreamState { - id: String, - model: String, - created: u64, - tool_calls: HashMap, // index -> (id, name, args) - current_tool_index: usize, - } - - let state = std::sync::Arc::new(tokio::sync::Mutex::new(StreamState { - id: id.clone(), - model: model.clone(), - created, - tool_calls: HashMap::new(), - current_tool_index: 0, - })); - - async_stream::stream! { - let mut stream = stream; - let state = state.clone(); - - loop { - match stream.recv().await { - Ok(Some(event)) => { - let mut state = state.lock().await; - - // Handle tool use start - if let Some(tool_call) = bedrock_stream_event_to_tool_start(&event) { - let index = state.current_tool_index; - state.tool_calls.insert( - index, - (tool_call.id.clone(), tool_call.name.clone(), String::new()), - ); - - // Send initial tool call chunk - let chunk = serde_json::json!({ - "id": state.id, - "object": "chat.completion.chunk", - "created": state.created, - "model": state.model, - "choices": [{ - "index": 0, - "delta": { - "tool_calls": [{ - "index": index, - "id": tool_call.id, - "type": "function", - "function": { - "name": tool_call.name, - "arguments": "" - } - }] - }, - "finish_reason": serde_json::Value::Null - }] - }); - - yield Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk))); - } - - // Handle text delta - if let Some(text) = bedrock_stream_event_to_text(&event) { - let chunk = serde_json::json!({ - "id": state.id, - "object": "chat.completion.chunk", - "created": state.created, - "model": state.model, - "choices": [{ - "index": 0, - "delta": { - "content": text - }, - "finish_reason": serde_json::Value::Null - }] - }); - - yield Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk))); - } - - // Handle tool use input delta - if let Some(input_delta) = bedrock_stream_event_to_tool_delta(&event) { - let index = state.current_tool_index; - if let Some((_id, _name, ref mut args)) = state.tool_calls.get_mut(&index) { - args.push_str(&input_delta); - - let chunk = serde_json::json!({ - "id": state.id, - "object": "chat.completion.chunk", - "created": state.created, - "model": state.model, - "choices": [{ - "index": 0, - "delta": { - "tool_calls": [{ - "index": index, - "function": { - "arguments": input_delta - } - }] - }, - "finish_reason": serde_json::Value::Null - }] - }); - - yield Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk))); - } - } - - // Handle content block stop - if bedrock_stream_event_is_block_stop(&event) { - state.current_tool_index += 1; - } - - // Handle message stop - if let aws_sdk_bedrockruntime::types::ConverseStreamOutput::MessageStop(stop) = &event { - let stop_reason = stop.stop_reason().as_str(); - let finish_reason = match stop_reason { - "end_turn" => "stop", - "max_tokens" => "length", - "tool_use" => "tool_calls", - "stop_sequence" => "stop", - "guardrail_intervened" | "content_filtered" => "content_filter", - _ => "stop", - }; - - let chunk = serde_json::json!({ - "id": state.id, - "object": "chat.completion.chunk", - "created": state.created, - "model": state.model, - "choices": [{ - "index": 0, - "delta": {}, - "finish_reason": finish_reason - }] - }); - - yield Ok(bytes::Bytes::from(format!("data: {}\n\n", chunk))); - } - } - Ok(None) => break, - Err(e) => { - yield Err(std::io::Error::new( - std::io::ErrorKind::Other, - e.to_string(), - )); - break; - } - } - } - - // Send [DONE] at the end - yield Ok(bytes::Bytes::from("data: [DONE]\n\n")); - } -} - -/// Handle non-streaming Bedrock request using the AWS SDK. -/// -/// Auth priority: bearer token → IAM credentials → environment credentials -pub async fn handle_bedrock_sdk_non_streaming( - model: &str, - body: &Bytes, - api_key: Option<&str>, - aws_access_key_id: Option<&str>, - aws_secret_access_key: Option<&str>, - aws_session_token: Option<&str>, - region: &str, -) -> Result<(http::StatusCode, http::HeaderMap, axum::body::Body)> { - let openai_req: OpenAIRequest = serde_json::from_slice(body) - .map_err(|e| Error::internal_err(format!("Failed to parse OpenAI request: {}", e)))?; - - // Create Bedrock client using shared helper - let bedrock_client = create_bedrock_client( - api_key, - aws_access_key_id, - aws_secret_access_key, - aws_session_token, - region, - ) - .await?; - - // Convert messages using shared conversion - let enable_prompt_caching = - windmill_ai::ai_bedrock::bedrock_model_supports_prompt_caching(model); - let (bedrock_messages, system_prompts) = - windmill_ai::ai_bedrock::openai_messages_to_bedrock( - &openai_req.messages, - enable_prompt_caching, - )?; - - // Build inference configuration - let inference_config = windmill_ai::ai_bedrock::create_inference_config( - openai_req.temperature, - openai_req.max_tokens, - ); - - // Convert tools using shared helper - let tool_config = build_tool_config_from_request( - openai_req.tools.as_deref(), - openai_req.tool_choice.as_ref(), - enable_prompt_caching, - )?; - - // Build the SDK request (non-streaming) - let mut request_builder = bedrock_client - .client() - .converse() - .model_id(model) - .set_messages(Some(bedrock_messages)); - - if !system_prompts.is_empty() { - request_builder = request_builder.set_system(Some(system_prompts)); - } - - if let Some(config) = inference_config { - request_builder = request_builder.inference_config(config); - } - - if let Some(config) = tool_config { - request_builder = request_builder.set_tool_config(Some(config)); - } - - // Send the request - tracing::debug!("Bedrock SDK non-streaming: sending converse request"); - let response = request_builder.send().await.map_err(|e| { - let error_msg = format!( - "Bedrock SDK non-streaming error: {}", - format_bedrock_error(&e) - ); - tracing::error!("Bedrock SDK non-streaming failed: {}", error_msg); - Error::internal_err(error_msg) - })?; - tracing::debug!( - "Bedrock SDK non-streaming: response received, stop_reason={}", - response.stop_reason().as_str() - ); - - // Convert response to OpenAI format - let id = format!("chatcmpl-{}", uuid::Uuid::new_v4().simple()); - let created = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_secs(); - - // Extract stop reason - let stop_reason = response.stop_reason().as_str(); - let finish_reason = match stop_reason { - "end_turn" => "stop", - "max_tokens" => "length", - "tool_use" => "tool_calls", - "stop_sequence" => "stop", - "guardrail_intervened" | "content_filtered" => "content_filter", - _ => "stop", - }; - - // Extract message content - let mut text_content = String::new(); - let mut tool_calls: Vec = Vec::new(); - - if let Some(output) = response.output() { - if let aws_sdk_bedrockruntime::types::ConverseOutput::Message(message) = output { - for block in message.content() { - match block { - aws_sdk_bedrockruntime::types::ContentBlock::Text(text) => { - text_content.push_str(text); - } - aws_sdk_bedrockruntime::types::ContentBlock::ToolUse(tool_use) => { - // Convert Document back to JSON string - let input_json = document_to_json(tool_use.input()); - tool_calls.push(OpenAIToolCall { - id: tool_use.tool_use_id().to_string(), - function: OpenAIFunction { - name: tool_use.name().to_string(), - arguments: serde_json::to_string(&input_json).unwrap_or_default(), - }, - r#type: "function".to_string(), - extra_content: None, - }); - } - _ => {} - } - } - } - } - - // Build the message - let message = if !tool_calls.is_empty() { - serde_json::json!({ - "role": "assistant", - "content": if text_content.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(text_content) }, - "tool_calls": tool_calls - }) - } else { - serde_json::json!({ - "role": "assistant", - "content": text_content - }) - }; - - // Extract usage information - let usage = if let Some(usage_data) = response.usage() { - serde_json::json!({ - "prompt_tokens": usage_data.input_tokens(), - "completion_tokens": usage_data.output_tokens(), - "total_tokens": usage_data.total_tokens() - }) - } else { - serde_json::json!({ - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0 - }) - }; - - // Build OpenAI-format response - let openai_resp = serde_json::json!({ - "id": id, - "object": "chat.completion", - "created": created, - "model": model, - "choices": [{ - "index": 0, - "message": message, - "finish_reason": finish_reason - }], - "usage": usage - }); - - let response_body = serde_json::to_vec(&openai_resp) - .map_err(|e| Error::internal_err(format!("Failed to serialize OpenAI response: {}", e)))?; - - let mut response_headers = http::HeaderMap::new(); - response_headers.insert("content-type", "application/json".parse().unwrap()); - - Ok(( - http::StatusCode::OK, - response_headers, - axum::body::Body::from(response_body), - )) -} - -/// Convert AWS Smithy Document to serde_json::Value -fn document_to_json(doc: &aws_smithy_types::Document) -> serde_json::Value { - match doc { - aws_smithy_types::Document::Object(map) => { - let mut json_map = serde_json::Map::new(); - for (k, v) in map { - json_map.insert(k.clone(), document_to_json(v)); - } - serde_json::Value::Object(json_map) - } - aws_smithy_types::Document::Array(arr) => { - serde_json::Value::Array(arr.iter().map(document_to_json).collect()) - } - aws_smithy_types::Document::Number(num) => match num { - aws_smithy_types::Number::PosInt(n) => serde_json::Value::Number((*n).into()), - aws_smithy_types::Number::NegInt(n) => serde_json::Value::Number((*n).into()), - aws_smithy_types::Number::Float(f) => serde_json::json!(*f), - }, - aws_smithy_types::Document::String(s) => serde_json::Value::String(s.clone()), - aws_smithy_types::Document::Bool(b) => serde_json::Value::Bool(*b), - aws_smithy_types::Document::Null => serde_json::Value::Null, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn determine_auth_config_prioritizes_bearer_token() { - let config = determine_auth_config( - Some("bearer-token"), - Some("AKIA123"), - Some("secret"), - Some("session-token"), - ); - - match config { - BedrockAuthConfig::BearerToken(token) => assert_eq!(token, "bearer-token"), - _ => panic!("expected bearer token auth config"), - } - } - - #[test] - fn determine_auth_config_uses_iam_with_optional_session_token() { - let config = - determine_auth_config(None, Some("AKIA123"), Some("secret"), Some("session-token")); - - match config { - BedrockAuthConfig::IamCredentials { - access_key_id, - secret_access_key, - session_token, - } => { - assert_eq!(access_key_id, "AKIA123"); - assert_eq!(secret_access_key, "secret"); - assert_eq!(session_token.as_deref(), Some("session-token")); - } - _ => panic!("expected IAM auth config"), - } - } - - #[test] - fn determine_auth_config_treats_empty_session_token_as_none() { - let config = determine_auth_config(None, Some("AKIA123"), Some("secret"), Some("")); - - match config { - BedrockAuthConfig::IamCredentials { session_token, .. } => { - assert!(session_token.is_none()); - } - _ => panic!("expected IAM auth config"), - } - } - - #[test] - fn determine_auth_config_falls_back_to_environment() { - let config = determine_auth_config(None, Some("AKIA123"), None, Some("session-token")); - assert!(matches!(config, BedrockAuthConfig::Environment)); - } -} diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index 85f0aedfc3..f8c4849e92 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -17,6 +17,7 @@ use itertools::Itertools; use quick_cache::sync::Cache; use serde_json::value::RawValue; use serde_json::Value; +use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::str::FromStr; use std::sync::Arc; @@ -344,6 +345,10 @@ pub fn workspaced_service() -> Router { "/result_by_id/{job_id}/{node_id}", get(get_result_by_id).layer(cors.clone()), ) + .route( + "/job_view_token/{id}", + get(get_job_view_token).layer(cors.clone()), + ) .route("/run/dependencies", post(run_dependencies_job)) .route("/run/dependencies_async", post(run_dependencies_job_async)) .route("/run/flow_dependencies", post(run_flow_dependencies_job)) @@ -426,12 +431,27 @@ struct JsonPath { pub approver: Option, } async fn get_result_by_id( + OptViewToken(view_token): OptViewToken, authed: ApiAuthed, tokened: Tokened, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, flow_id, node_id)): Path<(String, Uuid, String)>, Query(JsonPath { json_path, .. }): Query, ) -> windmill_common::error::JsonResult> { + // Reading a node's result requires being able to read the flow itself (the node + // belongs to it). Gate on the flow's visibility (created_by / RLS / root + // inheritance) before resolving via the root DB. + require_job_update_read_access( + &db, + &user_db, + &authed, + &w_id, + &flow_id, + view_token.as_deref(), + ) + .await?; + let res = windmill_queue::get_result_by_id(db.clone(), w_id.clone(), flow_id, node_id, json_path) .await?; @@ -441,6 +461,25 @@ async fn get_result_by_id( Ok(Json(res)) } +/// Mint a stateless "share read link" token for a job. Only a caller who can already +/// read the job (creator / RLS / flow ancestor / admin) may mint it. The returned +/// `{job_id}.{hmac}` is passed back as the `view_token` query param on the run page's +/// reads, granting an authenticated member read of this job and its flow subtree. +async fn get_job_view_token( + authed: ApiAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, id)): Path<(String, Uuid)>, +) -> error::Result { + // No `view_token` here: minting requires the caller's own read access, so a share + // link cannot be used to mint further links. `require_job_read_access` also + // enforces the caller's `if_jobs:filter_tags` scope, so a tag-scoped token can't + // mint a transferable link for a job outside its allowed tags. + require_job_update_read_access(&db, &user_db, &authed, &w_id, &id, None).await?; + let hmac = generate_view_token(&w_id, id, &db).await?; + Ok(format!("{id}.{hmac}")) +} + async fn get_root_job( Extension(db): Extension, Path((w_id, id)): Path<(String, Uuid)>, @@ -690,9 +729,11 @@ async fn get_scheduled_for( } async fn get_flow_job_debug_info( + OptViewToken(view_token): OptViewToken, OptAuthed(opt_authed): OptAuthed, tokened_o: OptTokened, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::Result { let job = GetQuery::new() @@ -700,6 +741,18 @@ async fn get_flow_job_debug_info( .fetch_queued((&db).into(), &id, &w_id) .await?; if let Some(job) = job { + if let Some(authed) = opt_authed.as_ref() { + require_job_read_access( + &db, + &user_db, + authed, + &w_id, + &id, + &job.created_by, + view_token.as_deref(), + ) + .await?; + } let is_flow = job.is_flow(); if job.is_flow_step || !is_flow { return Err(error::Error::BadRequest( @@ -857,10 +910,338 @@ struct GetJobQuery { pub approval_token: Option, } +/// Authorize an *authenticated* caller to read a single job's data +/// (full job / args / result / logs / live updates). +/// +/// Single-job read endpoints query through the root `DB` (RLS-bypassing), filtered +/// only by job id + workspace (+ token scope tags). That is required for the +/// unauthenticated approval / public-trigger / anonymous-job flows, but for a +/// logged-in user it meant any workspace member — e.g. a viewer with no ACL on the +/// runnable — could read another user's job args/result/logs simply by obtaining the +/// job UUID, even though the same job is hidden from them in `jobs/list` +/// (RLS-filtered) and the underlying script returns 404. (WIN-2026-jobs-read) +/// +/// Unauthenticated callers are still handled by each handler's anonymous-job check; +/// this gate applies only when a user is authenticated. Access is granted when: +/// - the caller created the job (`created_by`) — covers app components, webhooks and +/// the caller's own runs, whose `permissioned_as` is the policy identity rather +/// than the caller, so they would otherwise fail the RLS probe; or +/// - the job is visible to the caller under the same RLS as `jobs/list`, probed on +/// `v2_job` via `user_db` (admins BYPASSRLS). +/// +/// Optional share-read-link token (validated by [`validate_view_token`]). Read from +/// the `view_token` query parameter — needed for `EventSource`/SSE and direct links, +/// which can't set headers — falling back to the `X-View-Token` header, which lets the +/// frontend attach it to every generated-client request via a single interceptor +/// instead of threading it through each call. Read independently of each handler's own +/// `Query` extractor (axum allows only one typed `Query`). +pub struct OptViewToken(pub Option); + +impl axum::extract::FromRequestParts for OptViewToken { + type Rejection = std::convert::Infallible; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + _state: &S, + ) -> std::result::Result { + let from_query = parts.uri.query().and_then(|q| { + serde_urlencoded::from_str::>(q) + .ok() + .and_then(|pairs| { + pairs + .into_iter() + .find(|(k, _)| k == "view_token") + .map(|(_, v)| v) + }) + }); + let token = from_query.or_else(|| { + parts + .headers + .get("x-view-token") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + }); + Ok(OptViewToken(token)) + } +} + +/// Otherwise returns 404 — matching `scripts/get` and avoiding existence disclosure. +async fn require_job_read_access( + db: &DB, + user_db: &UserDB, + authed: &ApiAuthed, + w_id: &str, + job_id: &Uuid, + created_by: &str, + view_token: Option<&str>, +) -> error::Result<()> { + // Tag scope (`if_jobs:filter_tags:`) is an orthogonal hard restriction on a + // scoped token: it must never read a job outside its allowed tags, regardless of + // how authorization is otherwise satisfied (created_by / view token / RLS). Most + // read handlers also tag-filter their data query, but some (result_by_id, + // get_flow_job_debug_info, get_otel_traces) do not, so enforce it here — before + // the grants below — so a share token can't be used to escape the tag scope. + // `get_scope_tags` is `None` for unscoped callers (the common case), so this adds + // no query for normal sessions/tokens. + if let Some(tags) = get_scope_tags(authed) { + let in_scope = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = $1 AND workspace_id = $2 AND tag = ANY($3))", + job_id, + w_id, + &tags.iter().map(|t| t.to_string()).collect::>(), + ) + .fetch_one(db) + .await? + == Some(true); + if !in_scope { + return Err(Error::NotFound(format!("Job {job_id} not found"))); + } + } + + // Fast path: you can always read a job you launched. This is also load-bearing + // for apps — a component job runs as the app policy's `permissioned_as`, but its + // `created_by` is the launching viewer, so the RLS probe below would hide it. + if created_by == authed.username { + return Ok(()); + } + + // `username_override` is derived from the token *label* (`username_override_from_label`), + // which is fully user-controlled with no uniqueness/ownership check (webhook-/http-/ + // email-/ws- trigger tokens, `ephemeral-script-end-user-*`, and the generic `label-*` + // all flow through it). A bare `username_override == created_by` match is therefore + // forgeable: any member can mint a token with a colliding label and read another + // principal's jobs (IDOR — results/args/logs with resolved secrets). Bind the grant to + // a non-forgeable attribute instead: the job must actually run as the caller's own + // identity, i.e. its `permissioned_as_email` (the token owner's email, never set from + // the label) equals `authed.email`. This still admits every legitimate same-owner + // re-read (trigger tokens reading their own webhook/http/email jobs, the + // ephemeral-script-end-user worker token, generic labeled tokens) while denying + // cross-principal collisions. The DB hit only happens when an override is present and + // matches, so the common session/token path stays query-free. + if authed + .username_override + .as_deref() + .is_some_and(|u| u == created_by) + { + let job_email = sqlx::query_scalar!( + "SELECT permissioned_as_email FROM v2_job WHERE id = $1 AND workspace_id = $2", + job_id, + w_id, + ) + .fetch_optional(db) + .await?; + if job_email.as_deref() == Some(authed.email.as_str()) { + return Ok(()); + } + } + + // Share read link: a valid view token minted by someone with read access grants + // this authenticated member read of the shared job and its flow subtree. + if let Some(token) = view_token { + if validate_view_token(db, w_id, job_id, token).await? { + return Ok(()); + } + } + + // The probe below (chain walk + an RLS-scoped transaction) is comparatively + // expensive and the same (caller, job) is hit repeatedly — e.g. `getupdate` + // polling of a run you can see but did not launch, or an admin watching many + // runs. Cache the boolean outcome. All job-side inputs to the decision + // (created_by, runnable_path, permissioned_as, visible_to_owner, flow lineage) + // are immutable after creation, and every mutable caller-side input + // (is_admin / username / username_override / groups / folders) is folded into + // the key — so a permission change yields a new key rather than a stale hit, and + // no TTL is needed (size-bounded LRU; mirrors apps' PERMIT_CACHE). + let cache_key = job_read_access_cache_key(authed, w_id, job_id); + let visible = if let Some(visible) = JOB_READ_ACCESS_CACHE.get(&cache_key) { + visible + } else { + // Visibility is inherited along the flow hierarchy: if you can read ANY flow + // that (transitively) contains this job, you can read the job. A step runs as + // its flow's `permissioned_as` but its `runnable_path` is the inner runnable's + // — which the caller may have no direct ACL on — and the flow-run UI fetches + // each step by id, so gating purely on the step's own RLS visibility would + // break inspecting a flow you can see but did not launch. We therefore probe + // RLS visibility of the job OR any of its `parent_job` ancestors (admins + // BYPASSRLS) — the same visibility as `jobs/list`. + let chain_ids = job_ancestor_chain_ids(db, w_id, job_id).await?; + + let mut tx = user_db.clone().begin(authed).await?; + let visible = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = ANY($1) AND workspace_id = $2)", + &chain_ids[..], + w_id, + ) + .fetch_one(&mut *tx) + .await? + == Some(true); + tx.commit().await?; + + JOB_READ_ACCESS_CACHE.insert(cache_key, visible); + visible + }; + + if visible { + return Ok(()); + } + + // Denied. Distinguish "the run exists but you lack access" (actionable: ask a + // colleague for a share link) from "no such run", so the UI can guide the user. + // Only authenticated members reach this point and job UUIDs are non-enumerable, + // so disclosing mere existence to a member is an acceptable trade-off for the UX. + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM v2_job WHERE id = ANY($1) AND workspace_id = $2)", + &[*job_id][..], + w_id, + ) + .fetch_one(db) + .await? + == Some(true); + if exists { + Err(Error::PermissionDenied(format!( + "You do not have access to run {job_id}. Ask a user who can see it to open the run and \ + share a read-only link with you (the \"Share\" button on the run page)." + ))) + } else { + Err(Error::NotFound(format!("Job {job_id} not found"))) + } +} + +/// Self + every `parent_job` ancestor (intermediate sub-flows up to the top-level +/// root) of `job_id`, resolved via the root DB (flow lineage is not sensitive). +/// Falls back to `[job_id]` if the row is absent so callers still run their probe. +async fn job_ancestor_chain_ids(db: &DB, w_id: &str, job_id: &Uuid) -> error::Result> { + let chain_ids = sqlx::query_scalar!( + r#"WITH RECURSIVE chain(id, parent_job) AS ( + SELECT id, parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2 + UNION ALL + SELECT j.id, j.parent_job FROM v2_job j + JOIN chain c ON j.id = c.parent_job AND j.workspace_id = $2 + ) + SELECT id AS "id!" FROM chain"#, + job_id, + w_id, + ) + .fetch_all(db) + .await?; + Ok(if chain_ids.is_empty() { + vec![*job_id] + } else { + chain_ids + }) +} + +/// A share read link token has the form `{shared_job_id}.{hmac}` where `hmac` is +/// [`windmill_common::variables::generate_view_token`] for `shared_job_id`. It grants +/// read of that job and its whole flow subtree, so the run page can present a single +/// link that also renders the flow's steps. Returns true iff the signature is valid +/// AND `accessed_job_id` is the shared job or one of its descendants. +async fn validate_view_token( + db: &DB, + w_id: &str, + accessed_job_id: &Uuid, + token: &str, +) -> error::Result { + let Some((shared_id_str, provided_hmac)) = token.split_once('.') else { + return Ok(false); + }; + let Ok(shared_id) = Uuid::parse_str(shared_id_str) else { + return Ok(false); + }; + let Ok(provided_bytes) = hex::decode(provided_hmac) else { + return Ok(false); + }; + // Constant-time verification (same domain as `generate_view_token`, mirroring + // `verify_suspended_secret`); avoids the timing side-channel of comparing the + // hex strings with `!=`. + let key = get_workspace_key(w_id, db).await?; + let mut mac = HmacSha256::new_from_slice(key.as_bytes()).map_err(to_anyhow)?; + mac.update(shared_id.as_bytes()); + mac.update(b"view_token"); + if mac.verify_slice(&provided_bytes).is_err() { + return Ok(false); + } + if accessed_job_id == &shared_id { + return Ok(true); + } + // The token authorizes the shared job's subtree: accessed must descend from it, + // i.e. the shared job is among accessed's ancestors. + let chain = job_ancestor_chain_ids(db, w_id, accessed_job_id).await?; + Ok(chain.contains(&shared_id)) +} + +lazy_static::lazy_static! { + /// Caches the result of the `require_job_read_access` RLS visibility probe, + /// keyed by the caller's authorization-relevant identity plus the job id (see + /// [`job_read_access_cache_key`]). No TTL: the cached decision is a pure function + /// of immutable job-side state and the caller-side state encoded in the key, so a + /// permission change re-keys rather than going stale. Size-bounded LRU. + static ref JOB_READ_ACCESS_CACHE: Cache<[u8; 32], bool> = Cache::new(50_000); +} + +/// Key for [`JOB_READ_ACCESS_CACHE`]: a SHA-256 over every caller-side input that +/// affects job-read visibility (admin flag, username, username override, the sorted +/// group set, and the sorted folder set the caller has any grant on — RLS reads from +/// all of them) plus the workspace and job id. Sorting makes the key order-independent; +/// each variable-length field is length-prefixed so no choice of input values can make +/// two distinct identities hash equal (e.g. `["a","bc"]` vs `["ab","c"]`). +fn job_read_access_cache_key(authed: &ApiAuthed, w_id: &str, job_id: &Uuid) -> [u8; 32] { + let mut hasher = Sha256::new(); + // Length-prefix every variable-length field (u32 BE) to make the encoding injective. + let field = |hasher: &mut Sha256, bytes: &[u8]| { + hasher.update((bytes.len() as u32).to_be_bytes()); + hasher.update(bytes); + }; + hasher.update([authed.is_admin as u8]); + field(&mut hasher, authed.username.as_bytes()); + field( + &mut hasher, + authed.username_override.as_deref().unwrap_or("").as_bytes(), + ); + let mut groups: Vec<&str> = authed.groups.iter().map(String::as_str).collect(); + groups.sort_unstable(); + hasher.update((groups.len() as u32).to_be_bytes()); + for g in groups { + field(&mut hasher, g.as_bytes()); + } + let mut folders: Vec<&str> = authed.folders.iter().map(|f| f.0.as_str()).collect(); + folders.sort_unstable(); + hasher.update((folders.len() as u32).to_be_bytes()); + for f in folders { + field(&mut hasher, f.as_bytes()); + } + field(&mut hasher, w_id.as_bytes()); + hasher.update(job_id.as_bytes()); + hasher.finalize().into() +} + +/// [`require_job_read_access`] for callers (job-update poll / SSE) that haven't +/// already loaded `created_by` — fetches it (root DB, by id+workspace) first. +async fn require_job_update_read_access( + db: &DB, + user_db: &UserDB, + authed: &ApiAuthed, + w_id: &str, + job_id: &Uuid, + view_token: Option<&str>, +) -> error::Result<()> { + let created_by = sqlx::query_scalar!( + "SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2", + job_id, + w_id, + ) + .fetch_optional(db) + .await? + .ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?; + require_job_read_access(db, user_db, authed, w_id, job_id, &created_by, view_token).await +} + async fn get_job( + OptViewToken(view_token): OptViewToken, OptAuthed(opt_authed): OptAuthed, opt_tokened: OptTokened, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, Query(GetJobQuery { no_logs, no_code, approval_token }): Query, ) -> error::Result { @@ -903,6 +1284,23 @@ async fn get_job( let mut job = get.fetch(&db, &id, &w_id).await?; job.fetch_outstanding_wait_time(&db).await?; + // A valid approval token is itself the capability; otherwise an authenticated + // caller must pass the same visibility as `jobs/list` (see `require_job_read_access`). + if !has_valid_approval_token { + if let Some(authed) = opt_authed.as_ref() { + require_job_read_access( + &db, + &user_db, + authed, + &w_id, + &id, + job.created_by(), + view_token.as_deref(), + ) + .await?; + } + } + log_job_view( &db, opt_authed.as_ref(), @@ -1477,8 +1875,10 @@ async fn get_logs_from_disk( } async fn get_completed_job_logs_tail( + OptViewToken(view_token): OptViewToken, OptAuthed(opt_authed): OptAuthed, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::JsonResult { let tags = opt_authed @@ -1501,7 +1901,18 @@ async fn get_completed_job_logs_tail( .await?; if let Some(record) = record { - if opt_authed.is_none() && record.created_by != "anonymous" { + if let Some(authed) = opt_authed.as_ref() { + require_job_read_access( + &db, + &user_db, + authed, + &w_id, + &id, + &record.created_by, + view_token.as_deref(), + ) + .await?; + } else if record.created_by != "anonymous" { return Err(Error::BadRequest( "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), )); @@ -1519,9 +1930,11 @@ struct QueryJobLogs { } async fn get_job_logs( + OptViewToken(view_token): OptViewToken, OptAuthed(opt_authed): OptAuthed, opt_tokened: OptTokened, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, Query(query_job_logs): Query, ) -> error::Result { @@ -1552,7 +1965,18 @@ async fn get_job_logs( .await?; if let Some(record) = record { - if opt_authed.is_none() && record.created_by != "anonymous" { + if let Some(authed) = opt_authed.as_ref() { + require_job_read_access( + &db, + &user_db, + authed, + &w_id, + &id, + &record.created_by, + view_token.as_deref(), + ) + .await?; + } else if record.created_by != "anonymous" { return Err(Error::BadRequest( "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), )); @@ -1680,9 +2104,11 @@ async fn resolve_logs_to_string( } async fn get_flow_all_logs( + OptViewToken(view_token): OptViewToken, OptAuthed(opt_authed): OptAuthed, opt_tokened: OptTokened, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::Result { let tags = opt_authed @@ -1702,7 +2128,18 @@ async fn get_flow_all_logs( let root_job = not_found_if_none(root_job, "Job", id.to_string())?; - if opt_authed.is_none() && root_job.created_by != "anonymous" { + if let Some(authed) = opt_authed.as_ref() { + require_job_read_access( + &db, + &user_db, + authed, + &w_id, + &id, + &root_job.created_by, + view_token.as_deref(), + ) + .await?; + } else if root_job.created_by != "anonymous" { return Err(Error::BadRequest( "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), )); @@ -1858,9 +2295,11 @@ async fn get_flow_all_logs( } async fn get_args( + OptViewToken(view_token): OptViewToken, OptAuthed(opt_authed): OptAuthed, opt_tokened: OptTokened, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> JsonResult> { let tags = opt_authed @@ -1879,7 +2318,18 @@ async fn get_args( .await?; if let Some(record) = record { - if opt_authed.is_none() && record.created_by != "anonymous" { + if let Some(authed) = opt_authed.as_ref() { + require_job_read_access( + &db, + &user_db, + authed, + &w_id, + &id, + &record.created_by, + view_token.as_deref(), + ) + .await?; + } else if record.created_by != "anonymous" { return Err(Error::BadRequest( "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), )); @@ -1907,7 +2357,18 @@ async fn get_args( .fetch_optional(&db) .await?; let record = not_found_if_none(record, "Job Args", id.to_string())?; - if opt_authed.is_none() && record.created_by != "anonymous" { + if let Some(authed) = opt_authed.as_ref() { + require_job_read_access( + &db, + &user_db, + authed, + &w_id, + &id, + &record.created_by, + view_token.as_deref(), + ) + .await?; + } else if record.created_by != "anonymous" { return Err(Error::BadRequest( "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), )); @@ -2114,15 +2575,19 @@ async fn list_filtered_job_uuids( false, get_scope_tags(&authed), ); - let sqlb2 = list_queue_jobs_query( - w_id.as_str(), - &lq.into(), - &["v2_job.id"], - Pagination { page: None, per_page: None }, - false, - get_scope_tags(&authed), - ); - let query = sqlb.union_all(sqlb2.subquery()?).subquery()?; + let query = if lq.status.is_some() { + sqlb.subquery()? + } else { + let sqlb2 = list_queue_jobs_query( + w_id.as_str(), + &lq.into(), + &["v2_job.id"], + Pagination { page: None, per_page: None }, + false, + get_scope_tags(&authed), + ); + sqlb.union_all(sqlb2.subquery()?).subquery()? + }; let ids = sqlx::query_scalar(query.as_str()).fetch_all(&db).await?; Ok(Json(ids)) } @@ -2280,9 +2745,9 @@ async fn list_jobs( tracing::warn!("offset is not 0, but is ignored for list_jobs. Use created_before or completed_before instead."); } - if lq.success.is_some() && lq.running.is_some_and(|x| x) { + if (lq.success.is_some() || lq.status.is_some()) && lq.running.is_some_and(|x| x) { return Err(error::Error::BadRequest( - "cannot specify both success and running".to_string(), + "cannot specify success/status with running".to_string(), )); } @@ -2335,6 +2800,7 @@ async fn list_jobs( }; let sql = if lq.success.is_none() + && lq.status.is_none() && lq.label.is_none() && lq.result.is_none() && !lq.is_skipped.unwrap_or(false) @@ -2360,7 +2826,7 @@ async fn list_jobs( } else { if sqlc.is_none() { return Err(error::Error::BadRequest( - "cannot specify success, label, created_or_started_before, or starte + "cannot specify success, status, label, created_or_started_before, or starte d_before with running" .to_string(), )); @@ -2453,7 +2919,7 @@ pub async fn resume_suspended_flow_as_owner( // --- New approval system endpoints --- -use windmill_common::variables::generate_approval_token; +use windmill_common::variables::{generate_approval_token, generate_view_token}; /// Verify an approval token against the workspace key + job_id. async fn validate_approval_token( @@ -7106,7 +7572,13 @@ pub async fn run_job_by_hash_inner( Ok((uuid, delete_after_use, delete_after_secs)) } -async fn get_log_file(Path((_w_id, file_p)): Path<(String, String)>) -> error::Result { +async fn get_log_file( + OptViewToken(view_token): OptViewToken, + OptAuthed(opt_authed): OptAuthed, + Extension(db): Extension, + Extension(user_db): Extension, + Path((w_id, file_p)): Path<(String, String)>, +) -> error::Result { if file_p.contains("..") { return Err(error::Error::BadRequest("Invalid path".to_string())); } @@ -7118,27 +7590,69 @@ async fn get_log_file(Path((_w_id, file_p)): Path<(String, String)>) -> error::R "Invalid path: must have exactly 2 components".to_string(), )); } - if Uuid::parse_str(parts[0]).is_err() { - return Err(error::Error::BadRequest( - "Invalid path: first component must be a valid UUID".to_string(), - )); - } + let job_id = Uuid::parse_str(parts[0]).map_err(|_| { + error::Error::BadRequest("Invalid path: first component must be a valid UUID".to_string()) + })?; if !parts[1].ends_with(".txt") { return Err(error::Error::BadRequest( "Invalid path: file must end with .txt".to_string(), )); } + // Authorization: the log file directory is the job id, so gate access the same + // way as get_job_logs — the caller must be able to read the job. Non-logged-in + // callers may only read logs of jobs created by the anonymous user. + let tags = opt_authed + .as_ref() + .map(|authed| get_scope_tags(authed).map(|v| v.iter().map(|s| s.to_string()).collect_vec())) + .flatten(); + let created_by = sqlx::query_scalar!( + "SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2 AND ($3::text[] IS NULL OR tag = ANY($3))", + job_id, + w_id, + tags.as_ref().map(|v| v.as_slice()) + ) + .fetch_optional(&db) + .await? + .ok_or_else(|| error::Error::NotFound(format!("Job {job_id} not found")))?; + if let Some(authed) = opt_authed.as_ref() { + require_job_read_access( + &db, + &user_db, + authed, + &w_id, + &job_id, + &created_by, + view_token.as_deref(), + ) + .await?; + } else if created_by != "anonymous" { + return Err(error::Error::BadRequest( + "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), + )); + } + let local_file = format!("{}/logs/{file_p}", *WINDMILL_DIR); - if tokio::fs::metadata(&local_file).await.is_ok() { - let mut file = tokio::fs::File::open(local_file).await.map_err(to_anyhow)?; - let mut buffer = Vec::new(); - file.read_to_end(&mut buffer).await.map_err(to_anyhow)?; - let res = Response::builder() - .header(http::header::CONTENT_TYPE, "text/plain") - .body(Body::from(bytes::Bytes::from(buffer))) - .unwrap(); - return Ok(res); + // SECURITY (defense in depth): refuse to read through a symlink so a planted + // symlink in the logs directory cannot be used to exfiltrate arbitrary files. + // `symlink_metadata` returns the link's own metadata without following it. + match tokio::fs::symlink_metadata(&local_file).await { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(error::Error::BadRequest("Invalid path".to_string())); + } + Ok(_) => { + let mut file = tokio::fs::File::open(&local_file) + .await + .map_err(to_anyhow)?; + let mut buffer = Vec::new(); + file.read_to_end(&mut buffer).await.map_err(to_anyhow)?; + let res = Response::builder() + .header(http::header::CONTENT_TYPE, "text/plain") + .body(Body::from(bytes::Bytes::from(buffer))) + .unwrap(); + return Ok(res); + } + Err(_) => {} } #[cfg(all(feature = "enterprise", feature = "parquet"))] @@ -7182,9 +7696,11 @@ async fn get_log_file(Path((_w_id, file_p)): Path<(String, String)>) -> error::R } async fn get_job_update( + OptViewToken(view_token): OptViewToken, OptAuthed(opt_authed): OptAuthed, opt_tokened: OptTokened, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, job_id)): Path<(String, Uuid)>, Query(JobUpdateQuery { log_offset, @@ -7197,6 +7713,17 @@ async fn get_job_update( .. }): Query, ) -> JsonResult { + if let Some(authed) = opt_authed.as_ref() { + require_job_update_read_access( + &db, + &user_db, + authed, + &w_id, + &job_id, + view_token.as_deref(), + ) + .await?; + } Ok(Json( get_job_update_data( &opt_authed, @@ -7217,15 +7744,18 @@ async fn get_job_update( None, false, &mut false, + &mut false, ) .await?, )) } async fn get_job_update_sse( + OptViewToken(view_token): OptViewToken, OptAuthed(opt_authed): OptAuthed, opt_tokened: OptTokened, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, job_id)): Path<(String, Uuid)>, Query(JobUpdateQuery { log_offset, @@ -7239,6 +7769,20 @@ async fn get_job_update_sse( poll_delay_ms, }): Query, ) -> error::Result { + // Authorize once at connection time; `created_by` cannot change for a given job, + // mirroring the per-stream `anonymous_verified` latch in the streaming loop. + if let Some(authed) = opt_authed.as_ref() { + require_job_update_read_access( + &db, + &user_db, + authed, + &w_id, + &job_id, + view_token.as_deref(), + ) + .await?; + } + let (tx, rx) = tokio::sync::mpsc::channel(32); start_job_update_sse_stream( @@ -7307,6 +7851,10 @@ pub fn start_job_update_sse_stream( // Latched once the early_return node's failure is observed alongside a // failure_module — subsequent polls then skip the redundant per-node lookup. let mut early_return_suppressed = false; + // Latched once we've verified the job was created by "anonymous" — for + // unauthenticated SSE streams, this gates access and is checked once per + // stream rather than once per poll (created_by cannot change). + let mut anonymous_verified = false; // Send initial update immediately let mut running = running; @@ -7331,6 +7879,7 @@ pub fn start_job_update_sse_stream( early_return.as_deref(), has_failure_module, &mut early_return_suppressed, + &mut anonymous_verified, ) .await { @@ -7451,6 +8000,7 @@ pub fn start_job_update_sse_stream( early_return.as_deref(), has_failure_module, &mut early_return_suppressed, + &mut anonymous_verified, ) .await { @@ -7584,6 +8134,7 @@ async fn get_job_update_data( early_return: Option<&str>, has_failure_module: bool, early_return_suppressed: &mut bool, + anonymous_verified: &mut bool, ) -> error::Result { let tags = if log_view { log_job_view( @@ -7605,6 +8156,32 @@ async fn get_job_update_data( let ignore_flow_stream_job_id = is_flow.is_some_and(|x| !x) || flow_stream_job_id.is_some(); if only_result.unwrap_or(false) { + // Unauthenticated callers may only read jobs whose creator is "anonymous". + // The non-only_result branch enforces this via `record.created_by` from its + // main query, but the only_result branch below fetches solely the result by + // (workspace_id, job_id), so we guard here to close the gap. The + // `anonymous_verified` flag is preserved across SSE poll iterations so the + // lookup only happens once per stream — `created_by` cannot change for a + // given job once it has been created. + if opt_authed.is_none() && !*anonymous_verified { + let created_by = sqlx::query_scalar!( + "SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2", + job_id, + w_id, + ) + .fetch_optional(db) + .await? + .ok_or_else(|| Error::NotFound(format!("Job not found: {}", job_id)))?; + + if created_by != "anonymous" { + return Err(Error::BadRequest( + "As a non logged in user, you can only see jobs ran by anonymous users" + .to_string(), + )); + } + *anonymous_verified = true; + } + let (result, running, mut result_stream, mut new_stream_offset, new_flow_stream_job_id) = if let Some(tags) = tags { let r = sqlx::query!( @@ -7965,9 +8542,11 @@ async fn list_completed_jobs( } async fn get_completed_job<'a>( + OptViewToken(view_token): OptViewToken, OptAuthed(opt_authed): OptAuthed, opt_tokened: OptTokened, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::Result { let tags = opt_authed @@ -7982,6 +8561,20 @@ async fn get_completed_job<'a>( .await?; let cj = not_found_if_none(job_o, "Completed Job", id.to_string())?; + + if let Some(authed) = opt_authed.as_ref() { + require_job_read_access( + &db, + &user_db, + authed, + &w_id, + &id, + &cj.created_by, + view_token.as_deref(), + ) + .await?; + } + let response = Json(cj).into_response(); // let extra_log = query_scalar!( // "SELECT substr(logs, $1) as logs FROM large_logs WHERE workspace_id = $2 AND job_id = $3", @@ -8012,9 +8605,11 @@ pub struct RawResult { } async fn get_completed_job_result( + OptViewToken(view_token): OptViewToken, OptAuthed(opt_authed): OptAuthed, opt_tokened: OptTokened, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, Query(JsonPath { json_path, suspended_job, approver, resume_id, secret }): Query, ) -> error::Result { @@ -8059,26 +8654,40 @@ async fn get_completed_job_result( let mut raw_result = not_found_if_none(result_o, "Completed Job", id.to_string())?; - if opt_authed.is_none() && raw_result.created_by.unwrap_or_default() != "anonymous" { - match (suspended_job, resume_id, approver, secret) { - (Some(suspended_job), Some(resume_id), approver, Some(secret)) => { - let mut parent_job = id; - while parent_job != suspended_job { - let p_job = sqlx::query_scalar!( - "SELECT parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2", - parent_job, - &w_id - ) - .fetch_optional(&db) - .await? - .flatten(); - if let Some(p_job) = p_job { - parent_job = p_job; - } else { - return Err(Error::BadRequest("Approval secret of suspended job is not a parent of the job whose id's is being searched not found".to_string())); + let created_by = raw_result.created_by.take().unwrap_or_default(); + + // A valid approval secret for the suspended parent flow grants access to this + // node's result for ANY caller — logged in or not — since the approval page + // renders its form from this result. Try it first. If the secret triple is absent, + // or present but invalid, fall through to normal authorization: an authenticated + // reader with ACL must NOT be blocked just because a stale/garbage secret was + // attached (pre-fix the secret branch was skipped entirely for authed callers), + // while an unauthenticated caller, for whom the secret is the only credential, + // still ends up rejected below. + let approval_secret_ok = match (suspended_job, resume_id, secret) { + (Some(suspended_job), Some(resume_id), Some(secret)) => { + // Walk from `id` up to the claimed suspended parent. + let mut parent_job = id; + let mut reached = true; + while parent_job != suspended_job { + let p_job = sqlx::query_scalar!( + "SELECT parent_job FROM v2_job WHERE id = $1 AND workspace_id = $2", + parent_job, + &w_id + ) + .fetch_optional(&db) + .await? + .flatten(); + match p_job { + Some(p_job) => parent_job = p_job, + None => { + reached = false; + break; } } - verify_suspended_secret( + } + reached + && verify_suspended_secret( &w_id, &db, suspended_job, @@ -8086,14 +8695,28 @@ async fn get_completed_job_result( &QueryApprover { approver, flow_level: None }, secret, ) - .await? - } - _ => { - return Err(Error::BadRequest( - "As a non logged in user, you can only see jobs ran by anonymous users" - .to_string(), - )) - } + .await + .is_ok() + } + _ => false, + }; + + if !approval_secret_ok { + if let Some(authed) = opt_authed.as_ref() { + require_job_read_access( + &db, + &user_db, + authed, + &w_id, + &id, + &created_by, + view_token.as_deref(), + ) + .await?; + } else if created_by != "anonymous" { + return Err(Error::BadRequest( + "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), + )); } } @@ -8166,9 +8789,11 @@ struct GetCompletedJobQuery { } async fn get_completed_job_result_maybe( + OptViewToken(view_token): OptViewToken, OptAuthed(opt_authed): OptAuthed, opt_tokened: OptTokened, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, Query(GetCompletedJobQuery { get_started }): Query, ) -> error::Result { @@ -8194,7 +8819,18 @@ async fn get_completed_job_result_maybe( if let Some(mut res) = result_o { format_result(res.result_columns.as_ref(), res.result.as_mut()); - if opt_authed.is_none() && res.created_by != "anonymous" { + if let Some(authed) = opt_authed.as_ref() { + require_job_read_access( + &db, + &user_db, + authed, + &w_id, + &id, + &res.created_by, + view_token.as_deref(), + ) + .await?; + } else if res.created_by != "anonymous" { return Err(Error::BadRequest( "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), )); @@ -8217,6 +8853,36 @@ async fn get_completed_job_result_maybe( }) .into_response()) } else if get_started.is_some_and(|x| x) { + // No completed row yet — the job may be queued/running. Returning its + // running-state still discloses information about a (possibly private) job, so + // authorize first when the job exists. If it doesn't exist, fall through to a + // `started: false` response (which leaks nothing). + let created_by = sqlx::query_scalar!( + "SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2", + id, + &w_id + ) + .fetch_optional(&db) + .await?; + if let Some(created_by) = created_by { + if let Some(authed) = opt_authed.as_ref() { + require_job_read_access( + &db, + &user_db, + authed, + &w_id, + &id, + &created_by, + view_token.as_deref(), + ) + .await?; + } else if created_by != "anonymous" { + return Err(Error::BadRequest( + "As a non logged in user, you can only see jobs ran by anonymous users" + .to_string(), + )); + } + } let started = sqlx::query_scalar!( "SELECT running AS \"running!\" FROM v2_job_queue WHERE id = $1 AND workspace_id = $2", id, @@ -8251,8 +8917,10 @@ struct JobTiming { } async fn get_completed_job_timing( + OptViewToken(view_token): OptViewToken, OptAuthed(opt_authed): OptAuthed, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::JsonResult { let tags = opt_authed @@ -8278,7 +8946,18 @@ async fn get_completed_job_timing( let result = not_found_if_none(result, "Completed Job", id.to_string())?; - if opt_authed.is_none() && result.created_by != "anonymous" { + if let Some(authed) = opt_authed.as_ref() { + require_job_read_access( + &db, + &user_db, + authed, + &w_id, + &id, + &result.created_by, + view_token.as_deref(), + ) + .await?; + } else if result.created_by != "anonymous" { return Err(Error::BadRequest( "As a non logged in user, you can only see jobs ran by anonymous users".to_string(), )); @@ -8298,7 +8977,7 @@ async fn delete_completed_job<'a>( Extension(db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::Result { - let mut tx = user_db.begin(&authed).await?; + let mut tx = user_db.clone().begin(&authed).await?; require_admin(authed.is_admin, &authed.username)?; let tags = get_scope_tags(&authed); @@ -8342,17 +9021,21 @@ async fn delete_completed_job<'a>( tx.commit().await?; return get_completed_job( + OptViewToken(None), OptAuthed(Some(authed)), OptTokened { token: Some(token) }, Extension(db), + Extension(user_db), Path((w_id, id)), ) .await; } async fn get_otel_traces( + OptViewToken(view_token): OptViewToken, OptAuthed(opt_authed): OptAuthed, Extension(db): Extension, + Extension(user_db): Extension, Path((w_id, id)): Path<(String, Uuid)>, ) -> error::Result>> { // Check job exists and user has permission to view it @@ -8366,7 +9049,18 @@ async fn get_otel_traces( match job { Some(created_by) => { - if opt_authed.is_none() && created_by != "anonymous" { + if let Some(authed) = opt_authed.as_ref() { + require_job_read_access( + &db, + &user_db, + authed, + &w_id, + &id, + &created_by, + view_token.as_deref(), + ) + .await?; + } else if created_by != "anonymous" { return Err(Error::BadRequest( "As a non logged in user, you can only see jobs ran by anonymous users" .to_string(), diff --git a/backend/windmill-api/src/lib.rs b/backend/windmill-api/src/lib.rs index e7e4508971..41872d0353 100644 --- a/backend/windmill-api/src/lib.rs +++ b/backend/windmill-api/src/lib.rs @@ -73,8 +73,6 @@ pub mod auth; #[cfg(all(feature = "private", feature = "parquet"))] pub mod azure_proxy_ee; mod azure_proxy_oss; -#[cfg(feature = "bedrock")] -mod bedrock; mod capture; mod concurrency_groups; mod db; @@ -1263,3 +1261,26 @@ pub async fn check_any_server_started(db: &DB, not_before: chrono::DateTime not_before` (the moment a restart was initiated), +/// so rows older than the cutoff cannot influence any restart decision and +/// are safe to delete. +pub async fn cleanup_stale_server_heartbeats(db: &DB) -> anyhow::Result { + let prefix = format!("{SERVER_HEARTBEAT_TASK}:"); + let res = sqlx::query!( + "DELETE FROM background_task_state + WHERE name LIKE $1 + AND updated_at < NOW() - INTERVAL '7 days'", + format!("{prefix}%"), + ) + .execute(db) + .await?; + Ok(res.rows_affected()) +} diff --git a/backend/windmill-api/src/mcp_tools.rs b/backend/windmill-api/src/mcp_tools.rs index 10397f0dee..bba945ac54 100644 --- a/backend/windmill-api/src/mcp_tools.rs +++ b/backend/windmill-api/src/mcp_tools.rs @@ -5,11 +5,11 @@ use axum::{ use serde_json::value::RawValue; use windmill_api_auth::{check_scopes, ApiAuthed}; use windmill_common::{ - db::{UserDB, DB}, + db::{DbWithOptAuthed, UserDB, DB}, error::{Error, JsonResult, Result}, utils::{not_found_if_none, StripPath}, }; -use windmill_store::resources::explain_resource_perm_error; +use windmill_store::{resources::explain_resource_perm_error, variables::get_value_internal}; pub(crate) async fn get_mcp_tools( authed: ApiAuthed, @@ -65,7 +65,7 @@ pub(crate) async fn get_mcp_tools( if let Some(info) = token_info { if let (Some(account_id), Some(true)) = (info.account_id, info.is_expired) { - let refresh_tx = user_db.begin(&authed).await?; + let refresh_tx = user_db.clone().begin(&authed).await?; if let Err(e) = crate::oauth2_oss::_refresh_token( refresh_tx, token_var_path, @@ -85,7 +85,23 @@ pub(crate) async fn get_mcp_tools( } } - let client = windmill_mcp::McpClient::from_resource(mcp_resource, &db, &w_id) + // Resolve the token through the caller's permissioned (RLS + audit) path so + // a developer cannot exfiltrate a secret they are not allowed to read by + // pointing an MCP resource's token at it. + let token = if let Some(token_path) = &mcp_resource.token { + let token_var_path = token_path.trim_start_matches("$var:"); + if token_var_path.trim().is_empty() { + None + } else { + let db_authed = + DbWithOptAuthed::from_authed(&authed, db.clone(), Some(user_db.clone())); + Some(get_value_internal(&db_authed, &w_id, token_var_path, false).await?) + } + } else { + None + }; + + let client = windmill_mcp::McpClient::from_resource(mcp_resource, token) .await .map_err(|e| Error::ExecutionErr(format!("Failed to connect to MCP server: {}", e)))?; diff --git a/backend/windmill-api/src/service_logs.rs b/backend/windmill-api/src/service_logs.rs index 57131d823e..b1902f7c1a 100644 --- a/backend/windmill-api/src/service_logs.rs +++ b/backend/windmill-api/src/service_logs.rs @@ -133,7 +133,18 @@ async fn get_log_file( } } } - let file = tokio::fs::read(format!("{}{}", *TMP_WINDMILL_LOGS_SERVICE, path)).await; + let full_path = format!("{}{}", *TMP_WINDMILL_LOGS_SERVICE, path); + // SECURITY (defense in depth): refuse to read through a symlink so a planted + // symlink in the logs directory cannot be used to exfiltrate arbitrary files. + // `symlink_metadata` returns the link's own metadata without following it. + match tokio::fs::symlink_metadata(&full_path).await { + Ok(meta) if meta.file_type().is_symlink() => { + return Err(Error::BadRequest("Invalid path".to_string())); + } + Ok(_) => {} + Err(_) => return Err(Error::NotFound(format!("File {path} not found"))), + } + let file = tokio::fs::read(&full_path).await; if let Ok(bytes) = file { Ok(content_plain(Body::from(bytes::Bytes::from(bytes)))) } else { diff --git a/backend/windmill-api/src/slack_approvals.rs b/backend/windmill-api/src/slack_approvals.rs index 28e327c1ca..593ea3b298 100644 --- a/backend/windmill-api/src/slack_approvals.rs +++ b/backend/windmill-api/src/slack_approvals.rs @@ -3,15 +3,17 @@ use axum::{ Extension, }; use bytes::Bytes; +use hmac::{Hmac, Mac}; use http::HeaderMap; use hyper::StatusCode; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::Value; +use sha2::Sha256; use sqlx::types::Uuid; use std::collections::HashMap; -use windmill_common::error::Error; -use windmill_common::variables::get_secret_value_as_admin; +use windmill_common::error::{to_anyhow, Error}; +use windmill_common::variables::{get_secret_value_as_admin, get_workspace_key}; use crate::db::{ApiAuthed, DB}; use crate::jobs::{QueryApprover, ResumeUrls}; @@ -111,6 +113,9 @@ struct ModalActionValue { dynamic_enums_json: Option, resume_button_text: Option, cancel_button_text: Option, + // HMAC over (w_id, job_id, path) keyed on the workspace key; minted by + // `send_slack_message`, required by the OpenModal callback branch. + signature: Option, } #[derive(Deserialize, Debug)] @@ -119,8 +124,16 @@ struct PrivateMetadata { resource_path: String, container: Container, hide_cancel: Option, + // HMAC over (w_id, resource_path) keyed on the workspace key; minted when the modal is + // built, required by `handle_submission` before the resource_path is decrypted. + signature: Option, } +// Opportunistic transport-level check: when `SLACK_SIGNING_SECRET` is configured we verify +// the Slack request signature (which also defeats replay). It is NOT the primary defense: +// the secret is unset in the default deployment, so authorization of the sensitive actions +// is instead anchored on a per-workspace HMAC over the callback payload itself (see +// `verify_slack_payload`), which holds even when this check is a no-op. #[cfg(feature = "oauth2")] fn verify_slack_callback_signature(headers: &HeaderMap, body: &str) -> Result<(), Error> { if let Some(sv) = crate::SLACK_SIGNING_SECRET.as_ref() { @@ -143,6 +156,66 @@ fn verify_slack_callback_signature(_headers: &HeaderMap, _body: &str) -> Result< Ok(()) } +/// HMAC keyed on the per-workspace encryption key (the same trust anchor as resume-URL +/// signatures). Used to authenticate the `/api/slack` callback payload itself so the +/// unauthenticated route cannot be driven into decrypting arbitrary workspace variables, +/// regardless of whether `SLACK_SIGNING_SECRET` is configured. +type SlackPayloadHmac = Hmac; + +/// Domain-separation tag prepended to every Slack-payload MAC. The workspace key is also used +/// for resume-secret signatures (`create_signature` in `jobs.rs`), and those secrets are +/// distributed to approvers in resume URLs — so a fixed, scheme-specific prefix makes the two +/// MAC families non-interchangeable by construction rather than relying on their byte layouts +/// happening to differ. Bump the version suffix if the signed layout ever changes. +const SLACK_PAYLOAD_HMAC_DOMAIN: &[u8] = b"slack_payload_v1\0"; + +/// Sign the security-sensitive fields of a Slack callback payload with the workspace key. +/// Parts are joined with a `\0` delimiter (absent from paths/UUIDs) so distinct field tuples +/// cannot collide into the same MAC. +async fn sign_slack_payload(db: &DB, w_id: &str, parts: &[&[u8]]) -> Result { + let key = get_workspace_key(w_id, db).await?; + let mut mac = SlackPayloadHmac::new_from_slice(key.as_bytes()).map_err(to_anyhow)?; + mac.update(SLACK_PAYLOAD_HMAC_DOMAIN); + mac.update(w_id.as_bytes()); + for part in parts { + mac.update(b"\0"); + mac.update(part); + } + Ok(hex::encode(mac.finalize().into_bytes())) +} + +/// Verify a signature produced by [`sign_slack_payload`] in constant time. A missing or +/// malformed signature is rejected: an attacker cannot forge one without the workspace key. +async fn verify_slack_payload( + db: &DB, + w_id: &str, + parts: &[&[u8]], + signature: Option<&str>, +) -> Result<(), Error> { + let signature = signature.ok_or_else(|| { + Error::NotAuthorized("Slack callback rejected: missing payload signature".to_string()) + })?; + let provided = hex::decode(signature).map_err(|_| { + Error::NotAuthorized("Slack callback rejected: malformed payload signature".to_string()) + })?; + // Map a missing workspace key (e.g. non-existent workspace) to the same generic 401 as a + // bad signature, so an unauthenticated caller cannot use the status code (500 vs 401) as a + // workspace-existence oracle. + let key = get_workspace_key(w_id, db).await.map_err(|_| { + Error::NotAuthorized("Slack callback rejected: invalid payload signature".to_string()) + })?; + let mut mac = SlackPayloadHmac::new_from_slice(key.as_bytes()).map_err(to_anyhow)?; + mac.update(SLACK_PAYLOAD_HMAC_DOMAIN); + mac.update(w_id.as_bytes()); + for part in parts { + mac.update(b"\0"); + mac.update(part); + } + mac.verify_slice(&provided).map_err(|_| { + Error::NotAuthorized("Slack callback rejected: invalid payload signature".to_string()) + }) +} + pub async fn slack_app_callback_handler( authed: Option, opt_tokened: OptTokened, @@ -188,7 +261,30 @@ pub async fn slack_app_callback_handler( let job_id = Uuid::parse_str(&parsed_value.job_id)?; let flow_step_id = parsed_value.flow_step_id.as_deref(); - let slack_token = get_slack_token(&db, path, w_id).await?; + // Authorize the request before any privileged read: the button + // payload was minted by `send_slack_message` with an HMAC over + // (w_id, job_id, path) keyed on the workspace key. Without a valid + // signature an unauthenticated caller cannot reach the decryption + // below for an arbitrary variable, even when SLACK_SIGNING_SECRET + // is unset. + verify_slack_payload( + &db, + w_id, + &[parsed_value.job_id.as_bytes(), path.as_bytes()], + parsed_value.signature.as_deref(), + ) + .await?; + + // Map any lookup/decryption failure to a generic error: the + // raw error echoes the probed `path`/`w_id` back, which would be + // a cross-workspace existence oracle. Log the detail server-side. + let slack_token = + get_slack_token(&db, path, w_id).await.map_err(|e| { + tracing::warn!( + "Failed to resolve slack token for {w_id}/{path}: {e:#}" + ); + Error::BadRequest("Invalid Slack callback request".to_string()) + })?; let client = Client::new(); let container = payload.container.ok_or_else(|| { Error::BadRequest("No container found.".to_string()) @@ -281,6 +377,7 @@ pub async fn request_slack_approval( send_slack_message( &client, + &db, slack_token.as_str(), channel_id.as_str(), &w_id, @@ -334,12 +431,21 @@ async fn handle_submission( let resource_path = private_metadata.resource_path; let container: Container = private_metadata.container; let hide_cancel = private_metadata.hide_cancel; + let signature = private_metadata.signature; // If hide_cancel is true, we don't need to extract information from the private_metadata if hide_cancel.unwrap_or(false) && action == "cancel" { return Ok(()); } + let w_id = extract_w_id_from_resume_url(&resume_url)?; + // Authorize the submission BEFORE taking any action. `resource_path` comes from the + // (client-held) modal metadata and is not covered by the resume-URL signature, so a + // tampered/unsigned submission must be rejected up front — otherwise it could still drive + // the resume/cancel and reach the decryption below with a swapped path. Require the + // workspace-keyed HMAC minted when the modal was built. + verify_slack_payload(&db, w_id, &[resource_path.as_bytes()], signature.as_deref()).await?; + // Use the common handler to process the resume/cancel action handle_resume_action( authed, @@ -351,8 +457,12 @@ async fn handle_submission( ) .await?; - let w_id = extract_w_id_from_resume_url(&resume_url)?; - let slack_token = get_slack_token(&db, &resource_path, w_id).await?; + let slack_token = get_slack_token(&db, &resource_path, w_id) + .await + .map_err(|e| { + tracing::warn!("Failed to resolve slack token for {w_id}/{resource_path}: {e:#}"); + Error::BadRequest("Invalid Slack callback request".to_string()) + })?; update_original_slack_message(action, slack_token, container).await?; Ok(()) } @@ -780,6 +890,7 @@ async fn get_slack_token(db: &DB, slack_resource_path: &str, w_id: &str) -> anyh // Sends a Slack message with a button that opens a modal async fn send_slack_message( client: &Client, + db: &DB, bot_token: &str, channel_id: &str, w_id: &str, @@ -827,6 +938,18 @@ async fn send_slack_message( value["cancel_button_text"] = serde_json::json!(cancel_button_text); } + // Authenticate the button payload so the unauthenticated callback cannot be driven into + // decrypting an arbitrary variable: bind (w_id, job_id, path) with the workspace key. + // `job_id` is signed over its string form to match how it is parsed back on callback. + let signature = sign_slack_payload( + db, + w_id, + &[job_id.to_string().as_bytes(), resource_path.as_bytes()], + ) + .await + .map_err(|e| Box::new(e) as Box)?; + value["signature"] = serde_json::json!(signature); + let payload = serde_json::json!({ "channel": channel_id, "text": "A flow has been suspended. Please approve or reject the flow.", @@ -893,6 +1016,12 @@ async fn get_modal_blocks( resume_button_text: Option<&str>, cancel_button_text: Option<&str>, ) -> Result, Error> { + // Bind the resource_path embedded in the modal's private_metadata to the workspace key so + // it cannot be tampered with on the way back in `handle_submission`. Computed before `db` + // is moved into `get_approval_form_details`. + let private_metadata_signature = + sign_slack_payload(&db, w_id, &[resource_path.as_bytes()]).await?; + let approval_details = crate::approvals::get_approval_form_details( db, w_id, @@ -947,6 +1076,7 @@ async fn get_modal_blocks( container, resume_button_text, cancel_button_text, + &private_metadata_signature, ))) } @@ -959,6 +1089,7 @@ fn construct_payload( container: Container, resume_button_text: Option<&str>, cancel_button_text: Option<&str>, + signature: &str, ) -> serde_json::Value { let mut view = serde_json::json!({ "type": "modal", @@ -973,7 +1104,7 @@ fn construct_payload( "type": "plain_text", "text": resume_button_text.unwrap_or("Resume Workflow") }, - "private_metadata": serde_json::json!({ "resume_url": resume_url, "resource_path": resource_path, "container": container, "hide_cancel": hide_cancel }).to_string(), + "private_metadata": serde_json::json!({ "resume_url": resume_url, "resource_path": resource_path, "container": container, "hide_cancel": hide_cancel, "signature": signature }).to_string(), }); if !hide_cancel { diff --git a/backend/windmill-api/src/static_assets.rs b/backend/windmill-api/src/static_assets.rs index 2b93d59bc0..35728bc693 100644 --- a/backend/windmill-api/src/static_assets.rs +++ b/backend/windmill-api/src/static_assets.rs @@ -39,23 +39,43 @@ pub struct StaticFile(Uri); impl IntoResponse for StaticFile { fn into_response(self) -> Response { let original_path = self.0.path(); + let query = self.0.query(); let path = original_path.trim_start_matches('/'); - serve_path(path, original_path) + serve_path(path, original_path, query) } } #[cfg(feature = "static_frontend")] const TWO_HUNDRED: &str = "200.html"; -/// Check if the original path requires cross-origin isolation headers +/// Check if the original path requires cross-origin isolation headers. +/// /// These headers are needed for SharedArrayBuffer and TypeScript workers -/// Only enabled for /apps_raw paths (raw app editor) +/// (raw app editor at `/apps_raw/`, in-browser bundler at `/ui_builder/`). +/// +/// Public apps (`/public/` and custom paths `/a/`) opt in via the `wm_coep` +/// query param: a public (raw) app must set COEP to be embeddable as an iframe +/// inside a cross-origin-isolated page (which requires the embedded document to +/// also set COEP). It is opt-in rather than always-on because cross-origin +/// isolation also blocks subresources without CORP (e.g. external image URLs +/// or embeds used by classic apps), so we only enable it when the embedder +/// explicitly requests it. #[cfg(feature = "static_frontend")] -fn needs_cross_origin_isolation(original_path: &str) -> bool { - original_path.starts_with("/apps_raw/") || original_path.starts_with("/ui_builder/") +fn needs_cross_origin_isolation(original_path: &str, query: Option<&str>) -> bool { + original_path.starts_with("/apps_raw/") + || original_path.starts_with("/ui_builder/") + || ((original_path.starts_with("/public/") || original_path.starts_with("/a/")) + && query_has_flag(query, "wm_coep")) } -fn serve_path(path: &str, original_path: &str) -> Response { +/// Returns true if `query` contains the given flag key (with or without a +/// value), e.g. `?wm_coep`, `?wm_coep=on`, `?foo=1&wm_coep=1`. +#[cfg(feature = "static_frontend")] +fn query_has_flag(query: Option<&str>, flag: &str) -> bool { + query.is_some_and(|q| q.split('&').any(|kv| kv.split('=').next() == Some(flag))) +} + +fn serve_path(path: &str, original_path: &str, query: Option<&str>) -> Response { if path.starts_with("api/") { return Response::builder().status(404).body(Body::empty()).unwrap(); } @@ -71,7 +91,7 @@ fn serve_path(path: &str, original_path: &str) -> Response { // Add cross-origin isolation headers only for paths that need them // (apps_raw editor needs SharedArrayBuffer for TypeScript workers) - if needs_cross_origin_isolation(original_path) { + if needs_cross_origin_isolation(original_path, query) { res = res .header("Cross-Origin-Opener-Policy", "same-origin") .header("Cross-Origin-Embedder-Policy", "require-corp") @@ -102,12 +122,68 @@ fn serve_path(path: &str, original_path: &str) -> Response { None if path.starts_with("_app/") => { Response::builder().status(404).body(Body::empty()).unwrap() } - None => serve_path(TWO_HUNDRED, original_path), + None => serve_path(TWO_HUNDRED, original_path, query), } #[cfg(not(feature = "static_frontend"))] { - let _ = original_path; // suppress unused warning + let _ = (original_path, query); // suppress unused warning Response::builder().status(404).body(Body::empty()).unwrap() } } + +#[cfg(all(test, feature = "static_frontend"))] +mod tests { + use super::*; + + #[test] + fn test_query_has_flag() { + assert!(query_has_flag(Some("wm_coep"), "wm_coep")); + assert!(query_has_flag(Some("wm_coep=on"), "wm_coep")); + assert!(query_has_flag(Some("foo=1&wm_coep=1"), "wm_coep")); + assert!(query_has_flag(Some("wm_coep&foo=1"), "wm_coep")); + assert!(!query_has_flag(Some("wm_coepx=1"), "wm_coep")); + assert!(!query_has_flag(Some("foo=wm_coep"), "wm_coep")); + assert!(!query_has_flag(Some(""), "wm_coep")); + assert!(!query_has_flag(None, "wm_coep")); + } + + #[test] + fn test_needs_cross_origin_isolation() { + // editor + bundler are always isolated, regardless of query + assert!(needs_cross_origin_isolation("/apps_raw/edit/foo", None)); + assert!(needs_cross_origin_isolation("/ui_builder/index.html", None)); + + // public apps (and custom paths) are isolated only when they opt in via wm_coep + assert!(needs_cross_origin_isolation( + "/public/ws/secret", + Some("wm_coep") + )); + assert!(needs_cross_origin_isolation( + "/public/ws/secret", + Some("wm_coep=on") + )); + assert!(needs_cross_origin_isolation( + "/a/ws/my/path", + Some("wm_coep=on") + )); + assert!(!needs_cross_origin_isolation("/public/ws/secret", None)); + assert!(!needs_cross_origin_isolation("/a/ws/my/path", None)); + assert!(!needs_cross_origin_isolation( + "/public/ws/secret", + Some("foo=1") + )); + + // unrelated paths never get the headers + assert!(!needs_cross_origin_isolation( + "/apps/get/foo", + Some("wm_coep") + )); + // `/api/` must not be caught by the `/a/` prefix + assert!(!needs_cross_origin_isolation( + "/api/version", + Some("wm_coep") + )); + assert!(!needs_cross_origin_isolation("/", None)); + } +} diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index 58871866b1..d53998355b 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -484,13 +484,13 @@ async fn update_username_in_workpsace<'c>( ).execute(&mut **tx) .await?; - sqlx::query!( - r#"UPDATE workspace_runnable_dependencies SET app_path = REGEXP_REPLACE(app_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE app_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, - new_username, - old_username, - w_id - ).execute(&mut **tx) - .await?; + // NB: workspace_runnable_dependencies.app_path is intentionally NOT rewritten here. + // Its FK to app(path, workspace_id) is ON UPDATE CASCADE, so the `UPDATE app SET path` + // below propagates the new path automatically. Rewriting it manually here (before the + // app row is renamed) points the row at a not-yet-existing app path and violates + // fk_workspace_runnable_dependencies_app_path. (flow_path above DOES need the manual + // rewrite because flows are migrated via INSERT-new + DELETE-old, not UPDATE flow.path, + // so the cascade never fires for them.) sqlx::query!( r#"UPDATE workspace_runnable_dependencies SET runnable_path = REGEXP_REPLACE(runnable_path,'u/' || $2 || '/(.*)','u/' || $1 || '/\1') WHERE runnable_path LIKE ('u/' || $2 || '/%') AND workspace_id = $3"#, diff --git a/backend/windmill-common/src/error.rs b/backend/windmill-common/src/error.rs index 49dcf25450..f202bf27f9 100644 --- a/backend/windmill-common/src/error.rs +++ b/backend/windmill-common/src/error.rs @@ -72,7 +72,7 @@ pub enum Error { ExecutionRawError(Box), #[error("Error: {error:#} @{location:#}")] Anyhow { error: anyhow::Error, location: String }, - #[error("Error: {0:#?}")] + #[error("{}", format_json_err_message(.0))] JsonErr(serde_json::Value), #[error("{0}")] AIError(String), @@ -256,6 +256,7 @@ impl IntoResponse for Error { Self::SqlErr { .. } | Self::BadRequest(_) | Self::AIError(_) + | Self::JsonErr(_) | Self::QuotaExceeded(_) => axum::http::StatusCode::BAD_REQUEST, Self::BadGateway(_) => axum::http::StatusCode::BAD_GATEWAY, Self::Generic(status_code, _) => status_code, @@ -280,6 +281,59 @@ impl IntoResponse for Error { } } +/// Render a `JsonErr` payload as a readable message suitable for direct +/// display in a toast: surface the `error` field as the headline, append a +/// short summary of `details` (e.g. duplicate paths) when present, and fall +/// back to pretty JSON for unknown shapes. Avoids the Rust `Debug` output +/// (`Object { "error": String("..."), ... }`) that previously leaked to users. +fn format_json_err_message(v: &serde_json::Value) -> String { + if let Some(obj) = v.as_object() { + let headline = obj + .get("error") + .and_then(|e| e.as_str()) + .map(|s| s.to_string()); + let details_summary = obj.get("details").and_then(|d| { + let arr = d.as_array()?; + if arr.is_empty() { + return None; + } + let preview = arr + .iter() + .take(5) + .map(|item| match item { + serde_json::Value::Object(o) => { + let parts: Vec = o + .iter() + .map(|(k, val)| match val { + serde_json::Value::String(s) => format!("{k}={s}"), + _ => format!("{k}={val}"), + }) + .collect(); + format!("- {}", parts.join(", ")) + } + serde_json::Value::String(s) => format!("- {s}"), + other => format!("- {other}"), + }) + .collect::>() + .join("\n"); + let suffix = if arr.len() > 5 { + format!("\n... ({} more)", arr.len() - 5) + } else { + String::new() + }; + Some(format!("{preview}{suffix}")) + }); + + match (headline, details_summary) { + (Some(h), Some(d)) => return format!("{h}\n{d}"), + (Some(h), None) => return h, + (None, Some(d)) => return d, + (None, None) => {} + } + } + serde_json::to_string_pretty(v).unwrap_or_else(|_| v.to_string()) +} + pub trait OrElseNotFound { fn or_else_not_found(self, s: impl ToString) -> Result; } @@ -316,3 +370,53 @@ where Self(err.into()) } } + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn json_err_message_error_and_details() { + let v = json!({ + "error": "Duplicate HTTP route paths detected", + "details": [ + { "route_path": "a", "workspace_id": "admins", "http_method": "post" }, + { "route_path": "a", "workspace_id": "starter", "http_method": "post" }, + ], + }); + let rendered = Error::JsonErr(v).to_string(); + assert_eq!( + rendered, + "Duplicate HTTP route paths detected\n\ + - route_path=a, workspace_id=admins, http_method=post\n\ + - route_path=a, workspace_id=starter, http_method=post" + ); + } + + #[test] + fn json_err_message_error_only() { + let v = json!({ "error": "Something went wrong" }); + assert_eq!(Error::JsonErr(v).to_string(), "Something went wrong"); + } + + #[test] + fn json_err_message_truncates_long_details() { + let details: Vec<_> = (0..8).map(|i| json!({ "k": i })).collect(); + let v = json!({ "error": "boom", "details": details }); + let rendered = Error::JsonErr(v).to_string(); + assert!(rendered.starts_with("boom\n- k=0\n- k=1\n- k=2\n- k=3\n- k=4")); + assert!(rendered.ends_with("... (3 more)")); + // Items beyond the cap aren't enumerated. + assert!(!rendered.contains("- k=5")); + } + + #[test] + fn json_err_message_fallback_to_pretty_json() { + let v = json!([1, 2, 3]); + // Non-object payload falls back to pretty JSON instead of leaking + // Rust `Debug` syntax. + let rendered = Error::JsonErr(v).to_string(); + assert_eq!(rendered, "[\n 1,\n 2,\n 3\n]"); + } +} diff --git a/backend/windmill-common/src/global_settings.rs b/backend/windmill-common/src/global_settings.rs index 75f3fdf06b..8192f186d0 100644 --- a/backend/windmill-common/src/global_settings.rs +++ b/backend/windmill-common/src/global_settings.rs @@ -58,6 +58,11 @@ pub const NSJAIL_TMPFS_SIZE_MB_SETTING: &str = "nsjail_tmpfs_size_mb"; pub const NSJAIL_TMP_BACKING_SETTING: &str = "nsjail_tmp_backing"; pub const NSJAIL_TMP_BACKING_DISK: &str = "disk"; pub const NSJAIL_TMP_BACKING_TMPFS: &str = "tmpfs"; +pub const SANDBOX_IMAGE_MAX_SIZE_MB_SETTING: &str = "sandbox_image_max_size_mb"; +pub const SANDBOX_IMAGE_CACHE_MAX_MB_SETTING: &str = "sandbox_image_cache_max_mb"; +pub const SANDBOX_IMAGE_PULL_POLICY_SETTING: &str = "sandbox_image_pull_policy"; +pub const SANDBOX_IMAGE_DEFAULT_REGISTRY_SETTING: &str = "sandbox_image_default_registry"; +pub const SANDBOX_REGISTRY_AUTH_SETTING: &str = "sandbox_registry_auth"; pub const OBJECT_STORE_CONFIG_SETTING: &str = "object_store_cache_config"; pub const HUB_API_SECRET_SETTING: &str = "hub_api_secret"; diff --git a/backend/windmill-common/src/instance_config.rs b/backend/windmill-common/src/instance_config.rs index 1872b52140..bb56d5d0cc 100644 --- a/backend/windmill-common/src/instance_config.rs +++ b/backend/windmill-common/src/instance_config.rs @@ -586,6 +586,21 @@ pub struct OAuthConfig { pub req_body_auth: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub grant_types: Vec, + /// Optional URL overrides for the provider's sandbox environment. + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox: Option, +} + +/// URL overrides for an OAuth provider's sandbox environment. +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +#[cfg_attr(feature = "instance_config_schema", derive(schemars::JsonSchema))] +pub struct OAuthSandboxOverride { + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub userinfo_url: Option, } // --------------------------------------------------------------------------- @@ -961,6 +976,7 @@ const SENSITIVE_SETTINGS: &[&str] = &[ "ruby_repos", "powershell_repo_pat", "workspace_registries", + "sandbox_registry_auth", ]; /// Object-valued settings that contain sensitive sub-fields. diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 3c4244398e..c12d242737 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -259,6 +259,12 @@ lazy_static::lazy_static! { pub static ref QUIET_LOGS: bool = std::env::var("QUIET_LOGS").map(|s| s.parse::().unwrap_or(false)).unwrap_or(false); + /// Snapshot of the standard outbound-proxy env vars, read once at startup. + /// Lowercase (`no_proxy`, `http_proxy`, `https_proxy`) is preferred to match + /// the convention used by libcurl / reqwest; uppercase is the fallback. + pub static ref NO_PROXY: Option = std::env::var("no_proxy").ok().or_else(|| std::env::var("NO_PROXY").ok()); + pub static ref HTTP_PROXY: Option = std::env::var("http_proxy").ok().or_else(|| std::env::var("HTTP_PROXY").ok()); + pub static ref HTTPS_PROXY: Option = std::env::var("https_proxy").ok().or_else(|| std::env::var("HTTPS_PROXY").ok()); } const LATEST_VERSION_ID_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(60); @@ -276,6 +282,24 @@ pub async fn shutdown_signal( Ok(()) } + // Defined for the whole non-unix scope (not just windows) so it can be a + // plain `tokio::select!` branch: that macro does not accept `#[cfg(...)]` + // attributes on individual branches. On non-windows non-unix targets the + // future never resolves, so the branch is effectively inert there. + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + async fn ctrl_break() -> std::io::Result<()> { + #[cfg(windows)] + { + tokio::signal::windows::ctrl_break()?.recv().await; + Ok(()) + } + #[cfg(not(windows))] + { + std::future::pending::<()>().await; + Ok(()) + } + } + #[cfg(any(target_os = "linux", target_os = "macos"))] tokio::select! { _ = terminate() => { @@ -291,7 +315,12 @@ pub async fn shutdown_signal( #[cfg(not(any(target_os = "linux", target_os = "macos")))] tokio::select! { - _ = tokio::signal::ctrl_c() => {}, + _ = tokio::signal::ctrl_c() => { + tracing::info!("shutdown monitor received ctrl-c"); + }, + _ = ctrl_break() => { + tracing::info!("shutdown monitor received ctrl-break"); + }, _ = rx.recv() => { tracing::info!("shutdown monitor received killpill"); }, @@ -313,6 +342,9 @@ pub async fn shutdown_signal( _ = tokio::signal::ctrl_c() => { tracing::error!("2nd shutdown monitor received ctrl-c") }, + _ = ctrl_break() => { + tracing::error!("2nd shutdown monitor received ctrl-break") + }, } tracing::info!("Second terminate signal received, forcefully exiting"); diff --git a/backend/windmill-common/src/log_context.rs b/backend/windmill-common/src/log_context.rs index 4a517c7afb..cdb1e27aea 100644 --- a/backend/windmill-common/src/log_context.rs +++ b/backend/windmill-common/src/log_context.rs @@ -35,6 +35,11 @@ pub struct LogContext { pub uri: Option, pub trace_id: Option, + // Inbound W3C `traceparent` captured at enqueue (reserved `_wm_traceparent` + // arg). Carried here so the worker's OTLP span and the script's injected + // TRACEPARENT env can relocate into the originating distributed trace. + pub inbound_traceparent: Option, + // Auth (windmill-api-auth/src/auth.rs) pub email: Option, pub username: Option, diff --git a/backend/windmill-common/src/scripts.rs b/backend/windmill-common/src/scripts.rs index dd4c0bb42e..38cd444f29 100644 --- a/backend/windmill-common/src/scripts.rs +++ b/backend/windmill-common/src/scripts.rs @@ -393,6 +393,7 @@ pub async fn clone_script<'c>( modules: s.modules, auto_parent: None, labels: s.labels, + skip_draft_deletion: None, }; let new_hash = hash_script(&ns); diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index 07ac44b755..9c944cb778 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -174,6 +174,26 @@ pub async fn generate_approval_token( Ok(hex::encode(mac.finalize().into_bytes())) } +/// Stateless read-share signature for a job: `HMAC(workspace_key, job_id || "view_token")`. +/// Mirrors [`generate_approval_token`] but in a distinct domain so an approval token can +/// never be used as a view token (or vice-versa). Used to build a "share read link" that +/// grants an authenticated workspace member read access to a job (and its flow subtree) +/// they otherwise lack ACL on. No expiry/revocation (stateless), like the approval token. +pub async fn generate_view_token( + w_id: &str, + job_id: uuid::Uuid, + db: &DB, +) -> crate::error::Result { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let key = get_workspace_key(w_id, db).await?; + let mut mac = Hmac::::new_from_slice(key.as_bytes()) + .map_err(|e| crate::Error::internal_err(format!("HMAC key error: {e}")))?; + mac.update(job_id.as_bytes()); + mac.update(b"view_token"); + Ok(hex::encode(mac.finalize().into_bytes())) +} + pub async fn get_secret_value_as_admin( db: &DB, w_id: &str, diff --git a/backend/windmill-common/src/worker.rs b/backend/windmill-common/src/worker.rs index 5a7c6d2bfa..c7816d7a70 100644 --- a/backend/windmill-common/src/worker.rs +++ b/backend/windmill-common/src/worker.rs @@ -252,6 +252,17 @@ lazy_static::lazy_static! { pub static ref WORKSPACE_FAIRNESS_OVERLOADED: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(vec![]); pub static ref WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS: AtomicI64 = AtomicI64::new(0); + /// Stochastic admission probability for capped workspaces, expressed in + /// parts per 10_000 (so `420` = 4.2%). The refresh computes this from the + /// observed worker-second distribution and the configured cap so that + /// admission converges to the target *worker-second* share — independent + /// of how the capped vs uncapped workspaces compare on per-job durations. + /// See `workspace_fairness_ee::refresh_overloaded` for the derivation. + /// `10_000` (= admit all) is the default until the first refresh + /// classifies an overloaded set — before that, no workspace is capped so + /// `should_admit_capped` is moot and "admit all" is the correct no-op. + pub static ref WORKSPACE_FAIRNESS_ADMISSION_PPM: AtomicU32 = AtomicU32::new(10_000); + pub static ref SMTP_CONFIG: arc_swap::ArcSwap> = arc_swap::ArcSwap::from_pointee(None); pub static ref INDEXER_CONFIG: arc_swap::ArcSwap = arc_swap::ArcSwap::from_pointee(TantivyIndexerSettings::default()); @@ -848,6 +859,37 @@ pub struct BashAnnotations { pub sandbox: bool, } +impl BashAnnotations { + /// If the script declares `# sandbox ` (an image ref after the sandbox + /// annotation), returns that image ref. This selects the daemonless, sandboxed + /// container runtime: extract the image's rootfs and run it inside the job's + /// nsjail sandbox. + /// + /// A bare `# sandbox` (no image argument) returns `None` and keeps the plain + /// nsjail-sandboxed-bash behavior (the `sandbox` boolean modifier). `# docker` + /// is unaffected and keeps the legacy v1 (dind/daemon) path. + pub fn sandbox_image(code: &str) -> Option { + for line in code.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + // Mirror the annotation parser: stop at the first non-comment line. + if !line.starts_with('#') { + break; + } + let mut tokens = line[1..].split_whitespace(); + if tokens.next() == Some("sandbox") { + // `# sandbox ` -> container; bare `# sandbox` -> nsjail bash. + if let Some(image) = tokens.next() { + return Some(image.to_string()); + } + } + } + None + } +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum SqlResultCollectionStrategy { LastStatementAllRows, @@ -2213,6 +2255,34 @@ mod tests { use super::*; use std::collections::HashMap; + #[test] + fn test_bash_sandbox_image_annotation() { + // `# sandbox ` selects the container runtime and returns the image. + assert_eq!( + BashAnnotations::sandbox_image("# sandbox alpine:latest\necho hi"), + Some("alpine:latest".to_string()) + ); + // Extra whitespace and a leading non-spaced `#` still work. + assert_eq!( + BashAnnotations::sandbox_image("#sandbox python:3.12-slim\n"), + Some("python:3.12-slim".to_string()) + ); + // A bare `# sandbox` (no image) keeps the nsjail-bash modifier -> None. + assert_eq!(BashAnnotations::sandbox_image("# sandbox\necho hi"), None); + // `sandbox` must be its own token, not a prefix. + assert_eq!(BashAnnotations::sandbox_image("# sandboxed foo"), None); + // Stops at the first non-comment line (image declared too late is ignored). + assert_eq!( + BashAnnotations::sandbox_image("echo hi\n# sandbox alpine"), + None + ); + // `# docker` is a different annotation -> not a sandbox image. + assert_eq!( + BashAnnotations::sandbox_image("# docker alpine\necho hi"), + None + ); + } + #[test] fn test_mixed_tags() { let input = vec![ diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index e81a24817f..e20a896103 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -157,7 +157,7 @@ pub enum ObjectType { WorkspaceDependencies, } -pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28236/sync-script-to-git-repo-windmill"; +pub const LATEST_GIT_SYNC_SCRIPT_PATH: &str = "hub/28261/sync-script-to-git-repo-windmill"; /// Prefix used to identify fork workspaces. A workspace whose id starts with this string is a /// fork of another workspace. diff --git a/backend/windmill-git-sync/src/git_sync_oss.rs b/backend/windmill-git-sync/src/git_sync_oss.rs index ee2c09cf16..a4f82aba2e 100644 --- a/backend/windmill-git-sync/src/git_sync_oss.rs +++ b/backend/windmill-git-sync/src/git_sync_oss.rs @@ -33,3 +33,15 @@ pub async fn handle_fork_branch_creation<'c>( ) -> Result> { return Ok(vec![]); } + +#[cfg(not(feature = "private"))] +pub async fn handle_deployment_metadata_batch<'c>( + _email: &str, + _created_by: &str, + _db: &DB, + _w_id: &str, + _objs: Vec, + _deployment_message: Option, +) -> Result<()> { + return Ok(()); +} diff --git a/backend/windmill-git-sync/src/lib.rs b/backend/windmill-git-sync/src/lib.rs index efbc562cf9..b10f62e7a5 100644 --- a/backend/windmill-git-sync/src/lib.rs +++ b/backend/windmill-git-sync/src/lib.rs @@ -13,10 +13,14 @@ pub mod git_sync_ee; pub mod git_sync_oss; #[cfg(feature = "private")] -pub use git_sync_ee::{handle_deployment_metadata, handle_fork_branch_creation}; +pub use git_sync_ee::{ + handle_deployment_metadata, handle_deployment_metadata_batch, handle_fork_branch_creation, +}; #[cfg(not(feature = "private"))] -pub use git_sync_oss::{handle_deployment_metadata, handle_fork_branch_creation}; +pub use git_sync_oss::{ + handle_deployment_metadata, handle_deployment_metadata_batch, handle_fork_branch_creation, +}; #[derive(Clone, Debug)] pub enum DeployedObject { diff --git a/backend/windmill-mcp/Cargo.toml b/backend/windmill-mcp/Cargo.toml index 3968ea0836..36e0d03d18 100644 --- a/backend/windmill-mcp/Cargo.toml +++ b/backend/windmill-mcp/Cargo.toml @@ -29,3 +29,6 @@ http = { workspace = true, optional = true } tokio-util = { workspace = true, features = ["rt"], optional = true } tokio = { workspace = true, optional = true } futures.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/backend/windmill-mcp/src/client/mod.rs b/backend/windmill-mcp/src/client/mod.rs index bc6d2c24ac..1a555c141f 100644 --- a/backend/windmill-mcp/src/client/mod.rs +++ b/backend/windmill-mcp/src/client/mod.rs @@ -22,8 +22,6 @@ use rmcp::{ }; use serde_json::{json, Value}; use std::str::FromStr; -use windmill_common::variables::get_secret_value_as_admin; -use windmill_common::DB; /// MCP client for communicating with external MCP servers pub struct McpClient { @@ -34,18 +32,29 @@ pub struct McpClient { } impl McpClient { - /// Create a new MCP client from a resource configuration - pub async fn from_resource(resource: McpResource, db: &DB, w_id: &str) -> Result { + /// Create a new MCP client from a resource configuration. + /// + /// `token`, when present, is the already-resolved bearer token sent as an + /// `Authorization` header. It MUST be resolved by the caller through the + /// permissioned (RLS + audit) variable path — `from_resource` never reads + /// secrets itself, so a caller cannot trick it into decrypting a variable + /// they are not allowed to read. + pub async fn from_resource(resource: McpResource, token: Option) -> Result { + // The resource URL is author-controlled and we send a (potentially + // secret) bearer token to it, so it must be validated against SSRF + // before we connect (e.g. cloud metadata endpoints, internal services). + windmill_common::ssrf::validate_url_for_ssrf(&resource.url) + .await + .map_err(|e| anyhow::anyhow!("MCP server URL is not allowed: {}", e))?; + // Build custom reqwest client with headers if provided let mut headers = HeaderMap::new(); - if let Some(token_path) = &resource.token { - if !token_path.trim().is_empty() { - let value = - get_secret_value_as_admin(db, w_id, token_path.trim_start_matches("$var:")) - .await?; + if let Some(token) = token { + let token = token.trim(); + if !token.is_empty() { headers.insert( HeaderName::from_static("authorization"), - HeaderValue::from_str(format!("Bearer {}", value).as_str())?, + HeaderValue::from_str(format!("Bearer {}", token).as_str())?, ); } } @@ -64,6 +73,12 @@ impl McpClient { let reqwest_client = reqwest::Client::builder() .default_headers(headers) + // Don't follow redirects: the SSRF check above only validates the + // initial (author-controlled) URL, so following a redirect could + // still reach a private/internal address with the bearer token + // attached. The MCP streamable-HTTP endpoint is a direct endpoint + // and does not legitimately rely on redirects. + .redirect(reqwest::redirect::Policy::none()) .build() .context("Failed to build HTTP client")?; @@ -210,3 +225,32 @@ impl McpClient { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Regression test: `from_resource` must refuse to connect to a URL that + /// targets a private/internal address (here the AWS + /// instance-metadata endpoint), so a resource author cannot use the MCP + /// client as an SSRF primitive against internal services. The guard runs + /// before any connection attempt, so this fails fast without network access. + #[tokio::test] + async fn from_resource_rejects_ssrf_url() { + let resource = McpResource { + name: "evil".to_string(), + url: "http://169.254.169.254".to_string(), + token: None, + headers: None, + }; + + let msg = match McpClient::from_resource(resource, None).await { + Ok(_) => panic!("a link-local metadata URL must be rejected before connecting"), + Err(e) => e.to_string(), + }; + assert!( + msg.contains("not allowed") && msg.contains("private"), + "error should explain the URL was rejected as private/internal, got: {msg}" + ); + } +} diff --git a/backend/windmill-native-triggers/src/github/routes.rs b/backend/windmill-native-triggers/src/github/routes.rs index 4528c1b612..b2196f2249 100644 --- a/backend/windmill-native-triggers/src/github/routes.rs +++ b/backend/windmill-native-triggers/src/github/routes.rs @@ -2,9 +2,10 @@ use std::sync::Arc; use axum::{extract::Path, routing::get, Extension, Json, Router}; use http::Method; +use windmill_api_auth::ApiAuthed; use windmill_common::{error::JsonResult, DB}; -use crate::{get_workspace_integration, External, ServiceName}; +use crate::{get_workspace_integration, require_native_integration_use, External, ServiceName}; use super::{GitHub, GithubApiRepoResponse, GithubRepoEntry}; @@ -12,10 +13,12 @@ const PER_PAGE: usize = 100; const MAX_PAGES: usize = 10; async fn list_repos( + authed: ApiAuthed, Extension(handler): Extension>, Extension(db): Extension, Path(workspace_id): Path, ) -> JsonResult> { + require_native_integration_use(&authed)?; get_workspace_integration(&db, &workspace_id, ServiceName::Github).await?; let mut all_entries = Vec::new(); diff --git a/backend/windmill-native-triggers/src/google/routes.rs b/backend/windmill-native-triggers/src/google/routes.rs index f49a9f9fe6..c5f7611f10 100644 --- a/backend/windmill-native-triggers/src/google/routes.rs +++ b/backend/windmill-native-triggers/src/google/routes.rs @@ -7,9 +7,10 @@ use axum::{ }; use http::Method; use serde::{Deserialize, Serialize}; +use windmill_api_auth::ApiAuthed; use windmill_common::{error::JsonResult, DB}; -use crate::{get_workspace_integration, External, ServiceName}; +use crate::{get_workspace_integration, require_native_integration_use, External, ServiceName}; use super::Google; @@ -84,10 +85,12 @@ pub struct DriveFilesQuery { } async fn list_calendars( + authed: ApiAuthed, Extension(handler): Extension>, Extension(db): Extension, Path(workspace_id): Path, ) -> JsonResult> { + require_native_integration_use(&authed)?; get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?; let url = format!( @@ -113,11 +116,13 @@ async fn list_calendars( } async fn list_drive_files( + authed: ApiAuthed, Extension(handler): Extension>, Extension(db): Extension, Path(workspace_id): Path, Query(query): Query, ) -> JsonResult { + require_native_integration_use(&authed)?; get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?; let drive_query = if query.shared_with_me { @@ -186,10 +191,12 @@ struct SharedDriveApiEntry { } async fn list_shared_drives( + authed: ApiAuthed, Extension(handler): Extension>, Extension(db): Extension, Path(workspace_id): Path, ) -> JsonResult> { + require_native_integration_use(&authed)?; get_workspace_integration(&db, &workspace_id, ServiceName::Google).await?; let url = format!( diff --git a/backend/windmill-native-triggers/src/lib.rs b/backend/windmill-native-triggers/src/lib.rs index 743b9f8210..a81217d8d5 100644 --- a/backend/windmill-native-triggers/src/lib.rs +++ b/backend/windmill-native-triggers/src/lib.rs @@ -1226,6 +1226,20 @@ pub async fn store_workspace_integration( Ok(()) } +/// Authorization gate for the integration *use* routes (calendar/drive/repo/event +/// pickers). A workspace admin configures the integration, but any member who can +/// create a native trigger needs the pickers to configure one. Operators are +/// read-only and cannot create triggers, so they must not be able to drive the +/// admin-configured integration's upstream API and enumerate its data. +pub fn require_native_integration_use(authed: &ApiAuthed) -> Result<()> { + if authed.is_operator { + return Err(Error::NotAuthorized( + "Operators cannot use workspace integrations".to_string(), + )); + } + Ok(()) +} + pub async fn get_workspace_integration<'c, E: sqlx::Executor<'c, Database = Postgres>>( db: E, workspace_id: &str, diff --git a/backend/windmill-native-triggers/src/nextcloud/routes.rs b/backend/windmill-native-triggers/src/nextcloud/routes.rs index 9c40d2403c..2f54124d64 100644 --- a/backend/windmill-native-triggers/src/nextcloud/routes.rs +++ b/backend/windmill-native-triggers/src/nextcloud/routes.rs @@ -7,17 +7,21 @@ use windmill_common::{ DB, }; +use windmill_api_auth::ApiAuthed; + use crate::{ get_workspace_integration, nextcloud::{NextCloudEventType, OcsResponse}, - External, ServiceName, + require_native_integration_use, External, ServiceName, }; async fn list_available_events( + authed: ApiAuthed, Extension(handler): Extension>, Extension(db): Extension, Path(workspace_id): Path, ) -> JsonResult> { + require_native_integration_use(&authed)?; let integration = get_workspace_integration(&db, &workspace_id, ServiceName::Nextcloud).await?; let base_url = integration diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index ea65a83ba4..59b4184cea 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -18,9 +18,7 @@ use std::collections::HashMap; use std::fmt::Debug; use anyhow::anyhow; -use base64::Engine; use hmac::Mac; -use itertools::Itertools; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use sqlx::{Postgres, Transaction}; use tower_cookies::{Cookie, Cookies}; @@ -89,6 +87,119 @@ pub struct OAuthConfig { pub req_body_auth: Option, #[serde(default = "default_grant_types")] pub grant_types: Vec, + /// Optional URL overrides for the provider's sandbox environment. When + /// present and the admin has configured a `_sandbox` credentials + /// entry, `build_oauth_clients` registers a second client under that key. + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + /// Frontend-only metadata for per-instance OAuth providers (Snowflake, + /// ServiceNow, …) whose authorize/token URLs are derived from an + /// admin-entered instance name. Ignored by the backend, which only ever + /// sees the resulting concrete `connect_config`. + #[serde(skip_serializing_if = "Option::is_none")] + pub connect_config_template: Option, +} + +/// URL overrides for an OAuth provider's sandbox environment. Inherits +/// scopes, extra_params, etc. from the parent [`OAuthConfig`]. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct OAuthSandboxOverride { + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub token_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub userinfo_url: Option, +} + +/// Frontend metadata for a per-instance OAuth provider. The instance-settings +/// UI renders one generic instance-name input and substitutes `{instance}` into +/// `auth_url`/`token_url` to build the per-client `connect_config`. Adding a new +/// per-instance provider needs only a registry entry carrying this template — +/// no frontend code change. The backend never reads it. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ConnectConfigTemplate { + /// Properly-cased provider name for the settings dropdown (e.g. "ServiceNow"); + /// the UI falls back to a capitalized registry key when absent. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + pub label: String, + pub placeholder: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub help_url: Option, + pub auth_url: String, + pub token_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub req_body_auth: Option, + /// Key under `connect_config.extra_params` where the instance name is + /// stored (defaults to `instance`). Snowflake uses `account_identifier` for + /// backward compatibility with previously-saved configs. + #[serde(skip_serializing_if = "Option::is_none")] + pub extra_params_key: Option, + /// Optional host suffix stripped from the input before substitution (e.g. + /// `.service-now.com`), so the admin can paste a full host or a bare name. + #[serde(skip_serializing_if = "Option::is_none")] + pub strip_suffix: Option, + /// Maps OAuth-connected resource arg fields to value templates substituting + /// `{instance}` (e.g. ServiceNow's `instance_url` -> + /// `https://{instance}.service-now.com`). Applied by the resource-connect + /// flow so the created resource carries the instance-specific fields the + /// scripts need (ServiceNow's token response omits the host). + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_mapping: Option>, +} + +impl OAuthConfig { + /// Returns a copy of this config with sandbox URL overrides applied and + /// the nested `sandbox` field cleared. Returns `None` if no overrides are + /// set. + pub fn as_sandbox(&self) -> Option { + let sb = self.sandbox.as_ref()?; + let mut out = self.clone(); + out.sandbox = None; + if let Some(u) = &sb.auth_url { + out.auth_url = u.clone(); + } + if let Some(u) = &sb.token_url { + out.token_url = u.clone(); + } + if sb.userinfo_url.is_some() { + out.userinfo_url = sb.userinfo_url.clone(); + } + Some(out) + } +} + +/// Suffix appended to a provider name to identify its sandbox variant in the +/// instance credentials map and in `account.client`. +pub const SANDBOX_SUFFIX: &str = "_sandbox"; + +/// Strips [`SANDBOX_SUFFIX`] from a client name, returning the canonical +/// provider name. Returns the input unchanged if no suffix is present. +pub fn canonical_provider_name(client_name: &str) -> &str { + client_name + .strip_suffix(SANDBOX_SUFFIX) + .unwrap_or(client_name) +} + +/// Resolves a registry [`OAuthConfig`] for `client_name`, transparently +/// applying the `sandbox` override block when the name carries the sandbox +/// suffix (e.g. `docusign_sandbox` resolves to `docusign` with sandbox URLs +/// applied). Used so callers don't need to know whether a name is a sandbox +/// variant before looking it up. +pub fn resolve_registry_config( + static_configs: &HashMap, + client_name: &str, +) -> Option { + if let Some(cfg) = static_configs.get(client_name) { + return Some(cfg.clone()); + } + if client_name.ends_with(SANDBOX_SUFFIX) { + return static_configs + .get(canonical_provider_name(client_name)) + .and_then(|cfg| cfg.as_sandbox()); + } + None } /// OAuth client credentials @@ -181,181 +292,6 @@ pub struct OAuthCallback { pub state: String, } -/// Build all OAuth clients from configuration -pub async fn build_oauth_clients( - base_url: &str, - oauths_from_config: Option>, - connect_configs_json: &str, - login_configs_json: &str, -) -> anyhow::Result { - let connect_configs = - serde_json::from_str::>(connect_configs_json)?; - let login_configs = serde_json::from_str::>(login_configs_json)?; - - let oauths = if let Some(oauths) = oauths_from_config { - tracing::info!("Using OAuth clients from config: {oauths:?}"); - oauths - } else { - let path = "./oauth.json"; - let content: String = if let Ok(e) = std::env::var("OAUTH_JSON_AS_BASE64") { - std::str::from_utf8( - &base64::engine::general_purpose::STANDARD - .decode(e) - .map_err(to_anyhow)?, - )? - .to_string() - } else if std::path::Path::new(path).exists() { - std::fs::read_to_string(path).map_err(to_anyhow)? - } else { - tracing::warn!("oauth.json not found, no OAuth clients loaded"); - return Ok(AllClients { - logins: HashMap::new(), - connects: HashMap::new(), - slack: None, - }); - }; - - if content.is_empty() { - tracing::warn!("oauth.json is empty, no OAuth clients loaded"); - return Ok(AllClients { - logins: HashMap::new(), - connects: HashMap::new(), - slack: None, - }); - }; - match serde_json::from_str::>(&content) { - Ok(clients) => clients, - Err(e) => { - tracing::error!("deserializing oauth.json: {e}"); - HashMap::new() - } - } - .into_iter() - .collect() - }; - - tracing::info!("OAuth loaded clients: {}", oauths.keys().join(", ")); - - let logins = login_configs - .into_iter() - .filter_map(|x| oauths.get(&x.0).map(|c| (x.0, (c, x.1)))) - .chain(oauths.iter().filter_map(|x| { - x.1.login_config - .as_ref() - .map(|c| (x.0.clone(), (x.1, c.clone()))) - })) - .filter_map(|(k, (client_params, config))| { - let named_client = build_basic_client( - k.clone(), - config.clone(), - client_params.clone(), - true, - base_url, - None, - ); - named_client - .map(|named_client| { - ( - named_client.0, - ClientWithScopes { - client: named_client.1, - scopes: config.scopes.unwrap_or(vec![]), - extra_params: config.extra_params, - extra_params_callback: config.extra_params_callback, - allowed_domains: client_params.allowed_domains.clone(), - userinfo_url: config.userinfo_url, - display_name: client_params.display_name.clone(), - grant_types: client_params.grant_types.clone(), - }, - ) - }) - .map_err(|e| { - tracing::error!("Error building oauth client {k}: {e}"); - e - }) - .ok() - }) - .collect(); - - let connects = connect_configs - .into_iter() - .filter_map(|x| oauths.get(&x.0).map(|c| (x.0, (c, x.1)))) - .chain(oauths.iter().filter_map(|x| { - x.1.connect_config - .as_ref() - .map(|c| (x.0.clone(), (x.1, c.clone()))) - })) - .filter_map(|(k, (client_params, config))| { - let named_client = build_basic_client( - k.clone(), - config.clone(), - client_params.clone(), - false, - base_url, - if k == "supabase_wizard" { - Some(format!("{base_url}/oauth/callback_supabase")) - } else { - None - }, - ); - named_client - .map(|named_client| { - ( - named_client.0, - ClientWithScopes { - client: named_client.1, - scopes: config.scopes.unwrap_or(vec![]), - extra_params: config.extra_params, - extra_params_callback: config.extra_params_callback, - allowed_domains: None, - userinfo_url: None, - display_name: client_params.display_name.clone(), - grant_types: client_params.grant_types.clone(), - }, - ) - }) - .map_err(|e| { - tracing::error!("Error building oauth client {k}: {e}"); - e - }) - .ok() - }) - .collect(); - - let slack = oauths - .get("slack") - .map(|v| { - build_basic_client( - "slack".to_string(), - OAuthConfig { - auth_url: "https://slack.com/oauth/v2/authorize".to_string(), - token_url: "https://slack.com/api/oauth.v2.access".to_string(), - userinfo_url: None, - scopes: None, - extra_params: None, - extra_params_callback: None, - req_body_auth: None, - grant_types: vec!["authorization_code".to_string()], - }, - v.clone(), - false, - base_url, - Some(format!("{base_url}/oauth/callback_slack")), - ) - .map(|x| x.1) - .map_err(|e| { - tracing::error!("Error building oauth slack client: {e}"); - e - }) - .ok() - }) - .flatten(); - - let all_clients = AllClients { logins, connects, slack }; - tracing::debug!("Final oauth config: {all_clients:#?}"); - Ok(all_clients) -} - /// Build a basic OAuth client from configuration pub fn build_basic_client( name: String, @@ -433,38 +369,29 @@ pub async fn build_client_credentials_oauth_client( let oauth_client_config: OAuthClient = serde_json::from_value(oauth_config.clone()) .map_err(|e| error::Error::BadRequest(format!("Invalid OAuth config: {}", e)))?; - let mut connect_config = if let Some(ref config) = oauth_client_config.connect_config { - if !config.auth_url.is_empty() && !config.token_url.is_empty() { - config.clone() - } else { - let static_configs = - serde_json::from_str::>(connect_configs_json) - .map_err(|e| { - error::Error::InternalErr(format!( - "Failed to parse oauth_connect.json: {}", - e - )) - })?; - - static_configs.get(client_name).cloned().ok_or_else(|| { - error::Error::BadRequest(format!( - "OAuth configuration not found for '{}' in either global settings or static config", - client_name - )) - })? - } - } else { - let static_configs = - serde_json::from_str::>(connect_configs_json).map_err( - |e| error::Error::InternalErr(format!("Failed to parse oauth_connect.json: {}", e)), - )?; - - static_configs.get(client_name).cloned().ok_or_else(|| { + let parse_static_configs = || { + serde_json::from_str::>(connect_configs_json).map_err(|e| { + error::Error::InternalErr(format!("Failed to parse oauth_connect.json: {}", e)) + }) + }; + let resolve_from_registry = |client_name: &str| -> error::Result { + let static_configs = parse_static_configs()?; + resolve_registry_config(&static_configs, client_name).ok_or_else(|| { error::Error::BadRequest(format!( "OAuth configuration not found for '{}' in either global settings or static config", client_name )) - })? + }) + }; + + let mut connect_config = if let Some(ref config) = oauth_client_config.connect_config { + if !config.auth_url.is_empty() && !config.token_url.is_empty() { + config.clone() + } else { + resolve_from_registry(client_name)? + } + } else { + resolve_from_registry(client_name)? }; if let Some(override_url) = cc_token_url_override { @@ -905,4 +832,104 @@ mod tests { let verifier = SlackVerifier::new("test_secret").unwrap(); assert!(verifier.verify("123", "body", "wrong_sig").is_err()); } + + #[test] + fn canonical_provider_name_strips_sandbox_suffix() { + assert_eq!(canonical_provider_name("docusign_sandbox"), "docusign"); + assert_eq!(canonical_provider_name("docusign"), "docusign"); + assert_eq!(canonical_provider_name(""), ""); + // Only strips the suffix once; trailing suffix on already-canonical name. + assert_eq!( + canonical_provider_name("foo_sandbox_sandbox"), + "foo_sandbox" + ); + } + + fn sample_oauth_config(with_sandbox: bool) -> OAuthConfig { + OAuthConfig { + auth_url: "https://account.example.com/oauth/auth".to_string(), + token_url: "https://account.example.com/oauth/token".to_string(), + userinfo_url: Some("https://account.example.com/userinfo".to_string()), + scopes: Some(vec!["signature".to_string()]), + extra_params: None, + extra_params_callback: None, + req_body_auth: None, + grant_types: default_grant_types(), + sandbox: with_sandbox.then(|| OAuthSandboxOverride { + auth_url: Some("https://account-d.example.com/oauth/auth".to_string()), + token_url: Some("https://account-d.example.com/oauth/token".to_string()), + userinfo_url: None, + }), + connect_config_template: None, + } + } + + #[test] + fn as_sandbox_returns_none_when_no_override() { + assert!(sample_oauth_config(false).as_sandbox().is_none()); + } + + #[test] + fn as_sandbox_overlays_urls_and_inherits_rest() { + let resolved = sample_oauth_config(true).as_sandbox().unwrap(); + // URLs overridden by sandbox block + assert_eq!( + resolved.auth_url, + "https://account-d.example.com/oauth/auth" + ); + assert_eq!( + resolved.token_url, + "https://account-d.example.com/oauth/token" + ); + // userinfo_url not in override → inherits from parent + assert_eq!( + resolved.userinfo_url, + Some("https://account.example.com/userinfo".to_string()) + ); + // Scopes/grant_types inherited from parent + assert_eq!(resolved.scopes, Some(vec!["signature".to_string()])); + assert_eq!(resolved.grant_types, default_grant_types()); + // Nested sandbox field cleared on the resolved config + assert!(resolved.sandbox.is_none()); + } + + #[test] + fn resolve_registry_config_direct_lookup() { + let mut registry = HashMap::new(); + registry.insert("docusign".to_string(), sample_oauth_config(true)); + + let resolved = resolve_registry_config(®istry, "docusign").unwrap(); + assert_eq!(resolved.auth_url, "https://account.example.com/oauth/auth"); + // Direct lookup returns the entry as-is (sandbox block still attached). + assert!(resolved.sandbox.is_some()); + } + + #[test] + fn resolve_registry_config_sandbox_fallback() { + let mut registry = HashMap::new(); + registry.insert("docusign".to_string(), sample_oauth_config(true)); + + let resolved = resolve_registry_config(®istry, "docusign_sandbox").unwrap(); + // Sandbox-suffixed lookup resolves to parent's sandbox-overlaid config. + assert_eq!( + resolved.auth_url, + "https://account-d.example.com/oauth/auth" + ); + assert!(resolved.sandbox.is_none()); + } + + #[test] + fn resolve_registry_config_missing_returns_none() { + let registry: HashMap = HashMap::new(); + assert!(resolve_registry_config(®istry, "docusign").is_none()); + assert!(resolve_registry_config(®istry, "docusign_sandbox").is_none()); + } + + #[test] + fn resolve_registry_config_sandbox_without_block_returns_none() { + let mut registry = HashMap::new(); + // Parent exists but has no sandbox override. + registry.insert("docusign".to_string(), sample_oauth_config(false)); + assert!(resolve_registry_config(®istry, "docusign_sandbox").is_none()); + } } diff --git a/backend/windmill-queue/Cargo.toml b/backend/windmill-queue/Cargo.toml index ad485d33d8..ac956cb709 100644 --- a/backend/windmill-queue/Cargo.toml +++ b/backend/windmill-queue/Cargo.toml @@ -33,6 +33,7 @@ uuid.workspace = true chrono.workspace = true chrono-tz.workspace = true hex.workspace = true +rand.workspace = true reqwest.workspace = true lazy_static.workspace = true prometheus = { workspace = true, optional = true } diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index 1e1873cc9c..0a707418ba 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -3633,17 +3633,24 @@ async fn pull_single_job_and_mark_as_running_no_concurrency_limit<'c>( return Ok((None, false)); } - // Workspace fairness (cloud-only): if the fairness refresh has flagged any - // overloaded workspaces, try the fairness-aware pull queries first (which - // exclude those workspace_ids). When fairness is off or no workspace is - // currently capped, this branch is skipped and the hot path is identical - // to today's. Lazy refresh is fired from the same place; it runs at most - // once per process per refresh interval and never blocks this pull. + // Workspace fairness (Enterprise): if the fairness refresh has flagged + // any overloaded workspaces, this pull is randomly routed between two + // pull queries: + // * with probability `(cap + ε)/100`, the standard query (capped + // workspaces are admissible — they win via FIFO when noisy) + // * with probability `1 - (cap + ε)/100`, the fairness query (the + // overloaded workspace_ids are filtered out) + // Over many pulls this converges to a steady share around the cap, + // without the on/off oscillation a binary cap/uncap dispatch produces. + // If the chosen query returns nothing, we always fall back to the + // standard query so workers don't idle when only capped jobs remain. + // Lazy refresh fires from the same place: runs at most once per + // process per refresh interval, never blocks this pull. crate::workspace_fairness::maybe_refresh_overloaded(db); let overloaded = WORKSPACE_FAIRNESS_OVERLOADED.load_full(); let fairness_active = !overloaded.is_empty(); - if fairness_active { + if fairness_active && !crate::workspace_fairness::should_admit_capped() { let fairness_queries = WORKER_PULL_QUERIES_FAIRNESS.load(); let overloaded_slice: &[String] = overloaded.as_slice(); for query in fairness_queries.iter() { @@ -3667,10 +3674,10 @@ async fn pull_single_job_and_mark_as_running_no_concurrency_limit<'c>( } if highest_priority_job.is_none() { - // Standard pull path. Also acts as the fallback when fairness filtered - // out every candidate: prefer running a capped workspace's job over - // leaving a worker idle. The cap re-engages on the next refresh as - // soon as the workspace's footprint exceeds the threshold again. + // Standard pull path. Also acts as the fallback when fairness + // filtered out every candidate: prefer running a capped + // workspace's job over leaving a worker idle. (The cap is + // re-asserted statistically on subsequent pulls.) for query in queries.iter() { // tracing::info!("Pulling job with query: {}", query); // let instant = std::time::Instant::now(); @@ -5051,7 +5058,7 @@ async fn push_inner<'c, 'd>( content, path, hash, - language, + mut language, lock, cache_ttl, cache_ignore_s3_path, @@ -5061,6 +5068,21 @@ async fn push_inner<'c, 'd>( debouncing_settings, modules, }) => { + // Reconcile the preview language with the `//native` annotation, mirroring the + // deploy-time logic in `worker_lockfiles`. The editor sends `bun` for a TypeScript + // script even when it carries `//native`, which would otherwise tag the preview as + // `bun` and route it to a regular bun worker. A native-mode worker neither matches + // the `bun` tag nor accepts a non-native `script_lang`, so previewing a `//native` + // script on a native-only worker setup fails. Normalizing to `bunnative` (tag + // `nativets`) makes the preview run exactly like the deployed script would. + if language == ScriptLang::Bun || language == ScriptLang::Bunnative { + let anns = windmill_common::worker::TypeScriptAnnotations::parse(&content); + if anns.native && language == ScriptLang::Bun { + language = ScriptLang::Bunnative; + } else if !anns.native && language == ScriptLang::Bunnative { + language = ScriptLang::Bun; + } + } // Inject modules into job args as _MODULES so the worker can extract them if let Some(ref modules) = modules { match serde_json::to_string(modules).and_then(|s| RawValue::from_string(s)) { @@ -5270,6 +5292,7 @@ async fn push_inner<'c, 'd>( expr: skip_handler.stop_condition, skip_if_stopped: true, error_message: Some(skip_handler.stop_message), + error_include_result: false, }), ..Default::default() }); @@ -5380,6 +5403,7 @@ async fn push_inner<'c, 'd>( cache_ttl: cache_ttl.map(|val| val as u32), cache_ignore_s3_path: cache_ignore_s3_path, same_worker: false, + preserve_step_tags: false, early_return: None, skip_expr: None, preprocessor_module: None, diff --git a/backend/windmill-queue/src/lib.rs b/backend/windmill-queue/src/lib.rs index 296b0df2f3..be0d1590b6 100644 --- a/backend/windmill-queue/src/lib.rs +++ b/backend/windmill-queue/src/lib.rs @@ -15,6 +15,8 @@ pub use jobs::*; pub mod flow_status; pub mod tags; pub mod workspace_fairness; +#[cfg(feature = "private")] +pub mod workspace_fairness_ee; #[cfg(feature = "cloud")] pub mod cloud_usage; diff --git a/backend/windmill-queue/src/workspace_fairness.rs b/backend/windmill-queue/src/workspace_fairness.rs index a5b39055a8..8eb3a69394 100644 --- a/backend/windmill-queue/src/workspace_fairness.rs +++ b/backend/windmill-queue/src/workspace_fairness.rs @@ -1,262 +1,274 @@ -//! Per-workspace fairness for the shared worker pool (cloud-only). +//! # Per-workspace fairness for the shared worker pool (Enterprise feature) //! -//! On `app.windmill.dev` the cluster runs a single default worker group, so a -//! single workspace flooding the queue with jobs can degrade quality of service -//! for everyone else. This module computes the set of "overloaded" workspaces -//! that should be temporarily excluded from the pull query. +//! On multi-tenant deployments (notably `app.windmill.dev`, and any EE +//! cluster with a single shared worker group) a single workspace flooding +//! the queue with jobs can degrade quality of service for everyone else. +//! This module computes the set of "overloaded" workspaces whose share of +//! the worker pool must be capped, and the dispatch in `jobs.rs` uses a +//! **duration-weighted stochastic admission rule** at pull time to enforce +//! the cap as a *worker-second* share, not a pull-count share. //! -//! ## Detection signal +//! The full algorithm lives in [`crate::workspace_fairness_ee`] behind the +//! `private` feature — this OSS-facing module is the public surface that +//! the pull dispatch and integration tests call. When EE is on, the symbols +//! here transparently re-export the EE implementation. When EE is off, they +//! are no-ops: `maybe_refresh_overloaded` does nothing, `should_admit_capped` +//! always returns `true`, and the pull path is bit-identical to its +//! pre-fairness shape. **Both runtime correctness and the entire reasoning +//! below assume the EE module is compiled in**; the OSS build is a stub. //! -//! A workspace is overloaded when, over the last `WORKSPACE_FAIRNESS_DURATION_SECS` -//! seconds, it has accounted for at least `WORKSPACE_FAIRNESS_MAX_PERCENT`% of -//! cluster activity. "Cluster activity" counts both currently-running jobs and -//! jobs completed within the window — this captures workspaces hogging slots -//! with long-running jobs **and** workspaces spamming many small short-lived -//! jobs (where no individual job's `started_at` is old, but the aggregate -//! throughput share dominates). +//! Every numerical default mentioned below (`MAX_PERCENT = 50`, +//! `DURATION_SECS = 10`, `MIN_TOTAL = 4`, `WORKER_PING_LIVE_SECS = 60`, +//! `ADMISSION_EPSILON_PERCENT = 5`) is tunable via global settings or +//! constants; the values here are the as-shipped defaults at the time of +//! writing and what the design discussion below was calibrated against. //! -//! ## Coordinated refresh +//! ## 1. What "overloaded" means — worker-seconds, not jobs //! -//! The aggregation runs **at most once every `refresh_interval` seconds -//! cluster-wide**, regardless of fleet size. A single `UPDATE` statement on -//! `background_task_state` does double duty: -//! 1. The `WHERE updated_at < now() - $interval` predicate, combined with -//! row-level locking, ensures only the first process to commit per cycle -//! actually recomputes the value. Other processes that race in see the -//! `WHERE` re-evaluated against the now-fresh row and update zero rows. -//! 2. The same round trip falls through to a plain `SELECT` (via -//! `UNION ALL ... LIMIT 1`) so every caller reads the current value. +//! A workspace is overloaded when, over a rolling +//! `WORKSPACE_FAIRNESS_DURATION_SECS = 10s` window, it has consumed at least +//! `WORKSPACE_FAIRNESS_MAX_PERCENT = 50%` of cluster worker-time. Activity +//! is measured in **worker-seconds**: each job contributes the wall-clock +//! time it actually held a worker, intersected with the window. A +//! count-based signal — "what fraction of jobs in the window are from this +//! workspace" — gets badly fooled by job-duration heterogeneity: 600 +//! short (100ms) jobs and one long (60s) job consume the same worker-time +//! but the count-based form attributes 600× more weight to the spammy +//! workspace. Worker-seconds put both patterns on the same scale. //! -//! Each process mirrors the result into [`WORKSPACE_FAIRNESS_OVERLOADED`] -//! which the pull path reads at near-zero cost. +//! Two sources contribute to a workspace's worker-second total: //! -//! ## Cloud gating +//! - **Running** (live, currently-on-a-worker): driven from `v2_job_runtime` +//! filtered on `ping > now() - WORKER_PING_LIVE_SECS` (60s, ≈ 2× worker +//! heartbeat interval), then PK-joined to `v2_job` for the `kind` filter +//! and `v2_job_queue` for `started_at` / `suspend_until`. Contribution is +//! `clamp(min(now, ping) − max(started_at, window_start), 0, window)`. +//! End-of-interval is the per-job `ping`, which both (a) implements the +//! zombie defense — a worker that stopped pinging stops accruing +//! worker-seconds at its last heartbeat, so a backlog of stuck +//! `running = true` rows can't dominate the denominator — and (b) matches +//! the semantic of "worker-seconds the worker has confirmed". `v2_job_runtime` +//! is small (rows deleted on completion), so driving the scan from there +//! keeps the per-refresh cost bounded by the *in-flight* count rather +//! than by the queue size, even when one workspace has thousands of +//! `running = true` rows. //! -//! The feature is hard-gated to `CLOUD_HOSTED=true` **and** `BASE_URL` matching -//! `app.windmill.dev` (belt-and-suspenders against an on-prem instance importing -//! cloud's `global_settings` row). When either check fails, [`maybe_refresh_overloaded`] -//! and the pull-side dispatch both treat the feature as disabled. +//! - **Completed** (recently finished): pulled by an index scan over +//! `v2_job_completed (completed_at)`, then PK-joined to `v2_job`. The +//! index hit is critical — see "Why no `WITH params AS (...)` CTE" below. +//! Contribution is `clamp(min(completed_at, now) − max(started_at, +//! completed_at − duration_ms, window_start), 0, window)`. Clamping +//! start-of-interval by `completed_at − duration_ms` defends against +//! zombie rows that `zombie_monitor` force-failed: `started_at` may be +//! far in the past, but `duration_ms` reflects the actual measured worker +//! time, so the row only contributes its real runtime, not the idle wait +//! before force-fail. +//! +//! Both halves exclude **flow-orchestration kinds** +//! (`flow, flowpreview, flownode, singlestepflow`) and **concurrency- +//! suspended rows** (`suspend_until IS NOT NULL`) — these hold +//! `running = true` but consume no worker slot. Same predicate as +//! `handle_zombie_jobs` in `monitor.rs`. +//! +//! `WORKSPACE_FAIRNESS_MIN_TOTAL = 4` is also in worker-seconds (≈ 40 % +//! utilization of one worker over a 10s window) — below the floor, the +//! cluster is too quiet to bother capping anyone. +//! +//! ## 2. The cap is enforced stochastically, weighted by duration +//! +//! The pull dispatch in `jobs.rs` flips a coin on every pull: with +//! probability `p_c` it uses the standard pull query (capped workspaces +//! are admissible — FIFO will pick them if they're at the head), and with +//! probability `1 − p_c` it uses the *fairness pull query* which excludes +//! the overloaded workspaces. Doing it as a probabilistic split rather +//! than a binary cap/uncap gate keeps victim latency flat instead of +//! breathing in/out with each refresh cycle. +//! +//! The key design choice is how `p_c` is set. The natural first try is +//! `p_c = (MAX_PERCENT + ε) / 100` — a constant. That converges the +//! *pull-count* ratio to `MAX_PERCENT`, but only matches the worker-second +//! ratio when capped and uncapped workspaces share the same mean job +//! duration. The steady-state share equation is: +//! +//! `share = p_c · D_c / (p_c · D_c + (1 − p_c) · D_u)` +//! +//! where `D_c` and `D_u` are the per-job mean durations of capped and +//! uncapped workspaces respectively. With `D_c = 34s` and `D_u = 1s` (the +//! exact numbers observed during the lancom01-prod / jps-internal cloud +//! incident), a constant `p_c = 0.65` (60 % + 5 % ε) yields +//! +//! `share = 0.65 · 34 / (0.65 · 34 + 0.35 · 1) = 22.1 / 22.45 ≈ 98%` +//! +//! — i.e., the "60 % cap" was in practice giving capped workspaces 98 % +//! of worker-seconds. Victims were observed waiting 15s+ for pickup +//! despite the cap firing on every pull. +//! +//! Inverting the equation for the desired share `t = (MAX_PERCENT + ε) / 100`: +//! +//! `p_c = t · D_u / ((1 − t) · D_c + t · D_u)` +//! +//! Same numbers, target 0.65: `p_c ≈ 0.054` — about 12× tighter than the +//! count-based form. The refresh computes `p_c` and stores it in +//! [`WORKSPACE_FAIRNESS_ADMISSION_PPM`] (parts-per-10_000, fits in an +//! `AtomicU32`). The pull-time check is one atomic load plus one +//! `rand::rng().random_range(0..10_000)` draw — same hot-path cost as the +//! count-based form. +//! +//! ### `D_c`/`D_u` come from a separate, longer service-time window +//! +//! Crucially, `D_c` and `D_u` must be **true mean service times**, because +//! the share equation above is Little's-law-based +//! (`occupancy = arrival_rate × mean_service_time`). They are **not** taken +//! from the occupancy aggregation: that aggregation clamps each job's +//! contribution to the short occupancy window (`DURATION_SECS`, 10s), so a +//! job longer than the window contributes at most 10s — fine for measuring +//! *share*, but it would truncate `D_c` to ≤ 10s and systematically +//! under-admit the skew exactly when capped jobs are long (the case the cap +//! exists for: e.g. true `D_c = 34s` clamped to 10s gives `p_c ≈ 0.157`, an +//! 86 % effective share instead of 65 %). Instead, the refresh samples true +//! unclamped `duration_ms` of completed jobs over a longer, decoupled +//! service-time window (`DURATION_SAMPLE_SECS`, 60s) — long enough to avoid +//! truncation and to keep the mean stable when few jobs complete within the +//! 10s occupancy window. So the refresh emits two per-workspace signals: +//! windowed occupancy worker-seconds (for classification) and a 60s +//! service-time `(Σ duration_ms, count)` (for admission), merged per +//! workspace. +//! +//! ### Why we kept the fallback when the fairness pull returns empty +//! +//! The 100 − `p_c` % of pulls that try the fairness query (excluding +//! capped workspaces) fall back to the standard query if the fairness +//! query returns no row. The alternative — idle the worker, holding the +//! slot open in case a victim shows up — was considered but rejected for +//! the first iteration: with `p_c` correctly tightened, victims do get the +//! slot they need *when they exist*, and absent victims, falling back to +//! the capped pool is the right behaviour (otherwise the cluster +//! under-utilises itself for no benefit). Adding a reserve-capacity skip +//! is a fine-tuning lever for bursty victim arrival patterns and is left +//! as a follow-up. +//! +//! ### Degenerate cases +//! +//! If either bucket is empty — no capped jobs, no uncapped jobs, or a +//! capped workspace with zero completions in the 60s service-time window +//! (all its jobs still running) — the formula is undefined. The refresh +//! falls back to the count-based `p_c = t` in those cases — it matches +//! the pre-refactor behaviour and is the safest thing to do when there's +//! no service-time signal yet to weight on. +//! +//! ## 3. Coordinated refresh — exactly once per cycle, cluster-wide +//! +//! The aggregation is too expensive to run on every worker process every +//! pull (and would produce no new information on the sub-second +//! timescale). It runs **at most once every `refresh_interval` seconds +//! across the entire fleet**, gated by both a per-process CAS and a +//! DB-side row lock: +//! +//! 1. **Per-process gate** — `maybe_refresh_overloaded` (called from the +//! pull path) does `LAST_REFRESH_MICROS.compare_exchange` to ensure at +//! most one in-flight refresh per process per interval. If the CAS +//! fails or the interval hasn't elapsed yet, the call is a no-op. +//! Cost on the hot path: one atomic load, optionally one CAS. +//! +//! 2. **DB-side claim** — `refresh_overloaded` first does a cheap upsert +//! (`INSERT ... ON CONFLICT ON background_task_state ... WHERE +//! updated_at < NOW() − refresh_interval RETURNING true`). The `VALUES` +//! clause is all constants, so Postgres has no expensive work to do +//! even for losers. Only the unique winner per cycle gets `Some(true)`; +//! losers get `None` and skip the aggregation entirely. +//! +//! 3. **Winner-only aggregation** — the winner runs the +//! `v2_job_runtime ∪ v2_job_completed` worker-second aggregation +//! returning per-workspace `(workspace_id, worker_seconds, jobs)`, +//! classifies into overloaded/uncapped, computes `p_c`, and writes the +//! new payload `{"overloaded": [...], "admission_ppm": N}` back to +//! `background_task_state.workspace_fairness`. +//! +//! 4. **Everyone reads** — winner and losers alike then `SELECT` the +//! current value, parse it, and update their in-process +//! `WORKSPACE_FAIRNESS_OVERLOADED` and `WORKSPACE_FAIRNESS_ADMISSION_PPM` +//! atomics. This is what makes losers eventually see the winner's +//! decision; they just don't pay the aggregation cost. +//! +//! The refresh interval is `ACTIVE_REFRESH_SECS = 2s` when the cluster +//! currently has a capped workspace (faster — we want the cap to lift +//! promptly once load drops) and `IDLE_REFRESH_SECS = 5s` otherwise +//! (slower — minimise DB load during normal operation). The DB-side guard +//! always uses the tighter `ACTIVE_REFRESH_SECS` to bound the race +//! window; the per-process gate enforces the idle cadence. +//! +//! If a refresh fails (DB error, timeout > 5s), `LAST_REFRESH_MICROS` is +//! left set to the attempt's timestamp so the next attempt has to wait a +//! full interval — exactly the same cooldown as a successful refresh. +//! Resetting to `0` on failure would remove the rate limit entirely +//! precisely when DB load is highest, which is the wrong direction. +//! +//! ## 4. Audit logging +//! +//! Workspaces entering or leaving the capped set produce +//! `workspace_fairness.capped` / `workspace_fairness.uncapped` audit +//! entries scoped to the `admins` workspace, with the affected workspace +//! as the `resource` field. Emitted by the refresh winner only, so a +//! transition produces exactly one audit row regardless of fleet size. +//! The "previous list" diffed against is the DB value (not the per-process +//! cache) so a freshly-restarted worker that happens to win the first +//! claim doesn't emit spurious "newly capped" entries for workspaces that +//! were already capped before it started. +//! +//! ## 5. Notable SQL performance constraints +//! +//! - **No `WITH params AS (...)` CTE for `window_start`.** A natural +//! refactor would be to compute `NOW() - make_interval(secs => N)` once +//! in a CTE and reference it in both halves of the UNION. But Postgres +//! *materialises* the CTE and the optimiser can no longer push the +//! `completed_at > window_start` predicate down to the +//! `ix_job_completed_completed_at` index. On the production cloud DB +//! (~12M `v2_job_completed` rows), that turns a 10 ms index scan into a +//! ~47s full table scan. The query intentionally inlines `NOW()` and +//! `NOW() - make_interval(...)` at every callsite. +//! +//! - **Drive running side from `v2_job_runtime`, not `v2_job_queue`.** +//! Naive ordering ("scan v2_job_queue for `running = true`, join v2_job +//! for the kind filter") does a Seq Scan over ~thousands of running-or- +//! bookkeeping rows and does a PK lookup into `v2_job` for every one of +//! them — ~10 ms in prod, but worse: bounded by *queue size*. Pivoting +//! to drive the scan from `v2_job_runtime` filtered on +//! `ping > NOW() - 60s` narrows to the in-flight set (small, deletes- +//! on-completion) *before* any PK lookups: 1.3 ms, 9× less I/O, +//! bounded by *live worker count*. +//! +//! ## 6. Enterprise gating +//! +//! The cap is an Enterprise feature. `windmill-api-settings` rejects +//! `workspace_fairness_enabled = true` writes from non-EE builds, and on a +//! single-tenant self-hosted deployment the default +//! `workspace_fairness_enabled = false` keeps the pull path identical to +//! the pre-fairness baseline. At runtime the dispatch checks the atomic +//! only — when fairness is off, `maybe_refresh_overloaded` drains the +//! cached state in one pull cycle (resetting `WORKSPACE_FAIRNESS_OVERLOADED` +//! to empty and `WORKSPACE_FAIRNESS_ADMISSION_PPM` to 10_000 = "admit all"), +//! so toggling the feature off without restarting workers is safe. -use std::sync::atomic::Ordering; -use std::sync::Arc; -use std::time::Duration; +#[cfg(feature = "private")] +#[allow(unused)] +pub use crate::workspace_fairness_ee::*; -use sqlx::{Pool, Postgres}; +#[cfg(not(feature = "private"))] +mod oss_stubs { + use sqlx::{Pool, Postgres}; -use windmill_common::error::Result; -use windmill_common::worker::{ - is_cloud_production_host, WORKSPACE_FAIRNESS_DURATION_SECS, WORKSPACE_FAIRNESS_ENABLED, - WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS, WORKSPACE_FAIRNESS_MAX_PERCENT, - WORKSPACE_FAIRNESS_MIN_TOTAL, WORKSPACE_FAIRNESS_OVERLOADED, -}; + /// No-op on OSS — workspace fairness is an Enterprise feature. + #[inline] + pub fn maybe_refresh_overloaded(_db: &Pool) {} -pub const TASK_STATE_NAME: &str = "workspace_fairness"; - -/// Refresh interval when no workspace is currently capped. Slower cadence to -/// keep DB load minimal during normal operation. -const IDLE_REFRESH_SECS: u32 = 5; - -/// Refresh interval when at least one workspace is capped. Faster cadence so -/// the cap lifts promptly once load drops below threshold. -const ACTIVE_REFRESH_SECS: u32 = 2; - -/// Hard cap on the size of the overloaded list bound into the pull query. -const MAX_OVERLOADED_RETURNED: i64 = 64; - -/// Whether the feature can be active in this process. Combined gate: -/// - `WORKSPACE_FAIRNESS_ENABLED` setting toggled on, AND -/// - `CLOUD_HOSTED=true`, AND -/// - `BASE_URL` host is the production cloud host. -fn fairness_active() -> bool { - WORKSPACE_FAIRNESS_ENABLED.load(Ordering::Relaxed) && is_cloud_production_host() + /// No-op on OSS — always returns `true` so the dispatch never reaches + /// the fairness pull query (which is empty anyway, since + /// `store_pull_query` only materialises it when fairness is enabled). + #[inline] + pub fn should_admit_capped() -> bool { + true + } } -#[derive(serde::Deserialize)] -struct FairnessState { - #[serde(default)] - overloaded: Vec, -} - -/// Lazy, non-blocking refresh entry point called from the pull path. -/// -/// Cost on the hot path: one atomic load, optionally one compare-exchange. If -/// this process wins the per-interval CAS, the actual refresh is spawned as a -/// `tokio` task — the caller does not wait on it. -pub fn maybe_refresh_overloaded(db: &Pool) { - if !fairness_active() { - // Drain the cached list so the dispatch in jobs.rs falls back to the - // unmodified pull queries within at most one pull cycle. - if !WORKSPACE_FAIRNESS_OVERLOADED.load().is_empty() { - WORKSPACE_FAIRNESS_OVERLOADED.store(Arc::new(vec![])); - } - return; - } - - let interval_us = current_refresh_interval_micros(); - let now_us = chrono::Utc::now().timestamp_micros(); - let last = WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS.load(Ordering::Relaxed); - if now_us.saturating_sub(last) < interval_us { - return; - } - // Single in-flight refresh per process per cycle. If someone beat us, give up. - if WORKSPACE_FAIRNESS_LAST_REFRESH_MICROS - .compare_exchange(last, now_us, Ordering::Relaxed, Ordering::Relaxed) - .is_err() - { - return; - } - - let db = db.clone(); - tokio::spawn(async move { - match tokio::time::timeout(Duration::from_secs(5), refresh_overloaded(&db)).await { - Ok(Ok(())) => {} - // On failure, leave `LAST_REFRESH_MICROS` set to `now_us` (already done by the CAS - // above). The next attempt therefore has to wait a full `current_refresh_interval` - // — exactly the same cooldown as a successful refresh. Previously we wrote `0` - // here, which removed the rate limit entirely and let every subsequent pull spawn - // a fresh refresh task while the DB was under pressure (precisely the moment we - // most need to back off). - Ok(Err(e)) => { - tracing::warn!("workspace fairness refresh failed: {e:#}"); - } - Err(_) => { - tracing::warn!("workspace fairness refresh timed out after 5s"); - } - } - }); -} - -fn current_refresh_interval_micros() -> i64 { - let secs = if WORKSPACE_FAIRNESS_OVERLOADED.load().is_empty() { - IDLE_REFRESH_SECS - } else { - ACTIVE_REFRESH_SECS - }; - (secs as i64) * 1_000_000 -} - -/// Run the coordinated refresh. -/// -/// The previous implementation used a single `INSERT ... ON CONFLICT DO UPDATE -/// WHERE updated_at < ...` statement, which had a fatal flaw: Postgres evaluates -/// the `VALUES` clause (including the expensive `v2_job_queue ∪ v2_job_completed` -/// aggregation inlined there) **for every contender** to build the proposed row, -/// before the conflict-row check decides whether to actually apply the update. -/// So every worker process re-ran the heavy aggregation each cycle, and the -/// claimed "one heavy aggregation per cycle cluster-wide" property did not hold. -/// -/// This version splits the refresh into three small statements: -/// 1. Claim: a cheap upsert with only constant `VALUES`. Returns `Some(...)` -/// iff this process won the right to refresh (row was either missing or -/// had a stale `updated_at`). -/// 2. Winner-only: an `UPDATE ... SET value = ...` whose `SET` expression -/// contains the heavy aggregation. Postgres evaluates `SET` per row -/// matching `WHERE`; we only issue it when `won`, so the aggregation runs -/// exactly once per refresh cycle cluster-wide. -/// 3. Read: every caller reads the current value (winner sees its own fresh -/// write; losers see whatever the winner-from-this-or-the-prior-cycle -/// wrote). -async fn refresh_overloaded(db: &Pool) -> Result<()> { - let duration_secs = WORKSPACE_FAIRNESS_DURATION_SECS - .load(Ordering::Relaxed) - .clamp(1, i32::MAX as u32) as i32; - let max_percent = WORKSPACE_FAIRNESS_MAX_PERCENT - .load(Ordering::Relaxed) - .clamp(1, 100) as i64; - let min_total = WORKSPACE_FAIRNESS_MIN_TOTAL.load(Ordering::Relaxed) as i64; - // Use the tighter of the two intervals as the cluster-wide guard. The - // slower idle cadence is enforced by the per-process CAS gate in - // `maybe_refresh_overloaded`; the DB-side guard only needs to prevent - // two processes from racing into a refresh at the same time. - let refresh_secs = ACTIVE_REFRESH_SECS as i32; - - // Step 1: claim. The VALUES clause is all constants — Postgres has no - // expensive work to do for either the insert-side or the conflict-side. - // Returns Some(true) for the unique winner per cycle, None for losers. - let won = sqlx::query_scalar::<_, bool>( - r#" - INSERT INTO background_task_state (name, value, running, owner, updated_at) - VALUES ($1, '{"overloaded":[]}'::jsonb, false, NULL, NOW()) - ON CONFLICT (name) DO UPDATE - SET updated_at = NOW() - WHERE background_task_state.updated_at - < NOW() - make_interval(secs => $2::int) - RETURNING true - "#, - ) - .bind(TASK_STATE_NAME) - .bind(refresh_secs) - .fetch_optional(db) - .await? - .is_some(); - - // Step 2: winner-only aggregation + value write. `SET` is evaluated per - // updated row, so issuing this statement only when `won` guarantees the - // expensive aggregation never runs for a loser. - if won { - sqlx::query( - r#" - UPDATE background_task_state - SET value = jsonb_build_object('overloaded', ( - WITH active AS ( - SELECT workspace_id FROM v2_job_queue WHERE running = true - UNION ALL - SELECT workspace_id FROM v2_job_completed - WHERE completed_at > NOW() - make_interval(secs => $2::int) - ), - per_ws AS ( - SELECT workspace_id, COUNT(*)::int8 AS c FROM active GROUP BY 1 - ), - total AS (SELECT SUM(c)::int8 AS t FROM per_ws) - SELECT COALESCE(jsonb_agg(workspace_id ORDER BY c DESC), '[]'::jsonb) - FROM ( - SELECT workspace_id, c FROM per_ws, total - WHERE total.t >= $3 - AND per_ws.c * 100 >= $4 * total.t - ORDER BY c DESC - LIMIT $5 - ) capped - )) - WHERE name = $1 - "#, - ) - .bind(TASK_STATE_NAME) - .bind(duration_secs) - .bind(min_total) - .bind(max_percent) - .bind(MAX_OVERLOADED_RETURNED) - .execute(db) - .await?; - } - - // Step 3: read current state (winner reads its own fresh write). - let row: Option = - sqlx::query_scalar("SELECT value FROM background_task_state WHERE name = $1") - .bind(TASK_STATE_NAME) - .fetch_optional(db) - .await?; - - let new_list: Vec = match row { - Some(value) => match serde_json::from_value::(value) { - Ok(s) => s.overloaded, - Err(e) => { - tracing::warn!("workspace fairness state parse error: {e:#}"); - vec![] - } - }, - None => vec![], - }; - - let prev = WORKSPACE_FAIRNESS_OVERLOADED.load(); - if **prev != new_list { - tracing::info!( - "workspace fairness overloaded set changed: {} -> {} ({:?})", - prev.len(), - new_list.len(), - &new_list, - ); - WORKSPACE_FAIRNESS_OVERLOADED.store(Arc::new(new_list)); - } - - Ok(()) -} +#[cfg(not(feature = "private"))] +pub use oss_stubs::*; diff --git a/backend/windmill-store/Cargo.toml b/backend/windmill-store/Cargo.toml index cb1e41ad05..b3aca5e669 100644 --- a/backend/windmill-store/Cargo.toml +++ b/backend/windmill-store/Cargo.toml @@ -45,6 +45,8 @@ tracing.workspace = true uuid.workspace = true quick_cache.workspace = true lazy_static.workspace = true +sha2.workspace = true +hex.workspace = true sql-builder.workspace = true async-recursion.workspace = true futures.workspace = true diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index 5c17308bbf..402628e3ad 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -10,14 +10,14 @@ use std::collections::HashMap; use std::net::IpAddr; use windmill_api_auth::{ - check_scopes, maybe_refresh_folders, require_owner_of_path, require_super_admin, ApiAuthed, - Tokened, + build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path, + require_super_admin, ApiAuthed, Tokened, }; use windmill_common::db::DB; use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; use crate::secret_backend_ext::rename_vault_secret; -use crate::var_resource_cache::{cache_resource, get_cached_resource}; +use crate::var_resource_cache::{auth_identity, cache_resource, get_cached_resource}; use windmill_common::utils::{escape_ilike_pattern, BulkDeleteRequest}; use windmill_common::webhook::{WebhookMessage, WebhookShared}; @@ -194,6 +194,7 @@ async fn list_names( Extension(user_db): Extension, ) -> JsonResult> { let mut tx = user_db.begin(&authed).await?; + let allowed = build_scope_path_predicate(&authed, "resources", "read"); let rows = sqlx::query!( "SELECT value->>'name' as name, path from resource WHERE resource_type = $1 AND workspace_id = $2", rt, @@ -203,6 +204,7 @@ async fn list_names( .await? .into_iter() .filter_map(|x| x.name.map(|name| NamePath { name, path: x.path })) + .filter(|np| allowed(&np.path)) .collect::>(); tx.commit().await?; Ok(Json(rows)) @@ -225,6 +227,7 @@ async fn list_search_resources( #[cfg(not(feature = "enterprise"))] let n = 3; + let allowed = build_scope_path_predicate(&authed, "resources", "read"); let rows = sqlx::query_as!( SearchResource, "SELECT path, value from resource WHERE workspace_id = $1 LIMIT $2", @@ -234,6 +237,7 @@ async fn list_search_resources( .fetch_all(&mut *tx) .await? .into_iter() + .filter(|r| allowed(&r.path)) .collect::>(); tx.commit().await?; Ok(Json(rows)) @@ -338,9 +342,13 @@ async fn list_resources( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; + let allowed = build_scope_path_predicate(&authed, "resources", "read"); let rows = sqlx::query_as::<_, ListableResource>(&sql) .fetch_all(&mut *tx) - .await?; + .await? + .into_iter() + .filter(|r| allowed(&r.path)) + .collect::>(); tx.commit().await?; @@ -542,8 +550,18 @@ pub async fn get_resource_value_interpolated_internal<'a>( return Ok(Some(pg_creds)); } - if allow_cache { - if let Some(cached_value) = get_cached_resource(&workspace, &path) { + // Scope the cache to the caller's full authorization identity (not just email): the + // cached value is already decrypted/interpolated under this caller's RLS context, so it + // must never be served to a context that resolves to different permissions. Only + // job-independent values are ever stored (see the write below), so a hit is always safe + // to return regardless of the current `job_id`. + let cache_identity = allow_cache.then(|| match db_with_opt_authed.authed() { + Some(authed) => auth_identity(authed), + None => format!("\0system:{}", db_with_opt_authed.email()), + }); + + if let Some(identity) = cache_identity.as_deref() { + if let Some(cached_value) = get_cached_resource(&workspace, &path, identity) { return Ok(Some(cached_value)); } } @@ -567,17 +585,24 @@ pub async fn get_resource_value_interpolated_internal<'a>( let value = not_found_if_none(value_o, "Resource", path)?; if let Some(value) = value { - let r = transform_json_value( + // Track whether interpolation pulled in a `$WM_*` contextual variable. If it did, the + // result is job-dependent (and may embed `$WM_TOKEN`) and must not be cached; if not, + // it's job-independent and safe to cache and to serve to any job context. + let used_job_context = std::sync::atomic::AtomicBool::new(false); + let r = transform_json_value_tracked( &db_with_opt_authed, workspace, value, &job_id, token_for_context, 0, + &used_job_context, ) .await?; - if allow_cache { - cache_resource(&workspace, &path, r.clone()); + if let Some(identity) = cache_identity.as_deref() { + if !used_job_context.load(std::sync::atomic::Ordering::Relaxed) { + cache_resource(&workspace, &path, identity, r.clone()); + } } Ok(Some(r)) } else { @@ -593,14 +618,41 @@ pub async fn get_resource_value_interpolated_internal<'a>( // access could otherwise use to crash the API process. pub const MAX_RESOURCE_INTERPOLATION_DEPTH: u8 = 50; -#[async_recursion] pub async fn transform_json_value( - db_with_opt_authed: &DbWithOptAuthed, + db_with_opt_authed: &DbWithOptAuthed<'_, ApiAuthed>, workspace: &str, v: Value, job_id: &Option, token: Option<&str>, depth: u8, +) -> Result { + // Discard the job-context flag; callers that need it use `transform_json_value_tracked`. + let used_job_context = std::sync::atomic::AtomicBool::new(false); + transform_json_value_tracked( + db_with_opt_authed, + workspace, + v, + job_id, + token, + depth, + &used_job_context, + ) + .await +} + +/// Like [`transform_json_value`], but records into `used_job_context` whether the value +/// contains a `$WM_*` contextual variable (resolved from `job_id`/`token`). A value that did +/// not is job-independent and safe to cache; one that did must not be cached or shared across +/// jobs. +#[async_recursion] +pub async fn transform_json_value_tracked( + db_with_opt_authed: &DbWithOptAuthed<'_, ApiAuthed>, + workspace: &str, + v: Value, + job_id: &Option, + token: Option<&str>, + depth: u8, + used_job_context: &std::sync::atomic::AtomicBool, ) -> Result { if depth >= MAX_RESOURCE_INTERPOLATION_DEPTH { return Err(Error::internal_err(format!( @@ -644,15 +696,35 @@ pub async fn transform_json_value( tx.commit().await?; let v = not_found_if_none(v, "Resource", path)?; if let Some(v) = v { - transform_json_value(db_with_opt_authed, workspace, v, job_id, token, depth + 1) - .await + transform_json_value_tracked( + db_with_opt_authed, + workspace, + v, + job_id, + token, + depth + 1, + used_job_context, + ) + .await } else { Ok(Value::Null) } } - Value::String(y) if y.starts_with("$") && job_id.is_some() => { + // `$WM_*` is the reserved contextual-variable namespace (`$WM_TOKEN`, `$WM_JOB_ID`, + // ...); its resolved value depends on the job, so a value containing one is + // job-dependent and must never be cached — including on a no-job read, where the + // placeholder is left unresolved (caching it would then serve a stale placeholder to a + // later job read). Any other `$...` string (custom workspace envs, `$5.00`, `$HOME`, jq + // paths) is NOT interpolated here — it resolves to itself regardless of context and so + // stays cacheable (handled by the catch-all below). Note: custom workspace envs are + // intentionally not resolved inside resource values (they remain available to scripts). + Value::String(y) if y.starts_with("$WM_") => { + used_job_context.store(true, std::sync::atomic::Ordering::Relaxed); + let Some(job_id) = *job_id else { + // No job context to resolve against; leave the placeholder unchanged. + return Ok(Value::String(y)); + }; let mut tx = db_with_opt_authed.begin().await?; - let job_id = job_id.unwrap(); let job = sqlx::query!( "SELECT v2_job.permissioned_as_email, @@ -723,13 +795,14 @@ pub async fn transform_json_value( Value::Array(mut arr) if depth <= 2 && arr.len() <= 1000 => { for i in 0..arr.len() { let val = std::mem::take(&mut arr[i]); - arr[i] = transform_json_value( + arr[i] = transform_json_value_tracked( db_with_opt_authed, workspace, val, job_id, token, depth + 1, + used_job_context, ) .await?; } @@ -746,13 +819,14 @@ pub async fn transform_json_value( } Value::Object(mut m) => { for (a, b) in m.clone().into_iter() { - let v = transform_json_value( + let v = transform_json_value_tracked( db_with_opt_authed, workspace, b, job_id, token, depth + 1, + used_job_context, ) .await?; m.insert(a.clone(), v); diff --git a/backend/windmill-store/src/var_resource_cache.rs b/backend/windmill-store/src/var_resource_cache.rs index f7ce2aeecf..3e89f8579e 100644 --- a/backend/windmill-store/src/var_resource_cache.rs +++ b/backend/windmill-store/src/var_resource_cache.rs @@ -8,7 +8,9 @@ use quick_cache::sync::Cache; use serde_json::Value; +use sha2::{Digest, Sha256}; use std::time::{SystemTime, UNIX_EPOCH}; +use windmill_common::db::Authable; /// Cache TTL for variables and resources (30seconds) const CACHE_TTL_SECS: u64 = 30; @@ -40,11 +42,23 @@ impl CacheEntry { } } -lazy_static::lazy_static! { - /// Cache for individual variable values: key = "workspace_id:path" - pub static ref VARIABLE_CACHE: Cache> = Cache::new(1000); +/// A cached variable value plus whether it is a secret. `is_secret` is retained so a +/// cache hit can re-run the per-read side effects of a secret read (the +/// `variables.decrypt_secret` audit and running-job secret registration) that the +/// original miss performed — a hit must be observably equivalent to a miss. +#[derive(Clone, Debug)] +pub struct CachedVariable { + pub value: String, + pub is_secret: bool, +} - /// Cache for resource values: key = "workspace_id:path" +lazy_static::lazy_static! { + /// Cache for individual variable values. Key: [`identity_cache_key`] + /// (`identity:workspace_id:path`) — scoped to the caller's authorization context. + pub static ref VARIABLE_CACHE: Cache> = Cache::new(1000); + + /// Cache for interpolated resource values. Key: [`identity_cache_key`] + /// (`identity:workspace_id:path`) — scoped to the caller's authorization context. pub static ref RESOURCE_CACHE: Cache> = Cache::new(1000); } @@ -53,9 +67,73 @@ pub fn cache_key(workspace_id: &str, path: &str) -> String { format!("{}:{}", workspace_id, path) } -/// Get cached variable if available and not expired -pub fn get_cached_variable(workspace_id: &str, path: &str) -> Option { - let key = cache_key(workspace_id, path); +/// Hash the caller's full authorization context into a stable identity string. +/// +/// Email alone is **not** a sufficient scope: the same email can resolve to different +/// effective permissions (`username`, groups, folders, scopes, admin/operator) through +/// job- or owner-scoped tokens that share an email but carry a narrower `permissioned_as`. +/// Every input that determines what the caller may read is folded in, mirroring +/// `job_read_access_cache_key` in windmill-api, so a lower-privilege context can never +/// reuse a higher-privilege context's cache entry. Variable-length fields are +/// length-prefixed to keep the encoding injective. +pub fn auth_identity(authed: &A) -> String { + let mut hasher = Sha256::new(); + let field = |hasher: &mut Sha256, bytes: &[u8]| { + hasher.update((bytes.len() as u32).to_be_bytes()); + hasher.update(bytes); + }; + hasher.update([authed.is_admin() as u8, authed.is_operator() as u8]); + field(&mut hasher, authed.email().as_bytes()); + field(&mut hasher, authed.username().as_bytes()); + let mut groups: Vec<&str> = authed.groups().iter().map(String::as_str).collect(); + groups.sort_unstable(); + hasher.update((groups.len() as u32).to_be_bytes()); + for g in groups { + field(&mut hasher, g.as_bytes()); + } + let mut folders: Vec<&str> = authed.folders().iter().map(|f| f.0.as_str()).collect(); + folders.sort_unstable(); + hasher.update((folders.len() as u32).to_be_bytes()); + for f in folders { + field(&mut hasher, f.as_bytes()); + } + match authed.scopes() { + // u32::MAX length-prefix marks "no scopes" so it can't collide with an empty list. + None => hasher.update(u32::MAX.to_be_bytes()), + Some(scopes) => { + let mut scopes: Vec<&str> = scopes.iter().map(String::as_str).collect(); + scopes.sort_unstable(); + hasher.update((scopes.len() as u32).to_be_bytes()); + for s in scopes { + field(&mut hasher, s.as_bytes()); + } + } + } + hex::encode(hasher.finalize()) +} + +/// Generate an identity-scoped cache key (`identity:workspace_id:path`). +/// +/// Both the variable and resource caches store *already-decrypted* values that were +/// resolved under the caller's row-level-security context. The cache is consulted before +/// the per-folder RLS query runs, so an unscoped `workspace:path` key would let an entry +/// warmed by one caller (via `allow_cache=true`) be served to a different caller who has +/// no access to the underlying folder, leaking decrypted secrets within the TTL. `identity` +/// is [`auth_identity`] — the hash of the caller's full authorization context — so a hit +/// can only ever be returned to a caller whose authorized read populated it. +fn identity_cache_key(identity: &str, workspace_id: &str, path: &str) -> String { + format!("{}:{}", identity, cache_key(workspace_id, path)) +} + +/// Get cached variable if available and not expired. Scoped to `identity` +/// ([`auth_identity`]); see [`identity_cache_key`]. Returns the value and its `is_secret` +/// flag so the caller can re-run a secret read's side effects on a hit. +pub fn get_cached_variable( + workspace_id: &str, + path: &str, + identity: &str, +) -> Option { + let key = identity_cache_key(identity, workspace_id, path); VARIABLE_CACHE.get(&key).and_then(|entry| { if entry.is_expired() { VARIABLE_CACHE.remove(&key); @@ -67,17 +145,21 @@ pub fn get_cached_variable(workspace_id: &str, path: &str) -> Option { }) } -/// Cache variable data -pub fn cache_variable(workspace_id: &str, path: &str, email: &str, variable: String) { - let key = format!("{}:{}", email, cache_key(workspace_id, path)); +/// Cache variable data, scoped to the caller identity. See [`get_cached_variable`]. +pub fn cache_variable(workspace_id: &str, path: &str, identity: &str, variable: CachedVariable) { + let key = identity_cache_key(identity, workspace_id, path); let entry = CacheEntry::new(variable); VARIABLE_CACHE.insert(key.clone(), entry); tracing::debug!("Cached variable {}", key); } -/// Get cached resource if available and not expired -pub fn get_cached_resource(workspace_id: &str, path: &str) -> Option { - let key = cache_key(workspace_id, path); +/// Get cached resource if available and not expired. +/// +/// Scoped to `identity` ([`auth_identity`]); see [`identity_cache_key`]. The cached value +/// is the *already-interpolated* resource — its `$var:`/`$res:` secrets are resolved and +/// decrypted inline — so it must never cross authorization boundaries. +pub fn get_cached_resource(workspace_id: &str, path: &str, identity: &str) -> Option { + let key = identity_cache_key(identity, workspace_id, path); RESOURCE_CACHE.get(&key).and_then(|entry| { if entry.is_expired() { RESOURCE_CACHE.remove(&key); @@ -89,22 +171,28 @@ pub fn get_cached_resource(workspace_id: &str, path: &str) -> Option { }) } -/// Cache resource data -pub fn cache_resource(workspace_id: &str, path: &str, resource: Value) { - let key = cache_key(workspace_id, path); +/// Cache resource data, scoped to the caller identity. See [`get_cached_resource`]. +pub fn cache_resource(workspace_id: &str, path: &str, identity: &str, resource: Value) { + let key = identity_cache_key(identity, workspace_id, path); let entry = CacheEntry::new(resource); RESOURCE_CACHE.insert(key.clone(), entry); tracing::debug!("Cached resource {}", key); } -/// Invalidate specific variable from cache +/// Invalidate a variable from the cache. +/// +/// NOTE: entries are keyed by [`identity_cache_key`] (`identity:workspace:path`), so this +/// `workspace:path` key cannot target them — it only removes a legacy unscoped entry, if +/// any. Per-identity entries are not enumerable here; rely on the 30s TTL for staleness, +/// or use [`clear_all_caches`] to force a full flush. Currently unused. pub fn invalidate_variable_cache(workspace_id: &str, path: &str) { let key = cache_key(workspace_id, path); VARIABLE_CACHE.remove(&key); tracing::info!("Variable cache invalidated for {}", key); } -/// Invalidate specific resource from cache +/// Invalidate a resource from the cache. Same identity-scoping caveat as +/// [`invalidate_variable_cache`]. Currently unused. pub fn invalidate_resource_cache(workspace_id: &str, path: &str) { let key = cache_key(workspace_id, path); RESOURCE_CACHE.remove(&key); @@ -118,3 +206,106 @@ pub fn clear_all_caches() { RESOURCE_CACHE.clear(); tracing::debug!("All variable/resource caches cleared"); } + +#[cfg(test)] +mod tests { + use super::*; + + /// Minimal [`Authable`] double so we can assert which authorization fields the + /// cache identity is sensitive to, without standing up a full auth stack. + struct FakeAuthed { + email: String, + username: String, + is_admin: bool, + is_operator: bool, + groups: Vec, + folders: Vec<(String, bool, bool)>, + scopes: Option>, + } + + impl FakeAuthed { + fn base() -> Self { + Self { + email: "alice@x.dev".to_string(), + username: "alice".to_string(), + is_admin: false, + is_operator: false, + groups: vec!["all".to_string()], + folders: vec![("shared".to_string(), false, false)], + scopes: None, + } + } + } + + impl Authable for FakeAuthed { + fn email(&self) -> &str { + &self.email + } + fn username(&self) -> &str { + &self.username + } + fn is_admin(&self) -> bool { + self.is_admin + } + fn is_operator(&self) -> bool { + self.is_operator + } + fn groups(&self) -> &[String] { + &self.groups + } + fn folders(&self) -> &[(String, bool, bool)] { + &self.folders + } + fn scopes(&self) -> Option<&[String]> { + self.scopes.as_deref() + } + } + + // Email alone must NOT determine the cache identity: two contexts that share an email + // but resolve to different effective permissions must get distinct identities, so a + // lower-privilege context can never reuse a higher-privilege one's cached secret. + #[test] + fn auth_identity_is_not_just_email() { + let base = auth_identity(&FakeAuthed::base()); + + let mut more_folders = FakeAuthed::base(); + more_folders + .folders + .push(("secret".to_string(), false, false)); + assert_ne!(base, auth_identity(&more_folders), "folders must matter"); + + let mut more_groups = FakeAuthed::base(); + more_groups.groups.push(("devs").to_string()); + assert_ne!(base, auth_identity(&more_groups), "groups must matter"); + + let mut other_user = FakeAuthed::base(); + other_user.username = "bob".to_string(); + assert_ne!(base, auth_identity(&other_user), "username must matter"); + + let mut admin = FakeAuthed::base(); + admin.is_admin = true; + assert_ne!(base, auth_identity(&admin), "is_admin must matter"); + + let mut operator = FakeAuthed::base(); + operator.is_operator = true; + assert_ne!(base, auth_identity(&operator), "is_operator must matter"); + + let mut scoped = FakeAuthed::base(); + scoped.scopes = Some(vec!["resources:read:f/secret/x".to_string()]); + assert_ne!(base, auth_identity(&scoped), "scopes must matter"); + } + + // Identical authorization contexts must produce the same identity (so the same caller + // gets a cache hit), and ordering of groups/folders must not change the identity. + #[test] + fn auth_identity_is_stable_and_order_independent() { + let a = FakeAuthed::base(); + assert_eq!(auth_identity(&a), auth_identity(&FakeAuthed::base())); + + let mut reordered = FakeAuthed::base(); + reordered.groups = vec!["all".to_string(), "devs".to_string()]; + let mut other_order = FakeAuthed::base(); + other_order.groups = vec!["devs".to_string(), "all".to_string()]; + assert_eq!(auth_identity(&reordered), auth_identity(&other_order)); + } +} diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 4c2a0ed670..24739ce940 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -6,7 +6,10 @@ * LICENSE-AGPL for a copy of the license. */ -use windmill_api_auth::{check_scopes, maybe_refresh_folders, require_owner_of_path, ApiAuthed}; +use windmill_api_auth::{ + build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path, + ApiAuthed, +}; use windmill_common::db::DB; use windmill_common::workspaces::{check_deploy_rules, RuleCheckResult}; @@ -39,7 +42,9 @@ use windmill_common::{ worker::CLOUD_HOSTED, }; -use crate::var_resource_cache::{cache_variable, get_cached_variable}; +use crate::var_resource_cache::{ + auth_identity, cache_variable, get_cached_variable, CachedVariable, +}; use lazy_static::lazy_static; use serde::Deserialize; use sqlx::{Acquire, Postgres, Transaction}; @@ -188,9 +193,13 @@ async fn list_variables( let sql = sqlb.sql().map_err(|e| Error::internal_err(e.to_string()))?; let mut tx = user_db.begin(&authed).await?; + let allowed = build_scope_path_predicate(&authed, "variables", "read"); let rows = sqlx::query_as::<_, ListableVariable>(&sql) .fetch_all(&mut *tx) - .await?; + .await? + .into_iter() + .filter(|r| allowed(&r.path)) + .collect::>(); tx.commit().await?; Ok(Json(rows)) @@ -1197,15 +1206,55 @@ fn replace_path(v: serde_json::Value, path: &str, npath: &str) -> Value { } } +/// Emit the `variables.decrypt_secret` audit event for a secret-variable read. Run on both +/// the cache-miss and cache-hit paths so `allow_cache` never skips secret-access auditing. +async fn audit_decrypt_secret( + db_with_opt_authed: &DbWithOptAuthed<'_, ApiAuthed>, + w_id: &str, + path: &str, +) -> Result<()> { + let mut tx = db_with_opt_authed.db().begin().await?; + audit_log( + &mut *tx, + db_with_opt_authed, + "variables.decrypt_secret", + ActionKind::Execute, + w_id, + Some(path), + None, + ) + .await?; + tx.commit().await?; + Ok(()) +} + pub async fn get_value_internal<'a>( db_with_opt_authed: &'a DbWithOptAuthed<'a, ApiAuthed>, w_id: &str, path: &str, allow_cache: bool, ) -> Result { - if allow_cache { - if let Some(cached_variable) = get_cached_variable(&w_id, &path) { - return Ok(cached_variable); + // Scope the cache to the caller's full authorization identity (not just email): the + // cached value is the decrypted variable, resolved under this caller's RLS context. + let cache_identity = allow_cache.then(|| match db_with_opt_authed.authed() { + Some(authed) => auth_identity(authed), + None => format!("\0system:{}", db_with_opt_authed.email()), + }); + + if let Some(identity) = cache_identity.as_deref() { + if let Some(cached) = get_cached_variable(&w_id, &path, identity) { + // A cache hit must be observably equivalent to a miss: re-run the per-read side + // effects a secret read performs (the `variables.decrypt_secret` audit and + // running-job secret registration) so `allow_cache` never silently skips them. + if cached.is_secret { + audit_decrypt_secret(db_with_opt_authed, &w_id, &path).await?; + if !cached.value.is_empty() { + windmill_common::sensitive_log_masks::register_secret_for_all_running_jobs( + &cached.value, + ); + } + } + return Ok(cached.value); } } @@ -1227,19 +1276,7 @@ pub async fn get_value_internal<'a>( }; let r = if variable.is_secret { - // let audit_author = - let mut tx = db_with_opt_authed.db().begin().await?; - audit_log( - &mut *tx, - db_with_opt_authed, - "variables.decrypt_secret", - ActionKind::Execute, - &w_id, - Some(&variable.path), - None, - ) - .await?; - tx.commit().await?; + audit_decrypt_secret(db_with_opt_authed, &w_id, &variable.path).await?; let value = variable.value; if variable.is_expired.unwrap_or(false) && variable.account.is_some() { @@ -1275,9 +1312,16 @@ pub async fn get_value_internal<'a>( windmill_common::sensitive_log_masks::register_secret_for_all_running_jobs(&r); } - // Cache the result when explicitly allowed and caching appropriate - if allow_cache { - cache_variable(&w_id, &path, db_with_opt_authed.email(), r.clone()); + // Cache the result when explicitly allowed. Secrets are cached too: their per-read side + // effects (audit + running-job registration) are re-run on a hit (see the hit path above), + // and `is_secret` is stored so the hit knows to do so. + if let Some(identity) = cache_identity.as_deref() { + cache_variable( + &w_id, + &path, + identity, + CachedVariable { value: r.clone(), is_secret: variable.is_secret }, + ); } Ok(r) diff --git a/backend/windmill-test-utils/src/lib.rs b/backend/windmill-test-utils/src/lib.rs index 79dcc3bd84..ae2de880a4 100644 --- a/backend/windmill-test-utils/src/lib.rs +++ b/backend/windmill-test-utils/src/lib.rs @@ -914,6 +914,26 @@ pub async fn run_deployed_relative_imports( .await .unwrap(); + // Regression guard for the Deno lock-gen import map (generate_deno_lock): + // it must resolve workspace `/f/`/`/u/` imports, otherwise `deno cache --lock` + // fails with "not a dependency and not in import map". We match that + // specific failure rather than asserting lock_error_logs is empty — + // the field also captures benign, non-fatal lock-job output (e.g. Bun's + // "empty dependencies, skipping install"). (Runtime query to avoid + // touching the sqlx offline cache.) + let lock_error: Option = + sqlx::query_scalar("SELECT lock_error_logs FROM script WHERE path = $1") + .bind("f/system/test_import") + .fetch_one(&db2) + .await + .unwrap(); + if let Some(err) = &lock_error { + assert!( + !err.contains("not in import map"), + "lock generation failed to resolve a workspace import: {err}" + ); + } + let job = RunJob::from(JobPayload::ScriptHash { path: "f/system/test_import".to_string(), hash: ScriptHash(script.hash), diff --git a/backend/windmill-trigger-http/src/http_trigger_auth.rs b/backend/windmill-trigger-http/src/http_trigger_auth.rs index 19766cdbc2..10927d1ef9 100644 --- a/backend/windmill-trigger-http/src/http_trigger_auth.rs +++ b/backend/windmill-trigger-http/src/http_trigger_auth.rs @@ -337,6 +337,20 @@ mod zoom { return Ok(None); } + // Prevent this challenge endpoint from being used as a signing oracle. + // Legitimate Zoom validation tokens are short random hex strings that + // never contain colons. The exploit requires crafting a plainToken in the + // `v0:{timestamp}:{body}` webhook-signing format (always containing colons) + // to obtain a valid signature for an arbitrary body. Reject any token that + // does not look like a legitimate Zoom validation token. + if zoom_request_body.payload.plain_token.contains(':') + || zoom_request_body.payload.plain_token.len() > 128 + { + return Err(AuthenticationError::InvalidChallengeResponse( + "Zoom: invalid plainToken format".to_string(), + )); + } + let hmac_signature = calculate_hmac_signature( HmacAlgorithm::Sha256, &signature_config_data.secret_key, @@ -1540,6 +1554,52 @@ mod tests { assert!(response.is_none()); } + #[test] + fn test_zoom_challenge_normal_token_succeeds() { + // A legitimate Zoom validation token is a short random alphanumeric string. + let payload = r#"{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{"plainToken":"qgg8vlvZRS6UYooatFL8Aw"}}"#; + + let handler = WebhookType::Zoom.get_webhook_handler().unwrap(); + let config_data = SignatureConfigData { secret_key: "zoom_secret" }; + let response = handler + .handle_challenge_request(&HeaderMap::new(), &config_data, payload) + .unwrap(); + assert!(response.is_some()); + } + + #[test] + fn test_zoom_challenge_token_with_colons_rejected() { + // Exploit attempt: a plainToken crafted in the `v0:{ts}:{body}` signing format + // would let an attacker obtain a valid webhook signature for an arbitrary body. + let payload = r#"{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{"plainToken":"v0:1234567890:{\"forged\":\"body\"}"}}"#; + + let handler = WebhookType::Zoom.get_webhook_handler().unwrap(); + let config_data = SignatureConfigData { secret_key: "zoom_secret" }; + let result = handler.handle_challenge_request(&HeaderMap::new(), &config_data, payload); + assert!(matches!( + result, + Err(AuthenticationError::InvalidChallengeResponse(_)) + )); + } + + #[test] + fn test_zoom_challenge_token_too_long_rejected() { + // A plainToken exceeding 128 chars cannot be a legitimate Zoom validation token. + let long_token = "a".repeat(129); + let payload = format!( + r#"{{"event":"endpoint.url_validation","event_ts":1234567890,"payload":{{"plainToken":"{}"}}}}"#, + long_token + ); + + let handler = WebhookType::Zoom.get_webhook_handler().unwrap(); + let config_data = SignatureConfigData { secret_key: "zoom_secret" }; + let result = handler.handle_challenge_request(&HeaderMap::new(), &config_data, &payload); + assert!(matches!( + result, + Err(AuthenticationError::InvalidChallengeResponse(_)) + )); + } + // --- Custom webhook end-to-end --- #[test] diff --git a/backend/windmill-trigger-websocket/Cargo.toml b/backend/windmill-trigger-websocket/Cargo.toml index 6b239d237c..ccf8d2b120 100644 --- a/backend/windmill-trigger-websocket/Cargo.toml +++ b/backend/windmill-trigger-websocket/Cargo.toml @@ -20,6 +20,8 @@ windmill-trigger.workspace = true windmill-git-sync.workspace = true windmill-queue.workspace = true tokio-tungstenite.workspace = true +base64.workspace = true +url.workspace = true axum.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/backend/windmill-trigger-websocket/src/handler.rs b/backend/windmill-trigger-websocket/src/handler.rs index 5adfc5a1e2..df8bccaaa6 100644 --- a/backend/windmill-trigger-websocket/src/handler.rs +++ b/backend/windmill-trigger-websocket/src/handler.rs @@ -4,7 +4,6 @@ use async_trait::async_trait; use itertools::Itertools; use serde_json::value::RawValue; use sqlx::{types::Json as SqlxJson, PgConnection}; -use tokio_tungstenite::connect_async; use windmill_api_auth::ApiAuthed; use windmill_common::DB; use windmill_common::{ @@ -16,8 +15,8 @@ use windmill_git_sync::DeployedObject; use windmill_trigger::{Trigger, TriggerCrud, TriggerData}; use super::{ - get_url_from_runnable_value, TestWebsocketConfig, WebsocketConfig, WebsocketConfigRequest, - WebsocketTrigger, + get_url_from_runnable_value, proxy::connect_async_with_proxy, TestWebsocketConfig, + WebsocketConfig, WebsocketConfigRequest, WebsocketTrigger, }; #[async_trait] @@ -277,12 +276,14 @@ impl TriggerCrud for WebsocketTrigger { Cow::Borrowed(&url) }; - connect_async(&*connect_url).await.map_err(|err| { - Error::BadConfig(format!( - "Error connecting to WebSocket: {}", - err.to_string() - )) - })?; + connect_async_with_proxy(&*connect_url) + .await + .map_err(|err| { + Error::BadConfig(format!( + "Error connecting to WebSocket: {}", + err.to_string() + )) + })?; Ok(()) } diff --git a/backend/windmill-trigger-websocket/src/lib.rs b/backend/windmill-trigger-websocket/src/lib.rs index bcf4a144db..e61c067479 100644 --- a/backend/windmill-trigger-websocket/src/lib.rs +++ b/backend/windmill-trigger-websocket/src/lib.rs @@ -18,6 +18,7 @@ use windmill_trigger::trigger_helpers::{ pub mod handler; pub mod listener; +pub mod proxy; #[derive(Copy, Clone)] pub struct WebsocketTrigger; diff --git a/backend/windmill-trigger-websocket/src/listener.rs b/backend/windmill-trigger-websocket/src/listener.rs index b729c49a28..54dd4ddf60 100644 --- a/backend/windmill-trigger-websocket/src/listener.rs +++ b/backend/windmill-trigger-websocket/src/listener.rs @@ -1,4 +1,6 @@ -use super::{get_url_from_runnable_value, WebsocketConfig, WebsocketTrigger}; +use super::{ + get_url_from_runnable_value, proxy::connect_async_with_proxy, WebsocketConfig, WebsocketTrigger, +}; use anyhow::Context; use async_trait::async_trait; use futures::{stream::SplitSink, SinkExt, StreamExt}; @@ -8,7 +10,7 @@ use serde::Deserialize; use serde_json::value::RawValue; use std::{borrow::Cow, collections::HashMap, sync::Arc}; use tokio::{net::TcpStream, sync::RwLock}; -use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; +use tokio_tungstenite::{tungstenite::Message, MaybeTlsStream, WebSocketStream}; use windmill_common::{ error::{to_anyhow, Error, Result}, jobs::JobTriggerKind, @@ -171,7 +173,7 @@ impl Listener for WebsocketTrigger { Cow::Borrowed(&url) }; - let connection = connect_async(&*connect_url) + let connection = connect_async_with_proxy(&*connect_url) .await .map(|conn| Some(conn)) .map_err(|err| to_anyhow(err).into()); diff --git a/backend/windmill-trigger-websocket/src/proxy.rs b/backend/windmill-trigger-websocket/src/proxy.rs new file mode 100644 index 0000000000..8670b7ca3a --- /dev/null +++ b/backend/windmill-trigger-websocket/src/proxy.rs @@ -0,0 +1,436 @@ +//! HTTP CONNECT proxy support for outbound WebSocket connections. +//! +//! `tokio-tungstenite::connect_async` opens a raw TCP socket and does not +//! honour `HTTPS_PROXY` / `HTTP_PROXY` / `NO_PROXY`. On networks without +//! direct egress this leaves WebSocket triggers unable to reach the +//! upstream service. This module re-uses the env-var snapshots already +//! parsed by `windmill-common` and, when a proxy applies to the target +//! host, opens an HTTP CONNECT tunnel before delegating the TLS + +//! WebSocket handshake back to tungstenite. +//! +//! When no proxy env vars are set (the common case), this module +//! forwards straight to `tokio_tungstenite::connect_async` so the +//! networking path stays byte-for-byte identical to the previous +//! behaviour. + +use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _}; +use std::io; +use tokio::{ + io::{AsyncBufReadExt, AsyncWriteExt, BufReader}, + net::TcpStream, +}; +use tokio_tungstenite::{ + client_async_tls_with_config, connect_async, + tungstenite::{ + client::IntoClientRequest, + error::{Error as WsError, UrlError}, + handshake::client::Response, + }, + MaybeTlsStream, WebSocketStream, +}; +use url::Url; +use windmill_common::{HTTPS_PROXY, HTTP_PROXY, NO_PROXY}; + +/// Drop-in replacement for `tokio_tungstenite::connect_async` that routes +/// the underlying TCP connection through `HTTPS_PROXY` / `HTTP_PROXY` +/// (with `NO_PROXY` exclusions) when those env vars are set. When none +/// is set we short-circuit straight to `connect_async`, keeping the +/// behaviour for non-proxied deployments unchanged. +pub async fn connect_async_with_proxy( + request: R, +) -> Result<(WebSocketStream>, Response), WsError> +where + R: IntoClientRequest + Unpin, +{ + if HTTPS_PROXY.is_none() && HTTP_PROXY.is_none() { + return connect_async(request).await; + } + + let request = request.into_client_request()?; + let uri = request.uri().clone(); + let scheme = uri.scheme_str().unwrap_or_default().to_ascii_lowercase(); + let host = uri + .host() + .ok_or(WsError::Url(UrlError::NoHostName))? + .to_string(); + let port = uri + .port_u16() + .or_else(|| match scheme.as_str() { + "wss" => Some(443), + "ws" => Some(80), + _ => None, + }) + .ok_or(WsError::Url(UrlError::UnsupportedUrlScheme))?; + + let proxy = proxy_url_for(&scheme, &host).and_then(|raw| parse_proxy_target(&raw)); + + let Some(proxy) = proxy else { + // Proxy env was set but doesn't apply to this host (NO_PROXY hit + // or unparseable URL): preserve the original connect path. + return connect_async(request).await; + }; + + tracing::debug!( + "Connecting to WebSocket {}:{} through HTTP proxy {}:{}", + host, + port, + proxy.host, + proxy.port, + ); + let socket = http_connect_tunnel(&proxy, &host, port) + .await + .map_err(WsError::Io)?; + + client_async_tls_with_config(request, socket, None, None).await +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProxyTarget { + host: String, + port: u16, + /// Base64-encoded `user:pass` from URL userinfo, ready to drop into + /// the `Proxy-Authorization: Basic …` header value. + basic_auth: Option, +} + +/// Resolve the proxy URL string to use for outbound `(scheme, host)`. +/// +/// `wss://`/`https://` reads `HTTPS_PROXY`, `ws://`/`http://` reads +/// `HTTP_PROXY`. `NO_PROXY` short-circuits to `None`. The env-var +/// snapshots come from `windmill-common` so they share a single source +/// of truth with the worker's `PROXY_ENVS`. +fn proxy_url_for(scheme: &str, host: &str) -> Option { + if let Some(no_proxy) = NO_PROXY.as_deref() { + if matches_no_proxy(host, no_proxy) { + return None; + } + } + let primary = if scheme.eq_ignore_ascii_case("wss") || scheme.eq_ignore_ascii_case("https") { + HTTPS_PROXY.as_deref() + } else { + HTTP_PROXY.as_deref() + }; + primary + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) +} + +/// Match a host against a `NO_PROXY` value (comma-separated list). +/// +/// Supports the conventional rules used by `curl`/`reqwest`: +/// - `*` matches everything +/// - exact host match +/// - bare-domain entry (`example.com`) matches `example.com` and any +/// subdomain (`foo.example.com`) +/// - leading-dot entry (`.example.com`) is normalised to the bare form +/// (matches `example.com` and any subdomain), to match what `reqwest` +/// and most ops folks expect +/// - any `:port` suffix on entries is ignored +/// +/// CIDR/IP-range matches are intentionally not supported. +fn matches_no_proxy(host: &str, no_proxy: &str) -> bool { + let host = host.trim().trim_end_matches('.').to_ascii_lowercase(); + if host.is_empty() { + return false; + } + for raw in no_proxy.split(',') { + let entry = raw.trim().to_ascii_lowercase(); + if entry.is_empty() { + continue; + } + if entry == "*" { + return true; + } + let entry = entry.split(':').next().unwrap_or(&entry); + let entry = entry.trim_end_matches('.'); + let bare = entry.trim_start_matches('.'); + if bare.is_empty() { + continue; + } + if host == bare { + return true; + } + if host.ends_with(&format!(".{}", bare)) { + return true; + } + } + false +} + +/// Parse a proxy URL string into host/port and optional pre-encoded basic +/// auth. Accepts `host`, `host:port`, `scheme://host[:port]`, with an +/// optional `user[:pass]@` userinfo prefix. The scheme is used only to +/// pick a default port (`https` → 443, anything else → 80). +fn parse_proxy_target(raw: &str) -> Option { + let raw = raw.trim(); + if raw.is_empty() { + return None; + } + + // `url::Url::parse` requires an explicit scheme — prepend `http://` + // when the user passed a bare `host[:port]`. + let prepended = if raw.contains("://") { + std::borrow::Cow::Borrowed(raw) + } else { + std::borrow::Cow::Owned(format!("http://{raw}")) + }; + let url = Url::parse(&prepended).ok()?; + + // `host_str()` keeps brackets around IPv6 literals; strip them so + // `TcpStream::connect((host, port))` resolves the address correctly. + let host = url + .host_str()? + .trim_start_matches('[') + .trim_end_matches(']'); + if host.is_empty() { + return None; + } + let host = host.to_string(); + let port = + url.port_or_known_default() + .unwrap_or(if url.scheme().eq_ignore_ascii_case("https") { + 443 + } else { + 80 + }); + + let basic_auth = match (url.username(), url.password()) { + ("", None) => None, + (user, pass) => { + let creds = match pass { + Some(p) => format!("{user}:{p}"), + None => user.to_string(), + }; + Some(BASE64_STANDARD.encode(creds)) + } + }; + + Some(ProxyTarget { host, port, basic_auth }) +} + +/// Open a TCP connection to `proxy` and ask it to tunnel to +/// `(target_host, target_port)` via HTTP CONNECT. Returns the raw socket +/// once the proxy has acknowledged with a 2xx response — subsequent bytes +/// belong to the tunneled connection. +async fn http_connect_tunnel( + proxy: &ProxyTarget, + target_host: &str, + target_port: u16, +) -> io::Result { + let mut stream = TcpStream::connect((proxy.host.as_str(), proxy.port)).await?; + + let host_header = format!("{}:{}", target_host, target_port); + let mut req = format!("CONNECT {h} HTTP/1.1\r\nHost: {h}\r\n", h = host_header,); + if let Some(ref auth) = proxy.basic_auth { + req.push_str("Proxy-Authorization: Basic "); + req.push_str(auth); + req.push_str("\r\n"); + } + req.push_str("Proxy-Connection: keep-alive\r\n\r\n"); + + stream.write_all(req.as_bytes()).await?; + stream.flush().await?; + + let mut reader = BufReader::new(stream); + let mut status_line = String::new(); + let n = reader.read_line(&mut status_line).await?; + if n == 0 { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "HTTP proxy closed connection before sending CONNECT response", + )); + } + + let status_ok = status_line + .split_whitespace() + .nth(1) + .map(|s| s == "200") + .unwrap_or(false); + + loop { + let mut line = String::new(); + let n = reader.read_line(&mut line).await?; + if n == 0 || line == "\r\n" || line == "\n" { + break; + } + } + + if !status_ok { + return Err(io::Error::new( + io::ErrorKind::Other, + format!( + "HTTP proxy CONNECT to {} rejected: {}", + host_header, + status_line.trim_end() + ), + )); + } + + // A conforming proxy stays silent after the CONNECT response until the + // client speaks. If our read buffer is non-empty, the proxy spoke + // first — handing the raw socket to TLS would silently drop those + // bytes and break the handshake. + if !reader.buffer().is_empty() { + return Err(io::Error::new( + io::ErrorKind::Other, + "HTTP proxy sent unexpected bytes after CONNECT response", + )); + } + + Ok(reader.into_inner()) +} + +#[cfg(test)] +mod tests { + //! The single live test (`http_connect_tunnel_…_unwraps_stream`) drives + //! a real `TcpListener` masquerading as a proxy and verifies both the + //! on-the-wire CONNECT request and that the returned `TcpStream` + //! actually carries tunneled bytes. The other proxy-URL and NO_PROXY + //! checks are kept under `#[ignore]` for manual debugging — they cover + //! logic that's mostly delegated to `url::Url::parse` and trivial + //! string matching, so re-running them on every CI build is low ROI. + use super::*; + + #[test] + #[ignore = "covered by upstream `url::Url::parse`; run manually with `--ignored` if changed"] + fn parse_proxy_target_shapes_and_ipv6_and_basic_auth() { + let p = parse_proxy_target("http://outbound.eps.apple.com:80").unwrap(); + assert_eq!(p.host, "outbound.eps.apple.com"); + assert_eq!(p.port, 80); + + let p = parse_proxy_target("https://proxy.internal").unwrap(); + assert_eq!(p.port, 443); + + let p = parse_proxy_target("proxy.internal:3128").unwrap(); + assert_eq!(p.port, 3128); + + let p = parse_proxy_target("http://alice:s3cret@proxy.lan:8080").unwrap(); + // base64("alice:s3cret") = YWxpY2U6czNjcmV0 + assert_eq!(p.basic_auth.as_deref(), Some("YWxpY2U6czNjcmV0")); + + let p = parse_proxy_target("http://[::1]:3128").unwrap(); + assert_eq!(p.host, "::1"); + assert_eq!(p.port, 3128); + + assert!(parse_proxy_target("").is_none()); + assert!(parse_proxy_target("http://").is_none()); + } + + #[test] + #[ignore = "trivial string matching; run manually with `--ignored` if rules change"] + fn no_proxy_matching_rules() { + assert!(matches_no_proxy("example.com", "*")); + assert!(matches_no_proxy("example.com", "example.com")); + assert!(matches_no_proxy("api.example.com", "example.com")); + assert!(matches_no_proxy("api.example.com", ".example.com")); + assert!(matches_no_proxy("example.com", ".example.com")); + assert!(matches_no_proxy("example.com", "example.com:8080")); + assert!(matches_no_proxy("API.Example.COM", "example.com")); + assert!(!matches_no_proxy("notexample.com", "example.com")); + assert!(!matches_no_proxy("slack.com", "example.com,internal.lan")); + } + + /// Spin up a one-shot TCP listener acting as an HTTP proxy. + /// Reads the CONNECT request, asserts on it via `validate`, then + /// either replies `200 Connection Established` or the supplied + /// `respond` string. Echoes any further client bytes back so the test + /// can confirm the returned `TcpStream` carries the tunneled session. + async fn fake_proxy( + respond: &'static str, + validate: F, + ) -> (std::net::SocketAddr, tokio::task::JoinHandle>) + where + F: FnOnce(&str) + Send + 'static, + { + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + use tokio::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = String::new(); + { + let mut reader = BufReader::new(&mut socket); + loop { + let mut line = String::new(); + let n = reader.read_line(&mut line).await.unwrap(); + request.push_str(&line); + if n == 0 || line == "\r\n" || line == "\n" { + break; + } + } + } + validate(&request); + socket.write_all(respond.as_bytes()).await.unwrap(); + socket.flush().await.unwrap(); + + let mut tunneled = Vec::new(); + socket.read_to_end(&mut tunneled).await.unwrap(); + tunneled + }); + (addr, handle) + } + + #[tokio::test] + async fn http_connect_tunnel_sends_well_formed_request_and_unwraps_stream() { + use tokio::io::AsyncWriteExt; + + let (addr, handle) = fake_proxy( + "HTTP/1.1 200 Connection Established\r\nProxy-Agent: test\r\n\r\n", + |req| { + assert!( + req.starts_with("CONNECT slack.com:443 HTTP/1.1\r\n"), + "got: {req:?}" + ); + assert!(req.contains("Host: slack.com:443\r\n")); + assert!(!req.contains("Proxy-Authorization")); + }, + ) + .await; + + let proxy = + ProxyTarget { host: addr.ip().to_string(), port: addr.port(), basic_auth: None }; + let mut stream = http_connect_tunnel(&proxy, "slack.com", 443).await.unwrap(); + stream.write_all(b"hello-tls").await.unwrap(); + stream.shutdown().await.unwrap(); + + let tunneled = handle.await.unwrap(); + assert_eq!(tunneled, b"hello-tls"); + } + + #[tokio::test] + #[ignore = "manual; fake-proxy edge cases (auth, error status). Run with `--ignored` if `http_connect_tunnel` changes."] + async fn http_connect_tunnel_forwards_basic_auth_and_surfaces_non_2xx() { + use tokio::io::AsyncWriteExt; + + // Basic-auth header is forwarded. + let (addr, handle) = fake_proxy("HTTP/1.1 200 OK\r\n\r\n", |req| { + assert!(req.contains("Proxy-Authorization: Basic YWxpY2U6czNjcmV0\r\n")); + }) + .await; + let proxy = ProxyTarget { + host: addr.ip().to_string(), + port: addr.port(), + basic_auth: Some("YWxpY2U6czNjcmV0".to_string()), + }; + let mut stream = http_connect_tunnel(&proxy, "slack.com", 443).await.unwrap(); + stream.shutdown().await.unwrap(); + let _ = handle.await.unwrap(); + + // Non-2xx status surfaces as an error. + let (addr, handle) = fake_proxy( + "HTTP/1.1 407 Proxy Authentication Required\r\nProxy-Authenticate: Basic\r\n\r\n", + |_| {}, + ) + .await; + let proxy = + ProxyTarget { host: addr.ip().to_string(), port: addr.port(), basic_auth: None }; + let err = http_connect_tunnel(&proxy, "slack.com", 443) + .await + .unwrap_err(); + assert!(err.to_string().contains("407")); + let _ = handle.await.unwrap(); + } +} diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index 8b60eb0d44..c7d291d6a1 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -110,6 +110,11 @@ pub struct NewFlow { pub ws_error_handler_muted: Option, #[serde(default)] pub labels: Option>, + /// Caller-intent flag (set by the CLI / git sync): when true, deploying + /// this flow must NOT delete an existing user draft at the same path. + /// Transient — never persisted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_draft_deletion: Option, } impl NewFlow { @@ -169,6 +174,13 @@ pub struct FlowValue { #[serde(default)] #[serde(skip_serializing_if = "is_default")] pub same_worker: bool, + // When the flow runs on a custom worker tag, by default that tag is propagated to + // (and overrides) every step, script and nested sub-flow. Set this to true to instead + // let steps that declare their own non-empty tag run on it; steps without their own tag + // still inherit the flow's tag. Defaults to false to preserve the historical behavior. + #[serde(default)] + #[serde(skip_serializing_if = "is_default")] + pub preserve_step_tags: bool, #[serde(flatten)] pub concurrency_settings: ConcurrencySettings, #[serde(flatten)] @@ -303,6 +315,11 @@ pub struct StopAfterIf { pub expr: String, pub skip_if_stopped: bool, pub error_message: Option, + /// When stopping with an error (`error_message` set), embed the stopping + /// step's own result inside the raised error object (as `error.result`) + /// instead of discarding it. The top-level result stays `{ "error": .. }`. + #[serde(default, skip_serializing_if = "is_false")] + pub error_include_result: bool, } #[derive(Deserialize, Serialize, Debug, Clone, Default, PartialEq)] diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index 308a326b64..75cde586e9 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -557,6 +557,13 @@ pub struct OnBehalfOf { pub const ENTRYPOINT_OVERRIDE: &str = "_ENTRYPOINT_OVERRIDE"; +/// Reserved job-arg key holding the inbound W3C `traceparent` captured from the +/// request that enqueued the job (run endpoints). It rides the `args` jsonb like +/// [`ENTRYPOINT_OVERRIDE`]; normal scripts never see it because args are bound by +/// declared parameter name. Read back at root-job completion to link the job's +/// OTLP span to the originating distributed trace (EE/OTel only). +pub const WM_TRACEPARENT: &str = "_wm_traceparent"; + /// The entrypoint override (`_ENTRYPOINT_OVERRIDE` job arg -> /// `v2_job.script_entrypoint_override`) is interpolated verbatim into /// generated worker wrappers in a code position (e.g. the NativeTS diff --git a/backend/windmill-types/src/scripts.rs b/backend/windmill-types/src/scripts.rs index e9e9337e6d..c26947d8d6 100644 --- a/backend/windmill-types/src/scripts.rs +++ b/backend/windmill-types/src/scripts.rs @@ -540,9 +540,19 @@ pub struct NewScript { pub auto_parent: Option, #[serde(default)] pub labels: Option>, + /// Caller-intent flag (set by the CLI / git sync): when true, deploying + /// this script must NOT delete an existing user draft at the same path. + /// Transient — never persisted. Deliberately excluded from `impl Hash` + /// below (it must not affect the version hash) and from the no-op + /// comparison in the deploy handler (it isn't part of what the script + /// *is*). See `is_noop_deploy_against_parent`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skip_draft_deletion: Option, } // IMPORTANT: update this Hash impl when adding fields to NewScript +// (exception: caller-intent flags like `skip_draft_deletion` are intentionally +// omitted — they must not influence the computed version hash) impl Hash for NewScript { fn hash(&self, state: &mut H) { self.path.hash(state); diff --git a/backend/windmill-worker/nsjail/download.py.config.proto b/backend/windmill-worker/nsjail/download.py.config.proto index e56ef66de0..18957bb4a5 100644 --- a/backend/windmill-worker/nsjail/download.py.config.proto +++ b/backend/windmill-worker/nsjail/download.py.config.proto @@ -5,10 +5,21 @@ hostname: "python" log_level: ERROR time_limit: 900 -rlimit_as: 2048 +# uv's --compile-bytecode spawns a bytecode-compile thread pool sized to the +# host's CPU count. Each thread reserves virtual address space for its stack, so +# on high-core machines the aggregate overruns a low rlimit_as and installs fail +# intermittently with "OS can't spawn worker thread: Resource temporarily +# unavailable (os error 11)" / "memory allocation failed". A low cap (was 2048) +# is the address-space companion to the fd exhaustion fixed below; raised well +# above the run sandbox's 4096 to give the compile pool headroom on large nodes. +rlimit_as: 8192 rlimit_cpu: 1000 rlimit_fsize: 1024 -rlimit_nofile: 64 +# uv's --compile-bytecode spawns a Python interpreter that compiles .py files +# with parallelism scaling to the host's CPU count, opening many fds at once. +# A low cap (was 64) is exhausted on high-core machines -> "Too many open files". +# Matches the runtime configs (run.python3/run.ansible) which already use 10000. +rlimit_nofile: 10000 envar: "HOME=/user" envar: "LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH" diff --git a/backend/windmill-worker/nsjail/download_deps.py.sh b/backend/windmill-worker/nsjail/download_deps.py.sh index ce056e7e76..13fc00ddf9 100755 --- a/backend/windmill-worker/nsjail/download_deps.py.sh +++ b/backend/windmill-worker/nsjail/download_deps.py.sh @@ -31,6 +31,7 @@ $PY_PATH $INDEX_URL_ARG $EXTRA_INDEX_URL_ARG $TRUSTED_HOST_ARG --system --reinstall +--compile-bytecode " echo $CMD diff --git a/backend/windmill-worker/nsjail/run.docker.config.proto b/backend/windmill-worker/nsjail/run.docker.config.proto new file mode 100644 index 0000000000..a2da459fbe --- /dev/null +++ b/backend/windmill-worker/nsjail/run.docker.config.proto @@ -0,0 +1,103 @@ +name: "docker v2 run" + +mode: ONCE +hostname: "container" +log_level: ERROR +time_limit: {TIMEOUT} + +disable_rl: true + +cwd: {WORKDIR} + +clone_newnet: false +clone_newuser: {CLONE_NEWUSER} + +skip_setsid: true +keep_caps: false +# keep_env forwards nsjail's OWN process env (only windmill-trusted keys: reserved +# vars + proxy) to the child. The image's attacker-controlled Env is delivered via +# the envar directives below — NEVER nsjail's process env, so a hostile image cannot +# set LD_PRELOAD/LD_LIBRARY_PATH/LD_AUDIT on the nsjail binary itself. +keep_env: true +mount_proc: true + +# Image Env (+ PATH/HOME fallbacks), proto-escaped. Applied to the child only. +{ENVARS} + +# Map uid/gid 0 inside the jail to the (single) worker user outside. The image's +# rootfs is extracted as the worker user, so a root process inside the container +# owns the rootfs and runs like a normal "root in container" — without any subuid +# range. Multi-uid images are a later enhancement (newuidmap range). +uidmap { + inside_id: "0" + outside_id: "" + count: 1 +} +gidmap { + inside_id: "0" + outside_id: "" + count: 1 +} + +# The image's root filesystem, bound one top-level entry at a time. Binding the +# whole rootfs at "/" trips nsjail's read-only remount of its base root in a +# rootless userns ("mount(... MS_REMOUNT|MS_BIND|MS_RDONLY): Operation not +# permitted"); per-entry binds sit as rw submounts under nsjail's own tmpfs root +# and avoid it. Generated from the extracted rootfs. +{ROOTFS_MOUNTS} + +# Pseudo-filesystems the image expects. /tmp honors the same instance settings as +# every other nsjail job (nsjail_tmp_backing tmpfs/disk, nsjail_tmpfs_size_mb); +# /dev gets the standard nodes; /proc comes from mount_proc (the jail's own pid ns). +{TMP_MOUNT_BLOCK} + +mount { + src: "/dev/null" + dst: "/dev/null" + is_bind: true + rw: true +} + +mount { + src: "/dev/zero" + dst: "/dev/zero" + is_bind: true + rw: true +} + +mount { + src: "/dev/random" + dst: "/dev/random" + is_bind: true +} + +mount { + src: "/dev/urandom" + dst: "/dev/urandom" + is_bind: true +} + +# Host DNS config layered over the image's /etc so name resolution works on the +# job's network (mandatory:false: some minimal images have no /etc files to shadow). +mount { + src: "/etc/resolv.conf" + dst: "/etc/resolv.conf" + is_bind: true + mandatory: false +} + +mount { + src: "/etc/hosts" + dst: "/etc/hosts" + is_bind: true + mandatory: false +} + +# `# volume` mounts (and the same-worker /tmp/shared folder). Placed after the +# rootfs binds and the tmpfs /tmp so a volume target overrides any colliding image +# path and isn't shadowed by the tmpfs. Empty when there are no volumes. +{SHARED_MOUNT} + +iface_no_lo: true + +#{DEV} diff --git a/backend/windmill-worker/src/ai/mod.rs b/backend/windmill-worker/src/ai/mod.rs index 24e877ab13..ad0986bbea 100644 --- a/backend/windmill-worker/src/ai/mod.rs +++ b/backend/windmill-worker/src/ai/mod.rs @@ -1,6 +1,6 @@ // AI executor module structure // This module will contain all AI-related execution logic -pub mod query_builder; +pub mod stream_event_processor; pub mod tools; pub mod utils; diff --git a/backend/windmill-worker/src/ai/query_builder.rs b/backend/windmill-worker/src/ai/stream_event_processor.rs similarity index 100% rename from backend/windmill-worker/src/ai/query_builder.rs rename to backend/windmill-worker/src/ai/stream_event_processor.rs diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index fcc9cdf3c9..11100d4888 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -1,4 +1,4 @@ -use crate::ai::query_builder::StreamEventProcessor; +use crate::ai::stream_event_processor::StreamEventProcessor; use crate::ai::utils::{ add_message_to_conversation, execute_mcp_tool, get_step_name_from_flow, is_completed_input_transform, update_flow_status_module_with_actions, diff --git a/backend/windmill-worker/src/ai/utils.rs b/backend/windmill-worker/src/ai/utils.rs index 74e75ef0a5..353bab17a0 100644 --- a/backend/windmill-worker/src/ai/utils.rs +++ b/backend/windmill-worker/src/ai/utils.rs @@ -7,6 +7,8 @@ use std::{ }; use uuid::Uuid; use windmill_ai::types::*; +#[cfg(feature = "mcp")] +use windmill_common::client::AuthedClient; use windmill_common::flows::FlowModuleValue; use windmill_common::{ db::DB, @@ -546,7 +548,7 @@ pub async fn load_mcp_tools( db: &DB, workspace_id: &str, mcp_configs: Vec, - auth_token: &str, + client: &AuthedClient, ) -> Result<(HashMap>, Vec), Error> { let mut all_mcp_tools = Vec::new(); let mut mcp_clients = HashMap::new(); @@ -573,27 +575,47 @@ pub async fn load_mcp_tools( let resource_name = mcp_resource.name.clone(); - // Check if token needs refresh before creating MCP client - if let Some(ref token_path) = mcp_resource.token { + // Resolve the token through the job's permissioned (RLS + audit) path so + // the AI agent cannot exfiltrate a secret its identity is not allowed to + // read by pointing an MCP resource's token at it. + let token = if let Some(ref token_path) = mcp_resource.token { let token_var_path = token_path.trim_start_matches("$var:"); - if let Err(e) = - refresh_token_if_expired(db, workspace_id, token_var_path, auth_token).await - { - tracing::warn!( - "Failed to refresh token for MCP resource {}: {}. Proceeding with possibly expired token.", - resource_name, e - ); + if token_var_path.trim().is_empty() { + None + } else { + // Refresh first (best-effort) so the value we read is current. + if let Err(e) = + refresh_token_if_expired(db, workspace_id, token_var_path, &client.token).await + { + tracing::warn!( + "Failed to refresh token for MCP resource {}: {}. Proceeding with possibly expired token.", + resource_name, e + ); + } + Some( + client + .get_variable_value(token_var_path) + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to resolve token variable {} for MCP resource {}: {}", + token_var_path, resource_name, e + )) + })?, + ) } - } + } else { + None + }; // Create new MCP client for this execution tracing::debug!("Creating fresh MCP client for {}", resource_name); - let client = McpClient::from_resource(mcp_resource, db, workspace_id) + let mcp_conn = McpClient::from_resource(mcp_resource, token) .await .context("Failed to create MCP client")?; // Get raw MCP tools from client - let raw_mcp_tools = client.available_tools(); + let raw_mcp_tools = mcp_conn.available_tools(); // Convert to Windmill Tool format let converted_tools = @@ -616,7 +638,7 @@ pub async fn load_mcp_tools( all_mcp_tools.extend(filtered_tools); // Store client for later use and cleanup - let mcp_client = Arc::new(client); + let mcp_client = Arc::new(mcp_conn); mcp_clients.insert(resource_name, mcp_client); } @@ -663,7 +685,7 @@ pub async fn load_mcp_tools( _db: &DB, _workspace_id: &str, _mcp_configs: Vec, - _auth_token: &str, + _client: &windmill_common::client::AuthedClient, ) -> Result<(HashMap>, Vec), Error> { Ok((HashMap::new(), Vec::new())) } diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index dae4f277f2..2591cc6495 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -45,7 +45,7 @@ use windmill_common::{ use windmill_queue::{cancel_single_job, CanceledBy, MiniPulledJob}; use crate::{ - ai::query_builder::StreamEventProcessor, + ai::stream_event_processor::StreamEventProcessor, common::{build_args_map, resolve_job_timeout, OccupancyMetrics, StreamNotifier}, handle_child::{run_future_with_polling_update_job_poller_graceful, GracefulPollOutcome}, }; @@ -432,7 +432,7 @@ pub async fn handle_ai_agent_job( let mcp_clients = if !mcp_configs.is_empty() { let (clients, mcp_tools) = - load_mcp_tools(db, &job.workspace_id, mcp_configs, &client.token).await?; + load_mcp_tools(db, &job.workspace_id, mcp_configs, client).await?; tools.extend(mcp_tools); clients } else { @@ -586,16 +586,12 @@ pub async fn run_agent( tool_abort_handles: ToolAbortHandles, ) -> error::Result> { let output_type = args.output_type.as_ref().unwrap_or(&OutputType::Text); - // Skip get_base_url for Bedrock - it uses SDK directly, not HTTP - let base_url = if args.provider.kind == AIProvider::AWSBedrock { - String::new() - } else { - args.provider.get_base_url(db).await? - }; - let api_key = args.provider.get_api_key().unwrap_or(""); + let credentials = args.provider.to_provider_credentials(db).await?; + let base_url = &credentials.base_url; + let api_key = credentials.api_key.as_deref().unwrap_or(""); // Create the query builder for the provider - let query_builder = create_query_builder(&args.provider); + let query_builder = create_query_builder(&credentials); // Initialize messages let mut messages = @@ -859,12 +855,12 @@ pub async fn run_agent( } // Handle AWS Bedrock provider specially using the official SDK - let parsed = if args.provider.kind == AIProvider::AWSBedrock { + let parsed = if credentials.provider == AIProvider::AWSBedrock { #[cfg(feature = "bedrock")] { - let region = args - .provider - .get_region() + let region = credentials + .region + .as_deref() .unwrap_or(windmill_ai::ai_providers::USE_ENV_REGION); // Use Bedrock SDK via dedicated query builder windmill_ai::providers::bedrock::BedrockQueryBuilder::default() @@ -880,9 +876,9 @@ pub async fn run_agent( client, &job.workspace_id, structured_output_tool_name.as_deref(), - args.provider.get_aws_access_key_id(), - args.provider.get_aws_secret_access_key(), - args.provider.get_aws_session_token(), + credentials.aws_access_key_id.as_deref(), + credentials.aws_secret_access_key.as_deref(), + credentials.aws_session_token.as_deref(), ) .await? } @@ -913,14 +909,14 @@ pub async fn run_agent( .await?; let endpoint = - query_builder.get_endpoint(&base_url, args.provider.get_model(), output_type); - let auth_headers = query_builder.get_auth_headers(api_key, &base_url, output_type); + query_builder.get_endpoint(base_url, args.provider.get_model(), output_type); + let auth_headers = query_builder.get_auth_headers(api_key, base_url, output_type); let timeout = resolve_job_timeout(conn, &job.workspace_id, job.id, job.timeout) .await .0; - let resource_headers = args.provider.get_headers(); + let resource_headers = &credentials.custom_headers; // Helper to build HTTP request with headers let build_http_request = |body: String| { diff --git a/backend/windmill-worker/src/bash_executor.rs b/backend/windmill-worker/src/bash_executor.rs index 516c75fdba..4c470b8dfc 100644 --- a/backend/windmill-worker/src/bash_executor.rs +++ b/backend/windmill-worker/src/bash_executor.rs @@ -40,9 +40,9 @@ use crate::handle_child::run_future_with_polling_update_job_poller; use crate::{ common::{ - build_args_map, build_command_with_isolation, get_reserved_variables, read_file, - read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, start_child_process, - OccupancyMetrics, DEV_CONF_NSJAIL, + build_args_map, build_command_with_isolation, get_reserved_variables, raw_to_string, + read_file, read_file_content, resolve_nsjail_timeout, resolve_nsjail_tmp_mount_block, + start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, }, get_proxy_envs_for_lang, handle_child::handle_child, @@ -57,14 +57,6 @@ lazy_static::lazy_static! { pub static ref ANSI_ESCAPE_RE: Regex = Regex::new(r"\x1b\[[0-9;]*m").unwrap(); } -fn raw_to_string(x: &str) -> String { - match serde_json::from_str::(x) { - Ok(serde_json::Value::String(x)) => x, - Ok(x) => serde_json::to_string(&x).unwrap_or_else(|_| String::new()), - _ => String::new(), - } -} - #[tracing::instrument(level = "trace", skip_all)] pub async fn handle_bash_job( mem_peak: &mut i32, @@ -84,6 +76,28 @@ pub async fn handle_bash_job( ) -> Result, Error> { let annotation = windmill_common::worker::BashAnnotations::parse(&content); + // `# sandbox ` selects the daemonless, nsjail-sandboxed container runtime + // (extract the image's rootfs + run it inside the job's sandbox). A bare + // `# sandbox` keeps the plain nsjail-bash modifier; `# docker` keeps v1 (dind). + if let Some(image) = windmill_common::worker::BashAnnotations::sandbox_image(content) { + return crate::docker_v2::handle_docker_v2_job( + &image, + mem_peak, + canceled_by, + job, + conn, + client, + parent_runnable_path, + content, + job_dir, + shared_mount, + base_internal_url, + worker_name, + occupancy_metrics, + ) + .await; + } + // Check if sandbox annotation is used but nsjail is not available if annotation.sandbox && NSJAIL_AVAILABLE.is_none() { return Err(Error::ExecutionErr( diff --git a/backend/windmill-worker/src/common.rs b/backend/windmill-worker/src/common.rs index 9864093cd0..1bef9e4c6d 100644 --- a/backend/windmill-worker/src/common.rs +++ b/backend/windmill-worker/src/common.rs @@ -68,6 +68,16 @@ mount { #[cfg(not(debug_assertions))] pub const DEV_CONF_NSJAIL: &str = ""; +/// Turn a JSON value into the string a shell/CLI arg should receive: a JSON string +/// becomes its inner value, anything else is re-serialized compactly. +pub(crate) fn raw_to_string(x: &str) -> String { + match serde_json::from_str::(x) { + Ok(serde_json::Value::String(x)) => x, + Ok(x) => serde_json::to_string(&x).unwrap_or_else(|_| String::new()), + _ => String::new(), + } +} + pub async fn build_args_map<'a>( job: &'a MiniPulledJob, client: &AuthedClient, diff --git a/backend/windmill-worker/src/csharp_executor.rs b/backend/windmill-worker/src/csharp_executor.rs index 70df9fb4e0..944236cf2b 100644 --- a/backend/windmill-worker/src/csharp_executor.rs +++ b/backend/windmill-worker/src/csharp_executor.rs @@ -56,9 +56,13 @@ const DOTNET_ROOT_DEFAULT: &str = "C:\\Program Files\\dotnet"; #[cfg(unix)] const DOTNET_ROOT_DEFAULT: &str = "/usr/share/dotnet"; +#[cfg(feature = "csharp")] +const DOTNET_TARGET_FRAMEWORK_DEFAULT: &str = "net9.0"; + #[cfg(feature = "csharp")] lazy_static::lazy_static! { static ref DOTNET_ROOT: String = std::env::var("DOTNET_ROOT").unwrap_or_else(|_| DOTNET_ROOT_DEFAULT.to_string()); + static ref DOTNET_TARGET_FRAMEWORK: String = std::env::var("DOTNET_TARGET_FRAMEWORK").unwrap_or_else(|_| DOTNET_TARGET_FRAMEWORK_DEFAULT.to_string()); } #[cfg(feature = "csharp")] @@ -212,6 +216,7 @@ fn gen_cs_proj( ) }; + let target_framework = DOTNET_TARGET_FRAMEWORK.as_str(); write_file( job_dir, "Main.csproj", @@ -219,7 +224,7 @@ fn gen_cs_proj( r#" Exe - net9.0 + {target_framework} enable WindmillScriptCSharpInternal.Wrapper true @@ -510,9 +515,10 @@ pub async fn handle_csharp_job( let ws_suffix = crate::workspace_registry_cache_suffix(&job.workspace_id).await; let mut hash = calculate_hash(&format!( - "{}{}", + "{}{}{}", inner_content, - requirements_o.unwrap_or(&String::new()) + requirements_o.unwrap_or(&String::new()), + DOTNET_TARGET_FRAMEWORK.as_str() )); hash.push_str(&ws_suffix); let bin_path = format!("{}/{hash}", *CSHARP_CACHE_DIR); diff --git a/backend/windmill-worker/src/docker_v2.rs b/backend/windmill-worker/src/docker_v2.rs new file mode 100644 index 0000000000..3a4936e85d --- /dev/null +++ b/backend/windmill-worker/src/docker_v2.rs @@ -0,0 +1,892 @@ +//! Sandboxed container runtime: run a container as a sandboxed subprogram of the job. +//! +//! Unlike the legacy `# docker` (dind/daemon) path, this has no daemon and no Docker +//! API. It splits *pull* from *run*: +//! +//! 1. **pull/extract** (`crane`, no daemon/store/root): materialize the image's root +//! filesystem into `{job_dir}/rootfs` and read its OCI config +//! (Env/Cmd/Entrypoint/WorkingDir), via a digest-keyed rootfs cache. +//! 2. **run** (the job's own nsjail sandbox): execute the image command with the +//! extracted rootfs bound in as the new root, so the container inherits exactly +//! the job's confinement (filesystem mask, pid namespace, network, uid) and can't +//! escape past what the job itself can reach. +//! +//! Selected by `# sandbox ` (a bare `# sandbox` keeps plain nsjail-bash; +//! `# docker` keeps the v1 daemon path). The script body runs inside the image via +//! `/bin/sh`; an empty body runs the image's ENTRYPOINT/CMD. + +use std::process::Stdio; + +use serde::Deserialize; +use serde_json::{json, value::RawValue}; +use sqlx::types::Json; +use tokio::process::Command; + +use windmill_common::{client::AuthedClient, scripts::ScriptLang}; +use windmill_common::{ + error::Error, + worker::{to_raw_value, write_file, Connection}, +}; + +use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; + +use crate::{ + common::{ + build_args_map, get_reserved_variables, raw_to_string, resolve_nsjail_timeout, + resolve_nsjail_tmp_mount_block, start_child_process, OccupancyMetrics, DEV_CONF_NSJAIL, + }, + get_proxy_envs_for_lang, + handle_child::handle_child, + DISABLE_NUSER, NSJAIL_AVAILABLE, NSJAIL_PATH, SANDBOX_IMAGE_CACHE_MAX_MB, + SANDBOX_IMAGE_DEFAULT_REGISTRY, SANDBOX_IMAGE_MAX_SIZE_MB, SANDBOX_IMAGE_PULL_POLICY, + SANDBOX_REGISTRY_AUTH, +}; + +const NSJAIL_CONFIG_RUN_DOCKER_CONTENT: &str = include_str!("../nsjail/run.docker.config.proto"); + +const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; + +lazy_static::lazy_static! { + /// `crane` (google/go-containerregistry) — pulls + flattens an image to a rootfs + /// without a daemon, store, root, or privileged container. We never *run* the + /// image via crane (nsjail does the run), so a full container engine is overkill. + pub static ref CRANE_PATH: String = + std::env::var("CRANE_PATH").unwrap_or_else(|_| "crane".to_string()); + + /// `linux/` for the worker, pinned on every crane call so multi-arch images + /// resolve deterministically (and `crane manifest` returns a real manifest, not an + /// index). + static ref CRANE_PLATFORM: String = format!("linux/{}", match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + other => other, + }); + + /// Content-addressed cache of flattened rootfs tars, keyed by image digest. crane + /// has no persistent store, so this is what gives cross-job dedup (and, since it's + /// digest-keyed, automatic freshness when a moving tag changes). + static ref ROOTFS_CACHE_DIR: String = + format!("{}sandbox_rootfs", *windmill_common::worker::ROOT_CACHE_DIR); +} + +/// Guards against overlapping cache-eviction passes across concurrent jobs. +static EVICTION_RUNNING: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false); + +/// `sandbox_image_pull_policy` instance setting. With the digest-keyed cache, `newer` +/// (default) re-resolves the digest each job (cheap manifest fetch) so moving tags +/// like `:latest` stay fresh while unchanged digests reuse the cache. `missing` skips +/// the registry when a digest is already cached for the ref; `never` only uses the +/// cache (errors if absent); `always` == `newer` here. +async fn pull_policy() -> String { + let p = SANDBOX_IMAGE_PULL_POLICY.read().await.clone(); + match p.as_deref() { + Some(p @ ("missing" | "newer" | "always" | "never")) => p.to_string(), + _ => "newer".to_string(), + } +} + +/// `sandbox_image_max_size_mb` instance setting; 0 (or unset/non-positive) = no limit. +async fn max_image_size_mb() -> u64 { + SANDBOX_IMAGE_MAX_SIZE_MB.read().await.unwrap_or(0).max(0) as u64 +} + +/// `sandbox_image_cache_max_mb` instance setting; 0 (or unset/non-positive) = unbounded. +async fn image_cache_max_mb() -> u64 { + SANDBOX_IMAGE_CACHE_MAX_MB.read().await.unwrap_or(0).max(0) as u64 +} + +/// A ref is registry-qualified if the component before the first `/` looks like a +/// host (contains `.` or `:`, or is `localhost`). Bare repos (`alpine`, +/// `alpine:latest`, `myorg/img`) are unqualified and resolve against docker.io — +/// or the configured default registry. +fn registry_qualified(image: &str) -> bool { + match image.split_once('/') { + None => false, + Some((first, _)) => first.contains('.') || first.contains(':') || first == "localhost", + } +} + +/// Prepend the `sandbox_image_default_registry` instance setting to unqualified image +/// refs (fully-qualified refs are left untouched). +async fn resolve_image_ref(image: &str) -> String { + let registry = SANDBOX_IMAGE_DEFAULT_REGISTRY.read().await.clone(); + match registry { + Some(registry) if !registry.trim().is_empty() && !registry_qualified(image) => { + format!("{}/{}", registry.trim().trim_end_matches('/'), image) + } + _ => image.to_string(), + } +} + +/// If the `sandbox_registry_auth` instance setting holds a docker `auth.json` blob, +/// write it to a per-job `DOCKER_CONFIG` dir (`{job_dir}/.docker/config.json`, 0600, +/// removed with the job) and return the dir to pass to crane via `DOCKER_CONFIG`. +/// Returns `None` when unset. (docker `config.json` and podman `auth.json` share the +/// `{"auths": {...}}` schema, so the same blob works.) +async fn write_auth_dir(job_dir: &str) -> Result, Error> { + let auth = SANDBOX_REGISTRY_AUTH.read().await.clone(); + let Some(auth) = auth.filter(|a| !a.trim().is_empty()) else { + return Ok(None); + }; + let dir = format!("{job_dir}/.docker"); + tokio::fs::create_dir_all(&dir).await?; + let path = format!("{dir}/config.json"); + // Create 0600 from the start (registry credentials) — no world-readable window. + #[cfg(unix)] + { + use tokio::io::AsyncWriteExt; + let mut f = tokio::fs::OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) + .open(&path) + .await?; + f.write_all(auth.as_bytes()).await?; + } + #[cfg(not(unix))] + tokio::fs::write(&path, auth).await?; + Ok(Some(dir)) +} + +/// The subset of an image's OCI config we apply to the run. +#[derive(Deserialize, Default, Debug)] +struct OciConfig { + #[serde(default, rename = "Env")] + env: Option>, + #[serde(default, rename = "Cmd")] + cmd: Option>, + #[serde(default, rename = "Entrypoint")] + entrypoint: Option>, + #[serde(default, rename = "WorkingDir")] + working_dir: Option, +} + +/// Quote a string as a protobuf-text-format string literal for safe inclusion in +/// the nsjail config. Image-controlled values (mount srcs/dsts, symlink targets, +/// WorkingDir) flow into the config, so they MUST be escaped — an unescaped `"` or +/// newline would otherwise let a hostile image config inject arbitrary nsjail +/// directives and break out of the sandbox. Every byte is emitted as a printable +/// ASCII char or a valid protobuf escape (`\"`, `\\`, `\n`/`\r`/`\t`, or 3-digit +/// octal `\NNN` for control/non-ASCII bytes), so the result always parses. +fn proto_str(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for &b in s.as_bytes() { + match b { + b'"' => out.push_str("\\\""), + b'\\' => out.push_str("\\\\"), + b'\n' => out.push_str("\\n"), + b'\r' => out.push_str("\\r"), + b'\t' => out.push_str("\\t"), + 0x20..=0x7e => out.push(b as char), + _ => out.push_str(&format!("\\{b:03o}")), + } + } + out.push('"'); + out +} + +/// Render container env vars as nsjail `envar:` directives (one per line). Each +/// `KEY=VALUE` is proto-escaped, so image-controlled keys/values can neither break +/// the config nor reach nsjail's own process environment. +fn render_envars(env: &[(String, String)]) -> String { + env.iter() + .map(|(k, v)| format!("envar: {}", proto_str(&format!("{k}={v}")))) + .collect::>() + .join("\n") +} + +/// Run `crane` with the optional per-job `DOCKER_CONFIG` auth dir. +async fn crane(args: &[&str], auth_dir: Option<&str>) -> Result { + let mut cmd = Command::new(CRANE_PATH.as_str()); + cmd.args(args); + if let Some(dir) = auth_dir { + cmd.env("DOCKER_CONFIG", dir); + } + cmd.output() + .await + .map_err(|e| Error::ExecutionErr(format!("failed to run crane {}: {e}", args.join(" ")))) +} + +/// `crane config` output: the image config (Env/Cmd/Entrypoint/WorkingDir) is nested +/// under the top-level `config` key. +#[derive(Deserialize, Default)] +struct CraneConfig { + #[serde(default)] + config: OciConfig, +} + +/// Filesystem-safe cache key for a digest (`sha256:ab..` -> `sha256_ab..`). +fn digest_key(digest: &str) -> String { + digest.replace([':', '/'], "_") +} + +/// Filesystem-safe, collision-resistant key for an image ref (the ref->digest file). +fn ref_key(image: &str) -> String { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + image.hash(&mut h); + let safe: String = image + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { + c + } else { + '_' + } + }) + .collect(); + let safe = &safe[safe.len().saturating_sub(80)..]; + format!("{safe}_{:016x}", h.finish()) +} + +/// Resolve the image ref to a content digest, honoring the pull policy + a ref->digest +/// cache. `missing`/`never` reuse a cached digest without hitting the registry (`never` +/// errors if absent); `newer`/`always` always re-resolve via `crane digest`. +async fn resolve_digest( + image: &str, + policy: &str, + auth_dir: Option<&str>, +) -> Result { + let refs_dir = format!("{}/refs", *ROOTFS_CACHE_DIR); + let ref_file = format!("{refs_dir}/{}", ref_key(image)); + + if matches!(policy, "missing" | "never") { + if let Ok(d) = tokio::fs::read_to_string(&ref_file).await { + let d = d.trim().to_string(); + if !d.is_empty() + && tokio::fs::metadata(format!("{}/{}.tar", *ROOTFS_CACHE_DIR, digest_key(&d))) + .await + .is_ok() + { + return Ok(d); + } + } + if policy == "never" { + return Err(Error::ExecutionErr(format!( + "image {image} is not in the sandbox cache and SANDBOX_IMAGE_PULL_POLICY=never" + ))); + } + } + + let out = crane(&["digest", "--platform", &CRANE_PLATFORM, image], auth_dir).await?; + if !out.status.success() { + return Err(Error::ExecutionErr(format!( + "failed to resolve image {image}: {}", + String::from_utf8_lossy(&out.stderr) + ))); + } + let digest = String::from_utf8_lossy(&out.stdout).trim().to_string(); + let _ = tokio::fs::create_dir_all(&refs_dir).await; + // tmp+rename so a concurrent `missing`/`never` reader never sees a torn ref file. + let ref_tmp = format!("{ref_file}.tmp.{}", digest_key(&digest)); + if tokio::fs::write(&ref_tmp, &digest).await.is_ok() { + let _ = tokio::fs::rename(&ref_tmp, &ref_file).await; + } + Ok(digest) +} + +/// Pull (if not cached) and unpack `image` into `{job_dir}/rootfs`, returning its OCI +/// config. Uses `crane export`/`config` (no daemon/store/root) with a content-addressed +/// rootfs+config cache keyed by digest for cross-job dedup. +async fn extract_image(image: &str, job_dir: &str) -> Result { + let rootfs = format!("{job_dir}/rootfs"); + tokio::fs::create_dir_all(&rootfs).await?; + tokio::fs::create_dir_all(&*ROOTFS_CACHE_DIR).await?; + + let auth_dir = write_auth_dir(job_dir).await?; + let auth = auth_dir.as_deref(); + let digest = resolve_digest(image, &pull_policy().await, auth).await?; + // Pin every subsequent fetch to the resolved digest, not the (mutable) tag, so the + // content can't diverge from the digest we cache under if the tag moves mid-fetch. + let pinned = format!("{}@{digest}", image.split('@').next().unwrap_or(image)); + let key = digest_key(&digest); + let tar = format!("{}/{key}.tar", *ROOTFS_CACHE_DIR); + let cfg = format!("{}/{key}.json", *ROOTFS_CACHE_DIR); + let size_file = format!("{}/{key}.size", *ROOTFS_CACHE_DIR); + let token = std::path::Path::new(job_dir) + .file_name() + .map(|x| x.to_string_lossy().into_owned()) + .unwrap_or_default(); + + // Enforce the size cap on EVERY job (not just cache misses), using a cached size so + // a cache reuse needs no registry call — lowering the limit rejects cached images too. + enforce_image_size_limit(&pinned, &size_file, auth).await?; + + // Materialize the flattened rootfs. The cache tar can be evicted concurrently, so up + // to two attempts: hardlink the cache tar into the job dir (pins the inode against + // eviction) before extracting; if it vanished first, re-fetch. + let job_tar = format!("{job_dir}/rootfs.tar"); + for attempt in 0..2 { + if tokio::fs::metadata(&tar).await.is_err() { + fetch_into_cache(&pinned, &tar, &cfg, &token, auth).await?; + } + let config = read_oci_config(&cfg).await; + let _ = tokio::fs::remove_file(&job_tar).await; + // Stage the cache tar into the job dir so concurrent eviction can't unlink it out + // from under `tar -xf`. Prefer a hardlink (free), but the cache volume and the job + // dir are usually on *different* filesystems in the shipped deployments (the cache + // is its own volume/PVC) — there `hard_link` returns EXDEV, so fall back to a copy. + // `copy` reads through the source inode, so an eviction mid-copy still completes. + let staged = match tokio::fs::hard_link(&tar, &job_tar).await { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(e), // vanished — re-fetch + Err(_) => tokio::fs::copy(&tar, &job_tar).await.map(|_| ()), + }; + match staged { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound && attempt == 0 => { + continue; // evicted between the check and the staging — re-fetch + } + Err(e) => return Err(Error::ExecutionErr(format!("failed to stage rootfs: {e}"))), + } + // Extract as the worker user (rootfs is worker-owned → uid 0 inside the jail). + let untar = Command::new("tar") + .args(["-xf", &job_tar, "-C", &rootfs]) + .output() + .await + .map_err(|e| Error::ExecutionErr(format!("failed to run tar: {e}")))?; + let _ = tokio::fs::remove_file(&job_tar).await; + if !untar.status.success() { + return Err(Error::ExecutionErr(format!( + "failed to unpack image {image}: {}", + String::from_utf8_lossy(&untar.stderr) + ))); + } + return Ok(config); + } + Err(Error::ExecutionErr(format!( + "failed to materialize rootfs for {image} (cache evicted twice)" + ))) +} + +/// Fetch + flatten `pinned` (a `name@digest` ref) into the cache: export the rootfs tar +/// and write the OCI config sidecar, both via tmp+rename so concurrent readers never see +/// a torn file. The tar is published last (a present tar implies a present config). +async fn fetch_into_cache( + pinned: &str, + tar: &str, + cfg: &str, + token: &str, + auth: Option<&str>, +) -> Result<(), Error> { + let tar_tmp = format!("{tar}.tmp.{token}"); + let cfg_tmp = format!("{cfg}.tmp.{token}"); + let exported = crane( + &["export", "--platform", &CRANE_PLATFORM, pinned, &tar_tmp], + auth, + ) + .await?; + if !exported.status.success() { + let _ = tokio::fs::remove_file(&tar_tmp).await; + return Err(Error::ExecutionErr(format!( + "failed to export image {pinned}: {}", + String::from_utf8_lossy(&exported.stderr) + ))); + } + let config = crane(&["config", "--platform", &CRANE_PLATFORM, pinned], auth).await?; + if !config.status.success() { + let _ = tokio::fs::remove_file(&tar_tmp).await; + return Err(Error::ExecutionErr(format!( + "failed to read image {pinned} config: {}", + String::from_utf8_lossy(&config.stderr) + ))); + } + let _ = tokio::fs::write(&cfg_tmp, &config.stdout).await; + let _ = tokio::fs::rename(&cfg_tmp, cfg).await; + tokio::fs::rename(&tar_tmp, tar).await?; + Ok(()) +} + +/// Read the cached OCI config (Env/Cmd/Entrypoint/WorkingDir); tolerate a missing or torn +/// sidecar by falling back to defaults (the run still works off the body + image FS). +async fn read_oci_config(cfg: &str) -> OciConfig { + match tokio::fs::read(cfg).await { + Ok(bytes) => serde_json::from_slice::(&bytes) + .map(|c| c.config) + .unwrap_or_default(), + Err(_) => OciConfig::default(), + } +} + +/// Manifest descriptor (`crane manifest`), for the pre-download size guard. +#[derive(Deserialize, Default)] +struct CraneDescriptor { + #[serde(default)] + size: u64, +} +#[derive(Deserialize, Default)] +struct CraneManifest { + #[serde(default)] + layers: Vec, + #[serde(default)] + config: CraneDescriptor, +} + +/// Reject the image if its compressed download size exceeds `SANDBOX_IMAGE_MAX_SIZE_MB`. +/// Runs on EVERY job (so lowering the limit rejects already-cached images too); the size +/// is read from a `{digest}.size` sidecar when present (no registry call on cache reuse) +/// and otherwise fetched once via `crane manifest` (before any layer download) and cached. +/// No-op when the limit is 0 (unset). +async fn enforce_image_size_limit( + pinned: &str, + size_file: &str, + auth_dir: Option<&str>, +) -> Result<(), Error> { + let max = max_image_size_mb().await; + if max == 0 { + return Ok(()); + } + let bytes = match tokio::fs::read_to_string(size_file) + .await + .ok() + .and_then(|s| s.trim().parse::().ok()) + { + Some(b) => b, + None => { + let out = crane( + &["manifest", "--platform", &CRANE_PLATFORM, pinned], + auth_dir, + ) + .await?; + if !out.status.success() { + // Don't silently bypass the guard — surface it so an operator can see the + // size limit isn't being enforced for this image. + tracing::warn!( + "sandbox image size guard: `crane manifest {pinned}` failed, not enforcing \ + SANDBOX_IMAGE_MAX_SIZE_MB: {}", + String::from_utf8_lossy(&out.stderr) + ); + return Ok(()); + } + let manifest: CraneManifest = match serde_json::from_slice(&out.stdout) { + Ok(m) => m, + Err(e) => { + tracing::warn!( + "sandbox image size guard: cannot parse `crane manifest` json: {e}" + ); + return Ok(()); + } + }; + let b = manifest.config.size + manifest.layers.iter().map(|l| l.size).sum::(); + let _ = tokio::fs::write(size_file, b.to_string()).await; + b + } + }; + let mb = bytes / 1_000_000; + if mb > max { + return Err(Error::ExecutionErr(format!( + "image {pinned} is {mb} MB (compressed), over the SANDBOX_IMAGE_MAX_SIZE_MB limit of {max} MB" + ))); + } + Ok(()) +} + +/// Best-effort eviction: while the cached rootfs tars exceed `SANDBOX_IMAGE_CACHE_MAX_MB`, +/// remove the oldest by mtime (creation order — tars are write-once, cache hits don't +/// touch mtime). No-op when the limit is 0 (unset). Skipped if another pass is already +/// running. The per-job extracted rootfs lives in the job dir (cleaned with the job), so +/// only the content-addressed tar+config+size cache is pruned. Also sweeps orphaned +/// `*.tmp.*` files left by a crashed mid-export. +async fn enforce_image_cache_limit() { + use std::sync::atomic::Ordering; + let max_mb = image_cache_max_mb().await; + if max_mb == 0 { + return; + } + if EVICTION_RUNNING + .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) + .is_err() + { + return; + } + // Reset the guard on every exit path (incl. an early `break` or a panic), so a + // stuck flag can never permanently disable eviction until a worker restart. + struct ResetOnDrop; + impl Drop for ResetOnDrop { + fn drop(&mut self) { + EVICTION_RUNNING.store(false, std::sync::atomic::Ordering::SeqCst); + } + } + let _reset = ResetOnDrop; + let max_bytes = max_mb.saturating_mul(1_000_000); + + // (path, size, mtime) for every cached rootfs tar; also sweep orphaned tmp files. + async fn list_tars() -> Vec<(std::path::PathBuf, u64, std::time::SystemTime)> { + let mut out = Vec::new(); + let Ok(mut rd) = tokio::fs::read_dir(&*ROOTFS_CACHE_DIR).await else { + return out; + }; + while let Ok(Some(e)) = rd.next_entry().await { + let p = e.path(); + let name = e.file_name(); + let name = name.to_string_lossy(); + // Reclaim leftover `*.tmp.` files from a crashed mid-export. + if name.contains(".tmp.") { + let _ = tokio::fs::remove_file(&p).await; + continue; + } + if p.extension().and_then(|x| x.to_str()) != Some("tar") { + continue; + } + if let Ok(m) = e.metadata().await { + let mtime = m.modified().unwrap_or(std::time::UNIX_EPOCH); + out.push((p, m.len(), mtime)); + } + } + out + } + + loop { + let mut tars = list_tars().await; + let total: u64 = tars.iter().map(|(_, s, _)| *s).sum(); + if total <= max_bytes || tars.is_empty() { + break; + } + tars.sort_by_key(|(_, _, mtime)| *mtime); + let victim = tars[0].0.clone(); + if tokio::fs::remove_file(&victim).await.is_err() { + break; // can't reclaim — stop rather than spin on the same victim + } + // Drop the sibling config + size sidecars too. + let _ = tokio::fs::remove_file(victim.with_extension("json")).await; + let _ = tokio::fs::remove_file(victim.with_extension("size")).await; + tracing::info!("sandbox image cache eviction: removed {}", victim.display()); + } + // `_reset` drops here and clears EVICTION_RUNNING. +} + +/// Build the nsjail mount block that binds each top-level entry of the rootfs in +/// place. Binding the whole rootfs at `/` trips nsjail's read-only remount of its +/// base root in a rootless userns; per-entry binds avoid it. `proc`, `dev`, `tmp` +/// and `sys` are skipped — the profile provides them. +async fn generate_rootfs_mounts(rootfs: &str) -> Result { + let mut block = String::new(); + let mut entries = tokio::fs::read_dir(rootfs).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if matches!(name.as_ref(), "proc" | "dev" | "tmp" | "sys") { + continue; + } + let src = proto_str(&format!("{rootfs}/{name}")); + let dst = proto_str(&format!("/{name}")); + let file_type = entry.file_type().await?; + if file_type.is_symlink() { + // Recreate top-level symlinks (e.g. usr-merged /bin -> usr/bin) as + // symlinks in the jail. The target is image-controlled but only ever + // *resolved inside the jail* (against the bound rootfs dirs / jail + // pseudo-fs) — there is no host `/` in the jail for it to point at — and + // it is escaped via proto_str, so it can neither escape nor inject config. + let target = tokio::fs::read_link(entry.path()) + .await + .map(|p| p.to_string_lossy().into_owned()) + .unwrap_or_default(); + block.push_str(&format!( + "mount {{\n src: {}\n dst: {dst}\n is_symlink: true\n mandatory: false\n}}\n", + proto_str(&target), + )); + } else { + block.push_str(&format!( + "mount {{\n src: {src}\n dst: {dst}\n is_bind: true\n rw: true\n mandatory: false\n}}\n", + )); + } + } + Ok(block) +} + +#[tracing::instrument(level = "trace", skip_all)] +pub async fn handle_docker_v2_job( + image: &str, + mem_peak: &mut i32, + canceled_by: &mut Option, + job: &MiniPulledJob, + conn: &Connection, + client: &AuthedClient, + parent_runnable_path: Option, + content: &str, + job_dir: &str, + shared_mount: &str, + base_internal_url: &str, + worker_name: &str, + occupancy_metrics: &mut OccupancyMetrics, +) -> Result, Error> { + // The sandboxed container runtime *is* nsjail, so it requires nsjail. (`# docker` + // keeps the v1 dind path for non-sandboxed workers.) + if NSJAIL_AVAILABLE.is_none() { + return Err(Error::ExecutionErr(format!( + "`# sandbox {image}` runs the image inside nsjail, which is not available on \ + this worker. Install nsjail, or use a bare `# docker` (dind) instead." + ))); + } + + // Apply the default-registry instance setting to unqualified refs. + let resolved_image = resolve_image_ref(image).await; + let image = resolved_image.as_str(); + + append_logs( + &job.id, + &job.workspace_id, + format!("\n\n--- SANDBOXED CONTAINER (nsjail) ---\nextracting image {image}...\n"), + conn, + ) + .await; + + let config = extract_image(image, job_dir).await?; + let rootfs = format!("{job_dir}/rootfs"); + + // Best-effort: keep the cached rootfs tars under their size cap (overlaps the run). + tokio::spawn(enforce_image_cache_limit()); + + // Resolve the script args from the bash signature, like the bash executor. + let args = build_args_map(job, client, conn).await?.map(Json); + let job_args = if args.is_some() { + args.as_ref() + } else { + job.args.as_ref() + }; + let args_owned = windmill_parser_bash::parse_bash_sig(content)? + .args + .iter() + .map(|arg| { + job_args + .and_then(|x| x.get(&arg.name).map(|x| raw_to_string(x.get()))) + .unwrap_or_else(String::new) + }) + .collect::>(); + + // The body is everything that isn't a leading `#` annotation/comment line. With + // a body we run it via the image's `/bin/sh`; without one we run the image's + // ENTRYPOINT + CMD. + let has_body = content + .lines() + .any(|l| !l.trim().is_empty() && !l.trim_start().starts_with('#')); + + let cmd_args: Vec = if has_body { + // Pass the body straight to `sh -c` rather than writing a script file into + // the image-controlled rootfs: a malicious image could plant that path as a + // symlink to a host file and capture the worker's write before nsjail starts + // (sandbox-boundary bypass). `sh -c sh ` binds args as $1.. . + let mut v = vec![ + "/bin/sh".to_string(), + "-c".to_string(), + format!("set -e\n{content}"), + "sh".to_string(), + ]; + v.extend(args_owned.iter().cloned()); + v + } else { + let mut v = config.entrypoint.clone().unwrap_or_default(); + v.extend(config.cmd.clone().unwrap_or_default()); + if v.is_empty() { + return Err(Error::ExecutionErr(format!( + "image {image} has no ENTRYPOINT/CMD and the script body is empty — \ + nothing to run" + ))); + } + v.extend(args_owned.iter().cloned()); + v + }; + + let working_dir = config + .working_dir + .as_deref() + .filter(|w| !w.is_empty()) + .unwrap_or("/"); + + // The image's OCI Env is attacker-controlled (BOTH keys and values), so it must + // NOT enter the nsjail launcher's own process env: a hostile image could set + // LD_PRELOAD / LD_LIBRARY_PATH / LD_AUDIT and have the dynamic loader run code in + // the nsjail binary as the worker — outside the jail — before it sandboxes. + // Deliver it to the *child only* via proto-escaped `envar:` directives. + let mut container_env: Vec<(String, String)> = Vec::new(); + for kv in config.env.unwrap_or_default() { + if let Some((k, v)) = kv.split_once('=') { + container_env.push((k.to_string(), v.to_string())); + } + } + if !container_env.iter().any(|(k, _)| k == "PATH") { + container_env.push(("PATH".to_string(), DEFAULT_PATH.to_string())); + } + if !container_env.iter().any(|(k, _)| k == "HOME") { + container_env.push(("HOME".to_string(), "/root".to_string())); + } + let envars = render_envars(&container_env); + + // Render the nsjail profile: dynamic per-entry rootfs binds + image WorkingDir. + let nsjail_timeout = resolve_nsjail_timeout(conn, &job.workspace_id, job.id, job.timeout).await; + let rootfs_mounts = generate_rootfs_mounts(&rootfs).await?; + write_file( + job_dir, + "run.docker.config.proto", + &NSJAIL_CONFIG_RUN_DOCKER_CONTENT + .replace("{TIMEOUT}", &nsjail_timeout) + .replace("{CLONE_NEWUSER}", &(!*DISABLE_NUSER).to_string()) + // proto_str-quoted: WorkingDir is image-controlled, must not break out + // of the `cwd:` string and inject nsjail directives. + .replace("{WORKDIR}", &proto_str(working_dir)) + .replace("{ROOTFS_MOUNTS}", &rootfs_mounts) + .replace( + "{TMP_MOUNT_BLOCK}", + &resolve_nsjail_tmp_mount_block(job_dir).await, + ) + // `# volume` mounts + same-worker shared folder (empty if none). + .replace("{SHARED_MOUNT}", shared_mount) + // Image env as `envar:` directives (child-only), so it never touches + // nsjail's process env. + .replace("{ENVARS}", &envars) + .replace("#{DEV}", DEV_CONF_NSJAIL), + )?; + + // nsjail's OWN process env: only windmill-trusted keys (reserved vars so + // `wmill`/API calls work, + proxy). `keep_env: true` forwards these to the + // child. The image env is NOT here — see container_env above. + let mut reserved_variables = + get_reserved_variables(job, &client.token, conn, parent_runnable_path).await?; + reserved_variables.insert("RUST_LOG".to_string(), "info".to_string()); + reserved_variables.insert( + "BASE_INTERNAL_URL".to_string(), + base_internal_url.to_string(), + ); + + let proxy_envs = get_proxy_envs_for_lang( + &ScriptLang::Bash, + job.kind, + &job.id, + &job.workspace_id, + conn, + ) + .await?; + + let mut nsjail_run_args = vec!["--config", "run.docker.config.proto", "--"]; + nsjail_run_args.extend(cmd_args.iter().map(|s| s.as_str())); + + let mut nsjail_cmd = Command::new(NSJAIL_PATH.as_str()); + nsjail_cmd + .current_dir(job_dir) + .env_clear() + .envs(reserved_variables) + .envs(proxy_envs) + .args(nsjail_run_args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let child = start_child_process(nsjail_cmd, NSJAIL_PATH.as_str(), false).await?; + + handle_child( + &job.id, + conn, + mem_peak, + canceled_by, + child, + true, + worker_name, + &job.workspace_id, + "sandboxed container run", + job.timeout, + true, + &mut Some(occupancy_metrics), + None, + None, + ) + .await?; + + Ok(to_raw_value(&json!(format!( + "sandboxed container ({image}) completed successfully" + )))) +} + +#[cfg(test)] +mod tests { + use super::{digest_key, proto_str, ref_key, registry_qualified, render_envars}; + + #[test] + fn digest_key_is_filesystem_safe() { + assert_eq!(digest_key("sha256:4d889c14e7d5"), "sha256_4d889c14e7d5"); + // No `:` or `/` survives (both would break the cache filename). + let k = digest_key("sha256:ab/cd:ef"); + assert!(!k.contains(':') && !k.contains('/')); + } + + #[test] + fn ref_key_is_safe_and_stable() { + // Deterministic for a given ref... + assert_eq!(ref_key("ghcr.io/o/i:tag"), ref_key("ghcr.io/o/i:tag")); + // ...distinguishes different refs... + assert_ne!(ref_key("alpine:latest"), ref_key("alpine:edge")); + // ...and is filesystem-safe (no `/` or `:`), incl. for multibyte refs (no panic + // on the trailing-80 byte slice since every char maps to single-byte ASCII). + for r in [ + "alpine", + "ghcr.io/o/i:tag", + "localhost:5000/r@sha256:ab", + "rég/imagé:tag", + ] { + let k = ref_key(r); + assert!(!k.contains('/') && !k.contains(':')); + } + } + + #[test] + fn render_envars_emits_proto_directives() { + // Image-controlled env (incl. loader vars) is rendered as `envar:` directives + // — i.e. delivered to the child via the config, NOT nsjail's process env, so + // it can never set LD_PRELOAD/etc. on the nsjail binary itself. + let env = vec![ + ("PATH".to_string(), "/usr/bin".to_string()), + ("LD_PRELOAD".to_string(), "rootfs/evil.so".to_string()), + ]; + let out = render_envars(&env); + assert_eq!( + out, + "envar: \"PATH=/usr/bin\"\nenvar: \"LD_PRELOAD=rootfs/evil.so\"" + ); + // A value trying to inject extra directives is escaped, not interpreted. + let evil = vec![("X".to_string(), "v\"\nclone_newuser: false".to_string())]; + let line = render_envars(&evil); + assert!(line.starts_with("envar: \"")); + assert!(!line.contains("\nclone_newuser")); + assert!(line.contains("\\n")); + } + + #[test] + fn proto_str_escapes_injection() { + // Normal paths are just wrapped in quotes. + assert_eq!(proto_str("/app"), "\"/app\""); + // A `"` is escaped so it cannot close the surrounding string and inject + // subsequent nsjail directives — this is what the WorkingDir / mount-src + // sandboxing fixes depend on. + let malicious = "/x\"\nmount { src: \"/\" dst: \"/host\" is_bind: true }\n#"; + let escaped = proto_str(malicious); + assert!(escaped.starts_with('"') && escaped.ends_with('"')); + // No raw quote or newline survives inside the rendered literal. + let inner = &escaped[1..escaped.len() - 1]; + assert!(!inner.contains('\n')); + assert!(!inner.contains("\"") || inner.contains("\\\"")); + assert!(escaped.contains("\\\"")); // the inner quote is backslash-escaped + assert!(escaped.contains("\\n")); // the newline is escaped + // Control and non-ASCII bytes render as valid 3-digit octal escapes (never + // a raw byte or an invalid `\u{..}` that nsjail's parser would reject). + assert_eq!(proto_str("a\u{1b}b"), "\"a\\033b\""); // ESC (0x1b) + assert_eq!(proto_str("é"), "\"\\303\\251\""); // UTF-8 bytes 0xc3 0xa9 + } + + #[test] + fn registry_qualified_classifies_refs() { + // Unqualified: bare repos (with/without tag) and docker.io org/repo. + for img in ["alpine", "alpine:latest", "myorg/img", "myorg/img:1.2"] { + assert!(!registry_qualified(img), "{img} should be unqualified"); + } + // Qualified: the first path component is a host (has `.`/`:`) or localhost. + for img in [ + "ghcr.io/org/img", + "registry.example.com/img:tag", + "localhost:5000/img", + "localhost/img", + "host:5000/a/b", + ] { + assert!(registry_qualified(img), "{img} should be qualified"); + } + } +} diff --git a/backend/windmill-worker/src/lib.rs b/backend/windmill-worker/src/lib.rs index 08f9381205..7727982cf8 100644 --- a/backend/windmill-worker/src/lib.rs +++ b/backend/windmill-worker/src/lib.rs @@ -31,6 +31,7 @@ mod csharp_executor; mod dedicated_worker_ee; mod dedicated_worker_oss; mod deno_executor; +mod docker_v2; #[cfg(feature = "duckdb")] mod duckdb_executor; mod global_cache; diff --git a/backend/windmill-worker/src/otel_oss.rs b/backend/windmill-worker/src/otel_oss.rs index 2f65c534b4..913fee6003 100644 --- a/backend/windmill-worker/src/otel_oss.rs +++ b/backend/windmill-worker/src/otel_oss.rs @@ -7,3 +7,6 @@ use windmill_queue::MiniPulledJob; #[cfg(not(feature = "private"))] pub fn add_root_flow_job_to_otlp(_queued_job: &MiniPulledJob, _success: bool) {} + +#[cfg(not(feature = "private"))] +pub fn set_job_span_parent(_span: &tracing::Span, _job: &MiniPulledJob, _rj: &uuid::Uuid) {} diff --git a/backend/windmill-worker/src/python_executor.rs b/backend/windmill-worker/src/python_executor.rs index 4bec8d44bf..f74a523072 100644 --- a/backend/windmill-worker/src/python_executor.rs +++ b/backend/windmill-worker/src/python_executor.rs @@ -597,11 +597,57 @@ async fn postinstall( Ok(()) } +/// Python hard keywords cannot be used as a bare name in `import ` / +/// `from import `. A flow inline step whose id (or a folder on its +/// path) is such a keyword — e.g. a step id `in` — otherwise generates +/// `from pkg import in as inner_script`, a SyntaxError. Prefix these with `_`, +/// mirroring the existing digit-leading guard. +fn is_python_keyword(s: &str) -> bool { + matches!( + s, + "False" + | "None" + | "True" + | "and" + | "as" + | "assert" + | "async" + | "await" + | "break" + | "class" + | "continue" + | "def" + | "del" + | "elif" + | "else" + | "except" + | "finally" + | "for" + | "from" + | "global" + | "if" + | "import" + | "in" + | "is" + | "lambda" + | "nonlocal" + | "not" + | "or" + | "pass" + | "raise" + | "return" + | "try" + | "while" + | "with" + | "yield" + ) +} + /// Compute the directory (relative to job_dir) where Python writes the main script. /// Module files must be placed in this same directory for relative imports to work. pub fn compute_python_module_dir(script_path: &str) -> String { let script_path_splitted = script_path.split("/").map(|x| { - if x.starts_with(|x: char| x.is_ascii_digit()) { + if x.starts_with(|x: char| x.is_ascii_digit()) || is_python_keyword(x) { format!("_{}", x) } else { x.to_string() @@ -1236,6 +1282,12 @@ pub fn compute_py_codegen(content: &str, script_path: &str) -> PyScriptCodegen { .replace("-", "_") .replace(" ", "_") .to_lowercase(); + // `last` is lowercased above, so this catches a keyword id in any case. + let last = if is_python_keyword(&last) { + format!("_{last}") + } else { + last + }; let sig = windmill_parser_py::parse_python_signature(content, None, false).unwrap_or_default(); let pre_sig = windmill_parser_py::parse_python_signature( @@ -1605,6 +1657,12 @@ async fn prepare_wrapper( .replace("-", "_") .replace(" ", "_") .to_lowercase(); + // `last` is lowercased above, so this catches a keyword id in any case. + let last = if is_python_keyword(&last) { + format!("_{last}") + } else { + last + }; let module_dir = format!("{}/{}", job_dir, dirs); tokio::fs::create_dir_all(format!("{module_dir}/")).await?; @@ -2095,6 +2153,9 @@ async fn spawn_uv_install( "--no-cache", // If we invoke uv pip install, then we want to overwrite existing data "--reinstall", + // Compile .py to .pyc at install time so imports are fast even + // through read-only nsjail mounts (no in-memory compilation per job). + "--compile-bytecode", ]; if let Some(py_path) = py_path.as_ref() { @@ -3283,6 +3344,13 @@ mod tests { assert_eq!(compute_python_module_dir("u/@admin/script"), "u/.admin"); } + #[test] + fn test_compute_python_module_dir_keyword_segment() { + // A folder whose name is a Python keyword would otherwise produce an + // invalid `from f.in.x import ...`; it is underscore-prefixed. + assert_eq!(compute_python_module_dir("f/in/script"), "f/_in"); + } + #[test] fn test_compute_py_codegen_basic_args() { let code = "def main(x: str, y: int):\n return x\n"; @@ -3294,6 +3362,22 @@ mod tests { assert_eq!(cg.module_name, "script"); } + #[test] + fn test_compute_py_codegen_keyword_step_id() { + // Regression for a flow inline step whose auto-assigned id is a Python + // keyword (e.g. `in`): the generated wrapper must not emit + // `from pkg import in as inner_script` (SyntaxError). The module name is + // underscore-prefixed, matching the digit-leading guard. + let code = "def main():\n return 1\n"; + let cg = compute_py_codegen(code, "u/admin/myflow/in"); + assert_eq!(cg.module_name, "_in"); + assert_eq!(cg.module_dir_dot, "u.admin.myflow"); + + // Non-keyword ids are unaffected. + let cg2 = compute_py_codegen(code, "u/admin/myflow/step"); + assert_eq!(cg2.module_name, "step"); + } + #[test] fn test_compute_py_codegen_with_datetime_and_bytes() { let code = "import datetime\n\ndef main(name: str, created_at: datetime.datetime, file: bytes):\n return name\n"; diff --git a/backend/windmill-worker/src/python_versions.rs b/backend/windmill-worker/src/python_versions.rs index 5d06d3e673..0ae6b3462a 100644 --- a/backend/windmill-worker/src/python_versions.rs +++ b/backend/windmill-worker/src/python_versions.rs @@ -537,6 +537,11 @@ impl PyV { &v, "--python-preference=only-managed", "--no-bin", + // Compile the runtime's stdlib to bytecode at install time. The + // runtime is mounted read-only into the job nsjail, so without + // precompiled .pyc Python would recompile ~stdlib from source on + // every job (and can never persist it). Requires uv >= 0.9.25. + "--compile-bytecode", ]) // TODO: Do we need these? .envs([ diff --git a/backend/windmill-worker/src/result_processor.rs b/backend/windmill-worker/src/result_processor.rs index 4870730432..1824a9e493 100644 --- a/backend/windmill-worker/src/result_processor.rs +++ b/backend/windmill-worker/src/result_processor.rs @@ -185,6 +185,9 @@ async fn process_jc( span.record("labels", labels.join(",")); } } + // The secondary `job_postprocessing` span stays on the UUID-derived context + // (MiniCompletedJob carries no args, so the inbound traceparent isn't + // available here); the primary job span is relocated in `create_span_with_name`. windmill_common::otel_oss::set_span_parent(&span, &rj); if let Some(lg) = jc.job.script_lang.as_ref() { diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index 6b1216ad9d..db4d24e75c 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -13,6 +13,8 @@ use anyhow::anyhow; use futures::TryFutureExt; use tokio::sync::Mutex; use tokio::time::timeout; +// Re-export proxy env-var snapshots so callers (including EE modules) +// can keep importing them via `crate::{NO_PROXY, HTTP_PROXY, HTTPS_PROXY}`. use windmill_common::client::AuthedClient; use windmill_common::db::UserDbWithAuthed; use windmill_common::get_latest_deployed_hash_for_path; @@ -48,6 +50,7 @@ use windmill_common::{ worker_group_job_stats::JobStatsMap, KillpillSender, }; +pub use windmill_common::{HTTPS_PROXY, HTTP_PROXY, NO_PROXY}; #[cfg(feature = "enterprise")] use windmill_common::ee_oss::LICENSE_KEY_VALID; @@ -554,11 +557,9 @@ lazy_static::lazy_static! { .and_then(|x| x.parse::().ok()) .unwrap_or(false)); - pub static ref NO_PROXY: Option = std::env::var("no_proxy").ok().or(std::env::var("NO_PROXY").ok()); - pub static ref HTTP_PROXY: Option = std::env::var("http_proxy").ok().or(std::env::var("HTTP_PROXY").ok()); - pub static ref HTTPS_PROXY: Option = std::env::var("https_proxy").ok().or(std::env::var("HTTPS_PROXY").ok()); - - /// Static proxy environment variables from env vars (for languages not using dynamic OTEL tracing proxy config) + /// Static proxy environment variables from env vars (for languages not using dynamic OTEL tracing proxy config). + /// The underlying `NO_PROXY` / `HTTP_PROXY` / `HTTPS_PROXY` snapshots live in `windmill_common` + /// so other crates (e.g. native triggers) can reuse the same source of truth. pub static ref PROXY_ENVS: Vec<(&'static str, String)> = { let mut proxy_env = Vec::new(); if let Some(no_proxy) = NO_PROXY.as_ref() { @@ -693,6 +694,27 @@ lazy_static::lazy_static! { /// RAM-backed tmpfs sized by `nsjail_tmpfs_size_mb`. pub static ref NSJAIL_TMP_BACKING: Arc>> = Arc::new(RwLock::new(None)); + /// Reject a `# sandbox ` whose compressed download size exceeds this many + /// MB, before download. `None`/non-positive = no limit. (`sandbox_image_max_size_mb`.) + pub static ref SANDBOX_IMAGE_MAX_SIZE_MB: Arc>> = Arc::new(RwLock::new(None)); + + /// Best-effort cap (MB) on the worker's cached rootfs tars; oldest evicted after a + /// run when exceeded. `None`/non-positive = unbounded. (`sandbox_image_cache_max_mb`.) + pub static ref SANDBOX_IMAGE_CACHE_MAX_MB: Arc>> = Arc::new(RwLock::new(None)); + + /// Sandbox image pull policy (`missing`/`newer`/`always`/`never`). `None`/unrecognized + /// falls back to `newer`. (`sandbox_image_pull_policy`.) + pub static ref SANDBOX_IMAGE_PULL_POLICY: Arc>> = Arc::new(RwLock::new(None)); + + /// If set, unqualified sandbox image refs (e.g. `alpine`) are pulled from this + /// registry instead of docker.io. Fully-qualified refs are unaffected. + /// (`sandbox_image_default_registry`.) + pub static ref SANDBOX_IMAGE_DEFAULT_REGISTRY: Arc>> = Arc::new(RwLock::new(None)); + + /// Optional docker `auth.json` blob for private registries, written to a per-job + /// `DOCKER_CONFIG` dir for crane. (`sandbox_registry_auth`.) + pub static ref SANDBOX_REGISTRY_AUTH: Arc>> = Arc::new(RwLock::new(None)); + /// Optional mirror URL for `uv python install`. Wires to the `UV_PYTHON_INSTALL_MIRROR` /// env var when forwarded to uv. Can be set via the `UV_PYTHON_INSTALL_MIRROR` env var /// or the `uv_python_install_mirror` instance setting. @@ -918,14 +940,50 @@ pub async fn is_otel_tracing_proxy_enabled_for_lang(lang: &ScriptLang) -> bool { } } +/// Strict check that a string is a well-formed W3C `traceparent` +/// (`version-traceid-spanid-flags`, lowercase hex, non-zero ids, version != ff). +/// Used before forwarding an inbound header value verbatim to a job subprocess, +/// so we don't hand downstream OTel parsers something they'll reject. +#[cfg(all(feature = "private", feature = "enterprise"))] +fn valid_w3c_traceparent(tp: &str) -> bool { + let p: Vec<&str> = tp.split('-').collect(); + p.len() == 4 + && p[0].len() == 2 + && p[1].len() == 32 + && p[2].len() == 16 + && p[3].len() == 2 + // version "ff" is reserved/invalid per the W3C spec + && p[0] != "ff" + && p[1] != "00000000000000000000000000000000" + && p[2] != "0000000000000000" + // W3C mandates lowercase hex + && p + .iter() + .all(|s| s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))) +} + /// Get OTEL trace context environment variables for a job (TRACEPARENT, OTEL_TRACE_ID, OTEL_SPAN_ID). /// Returns an empty vec when OTEL tracing is not enabled or on non-enterprise builds. +/// +/// When the request that enqueued the job carried a valid inbound `traceparent` +/// (propagated via the job's [`LogContext`](windmill_common::log_context::LogContext)), +/// it is forwarded verbatim so the script's spans join the originating +/// distributed trace. Otherwise the trace context is derived from the job UUID. pub fn get_otel_context_envs(job_id: &uuid::Uuid) -> Vec<(&'static str, String)> { #[cfg(all(feature = "private", feature = "enterprise"))] if windmill_common::OTEL_TRACING_ENABLED.load(std::sync::atomic::Ordering::Relaxed) { - let trace_id = format!("{:032x}", job_id.as_u128()); - let span_id = format!("{:016x}", job_id.as_u64_pair().1); - let traceparent = format!("00-{}-{}-01", trace_id, span_id); + let inbound = windmill_common::log_context::current_log_context() + .and_then(|c| c.inbound_traceparent.clone()) + .filter(|tp| valid_w3c_traceparent(tp)); + let (traceparent, trace_id, span_id) = if let Some(tp) = inbound { + let trace_id = tp[3..35].to_string(); + let span_id = tp[36..52].to_string(); + (tp, trace_id, span_id) + } else { + let trace_id = format!("{:032x}", job_id.as_u128()); + let span_id = format!("{:016x}", job_id.as_u64_pair().1); + (format!("00-{}-{}-01", trace_id, span_id), trace_id, span_id) + }; return vec![ ("TRACEPARENT", traceparent), ("OTEL_TRACE_ID", trace_id), @@ -1465,7 +1523,10 @@ pub fn create_span_with_name( span.record("script_hash", script_hash.to_string().as_str()); } - windmill_common::otel_oss::set_span_parent(&span, &rj); + // Parent the job span on the inbound distributed trace when the request that + // enqueued it (or its flow root) carried a W3C `traceparent`; otherwise on + // the UUID-derived context. See `otel_ee::set_job_span_parent`. + crate::otel_oss::set_job_span_parent(&span, arc_job, &rj); span } @@ -1566,10 +1627,21 @@ pub fn log_context_for_job( trigger_kind: arc_job.trigger_kind.as_ref().map(|k| k.to_string()), trigger: arc_job.trigger.clone(), hostname: hostname.map(|h| h.to_string()), + inbound_traceparent: job_inbound_traceparent(arc_job), ..existing } } +/// Extract the inbound W3C `traceparent` captured at enqueue from a job's args +/// (reserved `_wm_traceparent` key). Present only on directly-triggered jobs +/// (and flow steps that inherited it). +pub(crate) fn job_inbound_traceparent(job: &MiniPulledJob) -> Option { + job.args + .as_ref() + .and_then(|a| a.get(windmill_common::jobs::WM_TRACEPARENT)) + .and_then(|raw| serde_json::from_str::(raw.get()).ok()) +} + pub async fn handle_all_job_kind_error( conn: &Connection, authed_client: &AuthedClient, diff --git a/backend/windmill-worker/src/worker_flow.rs b/backend/windmill-worker/src/worker_flow.rs index 6e2deb7446..c8d109c465 100644 --- a/backend/windmill-worker/src/worker_flow.rs +++ b/backend/windmill-worker/src/worker_flow.rs @@ -311,13 +311,14 @@ struct RecoveryObject { recover: Option, } -fn get_stop_after_if_data(stop_after_if: Option<&StopAfterIf>) -> (bool, Option) { +/// Returns `(skip_if_stopped, error_message, include_step_result)`. +fn get_stop_after_if_data(stop_after_if: Option<&StopAfterIf>) -> (bool, Option, bool) { if let Some(stop_after_if) = stop_after_if { // skip_if_stopped and error_message are mutually exclusive: // skip_if_stopped=true means clean stop (mark remaining as skipped), // error_message means stop with error. skip_if_stopped takes precedence. if stop_after_if.skip_if_stopped { - return (true, None); + return (true, None, false); } let err_msg = stop_after_if.error_message.as_ref().and_then(|message| { if message.is_empty() { @@ -326,9 +327,9 @@ fn get_stop_after_if_data(stop_after_if: Option<&StopAfterIf>) -> (bool, Option< Some(message.clone()) } }); - return (false, err_msg); + return (false, err_msg, stop_after_if.error_include_result); } - return (false, None); + return (false, None, false); } async fn get_id_ctx_for_expr( @@ -358,6 +359,7 @@ async fn evaluate_stop_after_all_iters_if( stop_early: &mut bool, skip_if_stop_early: &mut bool, stop_early_err_msg: &mut Option, + stop_early_include_result: &mut bool, nresult: &mut Option>>, args: HashMap>, flow_env: Option<&HashMap>>, @@ -394,8 +396,11 @@ async fn evaluate_stop_after_all_iters_if( if stop_early_after_all_iters { *stop_early = true; - (*skip_if_stop_early, *stop_early_err_msg) = - get_stop_after_if_data(Some(stop_after_all_iters_if)); + ( + *skip_if_stop_early, + *stop_early_err_msg, + *stop_early_include_result, + ) = get_stop_after_if_data(Some(stop_after_all_iters_if)); } Ok(()) } @@ -655,19 +660,24 @@ pub async fn update_flow_status_after_job_completion_internal( false }; - let (mut stop_early, mut stop_early_err_msg, mut skip_if_stop_early, continue_on_error) = - if stop_early_override.is_some() - && !is_flow_stop_early_override - && !parallel_loop - && !parallel_branchall - { - // we ignore stop_early_override (stop_early in children) if module is parallel or is a flow step - let se = stop_early_override.as_ref().unwrap(); - (true, None, *se, false) - } else if is_failure_step || module_step.is_preprocessor_step() { - (false, None, false, false) - } else if let Some(current_module) = current_module { - let stop_early = success + let ( + mut stop_early, + mut stop_early_err_msg, + mut skip_if_stop_early, + mut stop_early_include_result, + continue_on_error, + ) = if stop_early_override.is_some() + && !is_flow_stop_early_override + && !parallel_loop + && !parallel_branchall + { + // we ignore stop_early_override (stop_early in children) if module is parallel or is a flow step + let se = stop_early_override.as_ref().unwrap(); + (true, None, *se, false, false) + } else if is_failure_step || module_step.is_preprocessor_step() { + (false, None, false, false, false) + } else if let Some(current_module) = current_module { + let stop_early = success && !is_branch_all // we don't support stop_early per branch && !parallel_loop // we don't support anymore stop_early per iteration when parallel for loop (removed from frontend) && !is_identity_job // don't evaluate stop_after_if for skipped (identity) steps @@ -717,22 +727,23 @@ pub async fn update_flow_status_after_job_completion_internal( } else { false }; - let (skip_if_stopped, stop_early_err_msg) = if stop_early { - get_stop_after_if_data(current_module.stop_after_if.as_ref()) - } else { - (false, None) - }; - - ( - stop_early, - stop_early_err_msg, - skip_if_stopped, - current_module.continue_on_error.unwrap_or(false), - ) + let (skip_if_stopped, stop_early_err_msg, include_result) = if stop_early { + get_stop_after_if_data(current_module.stop_after_if.as_ref()) } else { - (false, None, false, false) + (false, None, false) }; + ( + stop_early, + stop_early_err_msg, + skip_if_stopped, + include_result, + current_module.continue_on_error.unwrap_or(false), + ) + } else { + (false, None, false, false, false) + }; + let skip_seq_branch_failure = match module_status { FlowStatusModule::InProgress { branchall: Some(BranchAllStatus { branch, .. }), @@ -974,6 +985,7 @@ pub async fn update_flow_status_after_job_completion_internal( &mut stop_early, &mut skip_if_stop_early, &mut stop_early_err_msg, + &mut stop_early_include_result, &mut nresult, args, resolved_flow_env.as_deref(), @@ -1173,6 +1185,7 @@ pub async fn update_flow_status_after_job_completion_internal( stop_early = false; stop_early_err_msg = None; skip_if_stop_early = false; + stop_early_include_result = false; } if is_loop || (is_branch_all && !stop_early) { @@ -1194,6 +1207,7 @@ pub async fn update_flow_status_after_job_completion_internal( &mut stop_early, &mut skip_if_stop_early, &mut stop_early_err_msg, + &mut stop_early_include_result, &mut nresult, args, resolved_flow_env.as_deref(), @@ -1310,12 +1324,22 @@ pub async fn update_flow_status_after_job_completion_internal( }; if stop_early && stop_early_err_msg.is_some() { - nresult = Some(Arc::new(to_raw_value(&serde_json::json! ({ - "error": { - "name": "EarlyStopError", - "message": stop_early_err_msg.as_ref().unwrap(), - } - })))); + let mut error = serde_json::json!({ + "name": "EarlyStopError", + "message": stop_early_err_msg.as_ref().unwrap(), + }); + if stop_early_include_result { + // Embed the stopping step's own result inside the error object instead + // of discarding it, keeping the top-level result shape `{ "error": .. }` + // unchanged. `nresult` is already set for loops/branchall (aggregated + // iteration results), otherwise fall back to the step result. + let step_result = nresult.clone().unwrap_or_else(|| result.clone()); + error["result"] = + serde_json::to_value(&step_result).unwrap_or(serde_json::Value::Null); + } + nresult = Some(Arc::new(to_raw_value( + &serde_json::json!({ "error": error }), + ))); } let step_counter = if inc_step_counter { @@ -2985,6 +3009,87 @@ struct PushNextFlowJobRec { // #[async_recursion] // #[instrument(level = "trace", skip_all)] +/// Resolve the worker tag for a flow's child job (step, nested sub-flow, preprocessor). +/// +/// A child normally inherits the parent flow job's tag so the whole flow runs on one worker +/// group. The exceptions, in order: +/// - the preprocessor step, or a flow running on the generic `flow` / `flow-{workspace}` tag, +/// always uses the child's own tag (`step_tag`); +/// - when the flow opts into `preserve_step_tags` and the child declares its own non-empty tag, +/// that tag is honored instead of being overridden by the flow tag; +/// - otherwise the child inherits the parent flow job's tag. +fn resolve_flow_step_tag( + is_preprocessor_step: bool, + flow_tag: &str, + workspace_id: &str, + preserve_step_tags: bool, + step_tag: Option<&str>, +) -> Option { + if is_preprocessor_step || flow_tag == "flow" || flow_tag == format!("flow-{}", workspace_id) { + step_tag.map(str::to_string) + } else if preserve_step_tags && step_tag.is_some_and(|t| !t.is_empty()) { + step_tag.map(str::to_string) + } else { + Some(flow_tag.to_string()) + } +} + +#[cfg(test)] +mod tag_resolution_tests { + use super::resolve_flow_step_tag; + + #[test] + fn step_inherits_custom_flow_tag_by_default() { + // Parent flow on a custom tag, step declares its own tag, preserve disabled: + // the step inherits the flow tag (historical behavior). + assert_eq!( + resolve_flow_step_tag(false, "worker-group-A", "w1", false, Some("worker-group-B")), + Some("worker-group-A".to_string()) + ); + } + + #[test] + fn step_keeps_own_tag_when_preserve_enabled() { + // The exact customer scenario: a sub-flow tagged worker-group-B run as a step of a + // flow tagged worker-group-A now runs on worker-group-B when preserve_step_tags is on. + assert_eq!( + resolve_flow_step_tag(false, "worker-group-A", "w1", true, Some("worker-group-B")), + Some("worker-group-B".to_string()) + ); + } + + #[test] + fn untagged_step_inherits_flow_tag_even_when_preserve_enabled() { + assert_eq!( + resolve_flow_step_tag(false, "worker-group-A", "w1", true, None), + Some("worker-group-A".to_string()) + ); + // An empty tag counts as "no tag" and still inherits. + assert_eq!( + resolve_flow_step_tag(false, "worker-group-A", "w1", true, Some("")), + Some("worker-group-A".to_string()) + ); + } + + #[test] + fn generic_flow_tag_always_uses_step_tag() { + for flow_tag in ["flow", "flow-w1"] { + assert_eq!( + resolve_flow_step_tag(false, flow_tag, "w1", false, Some("worker-group-B")), + Some("worker-group-B".to_string()) + ); + } + } + + #[test] + fn preprocessor_step_uses_step_tag() { + assert_eq!( + resolve_flow_step_tag(true, "worker-group-A", "w1", false, Some("worker-group-B")), + Some("worker-group-B".to_string()) + ); + } +} + async fn push_next_flow_job( flow_job: Arc, mut status: FlowStatus, @@ -4093,6 +4198,21 @@ async fn push_next_flow_job( } } + // Propagate the inbound W3C traceparent captured at enqueue to each step + // so the whole flow shares the originating distributed trace (the trace + // identity is otherwise derived from the root job UUID). Observability + // only — no security impact — so unlike _TEMP_SCRIPT_REFS it is not + // gated to previews. + if let Some(traceparent) = arc_flow_job_args + .as_ref() + .get(windmill_common::jobs::WM_TRACEPARENT) + { + push_args.extra.get_or_insert_with(HashMap::new).insert( + windmill_common::jobs::WM_TRACEPARENT.to_string(), + traceparent.clone(), + ); + } + tracing::debug!(id = %flow_job.id, root_id = %job_root, "computed args for job {i} of {len}"); let value_with_parallel = module.get_value_with_parallel()?; @@ -4118,13 +4238,13 @@ async fn push_next_flow_job( .map(|x| x.into()); tracing::debug!(id = %flow_job.id, root_id = %job_root, "computed perms for job {i} of {len}"); - let tag = if step.is_preprocessor_step() - || (flow_job.tag == "flow" || flow_job.tag == format!("flow-{}", flow_job.workspace_id)) - { - payload_tag.tag.clone() - } else { - Some(flow_job.tag.clone()) - }; + let tag = resolve_flow_step_tag( + step.is_preprocessor_step(), + &flow_job.tag, + &flow_job.workspace_id, + flow.preserve_step_tags, + payload_tag.tag.as_deref(), + ); let (email, permissioned_as) = if let Some(on_behalf_of) = payload_tag.on_behalf_of.as_ref() { @@ -4822,6 +4942,7 @@ fn payload_from_modules<'a>( modules_node: Option, failure_module: Option<&Box>, same_worker: bool, + preserve_step_tags: bool, id: impl FnOnce() -> String, path: impl FnOnce() -> String, opt_empty_inner_flows: bool, @@ -4842,7 +4963,13 @@ fn payload_from_modules<'a>( } Some(JobPayload::RawFlow { - value: FlowValue { modules, failure_module, same_worker, ..Default::default() }, + value: FlowValue { + modules, + failure_module, + same_worker, + preserve_step_tags, + ..Default::default() + }, path: Some(path()), restarted_from: None, }) @@ -5144,6 +5271,7 @@ async fn compute_next_flow_transform( modules_node, flow.failure_module.as_ref(), flow.same_worker, + flow.preserve_step_tags, || format!("{}-{i}", status.step), || format!("{}/forloop-{i}", flow_job.runnable_path()), true, @@ -5280,6 +5408,7 @@ async fn compute_next_flow_transform( modules_node, flow.failure_module.as_ref(), flow.same_worker, + flow.preserve_step_tags, || status.step.to_string(), || format!("{}/branchone-{}", flow_job.runnable_path(), branch_idx), true, @@ -5321,6 +5450,7 @@ async fn compute_next_flow_transform( modules_node, flow.failure_module.as_ref(), flow.same_worker, + flow.preserve_step_tags, || format!("{}-{i}", status.step), || format!("{}/branchall-{}", flow_job.runnable_path(), i), false, @@ -5391,6 +5521,7 @@ async fn compute_next_flow_transform( modules_node, flow.failure_module.as_ref(), flow.same_worker, + flow.preserve_step_tags, || format!("{}-{}", status.step, branch_status.branch), || { format!( @@ -5473,6 +5604,7 @@ async fn next_loop_iteration( modules_node, flow.failure_module.as_ref(), flow.same_worker, + flow.preserve_step_tags, || format!("{}-{}", status.step, ns.index), inner_path, true, diff --git a/backend/windmill-worker/src/worker_lockfiles.rs b/backend/windmill-worker/src/worker_lockfiles.rs index 38d55b5319..1634b4dc88 100644 --- a/backend/windmill-worker/src/worker_lockfiles.rs +++ b/backend/windmill-worker/src/worker_lockfiles.rs @@ -2070,6 +2070,32 @@ pub async fn handle_app_dependency_job( .and_then(|x| x.get("temp_script_refs")) .and_then(|v| serde_json::from_str(v.get()).ok()); + // The version captured at job creation can be stale (the app may have been + // redeployed since). Relock the current latest instead, mirroring the flow + // dependency handler. + let id = if triggered_by_relative_import { + let latest_version = sqlx::query_scalar!( + "SELECT id FROM app_version WHERE app_id = (SELECT id FROM app WHERE path = $1 AND workspace_id = $2) ORDER BY created_at DESC LIMIT 1", + job_path, + job.workspace_id + ) + .fetch_optional(db) + .await?; + match latest_version { + Some(latest_version) if latest_version != id => { + tracing::info!( + "App version changed since dependency job was queued ({} -> {}), using latest", + id, + latest_version + ); + latest_version + } + _ => id, + } + } else { + id + }; + sqlx::query!( "DELETE FROM workspace_runnable_dependencies WHERE app_path = $1 AND workspace_id = $2", job_path, @@ -2163,13 +2189,14 @@ pub async fn handle_app_dependency_job( .execute(db) .await?; - // NOTE: Temporary solution. - // Ideally we do this for every job regardless whether it was triggered by relative import or by creation/update of the app. - // NOTE: For now is not solving any problem but at some point we will introduce latest version caching - // and when we do this will be last operation that will make new version appear as the latest and will trigger cache invalidation for all worker. + // Re-publish the relocked version as latest for cache invalidation, but + // only if it is still the latest: the guard makes this a single atomic, + // never-demoting statement. Without it, a concurrent deploy that landed a + // newer version (e.g. same git-sync push) would be reverted, pointing a + // raw app's bundle_secret at a version with no bundle (404 / white screen). if triggered_by_relative_import { sqlx::query!( - "UPDATE app SET versions = array_append(versions, $1::bigint) WHERE path = $2 AND workspace_id = $3", + "UPDATE app SET versions = array_append(versions, $1::bigint) WHERE path = $2 AND workspace_id = $3 AND versions[array_upper(versions, 1)] = $1::bigint", id, &job_path, &job.workspace_id diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 10d31a0113..a4913917be 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.708.0"; +export const VERSION = "v1.719.0"; export async function login(email: string, password: string): Promise { return await windmill.UserService.login({ diff --git a/cli/package-lock.json b/cli/package-lock.json index f14fd32f8d..c502b3b25e 100644 --- a/cli/package-lock.json +++ b/cli/package-lock.json @@ -17,6 +17,7 @@ "jszip": "3.8.0", "minimatch": "^10.0.0", "open": "^10.0.0", + "pg-gateway": "0.3.0-beta.4", "svelte": "^5.45.2", "tar-stream": "^3.1.7", "windmill-parser-wasm-csharp": "1.510.1", @@ -1236,6 +1237,12 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/pg-gateway": { + "version": "0.3.0-beta.4", + "resolved": "https://registry.npmjs.org/pg-gateway/-/pg-gateway-0.3.0-beta.4.tgz", + "integrity": "sha512-CTjsM7Z+0Nx2/dyZ6r8zRsc3f9FScoD5UAOlfUx1Fdv/JOIWvRbF7gou6l6vP+uypXQVoYPgw8xZDXgMGvBa4Q==", + "license": "MIT" + }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", diff --git a/cli/src/commands/app/app.ts b/cli/src/commands/app/app.ts index a6f85fde1c..6f0263c5cf 100644 --- a/cli/src/commands/app/app.ts +++ b/cli/src/commands/app/app.ts @@ -192,6 +192,8 @@ export async function pushApp( deployment_message: message, ...localAppBody, ...preserveFields, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); } @@ -205,6 +207,8 @@ export async function pushApp( deployment_message: message, ...localAppBody, ...preserveFields, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); } @@ -480,6 +484,8 @@ const command = new Command() on_behalf_of_email: email, } as any, preserve_on_behalf_of: true, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); log.info(colors.green(`Updated permissioned_as for app ${appPath} to ${email}`)); diff --git a/cli/src/commands/app/raw_apps.ts b/cli/src/commands/app/raw_apps.ts index 5b9e880adc..fcfc68915e 100644 --- a/cli/src/commands/app/raw_apps.ts +++ b/cli/src/commands/app/raw_apps.ts @@ -466,6 +466,8 @@ export async function pushRawApp( summary: localApp.summary, policy: appForPolicy.policy, deployment_message: message, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, ...(localApp.custom_path ? { custom_path: localApp.custom_path } : {}), @@ -486,6 +488,8 @@ export async function pushRawApp( summary: localApp.summary, policy: appForPolicy.policy, deployment_message: message, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, ...(localApp.custom_path ? { custom_path: localApp.custom_path } : {}), diff --git a/cli/src/commands/flow/flow.ts b/cli/src/commands/flow/flow.ts index 03a8d14d51..c0e94c478d 100644 --- a/cli/src/commands/flow/flow.ts +++ b/cli/src/commands/flow/flow.ts @@ -4,7 +4,7 @@ import { Command } from "@cliffy/command"; import { Confirm } from "@cliffy/prompt/confirm"; import { Table } from "@cliffy/table"; import * as log from "../../core/log.ts"; -import { sep as SEP } from "node:path"; +import { dirname, sep as SEP } from "node:path"; import { stringify as yamlStringify } from "yaml"; import { yamlParseFile } from "../../utils/yaml.ts"; import { readTextFile, validateRequiredArgs } from "../../utils/utils.ts"; @@ -224,6 +224,8 @@ export async function pushFlow( deployment_message: message, ...localFlowBody, ...preserveFields, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); } @@ -237,6 +239,8 @@ export async function pushFlow( deployment_message: message, ...localFlowBody, ...preserveFields, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); } catch (e) { @@ -542,6 +546,7 @@ async function preview( data?: string; silent: boolean; remote?: boolean; + step?: string; } & SyncOptions, flowPath: string ) { @@ -562,7 +567,10 @@ async function preview( if (!isFlowDir) { // Check if it's a flow.yaml file if (flowPath.endsWith("flow.yaml") || flowPath.endsWith("flow.json")) { - flowPath = flowPath.substring(0, flowPath.lastIndexOf(SEP)); + // Use dirname so a bare "flow.yaml" (no parent dir) becomes "." + // instead of "" — the latter, after appending SEP below, becomes "/" + // and silently reads from filesystem root. + flowPath = dirname(flowPath); } else { throw new Error( "Flow path must be a .flow/__flow directory or a flow.yaml file" @@ -636,18 +644,35 @@ async function preview( const input = opts.data ? await resolve(opts.data) : {}; + log.debug(`Flow value: ${JSON.stringify(localFlow.value, null, 2)}`); + + // Single-step mode: run only the named module's runnable. + // The full-flow prep above (inline-script replacement, local PathScript + // substitution, tempScriptRefs build) is exactly what the single step needs + // too — PathScript modules have already been rewritten to inline rawscript + // when `useLocalPathScripts` is set, and tempScriptRefs covers relative + // imports in inline scripts. + // Compute the flow's windmill path (e.g. "f/cli_smoke/myrelflow"). Used as + // the anchor for relative-import resolution: inline scripts in this flow are + // treated as living at "/", so "./util" resolves to + // "/util" — matching the keys in temp_script_refs. + const flowWmPath = stripFlowSuffix(flowPath).replaceAll(SEP, "/"); + + if (opts.step) { + await previewStep(opts.step, localFlow, flowWmPath, workspace, input, tempScriptRefs, opts.silent); + return; + } + if (!opts.silent) { log.info(colors.yellow(`Running flow preview for ${flowPath}...`)); } - log.debug(`Flow value: ${JSON.stringify(localFlow.value, null, 2)}`); - // Run the flow preview — start the job, then poll for completion const jobId = await wmill.runFlowPreview({ workspace: workspace.workspaceId, requestBody: { value: localFlow.value, - path: flowPath.substring(0, flowPath.indexOf(".flow")).replaceAll(SEP, "/"), + path: flowWmPath, args: input, temp_script_refs: tempScriptRefs, }, @@ -674,6 +699,176 @@ async function preview( } } +async function previewStep( + stepId: string, + localFlow: FlowFile, + flowWmPath: string, + workspace: { workspaceId: string }, + baseArgs: Record, + tempScriptRefs: Record | undefined, + silent: boolean, +) { + const module = findStepInFlowValue(localFlow.value, stepId); + if (!module) { + const available = collectStepIds(localFlow.value).join(", ") || "(none)"; + throw new Error(`Step '${stepId}' not found in flow. Available steps: ${available}`); + } + + // The preprocessor module receives args via _ENTRYPOINT_OVERRIDE so the + // runner picks the preprocessor entrypoint (matches frontend behavior in + // copilot/chat/flow/core.ts). + const args = + stepId === "preprocessor" + ? { _ENTRYPOINT_OVERRIDE: "preprocessor", ...baseArgs } + : baseArgs; + + const moduleValue = module.value; + let jobId: string; + if (moduleValue?.type === "rawscript") { + log.info(colors.yellow(`Previewing step '${stepId}' (rawscript, ${moduleValue.language})...`)); + jobId = await wmill.runScriptPreview({ + workspace: workspace.workspaceId, + requestBody: { + content: moduleValue.content ?? "", + language: moduleValue.language, + // Anchor relative imports to "/" so + // temp_script_refs (keyed by Windmill paths) resolve correctly. + // Without `path`, the worker defaults to "tmp/main" and "../foo" + // resolves to "tmp/foo", missing every entry in temp_script_refs. + path: `${flowWmPath}/${stepId}`, + flow_path: flowWmPath, + args, + temp_script_refs: tempScriptRefs, + }, + }); + } else if (moduleValue?.type === "script") { + // Falls through here only when the deployed PathScript is what we want — + // either --remote was passed, or no local file exists for this path. + log.info(colors.yellow(`Previewing step '${stepId}' (script ${moduleValue.path})...`)); + const script = moduleValue.hash + ? await wmill.getScriptByHash({ + workspace: workspace.workspaceId, + hash: moduleValue.hash, + }) + : await wmill.getScriptByPath({ + workspace: workspace.workspaceId, + path: moduleValue.path, + }); + jobId = await wmill.runScriptPreview({ + workspace: workspace.workspaceId, + requestBody: { + content: script.content, + language: script.language as any, + // Anchor to the script's own deployed path so its relative imports + // resolve against the workspace tree (or temp_script_refs). + path: moduleValue.path, + flow_path: flowWmPath, + args, + temp_script_refs: tempScriptRefs, + }, + }); + } else if (moduleValue?.type === "flow") { + log.info(colors.yellow(`Previewing step '${stepId}' (flow ${moduleValue.path})...`)); + jobId = await wmill.runFlowByPath({ + workspace: workspace.workspaceId, + path: moduleValue.path, + requestBody: args, + }); + } else { + throw new Error( + `Cannot preview step of type '${moduleValue?.type ?? "unknown"}'. Supported types: rawscript, script, flow.` + ); + } + + const { result, success } = await pollForJobResult(workspace.workspaceId, jobId); + + if (!success) { + if (silent) { + console.log(JSON.stringify(result)); + } else { + log.info(colors.red.bold(`Step '${stepId}' failed:`)); + log.info(JSON.stringify(result, null, 2)); + } + process.exitCode = 1; + return; + } + + if (silent) { + console.log(JSON.stringify(result)); + } else { + log.info(colors.bold.underline.green(`Step '${stepId}' completed`)); + log.info(JSON.stringify(result, null, 2)); + } +} + +// Strip the `.flow`/`__flow` directory suffix to recover the flow's logical +// Windmill path. Workspaces with nonDottedPaths use `__flow`; the default +// uses `.flow`. A previous version used `indexOf(".flow")` which returned -1 +// (and thus `substring(0, -1) === ""`) for `__flow` folders and for the +// `dirname("flow.yaml") === "."` fallback — producing an empty path that +// broke relative-import resolution downstream. +function stripFlowSuffix(flowPath: string): string { + const stripped = flowPath.endsWith(SEP) ? flowPath.slice(0, -SEP.length) : flowPath; + if (stripped.endsWith(".flow")) return stripped.slice(0, -".flow".length); + if (stripped.endsWith("__flow")) return stripped.slice(0, -"__flow".length); + return stripped; +} + +function findStepInFlowValue(flowValue: any, stepId: string): any | undefined { + if (!flowValue) return undefined; + if (flowValue.failure_module?.id === stepId) return flowValue.failure_module; + if (flowValue.preprocessor_module?.id === stepId) return flowValue.preprocessor_module; + return findStepInModules(flowValue.modules ?? [], stepId); +} + +function findStepInModules(modules: any[], stepId: string): any | undefined { + for (const m of modules) { + if (m?.id === stepId) return m; + const v = m?.value; + if (!v) continue; + if (v.type === "forloopflow" || v.type === "whileloopflow") { + const found = findStepInModules(v.modules ?? [], stepId); + if (found) return found; + } else if (v.type === "branchone") { + for (const b of v.branches ?? []) { + const found = findStepInModules(b.modules ?? [], stepId); + if (found) return found; + } + const found = findStepInModules(v.default ?? [], stepId); + if (found) return found; + } else if (v.type === "branchall") { + for (const b of v.branches ?? []) { + const found = findStepInModules(b.modules ?? [], stepId); + if (found) return found; + } + } + } + return undefined; +} + +function collectStepIds(flowValue: any): string[] { + const ids: string[] = []; + const walkModules = (modules: any[]) => { + for (const m of modules) { + if (m?.id) ids.push(m.id); + const v = m?.value; + if (!v) continue; + if (v.type === "forloopflow" || v.type === "whileloopflow") { + walkModules(v.modules ?? []); + } else if (v.type === "branchone") { + for (const b of v.branches ?? []) walkModules(b.modules ?? []); + walkModules(v.default ?? []); + } else if (v.type === "branchall") { + for (const b of v.branches ?? []) walkModules(b.modules ?? []); + } + } + }; + if (flowValue?.preprocessor_module?.id) ids.push(flowValue.preprocessor_module.id); + if (flowValue?.failure_module?.id) ids.push(flowValue.failure_module.id); + walkModules(flowValue?.modules ?? []); + return ids; +} + export async function generateLocks( opts: GlobalOptions & { yes?: boolean; @@ -890,7 +1085,7 @@ const command = new Command() .action(run as any) .command( "preview", - "preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default." + "preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. Pass --step to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow)." ) .arguments("") .option( @@ -905,6 +1100,10 @@ const command = new Command() "--remote", "Use deployed workspace scripts for PathScript steps instead of local files." ) + .option( + "--step ", + "Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does." + ) .action(preview as any) .command( "generate-locks", @@ -964,6 +1163,8 @@ const command = new Command() path: flowPath, on_behalf_of_email: email, preserve_on_behalf_of: true, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, } as any, }); log.info(colors.green(`Updated permissioned_as for flow ${flowPath} to ${email}`)); diff --git a/cli/src/commands/init/init.ts b/cli/src/commands/init/init.ts index fd6b93380d..f1a10d7cc9 100644 --- a/cli/src/commands/init/init.ts +++ b/cli/src/commands/init/init.ts @@ -18,6 +18,7 @@ import { import { generateRTNamespace } from "../resource-type/resource-type.ts"; import { generateCommentedTemplate } from "./template.ts"; import { refreshPrompts } from "../refresh/prompts.ts"; +import { refreshTsconfig } from "../refresh/tsconfig.ts"; export interface InitOptions { useDefault?: boolean; @@ -238,6 +239,18 @@ async function initAction(opts: InitOptions) { await refreshPrompts({ yes: opts.useDefault === true }); + // Generate the IDE tsconfig (managed tsconfig.wmill.json + user tsconfig.json + // that extends it). Independent of any workspace binding — it's purely local. + try { + await refreshTsconfig({ yes: opts.useDefault === true }); + } catch (error) { + log.warn( + `Could not generate tsconfig: ${ + error instanceof Error ? error.message : error + }` + ); + } + // Generate resource type namespace (only if a workspace was bound) if (didBindWorkspace && boundProfile) { try { diff --git a/cli/src/commands/object-storage/object-storage.ts b/cli/src/commands/object-storage/object-storage.ts new file mode 100644 index 0000000000..2ce404e292 --- /dev/null +++ b/cli/src/commands/object-storage/object-storage.ts @@ -0,0 +1,344 @@ +import { Buffer } from "node:buffer"; +import { readFile, writeFile } from "node:fs/promises"; +import { basename } from "node:path"; + +import { GlobalOptions } from "../../types.ts"; +import { requireLogin } from "../../core/auth.ts"; +import { resolveWorkspace } from "../../core/context.ts"; +import { Command } from "@cliffy/command"; +import { Confirm } from "@cliffy/prompt/confirm"; +import { Table } from "@cliffy/table"; +import { colors } from "@cliffy/ansi/colors"; +import * as log from "../../core/log.ts"; +import * as wmill from "../../../gen/services.gen.ts"; +import { formatTimestamp } from "../../utils/utils.ts"; + +function formatBytes(n: number | undefined): string { + if (n == null) return "-"; + if (n < 1024) return `${n}B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}K`; + if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)}M`; + return `${(n / (1024 * 1024 * 1024)).toFixed(2)}G`; +} + +async function listStorages( + opts: GlobalOptions & { json?: boolean } +) { + if (opts.json) log.setSilent(true); + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + const names = await wmill.getSecondaryStorageNames({ + workspace: workspace.workspaceId, + includeDefault: true, + }); + + if (opts.json) { + console.log(JSON.stringify(names)); + return; + } + if (names.length === 0) { + log.info("No object storage configured for this workspace."); + return; + } + for (const name of names) { + console.log(name === "_default_" ? `${name} ${colors.dim("(default)")}` : name); + } +} + +async function listFiles( + opts: GlobalOptions & { + json?: boolean; + maxKeys?: number; + marker?: string; + storage?: string; + }, + prefix?: string +) { + if (opts.json) log.setSilent(true); + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + const result = await wmill.listStoredFiles({ + workspace: workspace.workspaceId, + maxKeys: opts.maxKeys ?? 100, + marker: opts.marker, + prefix, + storage: opts.storage, + }); + + if (opts.json) { + console.log(JSON.stringify(result)); + return; + } + const files = result.windmill_large_files ?? []; + if (files.length === 0) { + log.info("No files found."); + return; + } + new Table() + .header(["Key"]) + .padding(2) + .border(true) + .body(files.map((f) => [f.s3])) + .render(); + if (result.next_marker) { + log.info(`\nMore results available. Use --marker '${result.next_marker}' to paginate.`); + } +} + +async function upload( + opts: GlobalOptions & { + storage?: string; + contentType?: string; + contentDisposition?: string; + }, + localPath: string, + fileKey: string +) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + const buf = await readFile(localPath); + // Wrap Node Buffer in a Blob for the SDK request body. + const blob = new Blob([buf], { type: opts.contentType ?? "application/octet-stream" }); + + await wmill.fileUpload({ + workspace: workspace.workspaceId, + fileKey, + storage: opts.storage, + contentType: opts.contentType, + contentDisposition: opts.contentDisposition, + requestBody: blob, + }); + log.info(colors.green(`Uploaded ${localPath} -> ${fileKey}`)); +} + +async function download( + opts: GlobalOptions & { storage?: string; stdout?: boolean }, + fileKey: string, + outputPath?: string +) { + if (opts.stdout) log.setSilent(true); + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + // The generated request layer (cli/gen/core/request.ts:getResponseBody) + // routes by Content-Type: binary types → Blob, text/* → string, JSON → object. + // The generated return type is `Blob | File`, which is wrong for non-binary + // responses, so widen to unknown before normalizing. + const body: unknown = await wmill.fileDownload({ + workspace: workspace.workspaceId, + fileKey, + storage: opts.storage, + }); + let buf: Buffer; + if (typeof body === "string") { + buf = Buffer.from(body, "utf-8"); + } else if (body instanceof Blob) { + buf = Buffer.from(await body.arrayBuffer()); + } else if (body instanceof ArrayBuffer) { + buf = Buffer.from(body); + } else if (body == null) { + buf = Buffer.alloc(0); + } else { + buf = Buffer.from(JSON.stringify(body), "utf-8"); + } + + if (opts.stdout) { + process.stdout.write(buf); + return; + } + const dest = outputPath ?? basename(fileKey); + await writeFile(dest, buf); + log.info(colors.green(`Downloaded ${fileKey} -> ${dest}`)); +} + +async function del( + opts: GlobalOptions & { storage?: string; yes?: boolean }, + fileKey: string +) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + if (!opts.yes) { + const confirmed = await Confirm.prompt({ + message: `Delete '${fileKey}' from object storage${opts.storage ? ` (storage: ${opts.storage})` : ""}?`, + default: false, + }); + if (!confirmed) { + log.info("Aborted."); + return; + } + } + + await wmill.deleteS3File({ + workspace: workspace.workspaceId, + fileKey, + storage: opts.storage, + }); + log.info(colors.green(`Deleted ${fileKey}`)); +} + +async function move( + opts: GlobalOptions & { storage?: string }, + srcFileKey: string, + destFileKey: string +) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + await wmill.moveS3File({ + workspace: workspace.workspaceId, + srcFileKey, + destFileKey, + storage: opts.storage, + }); + log.info(colors.green(`Moved ${srcFileKey} -> ${destFileKey}`)); +} + +async function info( + opts: GlobalOptions & { json?: boolean; storage?: string }, + fileKey: string +) { + if (opts.json) log.setSilent(true); + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + const metadata = await wmill.loadFileMetadata({ + workspace: workspace.workspaceId, + fileKey, + storage: opts.storage, + }); + + if (opts.json) { + console.log(JSON.stringify(metadata)); + return; + } + console.log(colors.bold("Key:") + " " + fileKey); + console.log(colors.bold("Size:") + " " + formatBytes(metadata.size_in_bytes)); + console.log(colors.bold("Mime:") + " " + (metadata.mime_type ?? "-")); + console.log( + colors.bold("Last Modified:") + " " + + (metadata.last_modified ? formatTimestamp(metadata.last_modified) : "-") + ); + if (metadata.expires) { + console.log(colors.bold("Expires:") + " " + formatTimestamp(metadata.expires)); + } + if (metadata.version_id) { + console.log(colors.bold("Version Id:") + " " + metadata.version_id); + } +} + +async function preview( + opts: GlobalOptions & { + storage?: string; + bytesFrom?: number; + bytesLength?: number; + csvSeparator?: string; + csvHeader?: boolean; + mime?: string; + }, + fileKey: string +) { + const workspace = await resolveWorkspace(opts); + await requireLogin(opts); + + // Backend requires both byte fields; mirror the frontend's defaults + // (frontend/src/lib/components/S3FilePickerInner.svelte) for an interactive + // peek so the user gets useful output without passing flags. + const result = await wmill.loadFilePreview({ + workspace: workspace.workspaceId, + fileKey, + storage: opts.storage, + fileMimeType: opts.mime, + readBytesFrom: opts.bytesFrom ?? 0, + readBytesLength: opts.bytesLength ?? 128 * 1024, + csvSeparator: opts.csvSeparator, + csvHasHeader: opts.csvHeader, + }); + + if (result.msg) { + log.info(colors.yellow(result.msg)); + } + if (result.content != null) { + process.stdout.write(result.content); + if (!result.content.endsWith("\n")) process.stdout.write("\n"); + } +} + +const command = new Command() + .alias("s3") + .description("Object storage (S3) related commands. Operates on the workspace's default object storage; use --storage to target a configured secondary storage.") + .action(listStorages as any) + .command( + "list", + "List configured object storages for the workspace (default + secondary)." + ) + .option("--json", "Output as JSON (for piping to jq)") + .action(listStorages as any) + .command( + "files", + "List files in an object storage. Optionally filter by prefix." + ) + .alias("ls") + .arguments("[prefix:string]") + .option("--json", "Output as JSON (for piping to jq)") + .option("--max-keys ", "Page size (default 100)") + .option("--marker ", "Pagination marker from a previous response") + .option("--storage ", "Secondary storage name (omit for the workspace default)") + .action(listFiles as any) + .command( + "upload", + "Upload a local file to object storage at the given file key." + ) + .arguments(" ") + .option("--storage ", "Secondary storage name") + .option("--content-type ", "Content-Type header to set on the object") + .option("--content-disposition ", "Content-Disposition header to set on the object") + .action(upload as any) + .command( + "download", + "Download an object to a local file (or stdout). Default output path is the basename of the file key in the current directory." + ) + .arguments(" [output_path:string]") + .option("--storage ", "Secondary storage name") + .option("--stdout", "Write file contents to stdout instead of a file") + .action(download as any) + .command( + "delete", + "Delete an object from object storage. Prompts for confirmation unless --yes is set." + ) + .arguments("") + .option("--storage ", "Secondary storage name") + .option("--yes", "Skip the confirmation prompt") + .action(del as any) + .command( + "move", + "Move an object within the same storage (rename or relocate by key)." + ) + .arguments(" ") + .option("--storage ", "Secondary storage name") + .action(move as any) + .command( + "info", + "Show metadata (size, mime, last-modified) for an object." + ) + .arguments("") + .option("--json", "Output as JSON (for piping to jq)") + .option("--storage ", "Secondary storage name") + .action(info as any) + .command( + "preview", + "Preview the contents of an object (text/CSV). Use --bytes-from / --bytes-length to peek at a slice of binary files." + ) + .arguments("") + .option("--storage ", "Secondary storage name") + .option("--mime ", "Override the detected mime type (e.g. text/csv)") + .option("--bytes-from ", "Start offset in bytes") + .option("--bytes-length ", "Number of bytes to read") + .option("--csv-separator ", "CSV column separator (default ,)") + .option("--csv-header", "Treat the first CSV row as a header") + .action(preview as any); + +export default command; diff --git a/cli/src/commands/refresh/prompts.ts b/cli/src/commands/refresh/prompts.ts index 3f933d3de2..863ab4f9ff 100644 --- a/cli/src/commands/refresh/prompts.ts +++ b/cli/src/commands/refresh/prompts.ts @@ -32,7 +32,8 @@ export async function refreshPrompts(opts: { // If config can't be read, use the conservative default above. } - const interactive = process.stdin.isTTY && !opts.yes; + const assumeYes = opts.yes === true; + const interactive = process.stdin.isTTY && !assumeYes; try { const result = await writeAiGuidanceFiles({ @@ -42,8 +43,13 @@ export async function refreshPrompts(opts: { agentsSourcePath: process.env[WMILL_INIT_AI_AGENTS_SOURCE_ENV], claudeSourcePath: process.env[WMILL_INIT_AI_CLAUDE_SOURCE_ENV], resolveAgentsMdMigration: async () => { - if (!interactive) return "append"; - return await promptMigration(); + // Consent model (matches `wmill refresh tsconfig`): we only touch an + // existing user-owned file that we don't recognize when the user opts + // in. `--yes` (and `wmill init --default`) appends without asking; an + // interactive run prompts; a plain non-interactive run leaves it alone. + if (assumeYes) return "append"; + if (interactive) return await promptMigration(); + return "skip"; }, }); @@ -175,7 +181,7 @@ const command = new Command() .description("Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in.") .option( "--yes", - "Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include." + "Non-interactive: append the @AGENTS.cli.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched." ) .action(promptsAction as any); diff --git a/cli/src/commands/refresh/refresh.ts b/cli/src/commands/refresh/refresh.ts index 882284c428..d1fea23af2 100644 --- a/cli/src/commands/refresh/refresh.ts +++ b/cli/src/commands/refresh/refresh.ts @@ -1,8 +1,12 @@ import { Command } from "@cliffy/command"; import promptsCommand from "./prompts.ts"; +import tsconfigCommand from "./tsconfig.ts"; const command = new Command() - .description("Refresh wmill-managed project files (AGENTS.cli.md and skills)") - .command("prompts", promptsCommand); + .description( + "Refresh wmill-managed project files (AGENTS.cli.md, skills, tsconfig.wmill.json)" + ) + .command("prompts", promptsCommand) + .command("tsconfig", tsconfigCommand); export default command; diff --git a/cli/src/commands/refresh/tsconfig.ts b/cli/src/commands/refresh/tsconfig.ts new file mode 100644 index 0000000000..10d9993d50 --- /dev/null +++ b/cli/src/commands/refresh/tsconfig.ts @@ -0,0 +1,496 @@ +import { execSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import process from "node:process"; + +import { colors } from "@cliffy/ansi/colors"; +import { Command } from "@cliffy/command"; +import { Confirm } from "@cliffy/prompt/confirm"; +import * as log from "../../core/log.ts"; +import { readConfigFile } from "../../core/conf.ts"; + +/** + * The on-disk folders (`f/`, `u/`) that the absolute workspace import paths + * `/f/...` and `/u/...` map to. tsconfig `paths` and Deno import maps remap the + * `/f/`,`/u/` prefixes to these local folders, so the same workspace import + * resolves both on the Windmill worker and in a local editor (tsc/Bun/Deno all + * honor the `/`-prefixed key). + */ +const WORKSPACE_IMPORT_DIRS = ["f", "u"]; + +// wmill-managed files holding the recommended config. They are always +// (re)written so we can ship updated recommendations over time; users keep +// their own overrides in tsconfig.json / deno.json, which reference these +// managed files and are never overwritten. This mirrors how AGENTS.cli.md +// (managed) and AGENTS.md (user-owned) work for AI prompts. +const MANAGED_TSCONFIG = "tsconfig.wmill.json"; +const MANAGED_IMPORT_MAP = "import_map.wmill.json"; + +const MANAGED_NOTICE = + "// Managed by wmill — regenerated by `wmill init` / `wmill refresh tsconfig`.\n" + + "// Do not edit; put your overrides in tsconfig.json (which extends this file).\n"; + +// Embedded in tsconfig.wmill.json so any command can detect a stale managed file +// (the recommended config changed) and nudge the user to `wmill refresh tsconfig` +// — mirroring the prompts freshness marker in AGENTS.cli.md. +const TSCONFIG_HASH_PREFIX = "// wmill-tsconfig-hash: "; +const TSCONFIG_HASH_REGEX = /^\/\/ wmill-tsconfig-hash: ([0-9a-f]{12})/m; + +/** + * The recommended managed tsconfig, minus environment-dependent bits (`types` + * depends on whether bun-types is installed locally). This is both the source + * of the written file and the input to the freshness hash, so the hash only + * changes when wmill's *recommended* config changes — not when bun-types + * appears/disappears on a given machine. + */ +function buildManagedTsconfig(): { + compilerOptions: Record; + include: string[]; +} { + // Map "/f/*" -> ["./f/*"], "/u/*" -> ["./u/*"] so the editor resolves + // workspace imports against the local script folders. + // + // Known limitation: this resolves imports written with a plain `.ts` extension + // (the canonical form). Scripts stored with a flavor-specific extension — + // `.bun.ts`/`.deno.ts`/`.fetch.ts` for languages other than the project default + // (see filePathExtensionFromContentType) — won't resolve via these `paths` in a + // local editor. The worker (extension-agnostic API) and the in-app editor (ATA + // normalizes to `.ts`) handle those fine; only local tsc / VS Code is affected. + const paths: Record = {}; + for (const dir of WORKSPACE_IMPORT_DIRS) { + paths[`/${dir}/*`] = [`./${dir}/*`]; + } + return { + compilerOptions: { + target: "ESNext", + module: "ESNext", + moduleResolution: "bundler", + // Workspace imports carry an explicit `.ts` extension (e.g. "/f/foo/bar.ts"); + // allow it so the editor doesn't flag every cross-script import. + allowImportingTsExtensions: true, + noEmit: true, + strict: false, + // No `baseUrl`: with moduleResolution "bundler" the `paths` patterns resolve + // relative to this file, and `baseUrl` is deprecated in TypeScript 7+. + paths, + }, + include: ["**/*.ts", "rt.d.ts"], + }; +} + +function currentTsconfigHash(): string { + return createHash("sha256") + .update(JSON.stringify(buildManagedTsconfig())) + .digest("hex") + .slice(0, 12); +} + +// Exact `tsconfig.json` shapes the *previous* CLI generated (single-file, before +// the managed/user split — see the now-deleted resource-type/tsconfig.ts). When +// an existing tsconfig.json matches one of these verbatim, we know it's ours (not +// a user customization), so we can safely replace it wholesale with the thin stub +// that extends tsconfig.wmill.json. Anything else is treated as user-authored. +const LEGACY_GENERATED_TSCONFIGS: Record[] = [ + { + compilerOptions: { + target: "ESNext", + module: "ESNext", + moduleResolution: "bundler", + noEmit: true, + strict: false, + }, + include: ["**/*.ts", "rt.d.ts"], + }, + { + compilerOptions: { + target: "ESNext", + module: "ESNext", + moduleResolution: "bundler", + noEmit: true, + strict: false, + types: ["bun-types"], + }, + include: ["**/*.ts", "rt.d.ts"], + }, +]; + +// Order-sensitive deep equality (arrays compared positionally, objects by key +// set). Used only on small parsed JSON config objects. +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false; + } + return a.every((x, i) => deepEqual(x, b[i])); + } + if (a && b && typeof a === "object" && typeof b === "object") { + const ka = Object.keys(a as object); + const kb = Object.keys(b as object); + if (ka.length !== kb.length) return false; + return ka.every((k) => + deepEqual((a as Record)[k], (b as Record)[k]) + ); + } + return false; +} + +// How to handle an existing, user-authored config that doesn't yet reference the +// managed file. `assumeYes` (from `--yes` / `wmill init --default`) wires it +// without asking; otherwise we only wire it after an interactive confirmation — +// a non-interactive run with neither leaves the file untouched. +type WireMode = { interactive: boolean; assumeYes: boolean }; + +/** + * (Re)generate the wmill-managed TypeScript/Deno IDE config so the editor + * resolves `/f/`/`/u/` workspace imports against the local script folders. + * + * Split into a managed base file (always refreshed) and a user-owned file that + * references it. We only ever touch files that are ours: the managed file is + * regenerated, a tsconfig.json still in the previously-generated shape is + * migrated to the new split, and a genuinely custom config is wired only with + * the user's consent (interactive prompt, or `--yes`). + * + * Programmatic entry point reused by `wmill init`; also exposed as + * `wmill refresh tsconfig`. + */ +export async function refreshTsconfig(opts?: { yes?: boolean }): Promise { + let defaultTs: "bun" | "deno" = "bun"; + try { + const conf = await readConfigFile({ warnIfMissing: false }); + if (conf?.defaultTs === "deno") { + defaultTs = "deno"; + } + } catch { + // fall back to bun if wmill.yaml is missing or unreadable + } + + const assumeYes = opts?.yes === true; + const mode: WireMode = { + assumeYes, + interactive: !!process.stdin.isTTY && !assumeYes, + }; + + // tsconfig.json is useful for Bun and general TS tooling regardless of the + // default; the Deno import map is only relevant for Deno-default projects + // (the Deno LSP ignores tsconfig.json). + await refreshManagedTsconfig(defaultTs, mode); + if (defaultTs === "deno") { + await refreshManagedDenoImportMap(mode); + } +} + +async function refreshManagedTsconfig(defaultTs: "bun" | "deno", mode: WireMode) { + const managed = buildManagedTsconfig(); + + // Only reference bun-types if it's actually available; otherwise the IDE + // would flag the missing type definitions. (Excluded from the freshness hash + // since it's environment-, not recommendation-, dependent.) + const bunTypesAvailable = + defaultTs === "bun" ? ensureBunTypesAvailable() : false; + if (bunTypesAvailable) { + managed.compilerOptions.types = ["bun-types"]; + } + + const header = MANAGED_NOTICE + TSCONFIG_HASH_PREFIX + currentTsconfigHash() + "\n"; + writeFileSync( + path.join(process.cwd(), MANAGED_TSCONFIG), + header + JSON.stringify(managed, null, 2) + "\n" + ); + log.info(colors.green(`Refreshed ${MANAGED_TSCONFIG}`)); + + await ensureUserReferencesManaged({ + file: "tsconfig.json", + create: { extends: `./${MANAGED_TSCONFIG}` }, + legacyFormats: LEGACY_GENERATED_TSCONFIGS, + mode, + wire: (parsed) => { + // TypeScript does NOT merge `compilerOptions.paths` across `extends` — the + // nearest config that defines `paths` wins wholesale. So a config with its + // own `paths` would shadow the managed `/f/`/`/u/` mappings and they would + // silently fail to resolve. We don't touch the user's paths — warn + // and leave it for them to wire manually. + const co = parsed.compilerOptions; + const paths = + co && typeof co === "object" && !Array.isArray(co) + ? (co as Record).paths + : undefined; + if ( + paths && + typeof paths === "object" && + !Array.isArray(paths) && + Object.keys(paths).length > 0 + ) { + return ( + "defines its own `compilerOptions.paths` (TS won't merge ours in via " + + '`extends`); add "/f/*": ["./f/*"] and "/u/*": ["./u/*"] to it' + ); + } + const ext = parsed.extends; + const managed = `./${MANAGED_TSCONFIG}`; + // Insert the managed config FIRST in `extends` so the user's own base config + // keeps precedence on overlapping compilerOptions (strict/target/module) + // rather than being overridden by our defaults. + if (ext === undefined) { + parsed.extends = managed; + } else if (typeof ext === "string") { + parsed.extends = [managed, ext]; + } else if (Array.isArray(ext)) { + ext.unshift(managed); + } else { + return "unexpected `extends` value"; + } + return true; + }, + token: MANAGED_TSCONFIG, + hint: `"extends": "./${MANAGED_TSCONFIG}"`, + }); +} + +async function refreshManagedDenoImportMap(mode: WireMode) { + // Import-map prefix keys must end with "/": "/f/" -> "./f/", "/u/" -> "./u/". + const imports: Record = {}; + for (const dir of WORKSPACE_IMPORT_DIRS) { + imports[`/${dir}/`] = `./${dir}/`; + } + + // Deno import maps only allow `imports`/`scopes`, so no comment header here. + writeFileSync( + path.join(process.cwd(), MANAGED_IMPORT_MAP), + JSON.stringify({ imports }, null, 2) + "\n" + ); + log.info(colors.green(`Refreshed ${MANAGED_IMPORT_MAP}`)); + + await ensureUserReferencesManaged({ + file: "deno.json", + // Don't write deno.json if the project already uses deno.jsonc — a new + // deno.json would take precedence and shadow the existing config. + altFiles: ["deno.jsonc"], + create: { importMap: `./${MANAGED_IMPORT_MAP}` }, + mode, + wire: (parsed) => { + // Deno rejects `imports` + `importMap` together, so we can't auto-wire a + // deno.json that already defines its own imports. + if (parsed.imports !== undefined) { + return "deno.json already defines `imports` (can't also use importMap)"; + } + if ( + parsed.importMap !== undefined && + parsed.importMap !== `./${MANAGED_IMPORT_MAP}` + ) { + return "deno.json already sets a different `importMap`"; + } + parsed.importMap = `./${MANAGED_IMPORT_MAP}`; + return true; + }, + token: MANAGED_IMPORT_MAP, + hint: `"importMap": "./${MANAGED_IMPORT_MAP}"`, + }); +} + +/** + * Ensure a user-owned config file references the wmill-managed file. Mirrors how + * `wmill refresh prompts` wires `@AGENTS.cli.md` into AGENTS.md: + * - missing → create the minimal file (already linked); + * - exists & linked → leave it alone; + * - exists & unlinked → auto-wire it (parse JSON, apply `wire`, write back). + * Falls back to a one-line warning when the file can't be auto-edited safely + * (JSONC comments fail JSON.parse, or the structure already conflicts) — we + * never corrupt a file we can't round-trip. + */ +async function ensureUserReferencesManaged(opts: { + file: string; + // Sibling configs that, if already present, must not be shadowed by writing + // `opts.file` next to them (e.g. an existing deno.jsonc vs a new deno.json). + altFiles?: string[]; + create: Record; + // Mutate the parsed user config to reference the managed file. Returns true + // when wired, or a short reason string when it can't be wired cleanly (→ warn). + wire: (parsed: Record) => true | string; + // Verbatim shapes a previous CLI generated for this file. A match means the + // file is ours, so it's replaced wholesale (no prompt); anything else is + // treated as user-authored and only wired with consent. + legacyFormats?: Record[]; + mode: WireMode; + token: string; + hint: string; +}) { + // Prefer any existing config (including alternates) over creating a fresh one, + // so we never shadow a config the user already has. + const existing = [opts.file, ...(opts.altFiles ?? [])] + .map((f) => path.join(process.cwd(), f)) + .find((p) => existsSync(p)); + + if (!existing) { + const userPath = path.join(process.cwd(), opts.file); + writeFileSync(userPath, JSON.stringify(opts.create, null, 2) + "\n"); + log.info(colors.green(`Created ${opts.file} (references ${opts.token})`)); + return; + } + + const existingName = path.basename(existing); + let text = ""; + try { + text = readFileSync(existing, "utf-8"); + } catch { + return; + } + if (text.includes(opts.token)) { + log.info( + colors.gray(`${existingName} already references ${opts.token}, leaving it untouched`) + ); + return; + } + + // We only ever rewrite a config we can round-trip as JSON. Files with comments + // (JSONC) fail JSON.parse, so we warn instead of corrupting them. + let parsed: Record; + try { + parsed = JSON.parse(text); + } catch { + log.warn( + `${existingName} couldn't be auto-edited (it may contain comments). Add ${opts.hint} ` + + `to pick up wmill's recommended settings (incl. workspace /f/, /u/ import resolution).` + ); + return; + } + + // The file is still exactly what a previous CLI generated → it's ours, so + // migrate it to the new split (replace with the thin stub that extends the + // managed file). No prompt: we're not touching user-authored content. + if (opts.legacyFormats?.some((fmt) => deepEqual(parsed, fmt))) { + writeFileSync(existing, JSON.stringify(opts.create, null, 2) + "\n"); + log.info( + colors.green( + `Migrated previously-generated ${existingName} to reference ${opts.token}` + ) + ); + return; + } + + // Custom config. Try wiring a clone so we can report un-wireable cases without + // mutating, then only persist with the user's consent. + const next = JSON.parse(JSON.stringify(parsed)) as Record; + const wired = opts.wire(next); + if (wired !== true) { + log.warn( + `${existingName}: ${wired}. Add ${opts.hint} manually to pick up wmill's ` + + `recommended settings (incl. workspace /f/, /u/ import resolution).` + ); + return; + } + + const consent = opts.mode.assumeYes + ? true + : opts.mode.interactive + ? await Confirm.prompt({ + message: + `${existingName} isn't linked to wmill's ${opts.token}. Add ${opts.hint}? ` + + `Your settings are preserved (it's inserted first, so your config wins).`, + default: true, + }) + : false; + + if (!consent) { + log.info( + colors.gray( + `Left ${existingName} unchanged — add ${opts.hint} when ready to enable ` + + `workspace /f/, /u/ import resolution (or re-run \`wmill refresh tsconfig\`).` + ) + ); + return; + } + + writeFileSync(existing, JSON.stringify(next, null, 2) + "\n"); + log.info(colors.green(`Linked ${existingName} → ${opts.token}`)); +} + +function ensureBunTypesAvailable(): boolean { + const cwd = process.cwd(); + if (existsSync(path.join(cwd, "node_modules", "bun-types"))) { + return true; + } + + try { + execSync("bun --version", { stdio: "ignore" }); + } catch { + log.info( + "Install bun (https://bun.sh), run 'bun add -d bun-types', then re-run 'wmill refresh tsconfig' for Bun API autocompletion." + ); + return false; + } + + try { + log.info( + colors.yellow("Installing bun-types with 'bun add -d bun-types'...") + ); + execSync("bun add -d bun-types", { stdio: "inherit" }); + log.info(colors.green("Installed bun-types.")); + return true; + } catch (e) { + log.warn( + `Failed to install bun-types automatically: ${ + e instanceof Error ? e.message : e + }` + ); + log.info( + "Run 'bun add -d bun-types' manually, then 'wmill refresh tsconfig', for Bun API autocompletion." + ); + return false; + } +} + +/** + * One-line, non-blocking warning (to stderr) when the managed config is out of + * date. Mirrors the prompts freshness check (`warnIfPromptsStale`) exactly: + * gated on the *managed* file existing (= the project opted in by running + * init/refresh), and only ever warns that it's **stale** — never about a + * missing or unlinked user tsconfig.json. So a deliberately-unlinked / custom + * setup is never nagged, and a not-yet-initialized project stays silent. + * Gated identically in main.ts so it never fires during `wmill init`/`refresh`. + */ +export async function warnIfTsconfigStale(opts?: { cwd?: string }): Promise { + const cwd = opts?.cwd ?? process.cwd(); + const managedPath = path.join(cwd, MANAGED_TSCONFIG); + if (!existsSync(managedPath)) return; + + let managedText: string; + try { + managedText = readFileSync(managedPath, "utf-8"); + } catch { + return; + } + const match = managedText.match(TSCONFIG_HASH_REGEX); + if (!match) { + emitTsconfigWarning( + `${MANAGED_TSCONFIG} predates versioning. Run \`wmill refresh tsconfig\` to refresh it.` + ); + return; + } + if (match[1] !== currentTsconfigHash()) { + emitTsconfigWarning( + `${MANAGED_TSCONFIG} is out of date. Run \`wmill refresh tsconfig\` to refresh.` + ); + } +} + +// Send to stderr (not log.warn → stdout) so it never contaminates a piped +// command's output, matching the prompts freshness warning. +function emitTsconfigWarning(message: string): void { + process.stderr.write(`${colors.yellow(message)}\n`); +} + +const command = new Command() + .description( + "Refresh the wmill-managed tsconfig.wmill.json (and Deno import map for Deno projects)" + ) + .option( + "--yes", + "Non-interactive: wire an existing custom tsconfig.json/deno.json to the managed file without prompting (a previously-generated config is always migrated automatically)." + ) + .action((async (opts: { yes?: boolean }) => { + await refreshTsconfig({ yes: opts.yes === true }); + }) as any); + +export default command; diff --git a/cli/src/commands/resource-type/resource-type.ts b/cli/src/commands/resource-type/resource-type.ts index c51e4bff3a..96c8429eb4 100644 --- a/cli/src/commands/resource-type/resource-type.ts +++ b/cli/src/commands/resource-type/resource-type.ts @@ -12,7 +12,6 @@ import { } from "../../types.ts"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace } from "../../core/context.ts"; -import { generateTsconfigForIde } from "./tsconfig.ts"; import { colors } from "@cliffy/ansi/colors"; import { Command } from "@cliffy/command"; import { Table } from "@cliffy/table"; @@ -190,8 +189,6 @@ export async function generateRTNamespace(opts: GlobalOptions) { "Created rt.d.ts with resource types namespace (RT) for TypeScript." ) ); - - await generateTsconfigForIde(); } const command = new Command() diff --git a/cli/src/commands/resource-type/tsconfig.ts b/cli/src/commands/resource-type/tsconfig.ts deleted file mode 100644 index 2aa2c5335b..0000000000 --- a/cli/src/commands/resource-type/tsconfig.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { execSync } from "node:child_process"; -import { existsSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import process from "node:process"; - -import { colors } from "@cliffy/ansi/colors"; -import * as log from "../../core/log.ts"; -import { readConfigFile } from "../../core/conf.ts"; - -export async function generateTsconfigForIde() { - const tsconfigPath = path.join(process.cwd(), "tsconfig.json"); - if (existsSync(tsconfigPath)) { - log.info(colors.gray("tsconfig.json already exists, skipping")); - return; - } - - let defaultTs: "bun" | "deno" = "bun"; - try { - const conf = await readConfigFile({ warnIfMissing: false }); - if (conf?.defaultTs === "deno") { - defaultTs = "deno"; - } - } catch { - // fall back to bun if wmill.yaml is missing or unreadable - } - - // Only reference bun-types in tsconfig if it's actually available; otherwise - // the IDE will flag the missing type definitions. - const bunTypesAvailable = - defaultTs === "bun" ? ensureBunTypesAvailable() : false; - - const tsconfig: { - compilerOptions: Record; - include: string[]; - } = { - compilerOptions: { - target: "ESNext", - module: "ESNext", - moduleResolution: "bundler", - noEmit: true, - strict: false, - }, - include: ["**/*.ts", "rt.d.ts"], - }; - - if (bunTypesAvailable) { - tsconfig.compilerOptions.types = ["bun-types"]; - } - - writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2) + "\n"); - log.info(colors.green("Created tsconfig.json for IDE type support.")); -} - -function ensureBunTypesAvailable(): boolean { - const cwd = process.cwd(); - if (existsSync(path.join(cwd, "node_modules", "bun-types"))) { - return true; - } - - try { - execSync("bun --version", { stdio: "ignore" }); - } catch { - log.info( - "Install bun (https://bun.sh) then run 'bun add -d bun-types' and add \"types\": [\"bun-types\"] to tsconfig.json for Bun API autocompletion." - ); - return false; - } - - try { - log.info( - colors.yellow("Installing bun-types with 'bun add -d bun-types'...") - ); - execSync("bun add -d bun-types", { stdio: "inherit" }); - log.info(colors.green("Installed bun-types.")); - return true; - } catch (e) { - log.warn( - `Failed to install bun-types automatically: ${ - e instanceof Error ? e.message : e - }` - ); - log.info( - "Run 'bun add -d bun-types' manually and add \"types\": [\"bun-types\"] to tsconfig.json for Bun API autocompletion." - ); - return false; - } -} diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 808456f151..717e8acf46 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -297,7 +297,9 @@ export async function handleFile( if ( !isAppInlineScriptPath(path) && !isFlowInlineScriptPath(path) && - !isRawAppBackendPath(path) && + // Raw-app files (frontend included) belong to the app bundle, never + // standalone scripts — pushed via pushRawApp, not here. + !isRawAppPath(path) && (!isScriptModulePath(path) || moduleEntryPoint) && exts.some((exts) => path.endsWith(exts)) ) { @@ -758,6 +760,9 @@ async function createScript( workspace: Workspace ): Promise { const start = performance.now(); + // Preserve any user draft at this path: a CLI / git-sync deploy must not wipe + // an in-progress draft the way a UI "deploy from draft" intentionally does. + body = { ...body, skip_draft_deletion: true }; // skip_if_noop asks the backend to treat deploys identical to the parent // (same content, lockfile, and metadata) as a no-op, so the CLI does not // produce phantom git-sync / promotion commits on re-pushes. @@ -1796,6 +1801,8 @@ async function setPermissionedAs( parent_hash: remote.hash, on_behalf_of_email: email, preserve_on_behalf_of: true, + // Preserve any user draft at this path (see backend skip_draft_deletion). + skip_draft_deletion: true, }, }); log.info(colors.green(`Updated permissioned_as for script ${scriptPath} to ${email}`)); diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 625fe69780..ed3b7828f1 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -22,6 +22,7 @@ import { showConflict, showDiff, extractNativeTriggerInfo, + redactEncryptionKey, } from "../../types.ts"; import { downloadZip } from "./pull.ts"; import { runLint, printReport, checkMissingLocks } from "../lint/lint.ts"; @@ -2503,8 +2504,20 @@ export async function pull( } if (opts.onlyCreateBranch) { - // Branch is checked out locally; the caller pushes it. Symmetric with - // the non-onlyCreateBranch path: CLI does branch + pull, never push. + // Branch-only publish: there is no commit here, so the GPG-cache-warmth + // invariant that motivated moving commit+push to the hub script (WIN-1974, + // #9284) does not apply — a bare `git push` of the (empty) branch ref needs + // no signing. The hub script only runs its in-process commit+push for the + // non-onlyCreateBranch path (`if (!only_create_branch) git_push(...)`), so + // the CLI MUST publish the fork branch here or it is never pushed at all. + gitSyncDeployPush({ + items: deployItems, + authorName: process.env["WM_USERNAME"] || "windmill", + authorEmail: process.env["WM_EMAIL"] || "windmill@windmill.dev", + committerName: opts.gitCommitterName, + committerEmail: opts.gitCommitterEmail, + onlyCreateBranch: true, + }); return; } } @@ -3040,12 +3053,13 @@ export async function gitDeploy( ...(opts.extraIncludes ?? []), ...includes.extraIncludes, ], - includeSchedules: opts.includeSchedules || includes.includeSchedules, - includeGroups: opts.includeGroups || includes.includeGroups, - includeUsers: opts.includeUsers || includes.includeUsers, - includeTriggers: opts.includeTriggers || includes.includeTriggers, - includeSettings: opts.includeSettings || includes.includeSettings, - includeKey: opts.includeKey || includes.includeKey, + // Workspace-wide mode force-includes the deployed default-excluded kinds + // (full mirror). Individual-branch/promotion mode forces nothing — these + // keys stay ABSENT so pull resolves them from the promotion target's + // effective wmill.yaml filters. Spreading (not setting `false`) is what + // makes the deferral work: an explicit `false` would clobber the effective + // config in pull's Object.assign-based option merge. + ...includes.forcedIncludes, promotion, } as any); } @@ -3095,16 +3109,22 @@ function prettyChanges( ), ); } else if (change.name === "edited") { + const changeType = getTypeStrFromPath(change.path); log.info( colors.yellow( - `~ ${getTypeStrFromPath(change.path)} ` + + `~ ${changeType} ` + displayPath + colors.gray(wsNote) + (change.codebase ? ` (codebase changed)` : ""), ), ); if (change.before != change.after) { - if (change.path.endsWith(".yaml")) { + if (changeType === "encryption_key") { + showDiff( + redactEncryptionKey(change.before), + redactEncryptionKey(change.after), + ); + } else if (change.path.endsWith(".yaml")) { try { showDiff( yamlStringify( @@ -4022,6 +4042,10 @@ export async function push( originalWorkspaceSpecificPath, permissionedAsContext, isWsSpecific ? true : undefined, + { + noninteractive: (opts.yes ?? false) || !process.stdin.isTTY, + skipReencrypt: opts.skipReencryptOnKeyChange, + }, ); if (stateTarget) { @@ -4042,10 +4066,11 @@ export async function push( continue; } if ( - change.path.endsWith(".script.json") || - change.path.endsWith(".script.yaml") || - change.path.endsWith(".lock") || - isFileResource(change.path) + !isRawAppFile(change.path) && + (change.path.endsWith(".script.json") || + change.path.endsWith(".script.yaml") || + change.path.endsWith(".lock") || + isFileResource(change.path)) ) { continue; } else if ( @@ -4107,6 +4132,10 @@ export async function push( localFilePath, // Pass the actual local file path permissionedAsContext, isAddedWsSpecific ? true : undefined, + { + noninteractive: (opts.yes ?? false) || !process.stdin.isTTY, + skipReencrypt: opts.skipReencryptOnKeyChange, + }, ); if (stateTarget) { @@ -4663,6 +4692,10 @@ const command = new Command() .option("--include-groups", "Include syncing groups") .option("--include-settings", "Include syncing workspace settings") .option("--include-key", "Include workspace encryption key") + .option( + "--skip-reencrypt-on-key-change", + "When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt.", + ) .option("--skip-branch-validation", "Skip git branch validation and prompts") .option("--json-output", "Output results in JSON format") .option( diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index 5656cfe9d4..c4fda73b1b 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -88,6 +88,7 @@ export interface SyncOptions { includeGroups?: boolean; includeSettings?: boolean; includeKey?: boolean; + skipReencryptOnKeyChange?: boolean; skipBranchValidation?: boolean; message?: string; includes?: string[]; diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index fc3ee0a773..31df17af6b 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -4,3 +4,10 @@ */ export const WM_FORK_PREFIX = "wm-fork"; + +// CLI version — source of truth. Release tooling (.github/change-versions*.sh) +// rewrites this line. Kept here, rather than in main.ts, so low-level modules +// (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.719.0"; diff --git a/cli/src/core/settings.ts b/cli/src/core/settings.ts index 4b86cb3469..e4b618180f 100644 --- a/cli/src/core/settings.ts +++ b/cli/src/core/settings.ts @@ -445,11 +445,23 @@ export async function pushWorkspaceSettings( } } +export interface PushWorkspaceKeyOptions { + // True when no prompt may be shown (e.g. `--yes` was passed or stdin is not a + // TTY). In that case the re-encryption decision is taken from `skipReencrypt` + // / the WMILL_NO_REENCRYPT_ON_KEY_CHANGE env var instead of an interactive + // confirmation. + noninteractive?: boolean; + // Explicit re-encryption decision from `--skip-reencrypt-on-key-change`. + // When set it takes precedence over the prompt and the env var. + skipReencrypt?: boolean; +} + export async function pushWorkspaceKey( workspace: string, _path: string, key: string | undefined, - localKey: string + localKey: string, + opts?: PushWorkspaceKeyOptions ) { try { key = await wmill @@ -461,17 +473,46 @@ export async function pushWorkspaceKey( throw new Error(`Failed to get workspace encryption key: ${err}`); } if (localKey && key !== localKey) { - const confirm = await Confirm.prompt({ - message: - "The local workspace encryption key does not match the remote. Do you want to reencrypt all your secrets on the remote with the new key?\nSay 'no' if your local secrets are already encrypted with the new key (e.g. workspace/instance migration)\nOtherwise, say 'yes' and pull the secrets after the reencryption.\n", - default: true, - }); + // Changing the key on the remote means the existing ciphertexts (encrypted + // with the old key) become unreadable unless they are re-encrypted. By + // default we ask the backend to re-encrypt every secret variable with the + // new key, which preserves their plaintext values. The only reason to skip + // re-encryption is when the stored ciphertexts are *already* encrypted with + // the new key (e.g. a workspace/instance migration). + let reencrypt: boolean; + // Explicit choice via `--skip-reencrypt-on-key-change` or the env var wins + // over everything, regardless of interactivity. + const explicitSkip = + opts?.skipReencrypt || + (process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE ?? "").toLowerCase() === + "true"; + if (explicitSkip) { + reencrypt = false; + log.info( + "Workspace encryption key changed; leaving remote ciphertexts untouched (skip re-encryption requested)." + ); + } else if (opts?.noninteractive) { + // No TTY (or --yes) and no explicit skip: we can't prompt, so default to + // re-encrypting (matches the interactive default) to preserve secret + // values. Pass --skip-reencrypt-on-key-change (or set + // WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true) to opt out. + reencrypt = true; + log.info( + "Workspace encryption key changed; re-encrypting all remote secrets with the new key (non-interactive)." + ); + } else { + reencrypt = await Confirm.prompt({ + message: + "The local workspace encryption key does not match the remote. Do you want to reencrypt all your secrets on the remote with the new key?\nSay 'no' if your local secrets are already encrypted with the new key (e.g. workspace/instance migration)\nOtherwise, say 'yes' and pull the secrets after the reencryption.\n", + default: true, + }); + } log.debug(`Updating workspace encryption key...`); await wmill.setWorkspaceEncryptionKey({ workspace, requestBody: { new_key: localKey, - skip_reencrypt: !confirm, + skip_reencrypt: !reencrypt, }, }); } else { diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index 65ccdf7c2a..61d92755fb 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -10,7 +10,7 @@ export const SKILLS: SkillMetadata[] = [ { name: "write-script-bash", description: "MUST use when writing Bash scripts.", languageKey: "bash" }, { name: "write-script-bigquery", description: "MUST use when writing BigQuery queries.", languageKey: "bigquery" }, { name: "write-script-bun", description: "MUST use when writing Bun/TypeScript scripts.", languageKey: "bun" }, - { name: "write-script-bunnative", description: "MUST use when writing Bun Native scripts.", languageKey: "bunnative" }, + { name: "write-script-bunnative", description: "MUST use when writing Bun Native scripts. The script must start with //native to run on the native worker.", languageKey: "bunnative" }, { name: "write-script-csharp", description: "MUST use when writing C# scripts.", languageKey: "csharp" }, { name: "write-script-deno", description: "MUST use when writing Deno/TypeScript scripts.", languageKey: "deno" }, { name: "write-script-duckdb", description: "MUST use when writing DuckDB queries.", languageKey: "duckdb" }, @@ -19,7 +19,6 @@ export const SKILLS: SkillMetadata[] = [ { name: "write-script-java", description: "MUST use when writing Java scripts.", languageKey: "java" }, { name: "write-script-mssql", description: "MUST use when writing MS SQL Server queries.", languageKey: "mssql" }, { name: "write-script-mysql", description: "MUST use when writing MySQL queries.", languageKey: "mysql" }, - { name: "write-script-nativets", description: "MUST use when writing Native TypeScript scripts.", languageKey: "nativets" }, { name: "write-script-php", description: "MUST use when writing PHP scripts.", languageKey: "php" }, { name: "write-script-postgresql", description: "MUST use when writing PostgreSQL queries.", languageKey: "postgresql" }, { name: "write-script-powershell", description: "MUST use when writing PowerShell scripts.", languageKey: "powershell" }, @@ -926,7 +925,7 @@ ducklake(name: string = "main"): SqlTemplateFunction `, "write-script-bunnative": `--- name: write-script-bunnative -description: MUST use when writing Bun Native scripts. +description: MUST use when writing Bun Native scripts. The script must start with //native to run on the native worker. --- ## CLI Commands @@ -966,13 +965,14 @@ Use \`wmill resource-type list --schema\` to discover available resource types. # TypeScript (Bun Native) -Native TypeScript execution with fetch only - no external imports allowed. +Native TypeScript execution. Native scripts are Bun scripts that run on the native worker — a lightweight V8 isolate that exposes \`fetch\` and the JavaScript standard library — and can be heavily parallelized. Every script MUST start with \`//native\` on its first line so Windmill routes it to the native worker; without it the exact same script runs on the regular Bun worker. You may import npm packages and other Windmill scripts (e.g. \`./helper.ts\`) — imports are resolved and bundled just like a regular Bun script — as long as everything (your code and its dependencies) relies only on \`fetch\` and the standard library. Libraries that need Node/Bun runtime APIs (filesystem, \`node:*\` modules, child processes, native addons) will not work on the native worker; use the regular \`bun\` language for those. ## Structure Export a single **async** function called \`main\`: \`\`\`typescript +//native export async function main(param1: string, param2: number) { // Your code here return { result: param1, count: param2 }; @@ -988,6 +988,7 @@ On Windmill, credentials and configuration are stored in resources and passed as Use the \`RT\` namespace for resource types: \`\`\`typescript +//native export async function main(stripe: RT.Stripe) { // stripe contains API key and config from the resource } @@ -999,9 +1000,10 @@ Before using a resource type, check the \`rt.d.ts\` file in the project root to ## Imports -**No imports allowed.** Use the globally available \`fetch\` function: +**The constraint is the runtime, not the import list.** You may import npm packages and relative Windmill scripts; they are resolved and bundled exactly like a regular Bun script. But the native worker only provides \`fetch\` and the JavaScript standard library, so any imported code must work using only those. Anything requiring Node/Bun built-ins (\`node:fs\`, \`child_process\`, the \`Bun\` API, native modules) belongs in a regular \`bun\` script instead. Use the globally available \`fetch\` for HTTP: \`\`\`typescript +//native export async function main(url: string) { const response = await fetch(url); return await response.json(); @@ -1010,13 +1012,14 @@ export async function main(url: string) { ## Windmill Client -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. +\`windmill-client\` is available for Windmill-specific primitives such as the S3 helpers below (\`loadS3File\`, \`loadS3FileStream\`, \`writeS3File\`, \`S3Object\`). Use \`fetch\` for plain HTTP. ## Preprocessor Scripts For preprocessor scripts, the function should be named \`preprocessor\` and receives an \`event\` parameter: \`\`\`typescript +//native type Event = { kind: | "webhook" @@ -1049,6 +1052,7 @@ Windmill provides built-in support for S3-compatible storage operations. The \`w ### Receiving an S3Object as a script parameter \`\`\`typescript +//native import * as wmill from "windmill-client"; export async function main(file: wmill.S3Object) { @@ -1060,6 +1064,7 @@ export async function main(file: wmill.S3Object) { ### S3 operations \`\`\`typescript +//native import * as wmill from "windmill-client"; // Load file content from S3 @@ -2976,682 +2981,6 @@ All keys are optional: \`prefix\` (object key prefix), \`storage\` (named storag omit to use the workspace default), \`format\` (\`json\` (default), \`parquet\`, or \`csv\`). Use this for large result sets — rows stream directly to S3 instead of being buffered as the script return value. -`, - "write-script-nativets": `--- -name: write-script-nativets -description: MUST use when writing Native TypeScript scripts. ---- - -## CLI Commands - -Place scripts in a folder. - -After writing, tell the user which command fits what they want to do: - -- \`wmill script preview \` — **default when iterating on a local script.** Runs the local file without deploying. -- \`wmill script run \` — runs the script **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. -- \`wmill generate-metadata\` — generate \`.script.yaml\` and \`.lock\` files for the script you modified. -- \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". - -### Preview vs run — choose by intent, not habit - -If the user says "run the script", "try it", "test it", "does it work" while there are **local edits to the script file**, use \`script preview\`. Do NOT push the script to then \`script run\` it — pushing is a deploy, and deploying just to test overwrites the workspace version with untested changes. - -Only use \`script run\` when: -- The user explicitly says "run the deployed version" / "run what's on the server". -- There is no local script being edited (you're just invoking an existing script). - -Only use \`sync push\` when: -- The user explicitly asks to deploy, publish, push, or ship. -- The preview has already validated the change and the user wants it in the workspace. - -### After writing — offer to test, don't wait passively - -If the user hasn't already told you to run/test/preview the script, offer it as a one-sentence next step (e.g. "Want me to run \`wmill script preview\` with sample args?"). Do not present a multi-option menu. - -If the user already asked to test/run/try the script in their original request, skip the offer and just execute \`wmill script preview -d ''\` directly — pick plausible args from the script's declared parameters. The shape varies by language: \`main(...)\` for code languages, the SQL dialect's own placeholder syntax (\`$1\` for PostgreSQL, \`?\` for MySQL/Snowflake, \`@P1\` for MSSQL, \`@name\` for BigQuery, etc.), positional \`$1\`, \`$2\`, … for Bash, \`param(...)\` for PowerShell. - -\`wmill script preview\` does not deploy, but it still executes script code and may cause side effects; run it yourself when the user asked to test/preview (or after confirming that execution is intended). \`wmill sync push\` and \`wmill generate-metadata\` modify workspace state or local files — only run these when the user explicitly asks; otherwise tell them which to run. - -For a **visual** open-the-script-in-the-dev-page preview (rather than \`script preview\`'s run-and-print-result), use the \`preview\` skill. - -Use \`wmill resource-type list --schema\` to discover available resource types. - -# TypeScript (Native) - -Native TypeScript execution with fetch only - no external imports allowed. - -## Structure - -Export a single **async** function called \`main\`: - -\`\`\`typescript -export async function main(param1: string, param2: number) { - // Your code here - return { result: param1, count: param2 }; -} -\`\`\` - -Do not call the main function. - -## Resource Types - -On Windmill, credentials and configuration are stored in resources and passed as parameters to main. - -Use the \`RT\` namespace for resource types: - -\`\`\`typescript -export async function main(stripe: RT.Stripe) { - // stripe contains API key and config from the resource -} -\`\`\` - -Only use resource types if you need them to satisfy the instructions. Always use the RT namespace. - -Before using a resource type, check the \`rt.d.ts\` file in the project root to see all available resource types and their fields. This file is generated by \`wmill resource-type generate-namespace\`. - -## Imports - -**No imports allowed.** Use the globally available \`fetch\` function: - -\`\`\`typescript -export async function main(url: string) { - const response = await fetch(url); - return await response.json(); -} -\`\`\` - -## Windmill Client - -The windmill client is not available in native TypeScript mode. Use fetch to call APIs directly. - -## Preprocessor Scripts - -For preprocessor scripts, the function should be named \`preprocessor\` and receives an \`event\` parameter: - -\`\`\`typescript -type Event = { - kind: - | "webhook" - | "http" - | "websocket" - | "kafka" - | "email" - | "nats" - | "postgres" - | "sqs" - | "mqtt" - | "gcp"; - body: any; - headers: Record; - query: Record; -}; - -export async function preprocessor(event: Event) { - return { - param1: event.body.field1, - param2: event.query.id - }; -} -\`\`\` - - -# TypeScript SDK (windmill-client) - -Import: import * as wmill from 'windmill-client' - -workerHasInternalServer(): boolean - -/** - * Initialize the Windmill client with authentication token and base URL - * @param token - Authentication token (defaults to WM_TOKEN env variable) - * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable) - */ -setClient(token?: string, baseUrl?: string): void - -/** - * Create a client configuration from env variables - * @returns client configuration - */ -getWorkspace(): string - -/** - * Get a resource value by path - * @param path path of the resource, default to internal state path - * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error - * @returns resource value - */ -async getResource(path?: string, undefinedIfEmpty?: boolean): Promise - -/** - * Get the true root job id - * @param jobId job id to get the root job id from (default to current job) - * @returns root job id - */ -async getRootJobId(jobId?: string): Promise - -/** - * @deprecated Use runScriptByPath or runScriptByHash instead - */ -async runScript(path: string | null = null, hash_: string | null = null, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Run a script synchronously by its path and wait for the result - * @param path - Script path in Windmill - * @param args - Arguments to pass to the script - * @param verbose - Enable verbose logging - * @returns Script execution result - */ -async runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Run a script synchronously by its hash and wait for the result - * @param hash_ - Script hash in Windmill - * @param args - Arguments to pass to the script - * @param verbose - Enable verbose logging - * @returns Script execution result - */ -async runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Append a text to the result stream - * @param text text to append to the result stream - */ -appendToResultStream(text: string): void - -/** - * Stream to the result stream - * @param stream stream to stream to the result stream - */ -async streamResult(stream: AsyncIterable): Promise - -/** - * Run a flow synchronously by its path and wait for the result - * @param path - Flow path in Windmill - * @param args - Arguments to pass to the flow - * @param verbose - Enable verbose logging - * @returns Flow execution result - */ -async runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false): Promise - -/** - * Wait for a job to complete and return its result - * @param jobId - ID of the job to wait for - * @param verbose - Enable verbose logging - * @returns Job result when completed - */ -async waitJob(jobId: string, verbose: boolean = false): Promise - -/** - * Get the result of a completed job - * @param jobId - ID of the completed job - * @returns Job result - */ -async getResult(jobId: string): Promise - -/** - * Get the result of a job if completed, or its current status - * @param jobId - ID of the job - * @returns Object with started, completed, success, and result properties - */ -async getResultMaybe(jobId: string): Promise - -/** - * @deprecated Use runScriptByPathAsync or runScriptByHashAsync instead - */ -async runScriptAsync(path: string | null, hash_: string | null, args: Record | null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a script asynchronously by its path - * @param path - Script path in Windmill - * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @returns Job ID of the created job - */ -async runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a script asynchronously by its hash - * @param hash_ - Script hash in Windmill - * @param args - Arguments to pass to the script - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @returns Job ID of the created job - */ -async runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null = null): Promise - -/** - * Run a flow asynchronously by its path - * @param path - Flow path in Windmill - * @param args - Arguments to pass to the flow - * @param scheduledInSeconds - Schedule execution for a future time (in seconds) - * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job) - * @returns Job ID of the created job - */ -async runFlowAsync(path: string | null, args: Record | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true): Promise - -/** - * Resolve a resource value in case the default value was picked because the input payload was undefined - * @param obj resource value or path of the resource under the format \`$res:path\` - * @returns resource value - */ -async resolveDefaultResource(obj: any): Promise - -/** - * Get the state file path from environment variables - * @returns State path string - */ -getStatePath(): string - -/** - * Set a resource value by path - * @param path path of the resource to set, default to state path - * @param value new value of the resource to set - * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type - */ -async setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise - -/** - * Set the state - * @param state state to set - * @deprecated use setState instead - */ -async setInternalState(state: any): Promise - -/** - * Set the state - * @param state state to set - * @param path Optional state resource path override. Defaults to \`getStatePath()\`. - */ -async setState(state: any, path?: string): Promise - -/** - * Set the progress - * Progress cannot go back and limited to 0% to 99% range - * @param percent Progress to set in % - * @param jobId? Job to set progress for - */ -async setProgress(percent: number, jobId?: any): Promise - -/** - * Get the progress - * @param jobId? Job to get progress from - * @returns Optional clamped between 0 and 100 progress value - */ -async getProgress(jobId?: any): Promise - -/** - * Set a flow user state - * @param key key of the state - * @param value value of the state - */ -async setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise - -/** - * Get a flow user state - * @param path path of the variable - */ -async getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise - -/** - * Get the internal state - * @deprecated use getState instead - */ -async getInternalState(): Promise - -/** - * Get the state shared across executions - * @param path Optional state resource path override. Defaults to \`getStatePath()\`. - */ -async getState(path?: string): Promise - -/** - * Get a variable by path - * @param path path of the variable - * @returns variable value - */ -async getVariable(path: string): Promise - -/** - * Set a variable by path, create if not exist - * @param path path of the variable - * @param value value of the variable - * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false) - * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: "") - */ -async setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise - -/** - * Build a PostgreSQL connection URL from a database resource - * @param path - Path to the database resource - * @returns PostgreSQL connection URL string - */ -async databaseUrlFromResource(path: string): Promise - -async polarsConnectionSettings(s3_resource_path: string | undefined): Promise - -async duckdbConnectionSettings(s3_resource_path: string | undefined): Promise - -/** - * Get S3 client settings from a resource or workspace default - * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined) - * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) - * @returns S3 client configuration settings - */ -async denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise - -/** - * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * \`\`\`typescript - * let fileContent = await wmill.loadS3FileContent(inputFile) - * // if the file is a raw text file, it can be decoded and printed directly: - * const text = new TextDecoder().decode(fileContentStream) - * console.log(text); - * \`\`\` - * - * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) - */ -async loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise - -/** - * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * \`\`\`typescript - * let fileContentBlob = await wmill.loadS3FileStream(inputFile) - * // if the content is plain text, the blob can be read directly: - * console.log(await fileContentBlob.text()); - * \`\`\` - * - * @param workspace - Workspace to read from (defaults to the \`WM_WORKSPACE\` env var) - */ -async loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise - -/** - * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource. - * - * \`\`\`typescript - * const s3object = await writeS3File(s3Object, "Hello Windmill!") - * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8') - * console.log(fileContentAsUtf8Str) - * \`\`\` - * - * @param workspace - Workspace to write to (defaults to the \`WM_WORKSPACE\` env var) - */ -async writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise - -/** - * Permanently delete a file from S3 by key. - * - * \`\`\`typescript - * await wmill.deleteS3File({ s3: "path/to/file.txt" }) - * \`\`\` - * - * @param s3object - S3 object identifying the file to delete (must have \`s3\` set) - * @param workspace - Workspace to delete from (defaults to the \`WM_WORKSPACE\` env var) - */ -async deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise - -/** - * Sign S3 objects to be used by anonymous users in public apps - * @param s3objects s3 objects to sign - * @returns signed s3 objects - */ -async signS3Objects(s3objects: S3Object[]): Promise - -/** - * Sign S3 object to be used by anonymous users in public apps - * @param s3object s3 object to sign - * @returns signed s3 object - */ -async signS3Object(s3object: S3Object): Promise - -/** - * Generate a presigned public URL for an array of S3 objects. - * If an S3 object is not signed yet, it will be signed first. - * @param s3Objects s3 objects to sign - * @returns list of signed public URLs - */ -async getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl }: { baseUrl?: string } = {}): Promise - -/** - * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first. - * @param s3Object s3 object to sign - * @returns signed public URL - */ -async getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl }: { baseUrl?: string } = {}): Promise - -/** - * Get URLs needed for resuming a flow after this step - * @param approver approver name - * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step. - * This allows pre-approvals that can be consumed by any later suspend step in the same flow. - * @returns approval page UI URL, resume and cancel API URLs for resuming the flow - */ -async getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{ - approvalPage: string; - resume: string; - cancel: string; -}> - -/** - * @deprecated use getResumeUrls instead - */ -getResumeEndpoints(approver?: string): Promise<{ - approvalPage: string; - resume: string; - cancel: string; -}> - -/** - * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only) - * @param audience audience of the token - * @param expiresIn Optional number of seconds until the token expires - * @returns jwt token - */ -async getIdToken(audience: string, expiresIn?: number): Promise - -/** - * Convert a base64-encoded string to Uint8Array - * @param data - Base64-encoded string - * @returns Decoded Uint8Array - */ -base64ToUint8Array(data: string): Uint8Array - -/** - * Convert a Uint8Array to base64-encoded string - * @param arrayBuffer - Uint8Array to encode - * @returns Base64-encoded string - */ -uint8ArrayToBase64(arrayBuffer: Uint8Array): string - -/** - * Get email from workspace username - * This method is particularly useful for apps that require the email address of the viewer. - * Indeed, in the viewer context, WM_USERNAME is set to the username of the viewer but WM_EMAIL is set to the email of the creator of the app. - * @param username - * @returns email address - */ -async usernameToEmail(username: string): Promise - -/** - * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields. - * - * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form** - * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). - * - * @param {Object} options - The configuration options for the Slack approval request. - * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill. - * @param {string} options.channelId - The Slack channel ID where the approval request will be sent. - * @param {string} [options.message] - Optional custom message to include in the Slack approval request. - * @param {string} [options.approver] - Optional user ID or name of the approver for the request. - * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. - * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. - * @param {string} [options.resumeButtonText] - Optional text for the resume button. - * @param {string} [options.cancelButtonText] - Optional text for the cancel button. - * - * @returns {Promise} Resolves when the Slack approval request is successfully sent. - * - * @throws {Error} If the function is not called within a flow or flow preview. - * @throws {Error} If the \`JobService.getSlackApprovalPayload\` call fails. - * - * **Usage Example:** - * \`\`\`typescript - * await requestInteractiveSlackApproval({ - * slackResourcePath: "/u/alex/my_slack_resource", - * channelId: "admins-slack-channel", - * message: "Please approve this request", - * approver: "approver123", - * defaultArgsJson: { key1: "value1", key2: 42 }, - * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, - * resumeButtonText: "Resume", - * cancelButtonText: "Cancel", - * }); - * \`\`\` - * - * **Note:** This function requires execution within a Windmill flow or flow preview. - */ -async requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise - -/** - * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields. - * - * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form** - * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form). - * - * @param {Object} options - The configuration options for the Teams approval request. - * @param {string} options.teamName - The Teams team name where the approval request will be sent. - * @param {string} options.channelName - The Teams channel name where the approval request will be sent. - * @param {string} [options.message] - Optional custom message to include in the Teams approval request. - * @param {string} [options.approver] - Optional user ID or name of the approver for the request. - * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field. - * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field. - * - * @returns {Promise} Resolves when the Teams approval request is successfully sent. - * - * @throws {Error} If the function is not called within a flow or flow preview. - * @throws {Error} If the \`JobService.getTeamsApprovalPayload\` call fails. - * - * **Usage Example:** - * \`\`\`typescript - * await requestInteractiveTeamsApproval({ - * teamName: "admins-teams", - * channelName: "admins-teams-channel", - * message: "Please approve this request", - * approver: "approver123", - * defaultArgsJson: { key1: "value1", key2: 42 }, - * dynamicEnumsJson: { foo: ["choice1", "choice2"], bar: ["optionA", "optionB"] }, - * }); - * \`\`\` - * - * **Note:** This function requires execution within a Windmill flow or flow preview. - */ -async requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise - -/** - * Parse an S3 object from URI string or record format - * @param s3Object - S3 object as URI string (s3://storage/key) or record - * @returns S3 object record with storage and s3 key - */ -parseS3Object(s3Object: S3Object): S3ObjectRecord - -setWorkflowCtx(ctx: WorkflowCtx | null): void - -async sleep(seconds: number): Promise - -async step(name: string, fn: () => T | Promise): Promise - -/** - * Create a task that dispatches to a separate Windmill script. - * - * @example - * const extract = taskScript("f/data/extract"); - * // inside workflow: await extract({ url: "https://..." }) - */ -taskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike - -/** - * Create a task that dispatches to a separate Windmill flow. - * - * @example - * const pipeline = taskFlow("f/etl/pipeline"); - * // inside workflow: await pipeline({ input: data }) - */ -taskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike - -/** - * Mark an async function as a workflow-as-code entry point. - * - * The function must be **deterministic**: given the same inputs it must call - * tasks in the same order on every replay. Branching on task results is fine - * (results are replayed from checkpoint), but branching on external state - * (current time, random values, external API calls) must use \`step()\` to - * checkpoint the value so replays see the same result. - */ -workflow(fn: (...args: any[]) => Promise): void - -/** - * Suspend the workflow and wait for an external approval. - * - * Use \`getResumeUrls()\` (wrapped in \`step()\`) to obtain resume/cancel/approvalPage - * URLs before calling this function. - * - * @example - * const urls = await step("urls", () => getResumeUrls()); - * await step("notify", () => sendEmail(urls.approvalPage)); - * const { value, approver } = await waitForApproval({ timeout: 3600 }); - */ -waitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; }): PromiseLike<{ value: any; approver: string; approved: boolean }> - -/** - * Process items in parallel with optional concurrency control. - * - * Each item is processed by calling \`fn(item)\`, which should be a task(). - * Items are dispatched in batches of \`concurrency\` (default: all at once). - * - * @example - * const process = task(async (item: string) => { ... }); - * const results = await parallel(items, process, { concurrency: 5 }); - */ -async parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise - -/** - * Commit Kafka offsets for a trigger with auto_commit disabled. - * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path) - * @param topic - Kafka topic name (from event.topic) - * @param partition - Partition number (from event.partition) - * @param offset - Message offset to commit (from event.offset) - */ -async commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise - -/** - * Create a SQL template function for PostgreSQL/datatable queries - * @param name - Database/datatable name (default: "main") - * @returns SQL template function for building parameterized queries - * @example - * let sql = wmill.datatable() - * let name = 'Robin' - * let age = 21 - * await sql\` - * SELECT * FROM friends - * WHERE name = \${name} AND age = \${age}::int - * \`.fetch() - */ -datatable(name: string = "main"): DatatableSqlTemplateFunction - -/** - * Create a SQL template function for DuckDB/ducklake queries - * @param name - DuckDB database name (default: "main") - * @returns SQL template function for building parameterized queries - * @example - * let sql = wmill.ducklake() - * let name = 'Robin' - * let age = 21 - * await sql\` - * SELECT * FROM friends - * WHERE name = \${name} AND age = \${age} - * \`.fetch() - */ -ducklake(name: string = "main"): SqlTemplateFunction `, "write-script-php": `--- name: write-script-php @@ -5167,7 +4496,7 @@ Once the flow has real content, **offer** to open the visual preview as a one-se After writing, tell the user which command fits what they want to do: -- \`wmill flow preview \` — **default when iterating on a local flow.** Runs the local \`flow.yaml\` against local inline scripts without deploying. Add \`--remote\` to use deployed workspace scripts for PathScript steps instead of local files. +- \`wmill flow preview \` — **default when iterating on a local flow.** Runs the local \`flow.yaml\` against local inline scripts without deploying. Add \`--remote\` to use deployed workspace scripts for PathScript steps instead of local files. Add \`--step \` to run only one module in isolation (see "Single-step vs whole-flow preview" below). - \`wmill flow run \` — runs the flow **already deployed** in the workspace. Use only when the user explicitly wants to test the deployed version, not local edits. - \`wmill generate-metadata\` — regenerate stale \`.lock\` and \`.script.yaml\` files. By default it scans **scripts, flows, and apps** across the workspace; pass \`--skip-flows --skip-apps\` (or run from a subdirectory) to limit the scope when you only care about the flow you edited. - \`wmill sync push\` — deploy local changes to the workspace. Only suggest/run this when the user explicitly asks to deploy/publish/push — not when they say "run", "try", or "test". @@ -5184,6 +4513,12 @@ Only use \`sync push\` when: - The user explicitly asks to deploy, publish, push, or ship. - The preview has already validated the change and the user wants it in the workspace. +### Single-step vs whole-flow preview + +Use \`flow preview --step \` when the user is iterating on one module and the flow's upstream steps aren't part of what they're trying to validate. It runs only that step's runnable (rawscript: the inline script; script: the PathScript, locally if available; flow: the subflow by path) and is much faster than running the whole flow when previous steps are slow or expensive. The step id is resolved by walking nested branchone/branchall/forloopflow/whileloopflow modules and includes the special \`preprocessor\` and \`failure\` modules. + +Use \`flow preview \` (no \`--step\`) when steps depend on each other's outputs, when the user is validating the overall control flow, or when \`--step\` doesn't apply (branchone, branchall, forloopflow, whileloopflow, identity, and AI agent steps cannot themselves be tested in isolation — for branchone/branchall/forloopflow/whileloopflow, the *contained* steps can, by passing the inner step's id). + ### After writing — offer to run, don't wait passively This is about **programmatic execution** (\`wmill flow preview -d ''\`), which actually runs the flow and has side effects. Visual preview (the \`preview\` skill) is offered separately — see "Visual preview" below. @@ -5496,7 +4831,7 @@ Reference a specific resource using \`$res:\` prefix: ## OpenFlow Schema -{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"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')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`, +{"OpenFlow":{"type":"object","description":"Top-level flow definition containing metadata, configuration, and the flow structure","properties":{"summary":{"type":"string","description":"Short description of what this flow does"},"description":{"type":"string","description":"Detailed documentation for this flow"},"value":{"$ref":"#/components/schemas/FlowValue"},"schema":{"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')"},"on_behalf_of_email":{"type":"string","description":"The flow will be run with the permissions of the user with this email."}},"required":["summary","value"]},"FlowValue":{"type":"object","description":"The flow structure containing modules and optional preprocessor/failure handlers","properties":{"modules":{"type":"array","description":"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch","items":{"$ref":"#/components/schemas/FlowModule"}},"failure_module":{"description":"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types","$ref":"#/components/schemas/FlowModule"},"preprocessor_module":{"description":"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results","$ref":"#/components/schemas/FlowModule"},"same_worker":{"type":"boolean","description":"If true, all steps run on the same worker for better performance"},"preserve_step_tags":{"type":"boolean","description":"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag."},"concurrent_limit":{"type":"number","description":"Maximum number of concurrent executions of this flow"},"concurrency_key":{"type":"string","description":"Expression to group concurrent executions (e.g., by user ID)"},"concurrency_time_window_s":{"type":"number","description":"Time window in seconds for concurrent_limit"},"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce flow executions"},"debounce_key":{"type":"string","description":"Expression to group debounced executions"},"debounce_args_to_accumulate":{"type":"array","description":"Arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds that a job can be debounced"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of times a job can be debounced"},"skip_expr":{"type":"string","description":"JavaScript expression to conditionally skip the entire flow"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for flow results"},"cache_ignore_s3_path":{"type":"boolean"},"delete_after_secs":{"type":"integer","description":"If set, delete the flow job's args, result and logs after this many seconds following job completion"},"flow_env":{"type":"object","description":"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).","additionalProperties":{}},"priority":{"type":"number","description":"Execution priority (higher numbers run first)"},"early_return":{"type":"string","description":"JavaScript expression to return early from the flow"},"chat_input_enabled":{"type":"boolean","description":"Whether this flow accepts chat-style input"},"notes":{"type":"array","description":"Sticky notes attached to the flow","items":{"$ref":"#/components/schemas/FlowNote"}},"groups":{"type":"array","description":"Semantic groups of modules for organizational purposes","items":{"$ref":"#/components/schemas/FlowGroup"}}},"required":["modules"]},"Retry":{"type":"object","description":"Retry configuration for failed module executions","properties":{"constant":{"type":"object","description":"Retry with constant delay between attempts","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"seconds":{"type":"integer","description":"Seconds to wait between retries"}}},"exponential":{"type":"object","description":"Retry with exponential backoff (delay doubles each time)","properties":{"attempts":{"type":"integer","description":"Number of retry attempts"},"multiplier":{"type":"integer","description":"Multiplier for exponential backoff"},"seconds":{"type":"integer","minimum":1,"description":"Initial delay in seconds"},"random_factor":{"type":"integer","minimum":0,"maximum":100,"description":"Random jitter percentage (0-100) to avoid thundering herd"}}},"retry_if":{"$ref":"#/components/schemas/RetryIf"}}},"FlowNote":{"type":"object","description":"A sticky note attached to a flow for documentation and annotation","properties":{"id":{"type":"string","description":"Unique identifier for the note"},"text":{"type":"string","description":"Content of the note"},"position":{"type":"object","description":"Position of the note in the flow editor","properties":{"x":{"type":"number","description":"X coordinate"},"y":{"type":"number","description":"Y coordinate"}},"required":["x","y"]},"size":{"type":"object","description":"Size of the note in the flow editor","properties":{"width":{"type":"number","description":"Width in pixels"},"height":{"type":"number","description":"Height in pixels"}},"required":["width","height"]},"color":{"type":"string","description":"Color of the note (e.g., \\"yellow\\", \\"#ffff00\\")"},"type":{"type":"string","enum":["free","group"],"description":"Type of note - 'free' for standalone notes, 'group' for notes that group other nodes"},"locked":{"type":"boolean","default":false,"description":"Whether the note is locked and cannot be edited or moved"},"contained_node_ids":{"type":"array","items":{"type":"string"},"description":"For group notes, the IDs of nodes contained within this group"}},"required":["id","text","color","type"]},"FlowGroup":{"type":"object","description":"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.","properties":{"summary":{"type":"string","description":"Display name for this group"},"note":{"type":"string","description":"Markdown note shown below the group header"},"autocollapse":{"type":"boolean","default":false,"description":"If true, this group is collapsed by default in the flow editor. UI hint only."},"start_id":{"type":"string","description":"ID of the first flow module in this group (topological entry point)"},"end_id":{"type":"string","description":"ID of the last flow module in this group (topological exit point)"},"color":{"type":"string","description":"Color for the group in the flow editor"}},"required":["start_id","end_id"]},"RetryIf":{"type":"object","description":"Conditional retry based on error or result","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables"}},"required":["expr"]},"StopAfterIf":{"type":"object","description":"Early termination condition for a module","properties":{"skip_if_stopped":{"type":"boolean","description":"If true, following steps are skipped when this condition triggers"},"expr":{"type":"string","description":"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop"},"error_message":{"type":"string","nullable":true,"description":"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised."},"error_include_result":{"type":"boolean","description":"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false."}},"required":["expr"]},"FlowModule":{"type":"object","description":"A single step in a flow. Can be a script, subflow, loop, or branch","properties":{"id":{"type":"string","description":"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)"},"value":{"$ref":"#/components/schemas/FlowModuleValue"},"stop_after_if":{"description":"Early termination condition evaluated after this step completes","$ref":"#/components/schemas/StopAfterIf"},"stop_after_all_iters_if":{"description":"For loops only - early termination condition evaluated after all iterations complete","$ref":"#/components/schemas/StopAfterIf"},"skip_if":{"type":"object","description":"Conditionally skip this step based on previous results or flow inputs","properties":{"expr":{"type":"string","description":"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'"}},"required":["expr"]},"sleep":{"description":"Delay before executing this step (in seconds or as expression)","$ref":"#/components/schemas/InputTransform"},"cache_ttl":{"type":"number","description":"Cache duration in seconds for this step's results"},"cache_ignore_s3_path":{"type":"boolean"},"timeout":{"description":"Maximum execution time in seconds (static value or expression)","$ref":"#/components/schemas/InputTransform"},"delete_after_secs":{"type":"integer","description":"If set, delete the step's args, result and logs after this many seconds following job completion"},"summary":{"type":"string","description":"Short description of what this step does"},"mock":{"type":"object","description":"Mock configuration for testing without executing the actual step","properties":{"enabled":{"type":"boolean","description":"If true, return mock value instead of executing"},"return_value":{"description":"Value to return when mocked"}}},"suspend":{"type":"object","description":"Configuration for approval/resume steps that wait for user input","properties":{"required_events":{"type":"integer","description":"Number of approvals required before continuing"},"timeout":{"type":"integer","description":"Timeout in seconds before auto-continuing or canceling"},"resume_form":{"type":"object","description":"Form schema for collecting input when resuming","properties":{"schema":{"type":"object","description":"JSON Schema for the resume form"}}},"user_auth_required":{"type":"boolean","description":"If true, only authenticated users can approve"},"user_groups_required":{"description":"Expression or list of groups that can approve","$ref":"#/components/schemas/InputTransform"},"self_approval_disabled":{"type":"boolean","description":"If true, the user who started the flow cannot approve"},"hide_cancel":{"type":"boolean","description":"If true, hide the cancel button on the approval form"},"continue_on_disapprove_timeout":{"type":"boolean","description":"If true, continue flow on timeout instead of canceling"}}},"priority":{"type":"number","description":"Execution priority for this step (higher numbers run first)"},"continue_on_error":{"type":"boolean","description":"If true, flow continues even if this step fails"},"retry":{"description":"Retry configuration if this step fails","$ref":"#/components/schemas/Retry"},"debouncing":{"description":"Debounce configuration for this step (EE only)","type":"object","properties":{"debounce_delay_s":{"type":"integer","description":"Delay in seconds to debounce this step's executions across flow runs"},"debounce_key":{"type":"string","description":"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-"},"debounce_args_to_accumulate":{"type":"array","description":"Array-type arguments to accumulate across debounced executions","items":{"type":"string"}},"max_total_debouncing_time":{"type":"integer","description":"Maximum total time in seconds before forced execution"},"max_total_debounces_amount":{"type":"integer","description":"Maximum number of debounces before forced execution"}}}},"required":["value","id"]},"InputTransform":{"description":"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs","oneOf":[{"$ref":"#/components/schemas/StaticTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"StaticTransform":{"type":"object","description":"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'","properties":{"value":{"description":"The static value. For resources, use format '$res:path/to/resource'"},"type":{"type":"string","enum":["static"]}},"required":["type"]},"JavascriptTransform":{"type":"object","description":"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside loops, use 'flow_input.iter.value' for the current iteration value","properties":{"expr":{"type":"string","description":"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)"},"type":{"type":"string","enum":["javascript"]}},"required":["expr","type"]},"AiTransform":{"type":"object","description":"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.","properties":{"type":{"type":"string","enum":["ai"]}},"required":["type"]},"AIProviderKind":{"type":"string","description":"Supported AI provider types","enum":["openai","azure_openai","anthropic","mistral","deepseek","googleai","groq","openrouter","togetherai","customai","aws_bedrock"]},"ProviderConfig":{"type":"object","description":"Complete AI provider configuration with resource reference and model selection","properties":{"kind":{"$ref":"#/components/schemas/AIProviderKind"},"resource":{"type":"string","description":"Resource reference in format '$res:{resource_path}' pointing to provider credentials"},"model":{"type":"string","description":"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')"}},"required":["kind","resource","model"]},"StaticProviderTransform":{"type":"object","description":"Static provider configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/ProviderConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"ProviderTransform":{"description":"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticProviderTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticProviderTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"MemoryOff":{"type":"object","description":"No conversation memory/context","properties":{"kind":{"type":"string","enum":["off"]}},"required":["kind"]},"MemoryAuto":{"type":"object","description":"Automatic context management","properties":{"kind":{"type":"string","enum":["auto"]},"context_length":{"type":"integer","description":"Maximum number of messages to retain in context"},"memory_id":{"type":"string","description":"Identifier for persistent memory across agent invocations"}},"required":["kind"]},"MemoryMessage":{"type":"object","description":"A single message in conversation history","properties":{"role":{"type":"string","enum":["user","assistant","system"]},"content":{"type":"string"}},"required":["role","content"]},"MemoryManual":{"type":"object","description":"Explicit message history","properties":{"kind":{"type":"string","enum":["manual"]},"messages":{"type":"array","items":{"$ref":"#/components/schemas/MemoryMessage"}}},"required":["kind","messages"]},"MemoryConfig":{"description":"Conversation memory configuration","oneOf":[{"$ref":"#/components/schemas/MemoryOff"},{"$ref":"#/components/schemas/MemoryAuto"},{"$ref":"#/components/schemas/MemoryManual"}],"discriminator":{"propertyName":"kind","mapping":{"off":"#/components/schemas/MemoryOff","auto":"#/components/schemas/MemoryAuto","manual":"#/components/schemas/MemoryManual"}}},"StaticMemoryTransform":{"type":"object","description":"Static memory configuration passed directly to the AI agent","properties":{"value":{"$ref":"#/components/schemas/MemoryConfig"},"type":{"type":"string","enum":["static"]}},"required":["type","value"]},"MemoryTransform":{"description":"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined","oneOf":[{"$ref":"#/components/schemas/StaticMemoryTransform"},{"$ref":"#/components/schemas/JavascriptTransform"},{"$ref":"#/components/schemas/AiTransform"}],"discriminator":{"propertyName":"type","mapping":{"static":"#/components/schemas/StaticMemoryTransform","javascript":"#/components/schemas/JavascriptTransform","ai":"#/components/schemas/AiTransform"}}},"FlowModuleValue":{"description":"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type","oneOf":[{"$ref":"#/components/schemas/RawScript"},{"$ref":"#/components/schemas/PathScript"},{"$ref":"#/components/schemas/PathFlow"},{"$ref":"#/components/schemas/ForloopFlow"},{"$ref":"#/components/schemas/WhileloopFlow"},{"$ref":"#/components/schemas/BranchOne"},{"$ref":"#/components/schemas/BranchAll"},{"$ref":"#/components/schemas/Identity"},{"$ref":"#/components/schemas/AiAgent"}],"discriminator":{"propertyName":"type","mapping":{"rawscript":"#/components/schemas/RawScript","script":"#/components/schemas/PathScript","flow":"#/components/schemas/PathFlow","forloopflow":"#/components/schemas/ForloopFlow","whileloopflow":"#/components/schemas/WhileloopFlow","branchone":"#/components/schemas/BranchOne","branchall":"#/components/schemas/BranchAll","identity":"#/components/schemas/Identity","aiagent":"#/components/schemas/AiAgent"}}},"RawScript":{"type":"object","description":"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"content":{"type":"string","description":"The script source code. Should export a 'main' function"},"language":{"type":"string","description":"Programming language for this script","enum":["deno","bun","python3","go","bash","powershell","postgresql","mysql","bigquery","snowflake","mssql","oracledb","graphql","nativets","php","rust","ansible","csharp","nu","java","ruby","rlang","duckdb"]},"path":{"type":"string","description":"Optional path for saving this script"},"lock":{"type":"string","description":"Lock file content for dependencies"},"type":{"type":"string","enum":["rawscript"]},"tag":{"type":"string","description":"Worker group tag for execution routing"},"concurrent_limit":{"type":"number","description":"Maximum concurrent executions of this script"},"concurrency_time_window_s":{"type":"number","description":"Time window for concurrent_limit"},"custom_concurrency_key":{"type":"string","description":"Custom key for grouping concurrent executions"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"},"assets":{"type":"array","description":"External resources this script accesses (S3 objects, resources, etc.)","items":{"type":"object","required":["path","kind"],"properties":{"path":{"type":"string","description":"Path to the asset"},"kind":{"type":"string","description":"Type of asset","enum":["s3object","resource","ducklake","datatable","volume"]},"access_type":{"type":"string","nullable":true,"description":"Access level for this asset","enum":["r","w","rw"]},"alt_access_type":{"type":"string","nullable":true,"description":"Alternative access level","enum":["r","w","rw"]}}}}},"required":["type","content","language","input_transforms"]},"PathScript":{"type":"object","description":"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the script in the workspace (e.g., 'f/scripts/send_email')"},"hash":{"type":"string","description":"Optional specific version hash of the script to use"},"type":{"type":"string","enum":["script"]},"tag_override":{"type":"string","description":"Override the script's default worker group tag"},"is_trigger":{"type":"boolean","description":"If true, this script is a trigger that can start the flow"}},"required":["type","path","input_transforms"]},"PathFlow":{"type":"object","description":"Reference to an existing flow by path. Use this to call another flow as a subflow","properties":{"input_transforms":{"type":"object","description":"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments","additionalProperties":{"$ref":"#/components/schemas/InputTransform"}},"path":{"type":"string","description":"Path to the flow in the workspace (e.g., 'f/flows/process_user')"},"type":{"type":"string","enum":["flow"]}},"required":["type","path","input_transforms"]},"ForloopFlow":{"type":"object","description":"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations","properties":{"modules":{"type":"array","description":"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'","items":{"$ref":"#/components/schemas/FlowModule"}},"iterator":{"description":"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'","$ref":"#/components/schemas/InputTransform"},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["forloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","iterator","skip_failures","type"]},"WhileloopFlow":{"type":"object","description":"Executes nested modules repeatedly while a condition is true. The loop checks the condition after each iteration. Use stop_after_if on modules to control loop termination","properties":{"modules":{"type":"array","description":"Steps to execute in each iteration. Use stop_after_if to control when the loop ends","items":{"$ref":"#/components/schemas/FlowModule"}},"skip_failures":{"type":"boolean","description":"If true, iteration failures don't stop the loop. Failed iterations return null"},"type":{"type":"string","enum":["whileloopflow"]},"parallel":{"type":"boolean","description":"If true, iterations run concurrently (use with caution in while loops)"},"parallelism":{"description":"Maximum number of concurrent iterations when parallel=true","$ref":"#/components/schemas/InputTransform"},"squash":{"type":"boolean"}},"required":["modules","skip_failures","type"]},"BranchOne":{"type":"object","description":"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes","properties":{"branches":{"type":"array","description":"Array of branches to evaluate in order. The first branch with expr evaluating to true executes","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch condition"},"expr":{"type":"string","description":"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins"},"modules":{"type":"array","description":"Steps to execute if this branch's expr is true","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules","expr"]}},"default":{"type":"array","description":"Steps to execute if no branch expressions match","items":{"$ref":"#/components/schemas/FlowModule"}},"type":{"type":"string","enum":["branchone"]}},"required":["branches","default","type"]},"BranchAll":{"type":"object","description":"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently","properties":{"branches":{"type":"array","description":"Array of branches that all execute (either in parallel or sequentially)","items":{"type":"object","properties":{"summary":{"type":"string","description":"Short description of this branch's purpose"},"skip_failure":{"type":"boolean","description":"If true, failure in this branch doesn't fail the entire flow"},"modules":{"type":"array","description":"Steps to execute in this branch","items":{"$ref":"#/components/schemas/FlowModule"}}},"required":["modules"]}},"type":{"type":"string","enum":["branchall"]},"parallel":{"type":"boolean","description":"If true, all branches execute concurrently. If false, they execute sequentially"}},"required":["branches","type"]},"AgentTool":{"type":"object","description":"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool","properties":{"id":{"type":"string","description":"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')"},"summary":{"type":"string","description":"Short description of what this tool does (shown to the AI)"},"value":{"$ref":"#/components/schemas/ToolValue"}},"required":["id","value"]},"ToolValue":{"description":"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference","oneOf":[{"$ref":"#/components/schemas/FlowModuleTool"},{"$ref":"#/components/schemas/McpToolValue"},{"$ref":"#/components/schemas/WebsearchToolValue"}],"discriminator":{"propertyName":"tool_type","mapping":{"flowmodule":"#/components/schemas/FlowModuleTool","mcp":"#/components/schemas/McpToolValue","websearch":"#/components/schemas/WebsearchToolValue"}}},"FlowModuleTool":{"description":"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module","allOf":[{"type":"object","properties":{"tool_type":{"type":"string","enum":["flowmodule"]}},"required":["tool_type"]},{"$ref":"#/components/schemas/FlowModuleValue"}]},"WebsearchToolValue":{"type":"object","description":"A tool implemented as a websearch tool. The AI can call this like any other websearch tool","properties":{"tool_type":{"type":"string","enum":["websearch"]}},"required":["tool_type"]},"McpToolValue":{"type":"object","description":"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers","properties":{"tool_type":{"type":"string","enum":["mcp"]},"resource_path":{"type":"string","description":"Path to the MCP resource/server configuration"},"include_tools":{"type":"array","description":"Whitelist of specific tools to include from this MCP server","items":{"type":"string"}},"exclude_tools":{"type":"array","description":"Blacklist of tools to exclude from this MCP server","items":{"type":"string"}}},"required":["tool_type","resource_path"]},"AiAgent":{"type":"object","description":"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task","properties":{"input_transforms":{"type":"object","description":"Input parameters for the AI agent mapped to their values","properties":{"provider":{"$ref":"#/components/schemas/ProviderTransform"},"output_type":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n"},"user_message":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"The user's prompt/message to the AI agent. Supports variable interpolation with flow.input syntax."},"system_prompt":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"System instructions that guide the AI's behavior, persona, and response style. Optional."},"streaming":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n"},"memory":{"$ref":"#/components/schemas/MemoryTransform"},"output_schema":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n"},"user_attachments":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n"},"max_completion_tokens":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n"},"temperature":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n"},"max_iterations":{"allOf":[{"$ref":"#/components/schemas/InputTransform"}],"description":"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n"}},"required":["provider","user_message","output_type"]},"tools":{"type":"array","description":"Array of tools the agent can use. The agent decides which tools to call based on the task","items":{"$ref":"#/components/schemas/AgentTool"}},"type":{"type":"string","enum":["aiagent"]},"omit_output_from_conversation":{"type":"boolean","default":false,"description":"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled."},"parallel":{"type":"boolean","description":"If true, the agent can execute multiple tool calls in parallel"}},"required":["tools","type","input_transforms"]},"Identity":{"type":"object","description":"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder","properties":{"type":{"type":"string","enum":["identity"]},"flow":{"type":"boolean","description":"If true, marks this as a flow identity (special handling)"}},"required":["type"]},"FlowStatus":{"type":"object","properties":{"step":{"type":"integer"},"modules":{"type":"array","items":{"$ref":"#/components/schemas/FlowStatusModule"}},"user_states":{"additionalProperties":true},"preprocessor_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"}]},"failure_module":{"allOf":[{"$ref":"#/components/schemas/FlowStatusModule"},{"type":"object","properties":{"parent_module":{"type":"string"}}}]},"retry":{"type":"object","properties":{"fail_count":{"type":"integer"},"failed_jobs":{"type":"array","items":{"type":"string","format":"uuid"}}}}},"required":["step","modules","failure_module"]},"FlowStatusModule":{"type":"object","properties":{"type":{"type":"string","enum":["WaitingForPriorSteps","WaitingForEvents","WaitingForExecutor","InProgress","Success","Failure"]},"id":{"type":"string"},"job":{"type":"string","format":"uuid"},"count":{"type":"integer"},"progress":{"type":"integer"},"iterator":{"type":"object","properties":{"index":{"type":"integer"},"itered":{"type":"array","items":{}},"itered_len":{"type":"integer"},"args":{}}},"flow_jobs":{"type":"array","items":{"type":"string"}},"flow_jobs_success":{"type":"array","items":{"type":"boolean"}},"flow_jobs_duration":{"type":"object","properties":{"started_at":{"type":"array","items":{"type":"string"}},"duration_ms":{"type":"array","items":{"type":"integer"}}}},"branch_chosen":{"type":"object","properties":{"type":{"type":"string","enum":["branch","default"]},"branch":{"type":"integer"}},"required":["type"]},"branchall":{"type":"object","properties":{"branch":{"type":"integer"},"len":{"type":"integer"}},"required":["branch","len"]},"approvers":{"type":"array","items":{"type":"object","properties":{"resume_id":{"type":"integer"},"approver":{"type":"string"}},"required":["resume_id","approver"]}},"failed_retries":{"type":"array","items":{"type":"string","format":"uuid"}},"skipped":{"type":"boolean"},"agent_actions":{"type":"array","items":{"type":"object","oneOf":[{"type":"object","properties":{"job_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"type":{"type":"string","enum":["tool_call"]},"module_id":{"type":"string"}},"required":["job_id","function_name","type","module_id"]},{"type":"object","properties":{"call_id":{"type":"string","format":"uuid"},"function_name":{"type":"string"},"resource_path":{"type":"string"},"type":{"type":"string","enum":["mcp_tool_call"]},"arguments":{"type":"object"}},"required":["call_id","function_name","resource_path","type"]},{"type":"object","properties":{"type":{"type":"string","enum":["web_search"]}},"required":["type"]},{"type":"object","properties":{"type":{"type":"string","enum":["message"]}},"required":["content","type"]}]}},"agent_actions_success":{"type":"array","items":{"type":"boolean"}}},"required":["type"]}}`, "raw-app": `--- name: raw-app description: MUST use when creating raw apps. @@ -6838,10 +6173,11 @@ flow related commands - \`flow run \` - run a flow by path. - \`-d --data \` - Inputs specified as a JSON string or a file using @ or stdin using @-. - \`-s --silent\` - Do not ouput anything other then the final output. Useful for scripting. -- \`flow preview \` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. +- \`flow preview \` - preview a local flow without deploying it. Runs the flow definition from local files and uses local PathScripts by default. Pass --step to run only one module in isolation (resolves nested steps inside branchone/branchall/forloopflow/whileloopflow plus the special preprocessor/failure modules; supported step types: rawscript, script, flow). - \`-d --data \` - Inputs specified as a JSON string or a file using @ or stdin using @-. - \`-s --silent\` - Do not output anything other then the final output. Useful for scripting. - \`--remote\` - Use deployed workspace scripts for PathScript steps instead of local files. + - \`--step \` - Run only the named step instead of the whole flow. Honors --data as the step's args and --remote / local-PathScript resolution the same way the full-flow preview does. - \`flow new \` - create a new empty flow - \`--summary \` - flow summary - \`--description \` - flow description @@ -7057,6 +6393,42 @@ Validate Windmill flow, schedule, and trigger YAML files in a directory - \`--locks-required\` - Fail if scripts or flow inline scripts that need locks have no locks - \`-w, --watch\` - Watch for file changes and re-lint automatically +### object-storage + +**Alias:** \`s3\` + +**Subcommands:** + +- \`object-storage list\` - List configured object storages for the workspace (default + secondary). + - \`--json\` - Output as JSON (for piping to jq) +- \`object-storage files [prefix:string]\` - List files in an object storage. Optionally filter by prefix. + - \`--json\` - Output as JSON (for piping to jq) + - \`--max-keys \` - Page size (default 100) + - \`--marker \` - Pagination marker from a previous response + - \`--storage \` - Secondary storage name (omit for the workspace default) +- \`object-storage upload \` - Upload a local file to object storage at the given file key. + - \`--storage \` - Secondary storage name + - \`--content-type \` - Content-Type header to set on the object + - \`--content-disposition \` - Content-Disposition header to set on the object +- \`object-storage download [output_path:string]\` - Download an object to a local file (or stdout). Default output path is the basename of the file key in the current directory. + - \`--storage \` - Secondary storage name + - \`--stdout\` - Write file contents to stdout instead of a file +- \`object-storage delete \` - Delete an object from object storage. Prompts for confirmation unless --yes is set. + - \`--storage \` - Secondary storage name + - \`--yes\` - Skip the confirmation prompt +- \`object-storage move \` - Move an object within the same storage (rename or relocate by key). + - \`--storage \` - Secondary storage name +- \`object-storage info \` - Show metadata (size, mime, last-modified) for an object. + - \`--json\` - Output as JSON (for piping to jq) + - \`--storage \` - Secondary storage name +- \`object-storage preview \` - Preview the contents of an object (text/CSV). Use --bytes-from / --bytes-length to peek at a slice of binary files. + - \`--storage \` - Secondary storage name + - \`--mime \` - Override the detected mime type (e.g. text/csv) + - \`--bytes-from \` - Start offset in bytes + - \`--bytes-length \` - Number of bytes to read + - \`--csv-separator \` - CSV column separator (default ,) + - \`--csv-header\` - Treat the first CSV row as a header + ### protection-rules **Subcommands:** @@ -7083,12 +6455,14 @@ List all queues with their metrics ### refresh -Refresh wmill-managed project files (AGENTS.cli.md and skills) +Refresh wmill-managed project files (AGENTS.cli.md, skills, tsconfig.wmill.json) **Subcommands:** - \`refresh prompts\` - Refresh AGENTS.cli.md and managed skills. User-owned AGENTS.md and CLAUDE.md are never overwritten unless you opt in. - - \`--yes\` - Non-interactive: skip the migration prompt for existing AGENTS.md / CLAUDE.md without the expected include; defaults to appending the include. + - \`--yes\` - Non-interactive: append the @AGENTS.cli.md include to an existing AGENTS.md / CLAUDE.md without prompting. Without it, a non-interactive run leaves an unlinked file untouched. +- \`refresh tsconfig\` - Refresh the wmill-managed tsconfig.wmill.json (and Deno import map for Deno projects) + - \`--yes\` - Non-interactive: wire an existing custom tsconfig.json/deno.json to the managed file without prompting (a previously-generated config is always migrated automatically). ### resource @@ -7236,6 +6610,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--include-groups\` - Include syncing groups - \`--include-settings\` - Include syncing workspace settings - \`--include-key\` - Include workspace encryption key + - \`--skip-reencrypt-on-key-change\` - When the pushed encryption key differs from the remote, do NOT re-encrypt existing remote secrets. Only safe if they are already encrypted with the new key (e.g. workspace/instance migration). Default is to re-encrypt. - \`--skip-branch-validation\` - Skip git branch validation and prompts - \`--json-output\` - Output results in JSON format - \`-i --includes \` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string) @@ -7393,6 +6768,26 @@ workspace related commands - \`--team-name \` - Slack team name - \`workspace disconnect-slack\` + + +# Object Storage CLI + +\`wmill object-storage\` (alias \`wmill s3\`) exposes the workspace's object storage (S3-compatible: AWS S3, MinIO, GCS, R2, Azure Blob) over the per-workspace \`/job_helpers/*\` endpoints. + +## Key concepts (not obvious from per-command --help) + +- **\`file_key\` is the path inside the bucket** (e.g. \`reports/2026-05/orders.csv\`), not a Windmill path. Do NOT pass \`u/...\` or \`f/...\` here — those are Windmill paths to scripts/flows/resources, unrelated to objects in the bucket. +- **Scope is the active workspace.** Object storage is configured per-workspace (default storage + optional secondary storages). Switching workspaces switches which bucket the commands target. +- **\`--storage \` targets a secondary storage** configured on the workspace. Omit it to use the workspace's default object storage. Use \`wmill object-storage list\` to discover configured storages. +- **\`preview\` vs \`download\`**: \`preview\` returns a peek (CSV first rows, text content, or a byte slice via \`--bytes-from\`/\`--bytes-length\`) without writing to disk. Use \`download\` when you want the full file on disk. + +## Choosing a subcommand + +- Look at what's there: \`wmill object-storage files [prefix]\` (alias \`ls\`) — paginated, use \`--marker\` to continue. +- Inspect one file: \`wmill object-storage info \` for size/mime/last-modified, \`wmill object-storage preview \` for content peek. +- Move data in: \`wmill object-storage upload \` — set \`--content-type\` if the receiver cares (e.g. \`text/csv\`). +- Move data out: \`wmill object-storage download [output_path]\` — \`--stdout\` to pipe. +- Reorganize: \`wmill object-storage move \` (same storage), \`wmill object-storage delete \` (interactive confirm unless \`--yes\`). `, "preview": `--- name: preview diff --git a/cli/src/guidance/writer.ts b/cli/src/guidance/writer.ts index fcaae0671a..033519bf8e 100644 --- a/cli/src/guidance/writer.ts +++ b/cli/src/guidance/writer.ts @@ -207,13 +207,19 @@ async function reconcileIncludingFile(options: { } function referencesIncludeLine(content: string, includeLine: string): boolean { - // Match only when the include sits on a line by itself (allowing leading - // and trailing whitespace). Earlier we split on `\s+`, but that - // false-positives on commented-out includes like `` - // where the middle token equals the include. CRLF is handled by the - // `\r?\n` split. + // Match when the include appears as a whitespace-separated token on any + // line that isn't an HTML comment. We can't require the include to be on a + // line by itself: our own CLAUDE.md default is `Instructions are in + // @AGENTS.md` (one sentence), and a strict equality check made `wmill + // refresh prompts` re-prompt every run on files wmill itself wrote. + // Skipping comment-bearing lines keeps `` from + // false-positiving. for (const line of content.split(/\r?\n/)) { - if (line.trim() === includeLine) { + const trimmed = line.trim(); + if (trimmed.startsWith("")) { + continue; + } + if (trimmed.split(/\s+/).includes(includeLine)) { return true; } } diff --git a/cli/src/main.ts b/cli/src/main.ts index 6f4d5b86ce..c9e8d557c4 100755 --- a/cli/src/main.ts +++ b/cli/src/main.ts @@ -52,6 +52,7 @@ import docs from "./commands/docs/docs.ts"; import config from "./commands/config/config.ts"; import datatable from "./commands/datatable/datatable.ts"; import ducklake from "./commands/ducklake/ducklake.ts"; +import objectStorage from "./commands/object-storage/object-storage.ts"; import { fetchVersion } from "./core/context.ts"; export { @@ -77,6 +78,7 @@ export { config, datatable, ducklake, + objectStorage, hubPull, pull, push, @@ -87,10 +89,14 @@ export { token, }; -export const VERSION = "1.708.0"; - -// Re-exported from constants.ts to maintain backwards compatibility -export { WM_FORK_PREFIX } from "./core/constants.ts"; +// VERSION and WM_FORK_PREFIX are defined in constants.ts (which keeps its +// imports minimal) and re-exported here for backwards compatibility. VERSION is +// also imported below for internal use. Defining VERSION in constants.ts rather +// than here lets utils.ts read it without importing main.ts, which previously +// created a circular dependency (main → workspace → utils → main) and a TDZ +// crash ("Cannot access 'workspace' before initialization") on some load orders. +import { VERSION } from "./core/constants.ts"; +export { VERSION, WM_FORK_PREFIX } from "./core/constants.ts"; // Re-implementation of cliffy's internal `checkVersion` so the help path // can wrap it in try/catch. `_check_version` is not in cliffy's package @@ -210,6 +216,7 @@ const command = new Command() .command("config", config) .command("datatable", datatable) .command("ducklake", ducklake) + .command("object-storage", objectStorage) .command("version --version", "Show version information") .action(async (opts: any) => { console.log("CLI version: " + VERSION); @@ -301,6 +308,10 @@ async function main() { if (shouldRunFreshnessCheck(process.argv)) { const { warnIfPromptsStale } = await import("./guidance/freshness.ts"); await warnIfPromptsStale({ argv: process.argv }).catch(() => {}); + const { warnIfTsconfigStale } = await import( + "./commands/refresh/tsconfig.ts" + ); + await warnIfTsconfigStale().catch(() => {}); } await command.parse(args); diff --git a/cli/src/types.ts b/cli/src/types.ts index e0d45adb66..5de6cea48b 100644 --- a/cli/src/types.ts +++ b/cli/src/types.ts @@ -18,7 +18,11 @@ import { pushSchedule } from "./commands/schedule/schedule.ts"; import { pushWorkspaceUser } from "./commands/user/user.ts"; import { pushGroup } from "./commands/user/user.ts"; import { pushWorkspaceDependencies } from "./commands/dependencies/dependencies.ts"; -import { pushWorkspaceSettings, pushWorkspaceKey } from "./core/settings.ts"; +import { + pushWorkspaceSettings, + pushWorkspaceKey, + PushWorkspaceKeyOptions, +} from "./core/settings.ts"; import { pushTrigger, pushNativeTrigger } from "./commands/trigger/trigger.ts"; import { pushRawApp } from "./commands/app/raw_apps.ts"; import type { PermissionedAsContext } from "./core/permissioned_as.ts"; @@ -129,11 +133,46 @@ export function showDiff(local: string, remote: string) { export function showConflict(path: string, local: string, remote: string) { log.info(colors.yellow(`- ${path}`)); - showDiff(local, remote); + let isEncryptionKey = false; + try { + isEncryptionKey = getTypeStrFromPath(path) === "encryption_key"; + } catch { + // ignore + } + if (isEncryptionKey) { + showDiff(redactEncryptionKey(local), redactEncryptionKey(remote)); + } else { + showDiff(local, remote); + } log.info("\x1b[31mlocal\x1b[31m - \x1b[32mremote\x1b[32m"); log.info("\n"); } +// Reveal only the first 5 chars of the key so a rotation is still visible in +// the diff (different prefixes), without leaking the whole secret to stdout. +// The remaining chars are replaced with `*`, preserving length so the diff +// keeps showing whether the key length changed. +export function redactEncryptionKey(content: string): string { + if (!content) return content; + // The encryption_key payload is JSON-encoded (a quoted string). Parse it so + // we redact the key value itself, then re-serialize to JSON to preserve the + // file's shape; fall back to raw redaction if parsing fails. + try { + const parsed = JSON.parse(content); + if (typeof parsed === "string") { + return JSON.stringify(redactString(parsed)); + } + } catch { + // not JSON — treat content as the raw key + } + return redactString(content); +} + +function redactString(s: string): string { + if (s.length <= 5) return s; + return s.slice(0, 5) + "*".repeat(s.length - 5); +} + /** * Pushes an object to the workspace server based on its type * @param workspace - The workspace ID to push to @@ -144,6 +183,7 @@ export function showConflict(path: string, local: string, remote: string) { * @param alreadySynced - Array to track already synced items * @param message - Optional commit/update message * @param originalLocalPath - The original local file path (used for branch-specific resource file resolution) + * @param keyPushOpts - Options for the encryption_key push: non-interactive flag and explicit re-encryption choice */ export async function pushObj( workspace: string, @@ -156,6 +196,7 @@ export async function pushObj( originalLocalPath?: string, permissionedAsContext?: PermissionedAsContext, wsSpecific?: boolean, + keyPushOpts?: PushWorkspaceKeyOptions, ) { const typeEnding = getTypeStrFromPath(p); @@ -221,7 +262,7 @@ export async function pushObj( } else if (typeEnding === "settings") { await pushWorkspaceSettings(workspace, p, befObj, newObj); } else if (typeEnding === "encryption_key") { - await pushWorkspaceKey(workspace, p, befObj, newObj); + await pushWorkspaceKey(workspace, p, befObj, newObj, keyPushOpts); } else { throw new Error( `The item ${p} has an unrecognized type ending ${typeEnding}` diff --git a/cli/src/utils/git.ts b/cli/src/utils/git.ts index 6e0ba55ec9..0d2e8cb8a8 100644 --- a/cli/src/utils/git.ts +++ b/cli/src/utils/git.ts @@ -252,21 +252,44 @@ export function gitSyncIncludePattern( } } -export interface GitSyncDeployIncludes { - extraIncludes: string[]; +// `forcedIncludes` carries ONLY the include-* flags that must be force-set to +// true (overriding the repo's wmill.yaml). Kinds not present are intentionally +// omitted (never set to false) so the caller can spread this object and let +// the repo's effective config govern the rest — see deriveGitSyncDeployIncludes. +export type GitSyncForcedIncludes = Partial<{ includeSchedules: boolean; includeGroups: boolean; includeUsers: boolean; includeTriggers: boolean; includeSettings: boolean; includeKey: boolean; +}>; + +export interface GitSyncDeployIncludes { + extraIncludes: string[]; + forcedIncludes: GitSyncForcedIncludes; } // Mirrors the hub script's wmill_sync_pull include-derivation: build the -// --extra-includes set from the deployed items, and (only in workspace-wide -// mode — never with --use-individual-branch) opt object kinds that are -// excluded by default back in. Replaces the script's regexFromPath + +// --extra-includes set from the deployed items, and decide which default- +// excluded object kinds (triggers, schedules, groups, users, settings, key) +// must be force-included in the pull. Replaces the script's regexFromPath + // per-kind --include-* construction so the hub script can drop both. +// +// Branch-mode distinction (this is load-bearing — see the trigger-promotion +// bug it fixes): +// - Workspace-wide mode: the repo is a full mirror of the workspace, so a +// deployed object of a default-excluded kind MUST be re-included, even if +// wmill.yaml would otherwise skip it. We force the flag on. +// - Individual-branch (promotion) mode: the repo is a filtered prod surface +// whose own wmill.yaml filters decide what gets promoted. We force NOTHING +// here and the keys stay absent, so the caller's pull resolves them from +// the target's effective config (a deployed trigger lands iff the target +// includes triggers). Forcing `false` (the original behavior) did NOT +// defer — it CLOBBERED the effective config via Object.assign in pull's +// option merge, silently dropping kinds the target actually wanted (e.g. a +// deployed trigger when the target has includeTriggers: true), and the +// server then omitted the object from the tarball entirely. export function deriveGitSyncDeployIncludes( items: GitSyncDeployItem[], useIndividualBranch: boolean, @@ -283,18 +306,19 @@ export function deriveGitSyncDeployIncludes( } } - const has = (pred: (t: string) => boolean) => - !useIndividualBranch && items.some((i) => pred(i.path_type)); + const forcedIncludes: GitSyncForcedIncludes = {}; + if (!useIndividualBranch) { + const has = (pred: (t: string) => boolean) => + items.some((i) => pred(i.path_type)); + if (has((t) => t === "schedule")) forcedIncludes.includeSchedules = true; + if (has((t) => t === "group")) forcedIncludes.includeGroups = true; + if (has((t) => t === "user")) forcedIncludes.includeUsers = true; + if (has((t) => t.includes("trigger"))) forcedIncludes.includeTriggers = true; + if (has((t) => t === "settings")) forcedIncludes.includeSettings = true; + if (has((t) => t === "key")) forcedIncludes.includeKey = true; + } - return { - extraIncludes, - includeSchedules: has((t) => t === "schedule"), - includeGroups: has((t) => t === "group"), - includeUsers: has((t) => t === "user"), - includeTriggers: has((t) => t.includes("trigger")), - includeSettings: has((t) => t === "settings"), - includeKey: has((t) => t === "key"), - }; + return { extraIncludes, forcedIncludes }; } function git( diff --git a/cli/src/utils/utils.ts b/cli/src/utils/utils.ts index ff9fe9b78c..4af76517fa 100644 --- a/cli/src/utils/utils.ts +++ b/cli/src/utils/utils.ts @@ -11,6 +11,7 @@ import { readdir, readFile } from "node:fs/promises"; import { fetchVersion } from "../core/context.ts"; import { updateGlobalVersions } from "../commands/sync/global.ts"; import { isRawAppPath } from "./resource_folders.ts"; +import { VERSION } from "../core/constants.ts"; export function deepEqual(a: T, b: T): boolean { if (a === b) return true; @@ -320,6 +321,7 @@ export async function fetchRemoteVersion( if (version) { updateGlobalVersions(version); } + log.info(colors.gray("CLI version: " + VERSION)); log.info(colors.gray("Remote version: " + version)); } diff --git a/cli/test/git_unit.test.ts b/cli/test/git_unit.test.ts index f55e8d46b4..ebbc838f97 100644 --- a/cli/test/git_unit.test.ts +++ b/cli/test/git_unit.test.ts @@ -254,7 +254,7 @@ describe("deriveGitSyncDeployIncludes", () => { ]); }); - test("workspace-wide mode opts excluded kinds back in", () => { + test("workspace-wide mode force-includes deployed default-excluded kinds", () => { const r = deriveGitSyncDeployIncludes( [ { path_type: "schedule", path: "f/s" }, @@ -266,15 +266,36 @@ describe("deriveGitSyncDeployIncludes", () => { ], false ); - expect(r.includeSchedules).toBe(true); - expect(r.includeGroups).toBe(true); - expect(r.includeTriggers).toBe(true); - expect(r.includeSettings).toBe(true); - expect(r.includeKey).toBe(true); - expect(r.includeUsers).toBe(true); + // Full-mirror repo: a deployed object of a default-excluded kind must be + // re-included even if wmill.yaml would skip it, so the flag is forced on. + expect(r.forcedIncludes).toEqual({ + includeSchedules: true, + includeGroups: true, + includeTriggers: true, + includeSettings: true, + includeKey: true, + includeUsers: true, + }); }); - test("individual-branch mode NEVER sets include flags (matches hub script)", () => { + test("workspace-wide mode only forces the kinds actually deployed", () => { + const r = deriveGitSyncDeployIncludes( + [{ path_type: "script", path: "f/s" }], + false + ); + // Scripts are included by default — nothing to force. + expect(r.forcedIncludes).toEqual({}); + }); + + test("individual-branch (promotion) mode forces NOTHING — defers to wmill.yaml", () => { + // Regression: these flags used to be force-disabled (set to false) in + // promotion mode, which CLOBBERED the promotion target's effective + // wmill.yaml config (an explicit false wins in pull's Object.assign merge). + // The server then stripped the object from the tarball, the pull wrote + // nothing, and `git add '**'` failed with "pathspec did not match + // any files". Forcing nothing leaves the keys absent so the target's + // effective filters govern; extraIncludes still scopes the pull to the + // changed object. const r = deriveGitSyncDeployIncludes( [ { path_type: "schedule", path: "f/s" }, @@ -282,12 +303,27 @@ describe("deriveGitSyncDeployIncludes", () => { ], true ); - expect(r.includeSchedules).toBe(false); - expect(r.includeTriggers).toBe(false); - // extra-includes are still derived regardless of branch mode + expect(r.forcedIncludes).toEqual({}); expect(r.extraIncludes).toContain("f/s.schedule.*"); expect(r.extraIncludes).toContain("f/t.kafka_trigger.*"); }); + + test("regression: http_trigger promotion deploy does not clobber the target's includeTriggers", () => { + // Brad's scenario: an HTTP trigger is deployed and the promotion repo uses + // individual branches. path_type is "httptrigger" (the no-underscore value + // the backend puts on item.path_type — see git_sync_ee.rs + // insert_path_type_and_return_message). includeTriggers must NOT be forced + // false here, so the target's effective includeTriggers (true in Brad's + // config) is honored and the trigger file is pulled and committed. + const r = deriveGitSyncDeployIncludes( + [{ path_type: "httptrigger", path: "f/platform/on_call_chat_http_route" }], + true + ); + expect(r.forcedIncludes.includeTriggers).toBeUndefined(); + expect(r.extraIncludes).toContain( + "f/platform/on_call_chat_http_route.http_trigger.*" + ); + }); }); // ============================================================================= diff --git a/cli/test/gitsync_promotion.test.ts b/cli/test/gitsync_promotion.test.ts index 5b73e8f8e1..e33968d0fa 100644 --- a/cli/test/gitsync_promotion.test.ts +++ b/cli/test/gitsync_promotion.test.ts @@ -22,6 +22,18 @@ import { withTestBackend } from "./test_backend.ts"; import { shouldSkipOnCI } from "./cargo_backend.ts"; import { addWorkspace } from "../workspace.ts"; +// The HTTP-trigger promotion test creates an http_trigger, whose API routes are +// behind the `http_trigger` cargo feature — NOT in the default EE test feature +// set. The shared test backend reads TEST_FEATURES at construction (first +// `withTestBackend` call), so appending here at module load enables it. Guarded +// on shouldSkipOnCI() so we only widen the build when these EE tests actually +// run (i.e. EE_LICENSE_KEY present); minimal CI builds stay untouched. +if (!shouldSkipOnCI()) { + process.env["TEST_FEATURES"] = [process.env["TEST_FEATURES"], "http_trigger"] + .filter(Boolean) + .join(","); +} + function git(cwd: string, ...args: string[]): string { return execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); } @@ -44,6 +56,24 @@ function remoteHead(bareDir: string, branch: string): string { ).trim(); } +// True if `filePath` exists in the tree of `branch` on the bare remote. +function fileExistsOnBranch( + bareDir: string, + branch: string, + filePath: string, +): boolean { + try { + execFileSync( + "git", + ["--git-dir", bareDir, "cat-file", "-e", `refs/heads/${branch}:${filePath}`], + { stdio: "ignore" }, + ); + return true; + } catch { + return false; + } +} + test.skipIf(shouldSkipOnCI())( "git-sync promotion: use_individual_branch pushes to wm_deploy branch, not main", async () => { @@ -200,3 +230,419 @@ test.skipIf(shouldSkipOnCI())( }); }, ); + +/** + * Regression test for the promotion trigger-include bug (fix/gitsync-promotion- + * trigger-export): deploying a trigger (or any excluded-by-default kind: + * schedule, group, user, settings, key) with `use_individual_branch` must still + * land the object file on the `wm_deploy` branch. + * + * Root cause: `deriveGitSyncDeployIncludes` used to force the per-kind include + * flags (`includeTriggers` etc.) to false in individual-branch mode. The + * server-side tarball export STRIPS those object kinds entirely when their + * include flag is false (`if include_triggers { … }` in workspaces_export.rs), + * and `extraIncludes` is only a client-side filter over what the tarball + * already contains — it can't recover a file the server never sent. So the + * pull wrote no trigger file, the wm_deploy branch was created empty of the + * trigger, and production's `git add '**'` failed with "pathspec did not + * match any files". A script (always-included kind) never hit this — hence the + * dedicated trigger case here. + * + * Without the fix this test fails: the branch exists but the + * `*.http_trigger.yaml` file is absent from it. + */ +test.skipIf(shouldSkipOnCI())( + "git-sync promotion: use_individual_branch lands a trigger file on the wm_deploy branch", + async () => { + await withTestBackend(async (backend) => { + const ws = backend.workspace; // "test" + await addWorkspace( + { + remote: backend.baseUrl, + workspaceId: ws, + name: ws, + token: backend.token, + } as any, + { force: true, configDir: backend.testConfigDir }, + ); + + // --- 1. Bare "remote" seeded with an initial `main` commit --- + const bareDir = await mkdtemp(join(tmpdir(), "wmill_promo_trig_bare_")); + execFileSync("git", ["init", "--bare", "--initial-branch=main", bareDir]); + const seedDir = await mkdtemp(join(tmpdir(), "wmill_promo_trig_seed_")); + git(seedDir, "init", "--initial-branch=main"); + git(seedDir, "config", "user.email", "seed@windmill.dev"); + git(seedDir, "config", "user.name", "seed"); + await writeFile(join(seedDir, "README.md"), "# promo trigger test\n"); + git(seedDir, "add", "-A"); + git(seedDir, "commit", "-m", "seed"); + git(seedDir, "remote", "add", "origin", `file://${bareDir}`); + git(seedDir, "push", "-u", "origin", "main"); + const seedMain = remoteHead(bareDir, "main"); + + // --- 2. Workspace content: a script + an HTTP trigger pointing at it --- + await backend.apiRequest!(`/api/w/${ws}/folders/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "promo", owners: [], extra_perms: {} }), + }); + await backend.apiRequest!(`/api/w/${ws}/scripts/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: "f/promo/foo", + summary: "", + description: "", + content: "export async function main() { return 1 }", + language: "bun", + }), + }); + const trigRes = await backend.apiRequest!(`/api/w/${ws}/http_triggers/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: "f/promo/hook", + script_path: "f/promo/foo", + route_path: "promo_hook", + is_flow: false, + http_method: "post", + authentication_method: "none", + is_static_website: false, + request_type: "sync", + }), + }); + // Guard against the route silently 404ing (the http_trigger cargo feature + // not being built) — otherwise the pull below would find nothing to sync + // and the real assertion would fail with a confusing message. + expect(trigRes.status).toBe(201); + + // --- 3. git_repository resource + git-sync config (triggers included) --- + await backend.apiRequest!(`/api/w/${ws}/resources/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: "u/test/promo_repo", + resource_type: "git_repository", + value: { url: `file://${bareDir}`, branch: "main", token: "" }, + }), + }); + await backend.updateGitSyncConfig!({ + git_sync_settings: { + repositories: [ + { + git_repo_resource_path: "u/test/promo_repo", + script_path: "f/**", + use_individual_branch: true, + group_by_folder: false, + settings: { + include_path: ["f/**"], + include_type: ["script", "trigger"], + }, + }, + ], + }, + }); + + // The backend sets path_type "httptrigger" (no underscore) on the deploy + // item — see DeployedObject::HttpTrigger => "httptrigger" in git_sync_ee.rs. + const deployItems = JSON.stringify([ + { + path_type: "httptrigger", + path: "f/promo/hook", + commit_msg: "deploy hook", + }, + ]); + + const work = await mkdtemp(join(tmpdir(), "wmill_promo_trig_work_")); + git(work, "clone", `file://${bareDir}`, "."); + // Option B semantic: in promotion mode the deploy forces NOTHING — the + // trigger lands only because THIS target's effective wmill.yaml opts + // triggers in. (Reverting the source fix re-introduces the force-`false` + // that clobbers this `includeTriggers: true`, so the file is dropped.) + await writeFile( + join(work, "wmill.yaml"), + "defaultTs: bun\nincludes:\n - f/**\nexcludes: []\nincludeTriggers: true\n", + ); + const res = await backend.runCLICommand( + [ + "sync", + "git-deploy", + "--repository", + "u/test/promo_repo", + "--use-individual-branch", + "--git-deploy-items", + deployItems, + ], + work, + ); + expect(res.code).toBe(0); + + // Caller-half (mirrors the hub script): stage what the pull wrote, commit + // on the checked-out wm_deploy branch, push. + git(work, "config", "user.email", "test@windmill.dev"); + git(work, "config", "user.name", "test"); + git(work, "add", "-A"); + try { + git(work, "diff", "--cached", "--quiet"); + } catch { + git(work, "commit", "-m", "deploy hook"); + } + git(work, "push", "--porcelain", "-u", "origin", "HEAD"); + + const expectedBranch = `refs/heads/wm_deploy/${ws}/httptrigger/f__promo__hook`; + expect(remoteBranches(bareDir)).toContain(expectedBranch); + // The regression: the trigger file MUST be present on the branch. Without + // the fix the include flag is false, the server strips the trigger from + // the tarball, the pull writes nothing, and this file is absent. + expect( + fileExistsOnBranch( + bareDir, + `wm_deploy/${ws}/httptrigger/f__promo__hook`, + "f/promo/hook.http_trigger.yaml", + ), + ).toBe(true); + // Base branch untouched (individual-branch never pushes to the base). + expect(remoteHead(bareDir, "main")).toBe(seedMain); + + await rm(bareDir, { recursive: true, force: true }); + await rm(seedDir, { recursive: true, force: true }); + await rm(work, { recursive: true, force: true }); + }); + }, +); + +/** + * Same regression as the HTTP-trigger case above, for a `schedule` — a + * different excluded-by-default kind that exercises a DISTINCT path: its own + * include flag (`includeSchedules`), its own server-side `if include_schedules` + * tarball-strip branch, and its own `.schedule.yaml` extension. Unlike triggers + * it needs no extra cargo feature, so it guards the fix even where the + * trigger-specific features aren't built. + * + * Without the fix this test fails: the branch exists but the + * `*.schedule.yaml` file is absent from it. + */ +test.skipIf(shouldSkipOnCI())( + "git-sync promotion: use_individual_branch lands a schedule file on the wm_deploy branch", + async () => { + await withTestBackend(async (backend) => { + const ws = backend.workspace; // "test" + await addWorkspace( + { + remote: backend.baseUrl, + workspaceId: ws, + name: ws, + token: backend.token, + } as any, + { force: true, configDir: backend.testConfigDir }, + ); + + // --- 1. Bare "remote" seeded with an initial `main` commit --- + const bareDir = await mkdtemp(join(tmpdir(), "wmill_promo_sched_bare_")); + execFileSync("git", ["init", "--bare", "--initial-branch=main", bareDir]); + const seedDir = await mkdtemp(join(tmpdir(), "wmill_promo_sched_seed_")); + git(seedDir, "init", "--initial-branch=main"); + git(seedDir, "config", "user.email", "seed@windmill.dev"); + git(seedDir, "config", "user.name", "seed"); + await writeFile(join(seedDir, "README.md"), "# promo schedule test\n"); + git(seedDir, "add", "-A"); + git(seedDir, "commit", "-m", "seed"); + git(seedDir, "remote", "add", "origin", `file://${bareDir}`); + git(seedDir, "push", "-u", "origin", "main"); + const seedMain = remoteHead(bareDir, "main"); + + // --- 2. Workspace content: a script + a (disabled) schedule for it --- + await backend.apiRequest!(`/api/w/${ws}/folders/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "promo", owners: [], extra_perms: {} }), + }); + await backend.apiRequest!(`/api/w/${ws}/scripts/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: "f/promo/foo", + summary: "", + description: "", + content: "export async function main() { return 1 }", + language: "bun", + }), + }); + const schedRes = await backend.apiRequest!(`/api/w/${ws}/schedules/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: "f/promo/sched", + schedule: "0 0 12 * * *", + timezone: "UTC", + script_path: "f/promo/foo", + is_flow: false, + args: {}, + enabled: false, + }), + }); + expect(schedRes.status).toBe(200); + + // --- 3. git_repository resource + git-sync config (schedules included) --- + await backend.apiRequest!(`/api/w/${ws}/resources/create`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path: "u/test/promo_repo", + resource_type: "git_repository", + value: { url: `file://${bareDir}`, branch: "main", token: "" }, + }), + }); + await backend.updateGitSyncConfig!({ + git_sync_settings: { + repositories: [ + { + git_repo_resource_path: "u/test/promo_repo", + script_path: "f/**", + use_individual_branch: true, + group_by_folder: false, + settings: { + include_path: ["f/**"], + include_type: ["script", "schedule"], + }, + }, + ], + }, + }); + + const deployItems = JSON.stringify([ + { + path_type: "schedule", + path: "f/promo/sched", + commit_msg: "deploy sched", + }, + ]); + + const work = await mkdtemp(join(tmpdir(), "wmill_promo_sched_work_")); + git(work, "clone", `file://${bareDir}`, "."); + // Option B semantic: in promotion mode the deploy forces NOTHING — the + // schedule lands only because THIS target's effective wmill.yaml opts + // schedules in. (Reverting the source fix re-introduces the force-`false` + // that clobbers this `includeSchedules: true`, so the file is dropped.) + await writeFile( + join(work, "wmill.yaml"), + "defaultTs: bun\nincludes:\n - f/**\nexcludes: []\nincludeSchedules: true\n", + ); + const res = await backend.runCLICommand( + [ + "sync", + "git-deploy", + "--repository", + "u/test/promo_repo", + "--use-individual-branch", + "--git-deploy-items", + deployItems, + ], + work, + ); + expect(res.code).toBe(0); + + // Caller-half (mirrors the hub script): stage what the pull wrote, commit + // on the checked-out wm_deploy branch, push. + git(work, "config", "user.email", "test@windmill.dev"); + git(work, "config", "user.name", "test"); + git(work, "add", "-A"); + try { + git(work, "diff", "--cached", "--quiet"); + } catch { + git(work, "commit", "-m", "deploy sched"); + } + git(work, "push", "--porcelain", "-u", "origin", "HEAD"); + + const expectedBranch = `refs/heads/wm_deploy/${ws}/schedule/f__promo__sched`; + expect(remoteBranches(bareDir)).toContain(expectedBranch); + // The regression: the schedule file MUST be present on the branch. Without + // the fix the include flag is false, the server strips the schedule from + // the tarball, the pull writes nothing, and this file is absent. + expect( + fileExistsOnBranch( + bareDir, + `wm_deploy/${ws}/schedule/f__promo__sched`, + "f/promo/sched.schedule.yaml", + ), + ).toBe(true); + // Base branch untouched (individual-branch never pushes to the base). + expect(remoteHead(bareDir, "main")).toBe(seedMain); + + await rm(bareDir, { recursive: true, force: true }); + await rm(seedDir, { recursive: true, force: true }); + await rm(work, { recursive: true, force: true }); + }); + }, +); + +/** + * Regression test for WIN-1997: forking a workspace with git sync configured + * must publish a `wm-fork//` branch to the remote. + * + * The fork-branch callback runs the sync script with `only_create_branch: + * true` and no items. The hub script delegates branch checkout + push of that + * empty ref to `wmill sync git-deploy --only-create-branch` — its own + * in-process commit+push runs ONLY for the `!only_create_branch` path. So if + * the CLI doesn't push the freshly checked-out branch here, nothing does and + * the fork branch never reaches the remote (the symptom that broke the e2e + * test after #9284 moved commit+push to the caller). This guards that the CLI + * owns the push for the branch-only case. + */ +test.skipIf(shouldSkipOnCI())( + "git-sync fork: only_create_branch publishes the wm-fork branch (CLI owns the push)", + async () => { + await withTestBackend(async (backend) => { + // Bare "remote" seeded with an initial `main` commit. + const bareDir = await mkdtemp(join(tmpdir(), "wmill_fork_bare_")); + execFileSync("git", ["init", "--bare", "--initial-branch=main", bareDir]); + const seedDir = await mkdtemp(join(tmpdir(), "wmill_fork_seed_")); + git(seedDir, "init", "--initial-branch=main"); + git(seedDir, "config", "user.email", "seed@windmill.dev"); + git(seedDir, "config", "user.name", "seed"); + await writeFile(join(seedDir, "README.md"), "# fork test\n"); + git(seedDir, "add", "-A"); + git(seedDir, "commit", "-m", "seed"); + git(seedDir, "remote", "add", "origin", `file://${bareDir}`); + git(seedDir, "push", "-u", "origin", "main"); + const seedMain = remoteHead(bareDir, "main"); + + // The CWD the hub script runs git-deploy in: a clone of the repo on main. + const work = await mkdtemp(join(tmpdir(), "wmill_fork_work_")); + git(work, "clone", `file://${bareDir}`, "."); + await writeFile( + join(work, "wmill.yaml"), + "defaultTs: bun\nincludes:\n - f/**\nexcludes: []\n", + ); + + // Branch creation happens BEFORE the fork workspace exists (step 1 of the + // fork flow), so we pass the fork workspace id straight through — whoami + // returns synthetic superadmin info for it. No items, only_create_branch. + const forkWs = "wm-fork-clitest"; + const res = await backend.runCLICommand( + [ + "sync", + "git-deploy", + "--repository", + "u/test/unused_on_branch_only_path", + "--git-deploy-items", + "[]", + "--only-create-branch", + ], + work, + { workspace: forkWs }, + ); + expect(res.code).toBe(0); + + // The regression: with NO caller-side commit/push, the fork branch must + // already be on the remote because the CLI pushed it. + expect(remoteBranches(bareDir)).toContain("refs/heads/wm-fork/main/clitest"); + // Base branch untouched — branch-only publish creates no commit. + expect(remoteHead(bareDir, "main")).toBe(seedMain); + + await rm(bareDir, { recursive: true, force: true }); + await rm(seedDir, { recursive: true, force: true }); + await rm(work, { recursive: true, force: true }); + }); + }, +); diff --git a/cli/test/guidance_writer_unit.test.ts b/cli/test/guidance_writer_unit.test.ts index fa3a91d641..cbeb494d1c 100644 --- a/cli/test/guidance_writer_unit.test.ts +++ b/cli/test/guidance_writer_unit.test.ts @@ -391,6 +391,13 @@ describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", () ["between blank lines", "before\n\n@AGENTS.cli.md\n\nafter"], ["leading whitespace then include", " @AGENTS.cli.md\n"], ["CRLF line endings", "line one\r\n@AGENTS.cli.md\r\nline three"], + // Mid-sentence include: this is how our own CLAUDE.md default looks + // ("Instructions are in @AGENTS.md"). A strict line-equality check made + // `wmill refresh prompts` re-prompt every run on files wmill wrote. + ["mid-sentence include", "Instructions are in @AGENTS.cli.md\n"], + // `>` blockquote prefix doesn't disable Claude's `@`-import expansion, + // so we treat it as a reference too. + ["blockquoted include", "> @AGENTS.cli.md"], ])("treats %s as a reference (no append)", async (_label, content) => { await withTempDir(async (tempDir) => { await writeFile(join(tempDir, "AGENTS.md"), content, "utf8"); @@ -406,7 +413,6 @@ describe("writeAiGuidanceFiles — referencesAgentsCli (via reconciliation)", () ["@AGENTS-cli-md (lookalike)", "@AGENTS-cli-md"], ["@AGENTS.cli.md without surrounding whitespace", "foo@AGENTS.cli.md"], ["commented-out include", ""], - ["blockquoted include", "> @AGENTS.cli.md"], ])("does not treat %s as a reference (append happens)", async (_label, content) => { await withTempDir(async (tempDir) => { await writeFile(join(tempDir, "AGENTS.md"), content, "utf8"); diff --git a/cli/test/push_workspace_key_unit.test.ts b/cli/test/push_workspace_key_unit.test.ts new file mode 100644 index 0000000000..6d8950cbec --- /dev/null +++ b/cli/test/push_workspace_key_unit.test.ts @@ -0,0 +1,93 @@ +/** + * Unit tests for pushWorkspaceKey in settings.ts. + * + * Covers WIN-2005: changing the encryption key in encryption_key.yaml and + * pushing it must (by default) re-encrypt the remote secrets with the new key. + * + * Verifies that: + * - an unchanged key is a no-op (no setWorkspaceEncryptionKey call) + * - a changed key in non-interactive mode re-encrypts by default + * (skip_reencrypt = false), so secret plaintext values are preserved + * - the --skip-reencrypt-on-key-change flag keeps the remote ciphertexts + * untouched (skip_reencrypt = true) + * - WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true does the same via env var + */ + +import { expect, test, describe, beforeEach, afterEach, mock } from "bun:test"; + +// Track calls to mocked wmill functions +let remoteKey = ""; +let setEncryptionKeyCalls: { + workspace: string; + requestBody: { new_key: string; skip_reencrypt?: boolean }; +}[] = []; + +// Mock the wmill module before importing settings.ts +mock.module("../gen/services.gen.ts", () => ({ + getWorkspaceEncryptionKey: async (_args: { workspace: string }) => ({ + key: remoteKey, + }), + setWorkspaceEncryptionKey: async (args: { + workspace: string; + requestBody: { new_key: string; skip_reencrypt?: boolean }; + }) => { + setEncryptionKeyCalls.push(args); + }, +})); + +import { pushWorkspaceKey } from "../src/core/settings.ts"; + +describe("pushWorkspaceKey", () => { + const ws = "test-workspace"; + + beforeEach(() => { + remoteKey = ""; + setEncryptionKeyCalls = []; + delete process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE; + }); + + afterEach(() => { + delete process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE; + }); + + test("no-op when local key matches the remote key", async () => { + remoteKey = "samekey"; + await pushWorkspaceKey(ws, "encryption_key", undefined, "samekey", { + noninteractive: true, + }); + expect(setEncryptionKeyCalls.length).toBe(0); + }); + + test("changed key re-encrypts by default in non-interactive mode", async () => { + remoteKey = "oldkey"; + await pushWorkspaceKey(ws, "encryption_key", undefined, "newkey", { + noninteractive: true, + }); + expect(setEncryptionKeyCalls.length).toBe(1); + expect(setEncryptionKeyCalls[0].requestBody.new_key).toBe("newkey"); + // skip_reencrypt false => backend re-encrypts existing secrets with new key + expect(setEncryptionKeyCalls[0].requestBody.skip_reencrypt).toBe(false); + }); + + test("--skip-reencrypt-on-key-change skips re-encryption", async () => { + remoteKey = "oldkey"; + await pushWorkspaceKey(ws, "encryption_key", undefined, "newkey", { + noninteractive: true, + skipReencrypt: true, + }); + expect(setEncryptionKeyCalls.length).toBe(1); + expect(setEncryptionKeyCalls[0].requestBody.new_key).toBe("newkey"); + expect(setEncryptionKeyCalls[0].requestBody.skip_reencrypt).toBe(true); + }); + + test("WMILL_NO_REENCRYPT_ON_KEY_CHANGE=true skips re-encryption non-interactively", async () => { + remoteKey = "oldkey"; + process.env.WMILL_NO_REENCRYPT_ON_KEY_CHANGE = "true"; + await pushWorkspaceKey(ws, "encryption_key", undefined, "newkey", { + noninteractive: true, + }); + expect(setEncryptionKeyCalls.length).toBe(1); + expect(setEncryptionKeyCalls[0].requestBody.new_key).toBe("newkey"); + expect(setEncryptionKeyCalls[0].requestBody.skip_reencrypt).toBe(true); + }); +}); diff --git a/cli/test/raw_app_sync.test.ts b/cli/test/raw_app_sync.test.ts index ee20bc1947..3cf1172daa 100644 --- a/cli/test/raw_app_sync.test.ts +++ b/cli/test/raw_app_sync.test.ts @@ -382,6 +382,81 @@ excludes: []`, "utf-8"); }); }); +test("Raw App: frontend .ts file sorting first does not short-circuit the app push", async () => { + // Regression: in the push apply loop, raw-app changes are collapsed to a + // single representative change (changes[0]). Because every file inside a + // raw_app folder shares the same sort order, changes[0] is just the + // alphabetically-first changed path. When that path was a frontend file + // with a script extension (e.g. "Api.ts", which sorts before "App.tsx"), + // handleFile() mistook it for a standalone script: it pushed a bogus script + // at the truncated path (f/test/) AND returned true, so the loop + // `continue`d and pushRawApp() never ran. Result: the whole raw app silently + // failed to deploy while the CLI still reported success. + await withTestBackend(async (backend, tempDir) => { + const testWorkspace = { + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: "raw_app_ts_first_test", + token: backend.token + }; + await addWorkspace(testWorkspace, { force: true, configDir: backend.testConfigDir }); + + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +excludes: []`, "utf-8"); + + const appDir = path.join(tempDir, "f", "test", "ts_first_app.raw_app"); + await mkdir(path.join(tempDir, "f", "test"), { recursive: true }); + await createRawAppOnDisk(appDir); + + // A frontend .ts file whose name sorts before "App.tsx". + const apiTsPath = path.join(appDir, "Api.ts"); + await writeFile(apiTsPath, "export const API = '/api/v1'\n", "utf-8"); + + // Initial push: create the raw app on the backend. + const pushResult1 = await backend.runCLICommand( + ['sync', 'push', '--yes'], + tempDir, "raw_app_ts_first_test" + ); + expect(pushResult1.code).toEqual(0); + + // Edit App.tsx (and the .ts file that sorts first) and push again. + const appTsxPath = path.join(appDir, "App.tsx"); + const appTsxContent = await readFileContent(appTsxPath); + await writeFile( + appTsxPath, + appTsxContent.replace("hello world", "REGRESSION MARKER"), + "utf-8" + ); + await writeFile(apiTsPath, "export const API = '/api/v2'\n", "utf-8"); + + const pushResult2 = await backend.runCLICommand( + ['sync', 'push', '--yes'], + tempDir, "raw_app_ts_first_test" + ); + expect(pushResult2.code).toEqual(0); + + // The App.tsx edit must have landed on the remote app's bundled files. + const appResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/apps/get/p/f/test/ts_first_app` + ); + expect(appResp.status).toEqual(200); + const appJson = await appResp.json(); + const files = appJson?.value?.files ?? {}; + expect(files["/App.tsx"]).toContain("REGRESSION MARKER"); + // The first-sorting .ts file is part of the app bundle, with fresh content. + expect(files["/Api.ts"]).toContain("/api/v2"); + + // And no bogus standalone script was created at the truncated path + // (f/test/ts_first_app.raw_app/Api.ts -> f/test/ts_first_app). + const scriptResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/scripts/get/p/f/test/ts_first_app` + ); + expect(scriptResp.status).toEqual(404); + }); +}); + test("Raw App: delete file and push", async () => { await withTestBackend(async (backend, tempDir) => { // Set up workspace diff --git a/docker-compose.yml b/docker-compose.yml index 8b636c7702..8b30479481 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -49,25 +49,6 @@ services: logging: *default-logging - # Docker-in-Docker sidecar: provides an isolated Docker daemon so user scripts - # can run containers without accessing the host Docker socket. - dind: - image: docker:dind - privileged: true - restart: unless-stopped - environment: - DOCKER_TLS_CERTDIR: "" - volumes: - - dind-data:/var/lib/docker - expose: - - 2375 - healthcheck: - test: ["CMD", "docker", "info"] - interval: 10s - timeout: 5s - retries: 5 - logging: *default-logging - windmill_worker: image: ${WM_IMAGE} pull_policy: always @@ -89,22 +70,19 @@ services: # If running with non-root/non-windmill UID (e.g., user: "1001:1001"), # add: - HOME=/tmp - FAVOR_UNSHARE_PID=true - # Connect to the dind sidecar instead of the host Docker socket - - DOCKER_HOST=tcp://dind:2375 depends_on: db: condition: service_healthy - dind: - condition: service_healthy # to mount the worker folder to debug, KEEP_JOB_DIR=true and mount /tmp/windmill volumes: - worker_dependency_cache:/tmp/windmill/cache - worker_logs:/tmp/windmill/logs - ## WARNING: mounting the host Docker socket grants user scripts full access to - ## the host Docker daemon, enabling host filesystem access and privilege escalation. - ## Only use this if you fully trust all users who can run scripts. - ## To use it, remove the DOCKER_HOST env var and dind depends_on above, - ## and uncomment the line below: + ## Sandboxed containers (`# sandbox `) run daemonless via crane + nsjail + ## inside the worker itself — no Docker socket or dind sidecar required. + ## For the legacy full-compat docker (a bare `# docker`, trusted users only), + ## mount the host Docker socket by uncommenting the line below. WARNING: this + ## grants user scripts full access to the host Docker daemon (host filesystem + ## access and privilege escalation) — only use it if you fully trust all users. # - /var/run/docker.sock:/var/run/docker.sock logging: *default-logging @@ -237,4 +215,3 @@ volumes: windmill_index: null lsp_cache: null caddy_data: null - dind-data: null diff --git a/docker/DockerfileSlim b/docker/DockerfileSlim index 2bfa883466..aca18aea22 100644 --- a/docker/DockerfileSlim +++ b/docker/DockerfileSlim @@ -26,7 +26,7 @@ RUN make FROM ${DEBIAN_IMAGE} ARG APP=/usr/src/app -ARG LATEST_STABLE_PY=3.11.10 +ARG LATEST_STABLE_PY=3.12.12 # UV configuration ENV UV_CACHE_DIR=/tmp/windmill/cache/uv @@ -39,7 +39,7 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH # Install system dependencies RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \ + && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30 libgcrypt20 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* @@ -54,14 +54,17 @@ RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmo ENV TZ=Etc/UTC # Install UV -RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv +RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv # Preinstall python runtime to temp location (will copy with world-writable perms later) -RUN UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY +# --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run +# under the read-only nsjail runtime mount (uv >= 0.9.25). The copy below MUST preserve +# timestamps or Python's mtime-based .pyc invalidation discards these compiled files. +RUN UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY --compile-bytecode # Copy to final location with world-writable permissions for arbitrary UID support RUN mkdir -p /tmp/windmill/cache && \ - cp -r /tmp/build_cache/* /tmp/windmill/cache/ && \ + cp -r --preserve=timestamps /tmp/build_cache/* /tmp/windmill/cache/ && \ chmod -R a+rw /tmp/windmill/cache && \ rm -rf /tmp/build_cache && \ mkdir -p -m 777 /tmp/windmill/cache/uv @@ -83,10 +86,20 @@ COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/ # nsjail runtime deps and binary RUN apt-get update \ - && apt-get install -y --no-install-recommends libprotobuf-dev libnl-route-3-dev \ + && apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 libnl-3-200 \ && apt-get clean && rm -rf /var/lib/apt/lists/* COPY --from=nsjail /nsjail/nsjail /bin/nsjail +# crane: pulls + flattens images for the sandboxed container runtime (`# sandbox `). +# Single static binary — no daemon/store/root needed. See docs/docker-v2-runtime.md. +ARG CRANE_VERSION=v0.20.6 +RUN arch="$(dpkg --print-architecture)"; \ + case "$arch" in amd64) crane_arch=x86_64 ;; arm64) crane_arch=arm64 ;; *) echo >&2 "error: unsupported arch '$arch' for crane"; exit 1 ;; esac; \ + wget -O /tmp/crane.tgz "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_${crane_arch}.tar.gz" \ + && tar -xzf /tmp/crane.tgz -C /usr/local/bin crane \ + && rm /tmp/crane.tgz \ + && chmod +x /usr/local/bin/crane + WORKDIR ${APP} COPY --from=ghcr.io/windmill-labs/windmill:dev --chmod=755 ${APP}/windmill ${APP}/windmill diff --git a/docker/DockerfileSlimEe b/docker/DockerfileSlimEe index d6616b5b97..b8e5c06a01 100644 --- a/docker/DockerfileSlimEe +++ b/docker/DockerfileSlimEe @@ -26,7 +26,7 @@ RUN make FROM ${DEBIAN_IMAGE} ARG APP=/usr/src/app -ARG LATEST_STABLE_PY=3.11.10 +ARG LATEST_STABLE_PY=3.12.12 # UV configuration ENV UV_CACHE_DIR=/tmp/windmill/cache/uv @@ -39,7 +39,7 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH # Install system dependencies RUN apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release \ + && apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30 libgcrypt20 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* @@ -54,14 +54,17 @@ RUN curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmo ENV TZ=Etc/UTC # Install UV -RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.24/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv +RUN curl --proto '=https' --tlsv1.2 -LsSf https://github.com/astral-sh/uv/releases/download/0.9.25/uv-installer.sh | sh && mv /root/.local/bin/uv /usr/local/bin/uv # Preinstall python runtime to temp location (will copy with world-writable perms later) -RUN UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY +# --compile-bytecode precompiles the stdlib to .pyc so jobs don't recompile it on every run +# under the read-only nsjail runtime mount (uv >= 0.9.25). The copy below MUST preserve +# timestamps or Python's mtime-based .pyc invalidation discards these compiled files. +RUN UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $LATEST_STABLE_PY --compile-bytecode # Copy to final location with world-writable permissions for arbitrary UID support RUN mkdir -p /tmp/windmill/cache && \ - cp -r /tmp/build_cache/* /tmp/windmill/cache/ && \ + cp -r --preserve=timestamps /tmp/build_cache/* /tmp/windmill/cache/ && \ chmod -R a+rw /tmp/windmill/cache && \ rm -rf /tmp/build_cache && \ mkdir -p -m 777 /tmp/windmill/cache/uv @@ -83,10 +86,20 @@ COPY --from=docker:29-dind /usr/local/bin/docker /usr/local/bin/ # nsjail runtime deps and binary RUN apt-get update \ - && apt-get install -y --no-install-recommends libprotobuf-dev libnl-route-3-dev \ + && apt-get install -y --no-install-recommends libprotobuf32 libnl-route-3-200 libnl-3-200 \ && apt-get clean && rm -rf /var/lib/apt/lists/* COPY --from=nsjail /nsjail/nsjail /bin/nsjail +# crane: pulls + flattens images for the sandboxed container runtime (`# sandbox `). +# Single static binary — no daemon/store/root needed. See docs/docker-v2-runtime.md. +ARG CRANE_VERSION=v0.20.6 +RUN arch="$(dpkg --print-architecture)"; \ + case "$arch" in amd64) crane_arch=x86_64 ;; arm64) crane_arch=arm64 ;; *) echo >&2 "error: unsupported arch '$arch' for crane"; exit 1 ;; esac; \ + wget -O /tmp/crane.tgz "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_${crane_arch}.tar.gz" \ + && tar -xzf /tmp/crane.tgz -C /usr/local/bin crane \ + && rm /tmp/crane.tgz \ + && chmod +x /usr/local/bin/crane + WORKDIR ${APP} COPY --from=ghcr.io/windmill-labs/windmill-ee:dev --chmod=755 ${APP}/windmill ${APP}/windmill diff --git a/docker/RHEL8/Dockerfile b/docker/RHEL8/Dockerfile index cb5f36cef5..57b74b75af 100644 --- a/docker/RHEL8/Dockerfile +++ b/docker/RHEL8/Dockerfile @@ -1,6 +1,5 @@ ARG DEBIAN_IMAGE=debian:bookworm-slim ARG RUST_IMAGE=registry.access.redhat.com/ubi8/ubi:latest -ARG PYTHON_IMAGE=python:3.11.10-slim-bookworm FROM ${RUST_IMAGE} AS rust_base @@ -30,6 +29,7 @@ RUN npm ci COPY frontend . RUN mkdir /backend COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml +COPY /backend/oauth_connect.json /backend/oauth_connect.json COPY /openflow.openapi.yaml /openflow.openapi.yaml COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh COPY /system_prompts/auto-generated /system_prompts/auto-generated diff --git a/docker/RHEL9/Dockerfile b/docker/RHEL9/Dockerfile index 6d96804381..21816ba8a5 100644 --- a/docker/RHEL9/Dockerfile +++ b/docker/RHEL9/Dockerfile @@ -1,6 +1,5 @@ ARG DEBIAN_IMAGE=debian:bookworm-slim ARG RUST_IMAGE=registry.access.redhat.com/ubi9/ubi:latest -ARG PYTHON_IMAGE=python:3.11.10-slim-bookworm FROM ${RUST_IMAGE} AS rust_base @@ -30,6 +29,7 @@ RUN npm ci COPY frontend . RUN mkdir /backend COPY /backend/windmill-api/openapi.yaml /backend/windmill-api/openapi.yaml +COPY /backend/oauth_connect.json /backend/oauth_connect.json COPY /openflow.openapi.yaml /openflow.openapi.yaml COPY /backend/windmill-api/build_openapi.sh /backend/windmill-api/build_openapi.sh COPY /system_prompts/auto-generated /system_prompts/auto-generated diff --git a/docs/app-mode-ai-chat-review.md b/docs/app-mode-ai-chat-review.md index 51833d4f9a..eacfba08cb 100644 --- a/docs/app-mode-ai-chat-review.md +++ b/docs/app-mode-ai-chat-review.md @@ -1,354 +1,48 @@ # App Mode AI Chat Review -## Purpose +This note only tracks the highest-value next steps for making app-mode AI chat +safer and more efficient. -This document reviews the current app-mode AI chat design with a focus on: +## Recommended Next Steps -- keeping prompts and context as small as possible; -- requiring user confirmation for important actions; -- making datatable integration smooth and safe for users. +1. Add confirmation for dangerous app tools. -## Short verdict + Require explicit user confirmation before file writes, file deletes, backend + runnable writes, backend runnable deletes, and datatable SQL execution. Show a + useful diff or exact SQL before applying the action. -The app-mode AI chat has a solid foundation: mode-specific helpers, explicit `@` context, app snapshots/revert, datatable whitelisting, and generic confirmation UI already exist. +2. Enforce datatable SQL safety in code. -However, it is not yet optimal for minimal context and user-safe automation: + Do not rely on prompt instructions for SQL safety. Classify statements before + execution, block DDL unless table creation is allowed, and require + confirmation for DDL, DML, and row-returning reads that would expose data back + to the model. -1. **Context is still too large by default**, especially the app system prompt, broad file-discovery guidance, full datatable schemas, and persistent `@` context. (`get_files()` has since been replaced by metadata-only `list_files()`.) -2. **Important app/datatable actions are not consistently confirmed**. The confirmation infrastructure exists, but app tools mostly bypass it. -3. **Datatables UX is promising but has rough edges**: stale cached table context, weak SQL safety, policy persistence issues, and too-heavy full-schema fetching. +3. Keep default context demand-driven. -## Relevant files + Prefer selected context and targeted reads before broad discovery. Keep file + listings metadata-only, avoid sending full datatable schemas by default, and + keep SDK/reference material out of the base prompt unless it is requested or + needed for the task. -### AI chat orchestration +4. Improve app context lifecycle. -- `frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts` -- `frontend/src/lib/components/copilot/chat/chatLoop.ts` -- `frontend/src/lib/components/copilot/chat/shared.ts` -- `frontend/src/lib/components/copilot/chat/AIChat.svelte` -- `frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte` -- `frontend/src/lib/components/copilot/chat/AIChatInput.svelte` -- `frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte` + Treat `@` context as per-message by default, with an explicit pinning affordance + for context that should persist. Lazy-load file and runnable contents, and add + a visible approximate context-size indicator so users can spot prompt bloat. -### App mode +5. Refresh datatable context after mutations. -- `frontend/src/lib/components/copilot/chat/app/core.ts` -- `frontend/src/lib/components/copilot/chat/AppAvailableContextList.svelte` -- `frontend/src/lib/components/copilot/chat/ContextElementBadge.svelte` -- `frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte` + Refresh table metadata after data-panel changes and after AI-created tables so + follow-up tool calls and user-visible context do not use stale schema data. -### Raw app editor and datatables +6. Persist table creation policy explicitly. -- `frontend/src/lib/components/raw_apps/RawAppEditor.svelte` -- `frontend/src/lib/components/raw_apps/RawAppDataTableList.svelte` -- `frontend/src/lib/components/raw_apps/RawAppDataTableDrawer.svelte` -- `frontend/src/lib/components/raw_apps/DefaultDatabaseSelector.svelte` -- `frontend/src/lib/components/raw_apps/dataTableRefUtils.ts` -- `frontend/src/lib/components/raw_apps/datatableUtils.svelte.ts` -- `frontend/src/routes/(root)/(logged)/apps_raw/add/+page.svelte` -- `frontend/src/routes/(root)/(logged)/apps_raw/edit/[...path]/+page.svelte` + Store whether AI table creation is enabled as an explicit app setting instead + of inferring it from the presence of datatable configuration. -### Backend datatable APIs +7. Add focused eval coverage for these behaviors. -- `backend/windmill-api-workspaces/src/workspaces.rs` - - `list_datatables` - - `list_datatable_schemas` - - `get_datatable_schema` - - `edit_datatable_config` - -### System prompts - -- `system_prompts/README.md` -- `system_prompts/auto-generated/index.ts` -- `system_prompts/auto-generated/sdks/datatable-typescript.md` -- `system_prompts/auto-generated/sdks/datatable-python.md` - -## How app mode works today - -In the raw app editor, `RawAppEditor.svelte` initializes app-mode AI chat on mount: - -- calls `aiChatManager.saveAndClear()`; -- calls `aiChatManager.changeMode(AIMode.APP)`; -- registers app helpers through `aiChatManager.setAppHelpers(...)`. - -Those app helpers expose operations for: - -- frontend files; -- backend runnables; -- current selected editor context; -- linting; -- app snapshots and revert; -- datatable schema loading; -- SQL execution; -- app table whitelisting. - -When app mode is active, `AIChatManager.changeMode(AIMode.APP)` sets: - -- system prompt: `prepareAppSystemMessage(...)`; -- tools: `getAppTools()`; -- helpers: `appAiChatHelpers`. - -When the user sends a message, `prepareAppUserMessage(...)` builds the user prompt from: - -- current frontend/backend file selection, unless excluded; -- inspector-selected DOM element; -- editor code selection; -- additional `@`-mentioned context; -- the user instructions. - -`runChatLoop(...)` then sends the system message, history, user message, and tool definitions to the selected model. Tool calls go through `processToolCall(...)`, which supports confirmation only when a tool opts into `requiresConfirmation`. - -## Current app tools - -### Read and discovery tools - -These are generally safe without confirmation: - -- `list_files` -- `get_frontend_file` -- `get_backend_runnable` -- `get_selected_context` -- `lint` -- `search_workspace` -- `get_runnable_details` -- `search_hub_scripts` -- `list_datatables` -- `get_datatable_table_schema` - -### Mutating tools - -These currently execute directly in app mode: - -- `set_frontend_file` -- `patch_file` -- `delete_frontend_file` -- `set_backend_runnable` -- `delete_backend_runnable` -- `exec_datatable_sql` - -This is the biggest mismatch with the requirement that every important action should be confirmed by the user. - -## System prompt assessment - -The app system prompt is useful but heavier than ideal. - -### Strengths - -- Clearly explains raw app structure. -- Explains the frontend/backend runnable split. -- Encourages `patch_file` for small edits. -- Pushes datatables for persisted app storage. -- Explains that datatable DDL should go through `exec_datatable_sql`. -- Includes table creation policy context. - -### Concerns - -1. It always includes broad app-building instructions, even for small localized edits. -2. The previous prompt included the datatable SDK reference for both TypeScript and Python every time. This has since been removed; concise examples remain in the prompt. -3. The previous prompt told the model to start with `get_files()`, which encouraged loading all files even when selected context was sufficient. This is now improved by `list_files()`, but the prompt still needs to stay demand-driven. -4. It relies heavily on prompt instructions for datatable safety instead of enforcing safety in tools. -5. Custom workspace/user prompts are appended as `USER GIVEN INSTRUCTIONS`, which is flexible but can further increase context. - -### Recommendation - -The base app prompt should be shorter and more demand-driven: - -- Keep file discovery demand-driven: use selected and explicitly provided context first; call `list_files()` only when a broader metadata overview is needed. -- Keep full SDK details out of the default prompt; concise examples are usually enough. Add an on-demand SDK reference only if it does not cause unnecessary extra tool turns. -- Keep only minimal datatable rules in the base prompt: - - use datatables for persistence; - - call `list_datatables()` before schema work; - - DDL must use `exec_datatable_sql`; - - non-read SQL requires confirmation. - -## Additional context assessment - -The `@` context system is a good UX foundation. - -App mode exposes categories for: - -- frontend files; -- backend runnables; -- datatables. - -Selecting a datatable context includes its columns and also calls `addTableToWhitelist(...)`, adding the table to the app data panel. - -### Strengths - -- Context is explicit and user-controllable. -- Datatable table selection is naturally integrated into the chat input. -- Selected app file/runnable chips are visible and can be excluded. -- Inspector and code-selection context are compact and useful. - -### Concerns - -1. `@` context persists across messages until manually removed, which can silently bloat follow-up prompts. -2. Available app context currently includes file contents/runnable configs in memory before selection. -3. Each selected context item is truncated, but there is no overall context budget indicator. -4. Current file/runnable selection is included by default unless excluded, which is convenient but not minimal. - -### Recommendation - -- Make app `@` context per-message by default. -- Add an explicit “pin” option for context that should persist across messages. -- Lazy-load file/runnable content when selected or when a message is sent. -- Show an approximate context-size/token budget indicator. -- Prefer sending path/name and selected code first; fetch full files only when necessary. - -## Confirmation assessment - -The generic confirmation mechanism already exists: - -- `processToolCall(...)` checks `tool.requiresConfirmation`. -- `ToolExecutionDisplay.svelte` renders Run/Cancel controls. -- Script test runs, flow test runs, and mutating API calls already use confirmation. - -App mode should use the same infrastructure for important actions. - -### Suggested confirmation policy - -#### No confirmation required - -- `list_files`, as a metadata-only response; -- `get_frontend_file`; -- `get_backend_runnable`; -- `get_selected_context`; -- `list_datatables`, as table-name metadata only; -- `get_datatable_table_schema`, as a targeted schema read; -- `lint`; -- search tools. - -#### Confirmation required - -- `set_frontend_file`; -- `patch_file`; -- `delete_frontend_file`; -- `set_backend_runnable`; -- `delete_backend_runnable`; -- `exec_datatable_sql` for any DDL or DML; -- `exec_datatable_sql` for `SELECT` if it returns real row data that will be sent back to the model. - -### Recommended UX - -For files/runnables: - -- Prefer batched proposed edits. -- Show a diff. -- Let the user click “Apply changes”. -- Run lint after applying. - -For SQL: - -- Show the exact SQL. -- Classify the query as: - - schema read; - - data read; - - insert/update/delete; - - DDL. -- Require confirmation before data reads and all mutations. -- For table creation, require both: - - table creation policy enabled; - - explicit confirmation of the `CREATE TABLE` SQL. - -## Datatables integration assessment - -The datatable integration is directionally good and already has several strong user-facing pieces. - -### Current strengths - -The new app setup lets the user choose: - -- default datatable; -- schema mode: none, new, existing; -- whether AI can create tables; -- pre-whitelisted existing tables. - -The raw app data panel lets users: - -- add datatable table references; -- inspect tables through the DB manager drawer; -- configure the default datatable/schema for new tables. - -The AI chat integration lets users: - -- mention datatable tables through `@` context; -- add mentioned tables to the app whitelist; -- list datatable/schema/table names with `list_datatables()`; -- retrieve one table's columns with `get_datatable_table_schema()`; -- create tables through `exec_datatable_sql(..., new_table)`. - -### Concerns - -1. **`exec_datatable_sql` is too powerful without confirmation.** - It can run `SELECT`, `INSERT`, `UPDATE`, `DELETE`, `CREATE`, `DROP`, `ALTER`, etc. - -2. **Table creation policy is not fully enforced in code.** - The tool blocks `new_table` when policy is disabled, but it does not block DDL if the model omits `new_table`. - -3. **Table creation disabled state may not persist cleanly.** - `RawAppData` stores `datatable` and `schema`, but not an explicit `enabled` value. `RawAppEditor` infers enabled from `data.datatable !== undefined`, which can re-enable table creation after reopening. - -4. **Datatable context cache can become stale.** - `AIChatManager.refreshDatatables()` runs when app helpers are set, but may not refresh immediately after data panel changes or after AI creates a new table. - -5. **Full schema loading can still be too expensive internally.** - `list_datatables()` and `get_datatable_table_schema()` reduce what is sent to the model, but they still currently rely on app helpers that fetch full schema data before filtering. - -6. **Auto-whitelisting from `@table` is convenient but silent.** - It mutates app data without an obvious confirmation or undo affordance. - -### Recommended datatable tool design - -Instead of one broad schema tool and one unrestricted SQL tool, prefer smaller tools: - -- `list_datatables()` -- `list_datatable_tables(datatable, schema?, search?)` (optional backend/API optimization if table lists need server-side filtering) -- `get_datatable_table_schema(datatable, schema, table)` -- `preview_datatable_rows(datatable, schema, table, limit)` with confirmation -- `execute_datatable_sql(datatable, sql)` with query classification and confirmation -- `create_datatable_table(datatable, schema, table, columns)` as a structured safe path for table creation - -## Priority recommendations - -1. **Add confirmation to dangerous app tools** - - file/runnable writes; - - file/runnable deletes; - - datatable SQL; - - especially DDL/DML. - -2. **Enforce SQL safety in code, not only in prompts** - - block DDL unless `new_table` is provided and policy allows it; - - confirm all non-`SELECT` statements; - - consider confirming `SELECT` row reads too. - -3. **Reduce default prompt/tool context** - - keep `list_files()` metadata-only and demand-driven; - - use selected context first; - - keep full SDK references out of the default prompt; - - keep datatable tools split into smaller schema/table lookups. - -4. **Refresh datatable context reliably** - - refresh after data panel changes; - - refresh after `exec_datatable_sql(..., new_table)`; - - remove debug logging from datatable refresh. - -5. **Persist table creation policy explicitly** - - store a boolean such as `tableCreationEnabled` in raw app data; - - do not infer enabled solely from `data.datatable`. - -6. **Improve `@` context lifecycle** - - make app `@` context per-message by default; - - add pinning for persistent context; - - lazy-load file/runnable contents; - - show approximate context size. - -## Overall opinion - -The current architecture is good and extensible, but it should become more demand-driven and safer before being considered efficient and user-safe. - -The highest-impact changes are: - -- add confirmation for app mutations and datatable SQL; -- enforce datatable SQL policy programmatically; -- reduce the app system prompt and avoid automatic broad context loading; -- split datatable schema access into smaller, targeted tools. + Cover confirmation requirements, datatable SQL policy enforcement, selected + context minimization, and stale-schema refresh behavior with targeted app-mode + evals or lower-level tests where practical. diff --git a/docs/app-mode-ai-chat-token-baseline.md b/docs/app-mode-ai-chat-token-baseline.md deleted file mode 100644 index 95eab70c0d..0000000000 --- a/docs/app-mode-ai-chat-token-baseline.md +++ /dev/null @@ -1,245 +0,0 @@ -# App Mode AI Chat Token Baseline - -This baseline was collected before optimizing app-mode context/prompt/datatable behavior. - -> Note: The historical commands/results below include `app-token-selected-large-frontend-context` and `app-token-selected-large-backend-context`. Those cases were removed from the active eval suite because `runtime.appContext.selected` only verified that the file/runnable existed and did not serialize a selected file/runnable hint to the model. Future selected-file/runnable coverage should be reintroduced through the app context manager path. - -## Command - -Secrets were loaded from `~/windmill/ai_evals/.env` without printing them. - -```bash -cd ai_evals -set -a -source ~/windmill/ai_evals/.env -set +a -bun run cli -- run app \ - app-token-baseline-large-app-small-edit \ - app-token-selected-large-frontend-context \ - app-token-selected-large-backend-context \ - app-token-many-datatable-context \ - app-token-large-datatable-discovery \ - --model haiku \ - --runs 1 \ - --output results/app-token-baseline-current-max8.json -``` - -## Environment - -- Mode: `app` -- Model under test: `anthropic:claude-haiku-4-5-20251001` -- Transport: `direct` -- Judge model: `claude-sonnet-4-6` -- Runs per case: `1` -- Token-heavy app cases use `runtime.maxTurns: 8` - -## Results - -Pass rate: **100% (5/5)** - -| Case | Prompt tokens | Completion tokens | Total tokens | Tool calls | Tools used | -|---|---:|---:|---:|---:|---| -| `app-token-baseline-large-app-small-edit` | 73,682 | 519 | 74,201 | 4 | `get_files`, `get_frontend_file`, `patch_file` | -| `app-token-selected-large-frontend-context` | 36,305 | 348 | 36,653 | 2 | `get_frontend_file`, `patch_file` | -| `app-token-selected-large-backend-context` | 95,232 | 19,633 | 114,865 | 4 | `set_backend_runnable`, `get_backend_runnable` | -| `app-token-many-datatable-context` | 35,204 | 404 | 35,608 | 2 | `get_files`, `patch_file` | -| `app-token-large-datatable-discovery` | 114,964 | 4,047 | 119,011 | 7 | `get_files`, `get_datatables`, `set_backend_runnable`, `set_frontend_file`, `patch_file`, `lint` | - -Aggregate token usage: - -```json -{ - "totalTokenUsage": { - "prompt": 355387, - "completion": 24951, - "total": 380338 - }, - "averageTokenUsagePerAttempt": { - "prompt": 71077.4, - "completion": 4990.2, - "total": 76067.6 - } -} -``` - -## Interpretation - -The highest-token cases are: - -1. `app-token-large-datatable-discovery` — full datatable discovery with `get_datatables()` and app edits reached **119,011** total tokens. -2. `app-token-selected-large-backend-context` — selected large backend runnable plus a rewrite-style tool call reached **114,865** total tokens. -3. `app-token-baseline-large-app-small-edit` — a trivial heading edit still reached **74,201** total tokens, largely due broad file discovery. - -These cases should be rerun after prompt/context/tool changes to compare total and prompt-token reductions. - -## Follow-up: metadata-only `list_files` - -The contentful `get_files` app-mode tool was replaced with `list_files` to make broad app discovery cheaper and less sticky in chat history. - -Changes: - -- Renamed the overview tool from `get_files` to `list_files`. -- Changed the overview response from truncated source/config contents to metadata only. -- `list_files` returns: - - frontend files: `path`, character `size`, and file `kind`; - - backend runnables: `key`, `name`, `type`, and lightweight optional metadata such as `path`, `language`, `contentSize`, and `staticInputKeys`. -- Updated app-mode prompt guidance so the model no longer starts every task with broad file discovery. -- Kept targeted content tools as the path for inspection: - - `get_frontend_file(path)` for frontend source; - - `get_backend_runnable(key)` for runnable configuration/source. - -The same five cases were rerun with: - -```bash -cd ai_evals -set -a -source ~/windmill/ai_evals/.env -set +a -bun run cli -- run app \ - app-token-baseline-large-app-small-edit \ - app-token-selected-large-frontend-context \ - app-token-selected-large-backend-context \ - app-token-many-datatable-context \ - app-token-large-datatable-discovery \ - --model haiku \ - --runs 1 \ - --output results/app-token-after-list-files.json -``` - -Pass rate: **100% (5/5)** - -| Case | Prompt tokens | Completion tokens | Total tokens | Tool calls | Tools used | -|---|---:|---:|---:|---:|---| -| `app-token-baseline-large-app-small-edit` | 41,020 | 422 | 41,442 | 3 | `list_files`, `get_frontend_file`, `patch_file` | -| `app-token-selected-large-frontend-context` | 41,020 | 422 | 41,442 | 3 | `list_files`, `get_frontend_file`, `patch_file` | -| `app-token-selected-large-backend-context` | 53,511 | 9,714 | 63,225 | 3 | `list_files`, `get_backend_runnable`, `set_backend_runnable` | -| `app-token-many-datatable-context` | 46,990 | 475 | 47,465 | 3 | `list_files`, `get_frontend_file`, `patch_file` | -| `app-token-large-datatable-discovery` | 131,607 | 5,084 | 136,691 | 8 | `get_datatables`, `list_files`, `set_backend_runnable`, `set_frontend_file`, `patch_file`, `lint` | - -Aggregate token usage: - -```json -{ - "totalTokenUsage": { - "prompt": 314148, - "completion": 16117, - "total": 330265 - }, - "averageTokenUsagePerAttempt": { - "prompt": 62829.6, - "completion": 3223.4, - "total": 66053 - } -} -``` - -Comparison against the post-rebase / PR #8922 run (`results/app-token-after-origin-main-pr8922.json`): - -| Case | PR #8922 total | `list_files` total | Delta | Delta % | Prompt delta | -|---|---:|---:|---:|---:|---:| -| `app-token-baseline-large-app-small-edit` | 74,061 | 41,442 | -32,619 | -44.0% | -32,522 | -| `app-token-selected-large-frontend-context` | 74,061 | 41,442 | -32,619 | -44.0% | -32,522 | -| `app-token-selected-large-backend-context` | 71,050 | 63,225 | -7,825 | -11.0% | -7,787 | -| `app-token-many-datatable-context` | 35,497 | 47,465 | +11,968 | +33.7% | +11,886 | -| `app-token-large-datatable-discovery` | 97,128 | 136,691 | +39,563 | +40.7% | +38,295 | - -Aggregate comparison against the post-rebase / PR #8922 run: - -| Metric | PR #8922 | `list_files` | Delta | Delta % | -|---|---:|---:|---:|---:| -| Prompt tokens | 336,798 | 314,148 | -22,650 | -6.7% | -| Completion tokens | 14,999 | 16,117 | +1,118 | +7.5% | -| Total tokens | 351,797 | 330,265 | -21,532 | -6.1% | - -Compared to the original baseline above, the `list_files` run is **-50,073 total tokens** (**-13.2% total**). - -Interpretation: - -- The small edit and selected-frontend cases improved substantially because broad discovery no longer injects truncated contents for the whole app. -- The selected-backend case also improved, despite still needing targeted runnable inspection. -- The datatable-context cases can require an extra `get_frontend_file` after `list_files`, so the small datatable edit regressed in this single-run sample. -- The large datatable case remains dominated by datatable/schema prompt bloat and model variability; moving datatable SDK/reference and schema discovery behind smaller on-demand tools is still the next likely high-impact optimization. - -## Follow-up: targeted datatable tools and shorter datatable prompt - -The next pass reduced default datatable context by making datatable discovery metadata-first and removing the full datatable SDK reference from the system prompt. - -Changes: - -- Replaced the broad schema discovery tool with `list_datatables()` for datatable/schema/table names only. -- Added `get_datatable_table_schema(datatable_name, schema_name, table_name)` for targeted column lookup when column names/types are actually needed. -- Removed the full TypeScript + Python datatable SDK reference from the default app system prompt. -- Kept concise TypeScript and Python datatable examples in the prompt, which were enough for the benchmark cases. -- Strengthened prompt/tool guidance so table-list dashboards use `list_datatables()` directly and avoid schema/SDK lookups unless needed. - -The same five cases were rerun with: - -```bash -cd ai_evals -set -a -source ~/windmill/ai_evals/.env -set +a -bun run cli -- run app \ - app-token-baseline-large-app-small-edit \ - app-token-selected-large-frontend-context \ - app-token-selected-large-backend-context \ - app-token-many-datatable-context \ - app-token-large-datatable-discovery \ - --model haiku \ - --runs 1 \ - --output results/app-token-after-datatable-tools-v3.json -``` - -Pass rate: **100% (5/5)** - -| Case | Prompt tokens | Completion tokens | Total tokens | Tool calls | Tools used | -|---|---:|---:|---:|---:|---| -| `app-token-baseline-large-app-small-edit` | 37,516 | 425 | 37,941 | 3 | `list_files`, `get_frontend_file`, `patch_file` | -| `app-token-selected-large-frontend-context` | 37,516 | 358 | 37,874 | 3 | `list_files`, `get_frontend_file`, `patch_file` | -| `app-token-selected-large-backend-context` | 49,995 | 9,708 | 59,703 | 3 | `list_files`, `get_backend_runnable`, `set_backend_runnable` | -| `app-token-many-datatable-context` | 43,493 | 536 | 44,029 | 3 | `list_files`, `get_frontend_file`, `patch_file` | -| `app-token-large-datatable-discovery` | 24,193 | 2,043 | 26,236 | 4 | `list_datatables`, `list_files`, `get_frontend_file`, `set_frontend_file` | - -Aggregate token usage: - -```json -{ - "totalTokenUsage": { - "prompt": 192713, - "completion": 13070, - "total": 205783 - }, - "averageTokenUsagePerAttempt": { - "prompt": 38542.6, - "completion": 2614, - "total": 41156.6 - } -} -``` - -Comparison against the metadata-only `list_files` run (`results/app-token-after-list-files.json`): - -| Case | `list_files` total | Datatable-tools total | Delta | Delta % | Prompt delta | -|---|---:|---:|---:|---:|---:| -| `app-token-baseline-large-app-small-edit` | 41,442 | 37,941 | -3,501 | -8.4% | -3,504 | -| `app-token-selected-large-frontend-context` | 41,442 | 37,874 | -3,568 | -8.6% | -3,504 | -| `app-token-selected-large-backend-context` | 63,225 | 59,703 | -3,522 | -5.6% | -3,516 | -| `app-token-many-datatable-context` | 47,465 | 44,029 | -3,436 | -7.2% | -3,497 | -| `app-token-large-datatable-discovery` | 136,691 | 26,236 | -110,455 | -80.8% | -107,414 | - -Aggregate comparison: - -| Metric | `list_files` | Datatable tools | Delta | Delta % | -|---|---:|---:|---:|---:| -| Prompt tokens | 314,148 | 192,713 | -121,435 | -38.7% | -| Completion tokens | 16,117 | 13,070 | -3,047 | -18.9% | -| Total tokens | 330,265 | 205,783 | -124,482 | -37.7% | - -Compared to the post-rebase / PR #8922 run, the datatable-tools run is **-146,014 total tokens** (**-41.5% total**). Compared to the original baseline above, it is **-174,555 total tokens** (**-45.9% total**). - -Interpretation: - -- Removing the full datatable SDK reference from the default prompt saved about 3.5k prompt tokens in every case. -- The large datatable discovery case improved dramatically because the model used `list_datatables()` table-name metadata instead of loading full schemas. -- The small datatable-context edit is still higher than the post-rebase / PR #8922 run because selected file identifiers are not yet injected, so the model still discovers and reads `/index.tsx` before patching. -- A future context-manager-backed selected file/runnable flow should add cheap selected identifiers when that UX is ready, so selected-file tasks can skip `list_files()` without reintroducing implicit source-content bloat. diff --git a/docs/docker-v2-runtime.md b/docs/docker-v2-runtime.md new file mode 100644 index 0000000000..f38d17d5e6 --- /dev/null +++ b/docs/docker-v2-runtime.md @@ -0,0 +1,110 @@ +# Sandboxed container runtime (daemonless docker) + +Windmill bash scripts can run a container image. There are **two** runtimes: + +| | legacy `# docker` | sandboxed `# sandbox ` | +|---|---|---| +| selected by | bare `# docker` | `# sandbox ` | +| runtime | dind / Docker daemon (bollard, `dind` feature) | daemonless: extract rootfs + nsjail-run | +| boundary | separate (daemon outside the jail) | the job's own nsjail sandbox | +| nsjail | not provided (trusted-tenant) | **required** — this *is* the sandbox | +| safety | trusted-tenant | sandboxed (untrusted-capable) | +| compat | full `docker run`/`-d`/API | run-a-command subset | + +The three bash annotations are distinct and don't overload each other: + +- `# docker` → legacy daemon docker (unchanged). +- `# sandbox` → run the bash script under nsjail. +- `# sandbox ` → run that image's command under nsjail (this runtime). + +## Using it + +Put the image ref on a `# sandbox` annotation line; the rest of the script runs +**inside** that image: + +```bash +# sandbox python:3.12-slim +name="$1" # windmill args bind positionally, like any bash script +python3 -c "import sys; print('hello', sys.argv[1])" "$name" +``` + +- The body runs via the image's `/bin/sh -c` (so the image needs a shell). +- An **empty** body runs the image's `ENTRYPOINT` + `CMD`. +- Windmill args (declared `x="$1"`, …) are appended to the command. +- The image's `Env`, `WorkingDir` are applied; the windmill reserved variables + (`WM_TOKEN`, `BASE_INTERNAL_URL`, …) are injected so `wmill`/API calls work. + +## How it works + +1. **Pull/extract** ([`crane`](https://github.com/google/go-containerregistry), no + daemon/store/root): `crane export ` streams the image's flattened root + filesystem to a tar (layers + whiteouts applied, like `docker export`) and + `crane config` reads its OCI config. The tar + config are cached + content-addressed by digest (`crane digest`) so unchanged digests reuse the + cache; `tar -x` materializes the per-job `{job_dir}/rootfs`. crane is a single + ~25 MB static binary — we never *run* the image with it (nsjail does), so a full + container engine like podman isn't needed. +2. **Run** (the job's nsjail sandbox): nsjail binds each top-level entry of the + rootfs in place (binding the whole rootfs at `/` trips nsjail's read-only + remount of its base root in a rootless userns), mounts the standard + pseudo-filesystems (`/proc` from the jail's pid namespace, a tmpfs `/tmp`, + `/dev` nodes), maps uid/gid 0 inside → the worker user outside, and runs the + command. The container *is* the jail. + +``` +# sandbox ─▶ crane export → digest-keyed rootfs cache → tar -x → {job_dir}/rootfs ─▶ nsjail (chroot rootfs) + crane config (OCI config) ───────────────────────────────────────────▶ Env / Cmd / WorkingDir +``` + +Because the run is just the job's own nsjail with the image's filesystem as root, +the container inherits exactly the job's confinement: + +- **Filesystem**: only the rootfs + the job's mounts are visible — no host `/`, + no other job dirs, no dep cache. There is nothing to bind-mount escape to. +- **/proc**: the jail's own pid namespace — the worker and other jobs aren't + visible. +- **uid**: a single-uid jail — an escape lands as the unprivileged worker user. +- **network**: the job's network (same as any bash job). + +## Image storage, freshness & limits + +- **Where pulls live:** a content-addressed cache of flattened rootfs tars (+ OCI + config sidecars) keyed by image digest, under `{ROOT_CACHE_DIR}/sandbox_rootfs` + (persistent, dedups pulls across jobs). The per-job extracted rootfs lives in + `{job_dir}/rootfs` and is removed with the job. +- **Freshness (`SANDBOX_IMAGE_PULL_POLICY`, default `newer`):** the cache is keyed + by digest, so a moving tag whose digest changed re-pulls automatically. `newer` + (default) / `always` re-resolve the digest each job (one cheap `crane digest` + manifest fetch); `missing` reuses a cached digest for the ref without hitting the + registry; `never` only uses the cache (errors if absent). Pinning a digest + (`img@sha256:…`) is immutable and never stale. +- **Per-image size cap (`SANDBOX_IMAGE_MAX_SIZE_MB`, default 0 = off):** images + whose *compressed download* size (`crane manifest`) exceeds the cap are rejected + **before any layer is downloaded**. +- **Cache size cap (`SANDBOX_IMAGE_CACHE_MAX_MB`, default 0 = off):** best-effort + eviction — after a run, the oldest cached rootfs tars (by creation time) are + removed until the cache is back under the cap. + +## Requirements + +- [`crane`](https://github.com/google/go-containerregistry) and `tar` on the worker + for image pull/extract (a single static binary — no daemon, root, or privileged). +- `nsjail` on the worker — **required**. If nsjail is absent, a `# sandbox ` + job errors clearly (use a bare `# docker` + a daemon instead). + +## Limitations (by design — daemonless, run-to-completion) + +- No `docker run -d` + later `exec`/`attach`/`logs -f`, no `docker build`, + `compose`, swarm, healthchecks. +- No arbitrary `-v` host bind mounts, `--privileged`, `--cap-add`, `--device`, + host namespace sharing. +- Images that drop to a non-root uid or chown to arbitrary uids inside need a + subuid **range** in the jail (single-uid only today — follow-up: `newuidmap` + range mapping). +- The script result is a completion message; capture output via stdout/logs. + +## Follow-ups + +- Subuid-range nsjail variant for multi-uid images. +- Per-container isolated networking (slirp/pasta). +- Support under the non-nsjail `unshare` isolation mode. diff --git a/docs/failing-tests.md b/docs/failing-tests.md deleted file mode 100644 index d0ae44f109..0000000000 --- a/docs/failing-tests.md +++ /dev/null @@ -1,33 +0,0 @@ -# Failing Tests - -This file tracks benchmark cases that still fail or need follow-up validation. - -## Flow - -- `flow-test6-ai-agent-tools` - Latest failing run: `ai_evals/results/2026-04-09T11-25-24.107Z__flow` - Issues: - final output does not include the actions or tool-result details the prompt asks for - `open_support_ticket` contains a syntax bug - -- `flow-test7-simple-modification` - Latest failing run: `ai_evals/results/2026-04-09T11-25-24.107Z__flow` - Issues: - `validate_data` was added, but the failure behavior still does not match the requested contract - `save_results` throws instead of returning a graceful structured result - -- `flow-test11-preprocessor-and-failure-handler` - Latest failing run: `ai_evals/results/2026-04-09T11-25-24.107Z__flow` - Issues: - the model creates regular `preprocessor` and `failure` modules - it does not use Windmill's special top-level `preprocessor_module` and `failure_module` - -## Needs Reconfirmation - -- `flow-test4-order-processing-loop` - Full-suite failing run: `ai_evals/results/2026-04-09T11-25-24.107Z__flow` - Follow-up passing run after prompt improvement: `ai_evals/results/2026-04-09T13-29-15.877Z__flow` - Note: - this case failed on invalid `branchone` downstream result access - it passed after adding explicit branch-output guidance to the flow prompt - rerun the full flow suite to confirm the fix holds in the broader benchmark diff --git a/docs/system-prompt-testing-plan.md b/docs/system-prompt-testing-plan.md deleted file mode 100644 index 9b12f1c5e0..0000000000 --- a/docs/system-prompt-testing-plan.md +++ /dev/null @@ -1,1000 +0,0 @@ -# System Prompt And Skill Output Testing Plan - -Historical note: - -- This file is a planning document and no longer matches the current benchmark CLI in every detail. -- The current source of truth is [ai_evals/README.md](/home/farhad/windmill__worktrees/prompt-testing-plan/ai_evals/README.md) and [system-prompt-testing-status.md](/home/farhad/windmill__worktrees/prompt-testing-plan/docs/system-prompt-testing-status.md). -- In particular, the current tool no longer has the old variants, compare, or history workflow described below. - -## Goal - -Build a single testing strategy that answers one question reliably: - -> Given a user task, how good is the artifact produced by our AI system? - -This plan is intentionally focused on **black-box output evaluation**, not on unit testing frontend or CLI internals. - -The intended end state is a **new repo-level benchmark CLI** that runs a shared -eval suite across multiple surfaces. - -That benchmark CLI should be the main entrypoint for: - -- running one case -- running a benchmark set -- comparing baseline vs candidate variants -- writing benchmark history snapshots - -Frontend and Windmill CLI are not meant to become separate testing products. -They should be implemented as adapters behind this shared benchmark CLI. - -The system under test is: - -- Frontend AI Chat in `script`, `flow`, and `app` modes -- CLI local development experience driven by generated guidance and skills - -The artifact under test is: - -- Script code -- Flow JSON / module structure -- Raw app files and backend runnables -- Files and project artifacts produced in a local CLI workspace - -## Non-Goals - -This plan does **not** treat the following as the main testing target: - -- Unit testing helper functions, stores, or tool wrapper internals -- UI rendering behavior, DOM interactions, or component-level correctness -- `wmill init` correctness as a standalone product area -- Backend route correctness except where it affects prompt delivery or AI configuration - -Those may still need lightweight tests, but they are not the core of prompt reliability evaluation. - -## Core Principles - -### 1. Black-box evaluation only - -The runner should provide an input task to the real system setup, let it run, collect the final artifact, and score the result. - -In practice, this runner should be exposed through the new repo-level benchmark -CLI rather than through separate ad hoc test commands for each surface. - -### 2. Headless execution - -Frontend evaluation must be fully decoupled from the browser UI. It should exercise prompt assembly, tool selection, and tool execution logic without mounting Svelte components or clicking through the app. - -### 3. Real prompt environment - -All evals must use the same prompt-building path, tool definitions, and skill content that production uses, or a clearly defined variant of them. - -### 4. Artifact-first scoring - -The main score is based on the produced artifact, not on intermediate transcripts. - -### 5. Reliability over one-off success - -A prompt is not "good" because it passed once. Reliability means pass rate across repeated runs and across a representative case set. - -### 6. Track benchmark history over time - -The suite must not only evaluate the current output. It must also produce a -git-tracked benchmark history so the team can see whether the system is -improving over time. - -This history should focus on official benchmark snapshots, not on every local -experiment. - -### 7. Shared corpus, separate adapters - -Frontend and CLI should share the same evaluation corpus format when possible, but each surface should have its own execution adapter. - -### 8. CLI first, UI last - -The CLI should be the first surface brought to a high-confidence benchmark -state. - -It is the cleanest foundation for the suite because it produces direct files in -an isolated workspace, has less ambiguity than the frontend, and is easier to -score deterministically. - -Frontend should reuse the benchmark model proven on the CLI rather than define -a parallel testing philosophy. - -### 9. UI comes last - -The testing suite must exist and be trustworthy before building a studio UI on top of it. - -## Current State - -## Shared Prompt Source Of Truth - -The repo already has the right content split: - -- `system_prompts/` is the shared source of truth for core Windmill prompt content -- frontend adds chat-specific tool instructions on top -- CLI materializes guidance and skill content from generated outputs - -This is a strong foundation for a shared eval suite. - -## Execution Priority - -Even though the repo already has useful frontend eval scaffolding, the -implementation priority should be: - -1. build the repo-level benchmark CLI and use the Windmill CLI adapter as the - first implementation behind it -2. make the CLI artifact-evaluation path excellent -3. stabilize shared scoring, reporting, and benchmark history around that path -4. bring frontend onto the same benchmark model through the same benchmark CLI -5. build the UI only after the underlying suite is trustworthy - -This keeps the hardest product question focused on artifact quality rather than -on UI workflow. - -## Benchmark CLI As The Main Product - -The testing suite should have one primary interface: - -- a new repo-level benchmark CLI - -The benchmark CLI should be able to run: - -- Windmill CLI evals -- frontend evals -- shared reporting and comparison commands - -Illustrative command shape: - -```bash -ai-evals run --surface cli --case bun-hello-script -ai-evals run --surface frontend-flow --case support-flow -ai-evals compare --surface cli --variant baseline --variant candidate-a -ai-evals history latest -``` - -The exact binary name can change, but the architecture should not: - -- one benchmark CLI -- shared case loader -- shared scoring -- shared history writer -- separate surface adapters underneath - -## Temporary Bootstrap Code - -This bootstrap phase is now complete for frontend `flow`, `app`, and `script`. - -Frontend AI benchmark ownership has moved into `ai_evals/`, and the frontend -source tree no longer owns a separate AI benchmark suite under -`frontend/.../__tests__/...`. - -Benchmark authors should only need the repo-level benchmark CLI to run the -long-term suite. - -The only temporary frontend-specific piece that remains is a thin Vitest/Vite -loader bridge so the benchmark runner can import the production chat modules in -the same module/runtime environment they already expect. - -## Frontend: What Exists Today - -The current frontend benchmark path is **decoupled from the UI** and now owned -by `ai_evals`. - -They currently: - -- run through the shared headless chat loop -- use production prompt builders -- use production tool definitions -- use benchmark-owned helper adapters that write to temp workspaces on disk -- execute through the frontend module/runtime environment only as a loader bridge - -This means the current frontend evals are now a proper benchmark adapter, -not a frontend test suite. - -That is the correct direction. - -### Frontend Architecture Notes - -There are three categories of code involved: - -- shared production logic: - - production system prompt builders - - production tool definitions - - production `runChatLoop` -- benchmark-only infrastructure: - - case loading - - variant loading - - judge scoring - - benchmark result shaping - - history/reporting integration -- alternate helper adapters: - - production helpers mutate UI/editor state - - benchmark helpers mutate temp-workspace files - -This is important because the benchmark suite is **not** meant to duplicate the -frontend chat logic. It is meant to reuse the production chat loop and tool -definitions while swapping the execution backend from UI state to filesystem -state. - -## Frontend: What Is Missing - -### Coverage gaps - -- `script` is now exposed through the shared benchmark CLI, but it only has initial case coverage. -- Existing frontend coverage is still too small relative to the target benchmark corpus. - -### Reliability gaps - -- Frontend flow and app can already run with pass/fail results and repeated runs through the shared benchmark CLI. -- The remaining gap is turning that into stronger routine reliability gating with better deterministic validators and broader routine case coverage. -- Frontend reliability reporting is still less mature than the intended end state for official CI tiers and richer failure triage. - -### Prompt-iteration gaps - -- Frontend prompt variants are file-backed now, but the repo only ships baseline manifests by default. -- Creating and curating meaningful frontend candidate variants is still a mostly manual workflow compared with the CLI snapshot flow. -- Frontend prompt comparison exists through the shared `compare` command, but it still needs broader routine use and better variant coverage. - -### Artifact-validation gaps - -- The current flow and app helpers are file-backed now, but several effects are still lightweight and should become more realistic over time. -- Linting and runnable validation are currently too lightweight in the eval path. -- Datatable interactions are mocked rather than validated as output constraints. -- The suite does not yet enforce a strong deterministic validator layer before using an LLM judge. - -### Corpus gaps - -- Frontend surfaces already use shared case manifests under `ai_evals/cases/frontend/`. -- The remaining gap is breadth and representativeness, not the absence of a shared corpus. -- Cases still need richer metadata, stronger deterministic constraints, and a larger regression library built from real failures. - -### Reporting gaps - -- Frontend runs already emit the shared benchmark result shape and can write official history snapshots through the shared benchmark CLI. -- There is still no rich leaderboard or trend-oriented debugging workflow for frontend surfaces specifically. -- There is still no strong "worst failures first" report for debugging regressions. - -## Frontend: Perfect Testing Logic - -The perfect frontend testing logic is: - -Frontend should not be the place where the benchmark philosophy is invented. - -It should consume the shared case format, validator model, reporting format, -and history format already proven through the CLI path. - -### 1. Stay fully headless - -Do not mount the chat UI. - -Do not click through the frontend. - -Do not use Playwright for prompt evaluation. - -The runner should directly invoke: - -- the production system message builder -- the production user message builder -- the production tool list -- the production chat loop - -It is acceptable for the benchmark adapter to use the frontend Vitest/Vite -runtime as a thin loader bridge when production chat modules still depend on -that environment, as long as: - -- the benchmark entrypoint remains the shared benchmark CLI -- the benchmark logic and fixtures live under `ai_evals` -- the frontend source tree does not own a separate benchmark suite - -This keeps the suite decorrelated from the frontend UI while still testing the real AI logic. - -### 2. Test the three frontend AI surfaces separately - -#### Script mode - -Input: - -- user prompt -- optional initial script -- optional context such as selected workspace runnables or DB references - -Output: - -- final script code - -Scoring: - -- deterministic validators first -- LLM judge second - -Deterministic validators should include: - -- expected entrypoint present -- syntax / parse validity -- language-appropriate compile or lint check where feasible -- required behaviors or structures present -- forbidden patterns absent - -#### Flow mode - -Input: - -- user prompt -- optional initial flow -- optional schema -- optional workspace context - -Output: - -- final flow definition - -Scoring: - -- flow JSON is structurally valid -- expected module types exist -- expected branches / loops / tools exist -- schema shape matches required inputs -- required data flow connections are present -- LLM judge scores completeness and overall quality - -#### App mode - -Input: - -- user prompt -- optional initial app -- optional workspace context - -Output: - -- final frontend files -- final backend runnables - -Scoring: - -- expected files and runnables exist -- file structure is coherent -- app bundle / lint checks pass where feasible in headless mode -- required UI/backend behaviors are represented in the artifact -- LLM judge scores completeness and product quality - -### 3. Use repeated runs, not single runs - -Each case should run more than once. - -Recommended starting point: - -- PR smoke run: 2 runs per case on a small curated subset -- nightly reliability run: 5 to 10 runs per case on the full benchmark set - -Primary metric: - -- pass rate - -Secondary metrics: - -- average deterministic score -- average judge score -- worst-case judge score -- latency -- total tool calls - -### 4. Keep tool traces as diagnostics only - -Tool usage matters for debugging, but it should not be the primary score. - -The suite should record: - -- tool names -- tool arguments -- iteration count -- model/provider - -But the main question remains: - -> Was the final artifact good? - -### 5. Make prompt variants easy to test - -Prompt candidates should not require editing test code. - -The suite should support a file-based prompt variant workflow. - -Example direction: - -- `ai_evals/variants/frontend/script/baseline.md` -- `ai_evals/variants/frontend/script/candidate-a.md` -- `ai_evals/variants/frontend/flow/baseline.md` -- `ai_evals/variants/frontend/app/baseline.md` - -Each variant should be runnable side by side against the same case set. - -### 6. Separate benchmark cases from test code - -Benchmark cases should live in data files, not inline in test files. - -Each case should define: - -- surface -- user prompt -- initial artifact if any -- required constraints -- forbidden constraints -- judge rubric -- tags - -This makes the benchmark editable by prompt authors without changing runner logic. - -## CLI: What Exists Today - -The current CLI tests prove only one narrow property: - -> Given a prompt, does the model invoke the expected skill? - -That is useful as a smoke signal, but it is far from sufficient for output evaluation. - -The current CLI setup also depends on manual preparation of a `.claude/skills` folder, which makes repeated benchmarking and prompt iteration much harder than necessary. - -## CLI: What Is Missing - -### Output-evaluation gap - -- The current suite does not score the artifact produced by the CLI workflow. -- It only checks whether a skill was invoked. -- It does not verify that the resulting files are good. - -### Automation gap - -- The current setup requires manual copying of generated skills into a test folder. -- That makes the suite too fragile and too manual for rapid prompt iteration. - -### Reliability gap - -- There is no repeated-run measurement. -- There is no pass-rate metric. -- There is no baseline vs candidate comparison workflow. - -### Prompt-variant gap - -- There is no first-class way to test alternate skill bundles or alternate generated guidance. -- There is no clean candidate flow for "I changed skill content, show me whether reliability improved." - -### Corpus gap - -- CLI cases are not aligned with frontend benchmark cases. -- There is no shared benchmark language describing the task, initial state, and expected artifact. - -### Reporting gap - -- There is no stable output report for artifact comparison. -- There is no failure clustering by skill bundle, task family, or model. - -## CLI: Perfect Testing Logic - -The perfect CLI testing logic is: - -This should be the reference implementation for the suite. - -### 1. Evaluate the final artifact, not the skill invocation - -Skill invocation should be kept as diagnostic metadata only. - -The primary output should be the files produced in a temporary workspace. - -Example CLI artifacts: - -- generated script files -- generated flow files -- raw app project files -- schedule / trigger config files -- AGENTS / guidance files only when they are directly relevant to the task - -### 2. Create the workspace automatically - -The runner should create a fresh temporary project for every case. - -It should seed that workspace with: - -- initial files for the benchmark case -- the current generated CLI guidance and skills -- any fixture data required by the task - -It should never depend on a manually maintained test folder. - -### 3. Materialize the exact skill bundle under test - -The runner should be able to test: - -- the current production skill bundle -- a candidate skill bundle built from prompt changes - -For CLI, a "prompt variant" is effectively a skill-bundle variant. - -That means the suite should support alternate generated skill content without requiring ad hoc manual copies. - -### 4. Score the final workspace - -The scoring approach should match the frontend philosophy: - -- deterministic validators first -- LLM judge second - -Deterministic validators for CLI should include: - -- expected files created -- expected file names and locations -- required content patterns present -- expected artifact type produced -- optional parse / lint / compile validation where feasible - -### 5. Run repeated benchmarks - -The CLI should use the same reliability logic as frontend: - -- benchmark set -- repeated runs -- pass rate -- baseline vs candidate comparison - -### 6. Keep skill traces as diagnostics - -Record: - -- invoked skills -- order of invocation -- turns -- file changes - -But do not let that replace artifact evaluation. - -## Perfect Shared Benchmark Model - -The frontend and CLI should share the same benchmark concept. - -Each evaluation case should define: - -- `id` -- `surface` -- `user_prompt` -- `initial_state` -- `workspace_context` -- `artifact_checks` -- `judge_rubric` -- `tags` - -The same task should be runnable on multiple surfaces when it makes sense. - -This gives direct comparability between: - -- frontend script vs CLI script -- frontend flow vs CLI flow -- frontend app vs CLI app - -## Recommended Benchmark Categories - -The first benchmark set should be broad, but not huge. - -Recommended initial size: - -- 20 to 30 core cases - -Recommended categories: - -- from-scratch script creation -- script modification -- from-scratch flow creation -- flow modification -- from-scratch raw app creation -- raw app modification -- reuse of workspace assets -- tasks requiring datatable awareness -- tasks requiring constraints or edge-case handling -- known regressions from real failures - -Every category should contain both: - -- "easy success" cases -- "high ambiguity" cases - -This is essential for measuring reliability rather than only measuring best-case demos. - -## Scoring Model - -The suite should use three layers. - -## Layer 1: Deterministic Validators - -This is the hard gate. - -Examples: - -- parse succeeds -- artifact shape is valid -- required entrypoint exists -- expected files exist -- required module types exist -- expected inputs / schema fields exist -- forbidden patterns are absent - -If layer 1 fails, the run is a failure. - -## Layer 2: Task-Specific Validators - -These are stronger artifact checks derived from the benchmark case. - -Examples: - -- flow contains a loop and a conditional branch -- app includes a reset button path and backend wiring -- script performs the requested transformation - -These should still be deterministic whenever possible. - -## Layer 3: LLM Judge - -Use an LLM judge only after deterministic validation. - -The judge should answer: - -- Did the artifact satisfy the request? -- Is it complete? -- Is it coherent for Windmill? -- How close is it to the intended solution? - -The judge score is valuable, but it should not be the only oracle. - -## Benchmark History - -The suite should persist official benchmark summaries in a git-tracked history -layer so improvements and regressions can be reviewed over time. - -## What Should Be Git-Tracked - -Only official benchmark outputs should be committed: - -- post-merge benchmark snapshots on `main` -- scheduled nightly benchmark snapshots -- manually promoted benchmark snapshots when the team wants to record a result - -Each official snapshot should produce: - -- one detailed run JSON -- one entry in an append-only summary file -- regenerated rollups for trend views - -## What Should Not Be Git-Tracked - -The following should remain local or external by default: - -- raw transcripts -- full model messages -- large generated artifact bundles -- ad hoc local experiments -- temporary comparison runs - -This keeps git history focused on stable benchmark signals instead of noisy -debug output. - -## Reliability Metrics - -Every prompt or skill candidate should be reported with: - -- total cases -- passes -- pass rate -- average judge score -- median judge score -- worst-case judge score -- average latency -- average turns - -Per-case results should also be retained. - -This is the minimum needed to compare: - -- baseline vs candidate -- provider vs provider -- frontend vs CLI - -## Benchmark Metrics - -The history layer should track metrics in four groups. - -## Quality Metrics - -- `pass_rate` -- `deterministic_pass_rate` -- `judge_score_mean` -- `judge_score_median` -- `judge_score_p10` -- `category_pass_rate` - -## Reliability Metrics - -- `runs_per_case` -- `flake_rate` -- `path_consistency` - -## Efficiency Metrics - -- `latency_ms_mean` -- `latency_ms_median` -- `tokens_prompt_mean` -- `tokens_completion_mean` -- `tokens_total_mean` -- `tool_calls_mean` -- `iterations_mean` -- `estimated_cost_mean` -- `cost_per_success` -- `latency_per_success` - -## Provenance Metrics - -- `timestamp` -- `git_sha` -- `suite_version` -- `scoring_version` -- `surface` -- `variant_name` -- `provider` -- `model` -- `judge_model` - -The provenance metrics are essential. Without them, a trend line can mix prompt -changes with upstream model drift and become hard to interpret. - -## Efficiency Score - -The suite should not collapse everything into one number. - -It should track at least three top-level composite scores: - -- `quality_score` -- `efficiency_score` -- `value_score` - -Recommended interpretation: - -- `quality_score`: how good the artifact is -- `efficiency_score`: how fast and cheap the system is relative to peers -- `value_score`: quality-adjusted efficiency - -These composite scores should sit on top of the raw metrics, not replace them. - -## Proposed Suite Architecture - -The suite should be built in six layers. - -## Layer 1: Benchmark Data - -Purpose: - -- define the cases once - -Contents: - -- case files -- reusable initial fixtures -- evaluation metadata - -## Layer 2: Benchmark CLI - -Purpose: - -- provide one shared entrypoint for the suite - -Responsibilities: - -- load cases and variants -- select a surface adapter -- run one case or a benchmark set -- invoke shared scoring and history writing -- expose comparison and history commands - -## Layer 3: Surface Adapters - -Purpose: - -- run a case against one surface - -Adapters: - -- frontend-script adapter -- frontend-flow adapter -- frontend-app adapter -- CLI adapter - -Responsibilities: - -- prepare the correct prompt environment -- prepare the initial artifact state -- run the real model loop -- return the final artifact plus diagnostics - -## Layer 4: Scoring And Reporting - -Purpose: - -- evaluate the final artifact -- aggregate repeated runs -- compare variants - -Responsibilities: - -- deterministic validation -- LLM judging -- pass/fail computation -- result serialization -- comparison reports - -## Layer 5: Benchmark History - -Purpose: - -- preserve official benchmark summaries over time -- support trend analysis and regression review - -Responsibilities: - -- store official run snapshots -- append benchmark summary entries -- generate rollups for charts and dashboards -- keep provenance metadata for every tracked run - -## Layer 6: UI Studio - -Purpose: - -- provide a user interface for the exact same benchmark CLI and runner stack - -Important rule: - -The UI must not define its own execution semantics. - -It must only be a frontend over the same suite used in CI and local benchmarking. - -## Proposed Development Order - -### Phase 1: Stabilize the benchmark model - -Deliverables: - -- shared case schema -- shared result schema -- initial core benchmark set - -### Phase 2: Build the benchmark CLI shell - -Deliverables: - -- repo-level benchmark CLI entrypoint -- `run`, `compare`, and `history` command skeletons -- adapter selection layer -- temporary wiring to the first CLI adapter - -### Phase 3: Replace the CLI smoke suite with real artifact evaluation - -Deliverables: - -- temp-workspace runner -- automatic skill-bundle materialization -- artifact scoring -- repeated-run support -- baseline vs candidate skill-bundle comparison - -### Phase 4: Add shared reporting and benchmark history around the CLI path - -Deliverables: - -- baseline vs candidate reports -- pass-rate summaries -- worst-failure reports -- official run schema -- git-tracked benchmark summary file -- history snapshot writer -- rollup generation for trend charts - -### Phase 5: Finish the frontend black-box harness on top of the shared model - -Deliverables: - -- convert current flow and app evals into proper scored reliability tests -- add script eval support -- add repeated-run support -- add prompt-variant loading from files -- align frontend outputs with the shared result and history format -- expose frontend runs through the same benchmark CLI - -### Phase 6: Add CI tiers - -Deliverables: - -- fast PR smoke benchmark -- fuller nightly benchmark -- official history updates on `main` and scheduled runs -- manual benchmark mode for prompt authors - -### Phase 7: Build the UI studio - -Deliverables: - -- run selector -- variant selector -- per-case comparison view -- artifact diff view -- reliability dashboard -- trend dashboard backed by git-tracked benchmark history - -This phase comes last because the UI is only valuable once the underlying suite is stable and trusted. - -## Proposed Prompt Variant Workflow - -The suite should make it cheap to test new prompt candidates. - -Recommended workflow: - -1. Edit or add a candidate prompt file. -2. Run the benchmark against baseline and candidate. -3. Compare pass rate and score. -4. Inspect worst regressions first. -5. Promote only if the candidate improves the benchmark materially. - -For CLI, the same workflow applies, but the tested unit is the generated skill bundle rather than a single chat system prompt. - -## Suggested Repository Direction - -This plan does not require the UI studio to exist first. - -A reasonable repo structure would be: - -```text -ai_evals/ - cli/ - cases/ - fixtures/ - history/ - runs/ - rollups/ - variants/ - frontend/ - script/ - flow/ - app/ - cli/ - results/ # gitignored - scripts/ - adapters/ - scoring/ - reports/ -``` - -The exact folder names can change, but the architectural split should remain. - -## What "Done" Looks Like - -This project is successful when all of the following are true: - -- one repo-level benchmark CLI is the primary way to run prompt evals -- frontend prompt behavior is tested headlessly and independently from the UI -- CLI local-dev behavior is tested by evaluating the final files it produces -- benchmark cases are shared where possible between frontend and CLI -- prompt and skill candidates can be tested without editing test code -- reliability is reported as pass rate over repeated runs -- baseline vs candidate comparisons are easy to run and inspect -- the UI studio is only a thin interface over the same trusted runner - -## Final Recommendation - -The current frontend evals should be treated as a useful starting point, not the finished solution. - -They already prove that the repo can test AI behavior without coupling to the browser UI. - -The main work now is: - -- build the repo-level benchmark CLI as the durable entrypoint -- replace CLI invocation checks with artifact evaluation -- make the CLI path the reference benchmark implementation -- unify frontend under that same benchmark model -- make frontend evals complete and reliability-oriented only after the shared - scoring model is stable -- build the UI only after the suite is strong enough to stand on its own diff --git a/docs/system-prompt-testing-status.md b/docs/system-prompt-testing-status.md deleted file mode 100644 index 86beaadc89..0000000000 --- a/docs/system-prompt-testing-status.md +++ /dev/null @@ -1,140 +0,0 @@ -# System Prompt Testing Status - -This document describes the benchmark tool that exists today. It is the current -truth for `ai_evals/`. - -The longer planning document in -[system-prompt-testing-plan.md](/home/farhad/windmill__worktrees/prompt-testing-plan/docs/system-prompt-testing-plan.md) -still contains useful background, but parts of its workflow are now historical -because the old variants/history system was removed. - -## Current Tool - -There is one repo-level benchmark CLI under `ai_evals/` with three commands: - -- `bun run cli -- models` -- `bun run cli -- cases [mode]` -- `bun run cli -- run [caseIds...]` - -Supported modes: - -- `cli` -- `flow` -- `script` -- `app` - -Public `run` options: - -- `--runs ` -- `--output ` -- `--model ` -- `--verbose` -- `--record` - -There is no variant workflow and no compare command in the current tool. -Tracked history is intentionally minimal: `run --record` appends one compact -summary line to `ai_evals/history/.jsonl`. This is only allowed for -full-suite runs, not selected case ids. History lines include average token -usage when the benchmark mode reports it, plus average judge score and per-case -duration/judge/token usage summaries. - -## How It Works - -Each attempt runs: - -1. the current production prompts, tools, and guidance from this checkout -2. deterministic validation -3. LLM judging - -Results are written locally under `ai_evals/results/` as: - -- a summary JSON file -- a sibling artifacts directory containing the generated flow/script/app/workspace - -If `--record` is used, the CLI also appends a compact JSONL summary line to the -tracked file for that mode under `ai_evals/history/`. - -## Current Architecture - -- `ai_evals/cases/`: one YAML manifest per mode -- `ai_evals/fixtures/`: initial and expected fixtures -- `ai_evals/core/`: shared case loading, model resolution, validation, judging, and result writing -- `ai_evals/history/`: optional tracked pass-rate history written by `run --record`, one JSONL file per mode -- `ai_evals/modes/`: one runner per mode - -Execution model: - -- `flow`, `script`, and `app` reuse the production frontend chat loop and production tool definitions through the frontend Vitest bridge -- `cli` creates a temp workspace, writes the current checkout guidance into it, and runs the Anthropic agent SDK against that workspace - -## Case Model - -Each case is intentionally small: - -- `prompt` -- optional `initial` -- optional `expected` -- optional `validate` -- optional `cliExpect` - -`validate` is mainly used for stronger deterministic checks where exact fixture -matching would be too strict, especially for `flow` creation cases. - -`cliExpect` is used by CLI-mode cases to assert agent behavior deterministically, -including: - -- required or forbidden skills -- skills invoked before the first file mutation -- ordered `wmill` command proposals in the assistant response -- forbidden attempted `wmill` executions -- read-only guidance cases where the workspace must stay unchanged - -Examples of current deterministic checks: - -- schema contains one of several accepted input shapes -- `results.*` references resolve -- required code/input characteristics exist in some module -- expected workspace files are created in `cli` mode -- expected CLI skills and proposed `wmill` commands are observed in `cli` mode - -## Model Selection - -Model aliases are resolved through a shared registry in `ai_evals/core/models.ts`. - -Current aliases: - -- `haiku` -- `sonnet` -- `opus` -- `4o` - -Notes: - -- the `models` command also shows accepted alias spellings such as `gpt-4o` and `claude-opus-4.6` -- frontend modes can use Anthropic and OpenAI-backed aliases -- `cli` mode is Anthropic-only because it runs through the Anthropic agent SDK -- the judge model is separate and currently defaults to `claude-sonnet-4-6` - -## What Is Working Well - -- one simple local benchmark CLI -- real production execution paths instead of synthetic prompt variants -- local result and artifact persistence by default -- live frontend progress output -- reusable flow/script/app/cli runners under one tool -- deterministic validation can now catch real runtime-invalid flow wiring - -## What Still Needs Work - -- broader case coverage across all four modes -- stronger deterministic validators for more cases, especially app/script semantics -- clearer per-case validation metadata as the corpus grows -- CI automation for smoke and nightly runs - -## Recommended Next Focus - -The next high-value work is: - -1. add more realistic benchmark cases -2. keep simplifying deterministic validators so they check correctness, not one exact implementation -3. add CI only after the local benchmark signal is trustworthy diff --git a/docs/windmill-ai-refactor-plan.md b/docs/windmill-ai-refactor-plan.md deleted file mode 100644 index ea2b9f451e..0000000000 --- a/docs/windmill-ai-refactor-plan.md +++ /dev/null @@ -1,321 +0,0 @@ -# Refactor Plan: `windmill-ai` Crate - -## Context - -AI provider logic is currently split across three crates with duplicate code: - -- **windmill-common** — base types (`ai_types`, `ai_providers`, `ai_google`, `ai_bedrock`, `ai_cache`) -- **windmill-api** — chat proxy (`ai.rs`, `google.rs`, `bedrock.rs`) with its own request building for Google/Bedrock, plus `AIRequestConfig::prepare_request` for auth/URL handling -- **windmill-worker** — agent execution (`ai/` module) with `QueryBuilder` trait, SSE parsers, provider implementations - -The goal: a single `windmill-ai` crate with all AI provider logic. Both the API proxy and worker agent use `QueryBuilder` for every provider — no more duplicate logic. - -## Dependency Direction - -``` -windmill-ai → windmill-common (for DB, Error, AgentAction, AuthedClient, etc.) - → windmill-types (for S3Object) - → windmill-parser (for Typ, used in OpenAPISchema) - -windmill-api → windmill-ai -windmill-worker → windmill-ai -``` - -windmill-common does **NOT** re-export from windmill-ai (would be circular). All consumers update imports. - -## Reviewer Note: Keep API Proxy Unification Split - -The crate boundary, shared utilities, SSE parsers, image handling, and worker provider implementations are now in `windmill-ai`. The remaining duplication is the API proxy path: `AIRequestConfig::prepare_request`, `windmill-api/src/google.rs`, and `windmill-api/src/bedrock.rs` still own API-specific request transformation. - -Do not jump directly from the current state to full proxy and credential unification in one PR. The API proxy combines request transformation, endpoint selection, auth headers, custom headers, OAuth user injection, Azure URL handling, Anthropic Vertex handling, Bedrock SDK calls, and SSE keepalive behavior. Split the work by risk: -- Introduce shared proxy request and credential types first. -- Move the OpenAI-compatible proxy path into `windmill-ai` next, while keeping provider-native behavior unchanged. -- Move Anthropic/Vertex, Google AI, and Bedrock in separate follow-up PRs. -- Unify credential resolution only after all proxy request builders use the shared shape. - -Avoid adding modules whose only purpose is to re-export moved code. Direct imports from `windmill_ai` make ownership and dependency direction clearer at each call site. - -Also do not make `build_proxy_request(raw_body, path)` too narrow. The proxy path needs method, incoming headers, resolved credentials, base URL/platform, organization/user fields, custom headers, and Bedrock/Azure/Vertex-specific context. Introduce a structured `ProxyBuildArgs`/`ProviderCredentials` shape before deleting `AIRequestConfig::prepare_request`, `google.rs`, or `bedrock.rs`. - -## Completed Phase: Proxy Contract + OpenAI-Compatible Proxy ✅ - -Goal: introduce the shared API proxy contract in `windmill-ai` and move the OpenAI-compatible proxy request builder there without changing provider behavior. - -Suggested PR title: `refactor(ai): move openai-compatible proxy building to windmill-ai`. - -Scope: -- Add `windmill-ai/src/proxy.rs` and export it from `lib.rs`. -- Define `ProviderCredentials`, `ProxyBuildArgs`, and `ProxyRequest`. -- Include all context known to be needed by the current API proxy path: method, path, incoming headers, body, provider, base URL, API key, OAuth access token, organization/user fields, platform, 1M context flag, custom headers, region, and AWS credentials. -- Add a conversion from API-side `AIRequestConfig` to `ProviderCredentials`. -- Add `QueryBuilder::build_proxy_request` with a default unsupported-provider implementation. -- Implement `build_proxy_request` for OpenAI-compatible providers (`OpenAI`, `AzureOpenAI`, `Mistral`, `DeepSeek`, `Groq`, `OpenRouter`, `TogetherAI`, `CustomAI`). -- Route workspace and global API proxy requests for OpenAI-compatible providers through `windmill-ai`. -- Keep FIM transformation in `windmill-api` before calling the proxy builder. -- Keep `AIRequestConfig::prepare_request` for Anthropic/Vertex and remaining fallback paths. - -Out of scope: -- Do not move Anthropic/Vertex proxy behavior yet. -- Do not move Google AI or Bedrock proxy behavior yet. -- Do not change credential resolution, audit logging, cache behavior, SSE keepalive behavior, or Bedrock/Google special cases. -- Do not remove `windmill-api/src/google.rs`, `windmill-api/src/bedrock.rs`, or `AIRequestConfig::prepare_request`. - -Validation: -- `cargo test -p windmill-ai proxy` -- `cargo test -p windmill-api maps_request_config_to_provider_credentials` -- `cargo check -p windmill-ai -p windmill-api` -- `cargo check -p windmill-ai -p windmill-api --features bedrock` - -Follow-up status: Anthropic/Vertex proxy handling has since moved into -`windmill-ai`, and the dead `AIRequestConfig::prepare_request` fallback has -been removed. - -## Current Phase PR: Proxy Execution Mode + Google AI Proxy Migration - -Goal: introduce a shared provider execution classifier before moving Google AI -and Bedrock. `ProxyRequest` is a good contract for HTTP-forwarding providers -such as OpenAI-compatible providers and Anthropic, but Google AI also converts -responses back to OpenAI shape and Bedrock uses SDK execution. Model that split -explicitly before moving those providers, then move the Google AI proxy -transformation into `windmill-ai` as the first native-provider migration. - -Suggested PR title: `refactor(ai): add provider proxy execution mode`. - -Scope: -- Add `ProxyExecutionMode` in `windmill-ai::proxy`. -- Classify providers as HTTP-forwarding, native Google AI, or native Bedrock. -- Make `supports_query_builder_proxy` derive from the shared execution mode. -- Use the shared execution mode in `windmill-api/src/ai.rs` for workspace proxy routing. -- Move Google AI workspace proxy request conversion, streaming/non-streaming response conversion, and model-list normalization into `windmill-ai::providers::google_ai`. -- Share Google AI `GeminiTextRequest` and generation-config construction between worker agent requests and API proxy requests. -- Delete the API-local `windmill-api/src/google.rs` module. -- Keep global proxy behavior, Bedrock native handling, credential resolution, audit logging, caching, and SSE keepalive behavior unchanged. - -Out of scope: -- Do not move `windmill-api/src/bedrock.rs`. -- Do not unify `AIRequestConfig` and `ProviderWithResource`. - -Validation: -- `cargo test -p windmill-ai google_ai` -- `cargo test -p windmill-ai proxy` -- `cargo test -p windmill-api maps_request_config_to_provider_credentials` -- `cargo test -p windmill-ai anthropic` - -## Step-by-Step Plan - -Each step produces a compiling, working backend. - ---- - -### Step 1: Create `windmill-ai` crate, move base types from windmill-common ✅ - -Create `backend/windmill-ai/Cargo.toml` and `backend/windmill-ai/src/lib.rs`. - -Move from `windmill-common/src/` to `windmill-ai/src/`: -- `ai_types.rs` — OpenAI-compatible message types -- `ai_providers.rs` — `AIProvider` enum, `AIPlatform`, base URLs, `ProviderConfig` -- `ai_google.rs` — Gemini types and OpenAI↔Gemini conversion -- `ai_bedrock.rs` — Bedrock SDK wrapper (feature-gated on `bedrock`) -- `ai_cache.rs` — instance AI config revision tracking - -Update all imports (`windmill_common::ai_*` → `windmill_ai::ai_*`). - ---- - -### Step 2: Move worker AI types to windmill-ai ✅ - -Move from `windmill-worker/src/ai/types.rs` to `windmill-ai/src/types.rs`: -- `ProviderWithResource`, `ProviderResource` — credential types -- `TokenUsage` — token usage tracking -- `OutputType`, `SchemaType`, `AdditionalProperties` — output configuration -- `OpenAPISchema` — tool parameter schema (depends on `windmill-parser::Typ`) -- `Tool`, `Message`, `ResponseFormat`, `JsonSchemaFormat` — agent types -- `StreamingEvent` — SSE event enum -- `AIAgentArgs`, `AIAgentArgsRaw`, `AIAgentResult` — agent job args -- `Memory` — agent memory enum -- `S3ObjectWithType` — S3 image type -- `McpToolSource` stub (with same `#[cfg(feature = "mcp")]` pattern) - -Worker `ai/types.rs` becomes a re-export: `pub use windmill_ai::types::*`. - ---- - -### Step 3: Move QueryBuilder trait, ParsedResponse, and StreamEventSink abstraction to windmill-ai ✅ - -Move from `windmill-worker/src/ai/query_builder.rs` to `windmill-ai/src/query_builder.rs`: -- `BuildRequestArgs` struct -- `ParsedResponse` enum -- `QueryBuilder` trait (with all existing methods) - -New `StreamEventSink` trait in windmill-ai: -```rust -#[async_trait] -pub trait StreamEventSink: Send + Sync { - async fn send(&self, event: StreamingEvent, events_str: &mut String) -> Result<(), Error>; -} -``` - -`StreamEventSink` abstracts the worker's `StreamEventProcessor` so windmill-ai doesn't depend on windmill-queue or the worker's job logger. The worker's `StreamEventProcessor` implements `StreamEventSink`. All provider `parse_streaming_response` methods and SSE parsers accept `Box`. - ---- - -### Step 4: Move SSE parsers to windmill-ai ✅ - -Move from `windmill-worker/src/ai/sse.rs` to `windmill-ai/src/sse.rs`: -- `SSEParser` trait -- `OpenAISSEParser`, `AnthropicSSEParser`, `GeminiSSEParser`, `OpenAIResponsesSSEParser` -- All associated types (delta types, usage types, etc.) - ---- - -### Step 5: Move provider implementations to windmill-ai ✅ - -Move from `windmill-worker/src/ai/providers/` to `windmill-ai/src/providers/`: -- `anthropic.rs` — `AnthropicQueryBuilder` -- `openai.rs` — `OpenAIQueryBuilder` -- `google_ai.rs` — `GoogleAIQueryBuilder` -- `bedrock.rs` — `BedrockQueryBuilder` (feature-gated) -- `other.rs` — `OtherQueryBuilder` (Mistral, DeepSeek, Groq, TogetherAI, CustomAI) -- `openrouter.rs` — `OpenRouterQueryBuilder` -- `mod.rs` with `create_query_builder` factory - -Move utility functions providers depend on: -- `should_use_structured_output_tool` (from `utils.rs`) -- `extract_text_content` (from `utils.rs`) - ---- - -### Step 6: Move image_handler to windmill-ai ✅ - -Move from `windmill-worker/src/ai/image_handler.rs` to `windmill-ai/src/image_handler.rs`: -- `download_and_encode_s3_image` — no signature change needed -- `prepare_messages_for_api` — no signature change needed -- `upload_image_to_s3` — **refactor**: `(base64_image, workspace_id, job_id, client)` instead of `(base64_image, &MiniPulledJob, client)` to remove windmill-queue dependency - ---- - -### Step 7: Move shared utilities to windmill-ai ✅ - -Move `AI_HTTP_HEADERS` lazy_static (currently duplicated in `windmill-api/src/ai.rs` and `windmill-worker/src/ai_executor.rs`) to `windmill_ai::utils`. Both consumers import from windmill-ai. - ---- - -### Step 8: Add proxy support to QueryBuilder — API uses QueryBuilder for all providers - -This is the key unification step. Add a new method to the `QueryBuilder` trait: - -```rust -/// Build a request from a raw OpenAI-format proxy request. -/// Used by the API chat proxy. Handles format conversion for non-OpenAI providers. -fn build_proxy_request( - &self, - args: &ProxyBuildArgs<'_>, -) -> Result; -``` - -Where `ProxyBuildArgs` carries the API proxy context that provider implementations need: -```rust -pub struct ProxyBuildArgs<'a> { - pub method: &'a http::Method, - pub path: &'a str, - pub headers: &'a http::HeaderMap, - pub body: &'a [u8], - pub credentials: &'a ProviderCredentials, -} -``` - -And `ProxyRequest` contains the transformed request: -```rust -pub struct ProxyRequest { - pub method: http::Method, - pub url: String, - pub headers: Vec<(String, String)>, - pub body: Vec, -} -``` - -**Provider implementations:** -- **OpenAI-compatible** (OpenAI, Mistral, DeepSeek, Groq, TogetherAI, CustomAI, OpenRouter): Minimal transformation — pass body through, build URL and auth headers. -- **Anthropic**: Handle standard vs Vertex AI. For Vertex: transform body (extract model, add anthropic_version). For standard: pass through with appropriate headers. -- **Google AI**: Convert OpenAI format → Gemini format (using existing `ai_google` functions). Replaces `windmill-api/src/google.rs`. -- **Bedrock**: Convert OpenAI format → Bedrock SDK calls. Replaces `windmill-api/src/bedrock.rs`. - -**Refactor API proxy** (`windmill-api/src/ai.rs`): -1. Parse provider from headers, resolve credentials → `ProviderCredentials` -2. Create `QueryBuilder` via `create_query_builder` -3. Call `query_builder.build_proxy_request(&proxy_args)` → `ProxyRequest` -4. Send the request, return response with SSE keepalive injection - -**Remove** from windmill-api: -- `AIRequestConfig::prepare_request` — replaced by `QueryBuilder::build_proxy_request` -- `google.rs` — replaced by `GoogleAIQueryBuilder::build_proxy_request` -- `bedrock.rs` — replaced by `BedrockQueryBuilder::build_proxy_request` -- `transform_anthropic_for_vertex` — moved to `AnthropicQueryBuilder` -- `supports_native_fim`, `transform_fim_to_chat_completions` — moved to windmill-ai - -**Keep** in API: -- `AIRequestConfig::new` credential resolution until it is refactored to produce `ProviderCredentials` -- HTTP routes, audit logging, request caching -- `inject_keepalives`, `is_sse_response` helpers -- `AIConfig`, `ExpiringAIRequestConfig` caching types - ---- - -### Step 9: Unify credential resolution - -Merge `AIRequestConfig` (API-side) and `ProviderWithResource` (worker-side) into a single credential shape in windmill-ai. - -Both currently carry: api_key, base_url, region, platform, custom_headers, AWS credentials. The API's `AIRequestConfig::new` resolves credentials from DB (workspace/instance settings). The worker's `ProviderWithResource` gets credentials from the flow module definition. - -Extend `windmill_ai::proxy::ProviderCredentials` as needed so both can produce it: -```rust -pub struct ProviderCredentials { - pub provider: AIProvider, - pub base_url: String, - pub api_key: Option, - pub access_token: Option, - pub organization_id: Option, - pub user: Option, - pub platform: AIPlatform, - pub region: Option, - pub aws_access_key_id: Option, - pub aws_secret_access_key: Option, - pub aws_session_token: Option, - pub enable_1m_context: bool, - pub custom_headers: HashMap, -} -``` - -The `create_query_builder` factory takes `&ProviderCredentials` instead of `&ProviderWithResource`. - ---- - -## Final Crate Structure - -``` -windmill-ai/src/ -├── lib.rs # module exports -├── ai_types.rs # OpenAI-compatible message types -├── ai_providers.rs # AIProvider enum, base URLs, config -├── ai_google.rs # Gemini types and conversions -├── ai_bedrock.rs # Bedrock SDK wrapper (feature: bedrock) -├── ai_cache.rs # Instance AI config revision -├── types.rs # TokenUsage, Tool, OpenAPISchema, etc. -├── proxy.rs # ProviderCredentials, ProxyBuildArgs, ProxyRequest -├── query_builder.rs # QueryBuilder trait, BuildRequestArgs, ParsedResponse, StreamEventSink -├── sse.rs # SSE parsers (OpenAI, Anthropic, Gemini, Responses) -├── image_handler.rs # S3 image upload/download -├── utils.rs # extract_text_content, should_use_structured_output_tool -└── providers/ - ├── mod.rs # create_query_builder factory - ├── anthropic.rs # build_request + build_proxy_request - ├── openai.rs # build_request + build_proxy_request - ├── google_ai.rs # build_request + build_proxy_request - ├── bedrock.rs # build_request + build_proxy_request (feature: bedrock) - ├── other.rs # build_request + build_proxy_request - └── openrouter.rs # build_request + build_proxy_request -``` - -**windmill-worker** keeps: `ai_executor.rs`, `ai/tools.rs`, `ai/utils.rs` (flow/conversation/MCP logic), `StreamEventProcessor` (impl of `StreamEventSink`). - -**windmill-api** keeps: HTTP routes (`ai.rs` proxy endpoints), audit logging, caching, credential resolution from DB. `google.rs` and `bedrock.rs` deleted. diff --git a/fixtures/README.md b/fixtures/README.md new file mode 100644 index 0000000000..693a813936 --- /dev/null +++ b/fixtures/README.md @@ -0,0 +1,68 @@ +# Fixtures + +Helpers for sharing a reproducible test workspace alongside a PR. + +The workflow is: + +1. While iterating on a PR, snapshot your local test workspace into + `fixtures/cli-sync/` so a teammate (or CI) can replay it. +2. Commit the snapshot on your branch so reviewers can load it locally. +3. **Before merging**, clear `fixtures/cli-sync/` again. CI fails on `main` / + PRs that try to merge a non-empty fixture (see `check-empty.sh`). + +## Scripts + +All scripts assume: + +- A local Windmill backend running at `http://localhost:8000` (see top-level + `AGENTS.md` for `cargo run` / `npm run dev`). +- Default super-admin credentials `admin@windmill.dev` / `changeme`. +- `bun` and `python3` are installed and on `PATH`. No `wmill` install + required — the scripts invoke `cli/src/main.ts` via `bun run` directly. + `python3` is used only to build correctly-escaped JSON request bodies. + +The login token is passed to the CLI via `--token`, which means it appears in +`/proc//cmdline` for the duration of the `bun run` call. This is fine +against a local dev instance; if you point the scripts at a real instance, +the credentials are no more exposed than running `wmill` directly with +explicit flags, but bear it in mind. + +### `load.sh` — load the fixture into a fresh workspace + +```bash +./fixtures/load.sh +``` + +Logs in as `admin@windmill.dev`, creates a new workspace with a random id +(`fixture-<8 hex chars>`), and pushes `fixtures/cli-sync/` into it via +`wmill sync push`. Prints the workspace id at the end so you can open it in +the UI. + +Flags: +- `--base-url ` (default `http://localhost:8000`) +- `--email ` (default `admin@windmill.dev`) +- `--password ` (default `changeme`) — prefer `WMILL_PASSWORD=` env + var when using a real password, since `--password` ends up in `ps` / + shell history. +- `--workspace ` (default `fixture-`) +- `--dir ` (default `fixtures/cli-sync`) + +### `snapshot.sh` — snapshot a workspace into the fixture folder + +```bash +./fixtures/snapshot.sh +``` + +Pulls the given workspace into `fixtures/cli-sync/` via `wmill sync pull`. +The target directory is cleared first (everything except `wmill.yaml` and +`.gitkeep`) so the snapshot reflects exactly what's in the workspace. + +Flags: same as `load.sh` minus `--workspace` (passed as positional arg). + +### `check-empty.sh` — fail if the fixture is non-empty + +Used by CI to guard `main`. Run it locally before opening a PR for merge: + +```bash +./fixtures/check-empty.sh +``` diff --git a/fixtures/check-empty.sh b/fixtures/check-empty.sh new file mode 100755 index 0000000000..9adfc25d3c --- /dev/null +++ b/fixtures/check-empty.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Fail if fixtures/cli-sync/ contains anything beyond the fixture scaffold +# (wmill.yaml, .gitkeep). Used by CI to guard `main` against accidentally +# merging PRs with a test workspace snapshot still committed. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DIR="${1:-$SCRIPT_DIR/cli-sync}" + +ALLOWED_RE='^fixtures/cli-sync/(\.gitkeep|wmill\.yaml)$' + +# Use `git ls-files` so we only check tracked files. Untracked local snapshots +# are fine — devs may keep them locally between sessions. +EXTRA=$(cd "$REPO_ROOT" && git ls-files fixtures/cli-sync \ + | grep -vE "$ALLOWED_RE" || true) + +if [[ -n "$EXTRA" ]]; then + echo "✗ fixtures/cli-sync/ contains committed snapshot files:" >&2 + echo "$EXTRA" | sed 's/^/ /' >&2 + echo >&2 + echo " Run fixtures/snapshot.sh against an empty workspace or" >&2 + echo " remove the files before merging." >&2 + exit 1 +fi + +echo "✓ fixtures/cli-sync/ is clean" diff --git a/fixtures/cli-sync/.gitkeep b/fixtures/cli-sync/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/fixtures/cli-sync/wmill.yaml b/fixtures/cli-sync/wmill.yaml new file mode 100644 index 0000000000..82029f8951 --- /dev/null +++ b/fixtures/cli-sync/wmill.yaml @@ -0,0 +1,15 @@ +defaultTs: bun +includes: + - f/** +excludes: [] +skipVariables: false +skipResources: false +skipResourceTypes: false +skipSecrets: true +includeSchedules: true +includeTriggers: true +includeUsers: false +includeGroups: false +includeSettings: false +includeKey: false +syncBehavior: v1 diff --git a/fixtures/load.sh b/fixtures/load.sh new file mode 100755 index 0000000000..09d0719d3d --- /dev/null +++ b/fixtures/load.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Load fixtures/cli-sync/ into a fresh workspace on a local Windmill instance. +# +# Requires: bun on PATH. No `wmill` install needed — we invoke +# cli/src/main.ts directly via `bun run`. +# +# Assumes a Windmill backend running at http://localhost:8000 with the +# default super-admin (admin@windmill.dev / changeme). Override via flags. +set -euo pipefail + +BASE_URL="http://localhost:8000" +EMAIL="admin@windmill.dev" +# Prefer WMILL_PASSWORD env var over --password flag — flags leak into +# /proc//cmdline and shell history. +PASSWORD="${WMILL_PASSWORD:-changeme}" +WORKSPACE="" +DIR="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --base-url) BASE_URL="$2"; shift 2 ;; + --email) EMAIL="$2"; shift 2 ;; + --password) PASSWORD="$2"; shift 2 ;; + --workspace) WORKSPACE="$2"; shift 2 ;; + --dir) DIR="$2"; shift 2 ;; + -h|--help) + sed -n '2,8p' "$0"; exit 0 ;; + *) echo "Unknown flag: $1" >&2; exit 2 ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DIR="${DIR:-$SCRIPT_DIR/cli-sync}" +DIR="$(cd "$DIR" && pwd)" +CLI_ENTRY="$REPO_ROOT/cli/src/main.ts" + +if ! command -v bun >/dev/null 2>&1; then + echo "✗ bun is required but not found on PATH" >&2 + exit 1 +fi +if [[ ! -f "$CLI_ENTRY" ]]; then + echo "✗ Cannot find CLI entry at $CLI_ENTRY" >&2 + exit 1 +fi +if [[ ! -f "$DIR/wmill.yaml" ]]; then + echo "✗ No wmill.yaml in $DIR — is the fixture folder set up?" >&2 + exit 1 +fi + +if [[ -z "$WORKSPACE" ]]; then + # Use $RANDOM rather than piping /dev/urandom through head -c, which + # SIGPIPEs `tr` and aborts the script under `set -o pipefail`. + printf -v WORKSPACE 'fixture-%04x%04x' $RANDOM $RANDOM +fi + +# JSON body builder — interpolation via printf '%s' is unsafe for arbitrary +# emails / passwords / workspace ids. Python is universal enough for a dev +# script and produces correctly escaped JSON. +json_object() { + python3 -c ' +import json, sys +print(json.dumps(dict(zip(sys.argv[1::2], sys.argv[2::2])))) +' "$@" +} + +echo "→ Logging in as $EMAIL on $BASE_URL" +TOKEN="$(curl -sS -f -X POST "$BASE_URL/api/auth/login" \ + -H "Content-Type: application/json" \ + -d "$(json_object email "$EMAIL" password "$PASSWORD")")" +if [[ -z "$TOKEN" ]]; then + echo "✗ Login failed (empty token)" >&2 + exit 1 +fi + +echo "→ Creating workspace '$WORKSPACE'" +CREATE_OUT="$(mktemp)" +trap 'rm -f "$CREATE_OUT"' EXIT +HTTP_CODE="$(curl -sS -o "$CREATE_OUT" -w '%{http_code}' \ + -X POST "$BASE_URL/api/workspaces/create" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "$(json_object id "$WORKSPACE" name "$WORKSPACE")")" +if [[ "$HTTP_CODE" != "200" && "$HTTP_CODE" != "201" ]]; then + echo "✗ Workspace creation failed (HTTP $HTTP_CODE):" >&2 + cat "$CREATE_OUT" >&2 + echo >&2 + exit 1 +fi + +echo "→ Pushing $DIR to workspace '$WORKSPACE'" +( + cd "$DIR" + bun run "$CLI_ENTRY" sync push --yes \ + --base-url "$BASE_URL" \ + --workspace "$WORKSPACE" \ + --token "$TOKEN" +) + +echo +echo "✓ Fixture loaded into workspace '$WORKSPACE'" +echo " Open: ${BASE_URL%/}/?workspace=$WORKSPACE" diff --git a/fixtures/snapshot.sh b/fixtures/snapshot.sh new file mode 100755 index 0000000000..a984a47635 --- /dev/null +++ b/fixtures/snapshot.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Snapshot a workspace into fixtures/cli-sync/ so it can be committed +# alongside a PR. +# +# Requires: bun on PATH. No `wmill` install needed. +# +# Assumes a Windmill backend running at http://localhost:8000 with the +# default super-admin (admin@windmill.dev / changeme). Override via flags. +set -euo pipefail + +BASE_URL="http://localhost:8000" +EMAIL="admin@windmill.dev" +# Prefer WMILL_PASSWORD env var over --password flag — flags leak into +# /proc//cmdline and shell history. +PASSWORD="${WMILL_PASSWORD:-changeme}" +DIR="" +WORKSPACE="" + +if [[ $# -lt 1 || "$1" == "-h" || "$1" == "--help" ]]; then + sed -n '2,8p' "$0" + echo + echo "Usage: $(basename "$0") [--base-url URL] [--email E] [--password P] [--dir PATH]" + exit 0 +fi + +WORKSPACE="$1"; shift + +while [[ $# -gt 0 ]]; do + case "$1" in + --base-url) BASE_URL="$2"; shift 2 ;; + --email) EMAIL="$2"; shift 2 ;; + --password) PASSWORD="$2"; shift 2 ;; + --dir) DIR="$2"; shift 2 ;; + *) echo "Unknown flag: $1" >&2; exit 2 ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DIR="${DIR:-$SCRIPT_DIR/cli-sync}" +DIR="$(cd "$DIR" && pwd)" +CLI_ENTRY="$REPO_ROOT/cli/src/main.ts" + +if ! command -v bun >/dev/null 2>&1; then + echo "✗ bun is required but not found on PATH" >&2 + exit 1 +fi +if [[ ! -f "$CLI_ENTRY" ]]; then + echo "✗ Cannot find CLI entry at $CLI_ENTRY" >&2 + exit 1 +fi +if [[ ! -f "$DIR/wmill.yaml" ]]; then + echo "✗ No wmill.yaml in $DIR — is the fixture folder set up?" >&2 + exit 1 +fi + +# JSON body builder — interpolation via printf '%s' is unsafe for arbitrary +# emails / passwords. Python is universal enough for a dev script and +# produces correctly escaped JSON. +json_object() { + python3 -c ' +import json, sys +print(json.dumps(dict(zip(sys.argv[1::2], sys.argv[2::2])))) +' "$@" +} + +echo "→ Logging in as $EMAIL on $BASE_URL" +TOKEN="$(curl -sS -f -X POST "$BASE_URL/api/auth/login" \ + -H "Content-Type: application/json" \ + -d "$(json_object email "$EMAIL" password "$PASSWORD")")" +if [[ -z "$TOKEN" ]]; then + echo "✗ Login failed (empty token)" >&2 + exit 1 +fi + +# Clear previous snapshot content while preserving the fixture scaffold +# (wmill.yaml, .gitkeep). Anything else is removed so the snapshot reflects +# exactly what is in the workspace. +echo "→ Clearing previous snapshot in $DIR" +( + cd "$DIR" + find . -mindepth 1 -maxdepth 1 \ + ! -name 'wmill.yaml' \ + ! -name '.gitkeep' \ + -exec rm -rf {} + +) + +echo "→ Pulling workspace '$WORKSPACE' into $DIR" +( + cd "$DIR" + bun run "$CLI_ENTRY" sync pull --yes \ + --base-url "$BASE_URL" \ + --workspace "$WORKSPACE" \ + --token "$TOKEN" +) + +echo +echo "✓ Snapshot of '$WORKSPACE' written to $DIR" +echo " Commit the changes to share with reviewers. Run fixtures/check-empty.sh" +echo " to verify the dir is empty again before merging." diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ba905cfea3..26ae73cfd0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.708.0", + "version": "1.719.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.708.0", + "version": "1.719.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -66,6 +66,7 @@ "quill": "^1.3.7", "rehype-github-alerts": "^3.0.0", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "rfc4648": "^1.5.3", "runed": "^0.36.0", "svelte-carousel": "^1.0.25", @@ -6352,6 +6353,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-parse5": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", @@ -10888,6 +10904,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3a14adebe0..636009b1b3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,8 +1,9 @@ { "name": "@windmill-labs/components", - "version": "1.708.0", + "version": "1.719.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", "build": "vite build", "build:utils": "vite build --config sharedUtils/vite.sharedUtils.config.js", "preview": "vite preview", @@ -125,7 +126,6 @@ "lru-cache": "^11.1.0", "lucide-svelte": "^0.540.0", "mdast-util-find-and-replace": "^3.0.2", - "unist-util-visit": "^5.0.0", "minimatch": "^10.0.1", "monaco-editor": "npm:@codingame/monaco-vscode-editor-api@=25.0.0", "monaco-languageclient": "10.6.0", @@ -140,12 +140,14 @@ "quill": "^1.3.7", "rehype-github-alerts": "^3.0.0", "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", "rfc4648": "^1.5.3", "runed": "^0.36.0", "svelte-carousel": "^1.0.25", "svelte-exmarkdown": "^5.0.0", "svelte-infinite-loading": "^1.4.0", "tailwind-merge": "^1.13.2", + "unist-util-visit": "^5.0.0", "vscode": "npm:@codingame/monaco-vscode-extension-api@=25.0.0", "vscode-languageclient": "~9.0.1", "vscode-uri": "~3.1.0", diff --git a/frontend/scripts/ui_builder_artifact.json b/frontend/scripts/ui_builder_artifact.json index 1cfc1aef8c..2863ec67ab 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": "6715153", - "sha256": "1485930ea5f5309e4bdc09a55aae72eae8230eb74f0928715a0e6fe610703d9b" + "version": "fe13d03", + "sha256": "c588569103ce065a26f334d9b625f616cd23d4204deaacd6fadcfbf16f23462c" } diff --git a/frontend/src/lib/aiStore.ts b/frontend/src/lib/aiStore.ts index 8abc303e18..f2bddb8874 100644 --- a/frontend/src/lib/aiStore.ts +++ b/frontend/src/lib/aiStore.ts @@ -21,6 +21,7 @@ export const copilotInfo = writable<{ enabled: boolean codeCompletionModel?: AIProviderModel defaultModel?: AIProviderModel + metadataModel?: AIProviderModel aiModels: AIProviderModel[] customPrompts?: Record maxTokensPerModel?: Record @@ -28,6 +29,7 @@ export const copilotInfo = writable<{ enabled: false, codeCompletionModel: undefined, defaultModel: undefined, + metadataModel: undefined, aiModels: [], customPrompts: {}, maxTokensPerModel: {} @@ -65,6 +67,7 @@ export function setCopilotInfo(aiConfig: AIConfig) { enabled: true, codeCompletionModel: aiConfig.code_completion_model, defaultModel: aiConfig.default_model, + metadataModel: aiConfig.metadata_model, aiModels: aiModels, customPrompts: aiConfig.custom_prompts ?? {}, maxTokensPerModel: aiConfig.max_tokens_per_model ?? {} @@ -76,6 +79,7 @@ export function setCopilotInfo(aiConfig: AIConfig) { enabled: false, codeCompletionModel: undefined, defaultModel: undefined, + metadataModel: undefined, aiModels: [], customPrompts: {}, maxTokensPerModel: {} @@ -92,6 +96,15 @@ export function getCurrentModel(): AIProviderModel { return model } +export function getMetadataModel(): AIProviderModel { + const info = get(copilotInfo) + const model = info.metadataModel ?? info.defaultModel ?? info.aiModels[0] + if (!model) { + throw new Error('No model selected') + } + return model +} + export function tryGetCurrentModel(): AIProviderModel | undefined { return get(copilotSessionModel) ?? get(copilotInfo).defaultModel ?? get(copilotInfo).aiModels[0] } diff --git a/frontend/src/lib/components/AddUser.svelte b/frontend/src/lib/components/AddUser.svelte index 3a76bb96e1..e3b1455d77 100644 --- a/frontend/src/lib/components/AddUser.svelte +++ b/frontend/src/lib/components/AddUser.svelte @@ -9,6 +9,8 @@ import { goto } from '$lib/navigation' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' + import Toggle from './Toggle.svelte' + import Tooltip from './Tooltip.svelte' import { UserPlus } from 'lucide-svelte' const dispatch = createEventDispatcher() @@ -36,7 +38,12 @@ if (!username) return await WorkspaceService.createServiceAccount({ workspace: $workspaceStore!, - requestBody: { username: username! } + requestBody: { + username: username!, + is_admin: serviceAccountRole === 'admin', + operator: serviceAccountRole === 'operator', + add_to_deployers: serviceAccountRole === 'developer' && addToDeployers + } }) sendUserToast(`Service account '${username}' created`) } else { @@ -80,7 +87,10 @@ } type UserRole = 'operator' | 'developer' | 'admin' | 'service_account' + type ServiceAccountRole = 'operator' | 'developer' | 'admin' let selected: UserRole = $state('developer' as UserRole) + let serviceAccountRole: ServiceAccountRole = $state('operator' as ServiceAccountRole) + let addToDeployers: boolean = $state(true) let isServiceAccount = $derived(selected === 'service_account') @@ -144,6 +154,52 @@ /> {/snippet} + + {#if isServiceAccount} + Service account role + + {#snippet children({ item })} + + + + {/snippet} + + + {#if serviceAccountRole === 'developer'} +
+ + + Add to wm_deployers + + Recommended when this service account will be used as a wmill sync push + / CI deploy identity. Members of wm_deployers can deploy on behalf of + other users in the target workspace. + Learn more. + + +
+ {/if} + {/if} {:else} diff --git a/frontend/src/lib/components/DisplayResultControlBar.svelte b/frontend/src/lib/components/DisplayResultControlBar.svelte index d45ca3f92d..45011d2db1 100644 --- a/frontend/src/lib/components/DisplayResultControlBar.svelte +++ b/frontend/src/lib/components/DisplayResultControlBar.svelte @@ -4,6 +4,7 @@ import Popover from './Popover.svelte' import { copyToClipboard } from '$lib/utils' import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' + import { appendViewToken } from '$lib/viewToken' import type { DisplayResultUi } from './custom_ui' import { createEventDispatcher } from 'svelte' @@ -41,9 +42,11 @@ let resultApiPath = $derived( workspaceId && jobId - ? nodeId - ? `/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}` - : `/w/${workspaceId}/jobs_u/completed/get_result/${jobId}` + ? appendViewToken( + nodeId + ? `/w/${workspaceId}/jobs/result_by_id/${jobId}/${nodeId}` + : `/w/${workspaceId}/jobs_u/completed/get_result/${jobId}` + ) : undefined ) let downloadName = $derived(`${filename ?? 'result'}.json`) diff --git a/frontend/src/lib/components/DropdownV2.svelte b/frontend/src/lib/components/DropdownV2.svelte index c6243f3529..200f66f538 100644 --- a/frontend/src/lib/components/DropdownV2.svelte +++ b/frontend/src/lib/components/DropdownV2.svelte @@ -13,6 +13,7 @@ import DropdownV2Inner from './DropdownV2Inner.svelte' import { pointerDownOutside } from '$lib/utils' import { createDropdownMenu, melt, createSync } from '@melt-ui/svelte' + import type { MenubarMenuElements } from '@melt-ui/svelte' import ResolveOpen from '$lib/components/common/menu/ResolveOpen.svelte' import Button from '$lib/components/common/button/Button.svelte' import { twMerge } from 'tailwind-merge' @@ -40,7 +41,10 @@ size?: ButtonType.UnifiedSize btnText?: string buttonReplacement?: import('svelte').Snippet - menu?: import('svelte').Snippet + // In customMenu mode the snippet receives the melt-ui `item` action + // store so consumers can wrap their own rows in (or + // `use:melt={$item}`) and get arrow-key navigation + aria wiring. + menu?: import('svelte').Snippet<[{ item: MenubarMenuElements['item']; close: () => void }]> maxHeight?: string | undefined } @@ -172,7 +176,7 @@ transition:fly={{ duration: enableFlyTransition ? 100 : 0, y: -16 }} > {#if customMenu} - {@render menu?.()} + {@render menu?.({ item, close })} {:else}
import MenuItem from '$lib/components/meltComponents/MenuItem.svelte' import DropdownSubmenuItem from '$lib/components/DropdownSubmenuItem.svelte' - import { Loader2 } from 'lucide-svelte' + import { Check, Loader2 } from 'lucide-svelte' import { twMerge } from 'tailwind-merge' import type { MenubarMenuElements, createDropdownMenu } from '@melt-ui/svelte' import type { Item } from '$lib/utils' @@ -62,8 +62,17 @@ {item.displayName}

{@render item.extra?.()} - {#if item.shortcut} - {item.shortcut} + {#if item.shortcut || item.selected} + +
+ {#if item.shortcut} + {item.shortcut} + {/if} + {#if item.selected} + + {/if} +
{/if} {#if item.tooltip} diff --git a/frontend/src/lib/components/Editor.svelte b/frontend/src/lib/components/Editor.svelte index 03f4c04642..698e799aad 100644 --- a/frontend/src/lib/components/Editor.svelte +++ b/frontend/src/lib/components/Editor.svelte @@ -40,6 +40,7 @@ } from '$lib/stores' import { editorConfig, registerWebviewPaste, updateOptions } from '$lib/editorUtils' + import { editorFontSize } from '$lib/editorFontSize.svelte' import { createHash as randomHash } from '$lib/editorLangUtils' import { workspaceStore } from '$lib/stores' import { @@ -147,6 +148,11 @@ preparedAssetsSqlQueries?: InferAssetsSqlQueryDetails[] | undefined // To execute preview scripts with the right worker group customTag?: string + // Opt-in: reflect external `code` prop mutations back into Monaco (see + // the effect below). One-way `code={...}` callers that need live + // external updates — e.g. the inline flow rawscript — set this. Off by + // default so every other caller's behavior is unchanged. + syncExternalCode?: boolean } let { @@ -178,7 +184,8 @@ enablePreprocessorSnippet = false, rawAppRunnableKey = undefined, preparedAssetsSqlQueries, - customTag + customTag, + syncExternalCode = false }: Props = $props() $effect.pre(() => { @@ -1374,7 +1381,7 @@ $relativeLineNumbers ), model, - fontSize: !small ? 13.5 : 12, + fontSize: small ? editorFontSize.small : editorFontSize.regular, lineNumbersMinChars, // overflowWidgetsDomNode: widgets, tabSize: lang == 'python' ? 4 : 2, @@ -1749,6 +1756,13 @@ let aiChatInlineWidget: AIChatInlineWidget | null = $state(null) + $effect(() => { + const fontSize = small ? editorFontSize.small : editorFontSize.regular + if (editor) { + editor.updateOptions({ fontSize }) + } + }) + let loadTimeout: number | undefined = undefined onMount(async () => { if (BROWSER) { @@ -1829,6 +1843,30 @@ $effect(() => { lang = scriptLangToEditorLang(scriptLang) }) + + // Opt-in (syncExternalCode): reflect external `code` prop mutations into + // Monaco's model. Parents that pass `code={...}` one-way (no bind) — e.g. + // the inline rawscript in the flow editor — otherwise mutate the prop + // without Monaco ever showing the change (the AI chat editing a flow + // module's content in a session is the motivating case). Gated off by + // default: Editor is sensitive and most callers either bind:code (and + // carry their own external-sync) or treat code as init-only, so a blanket + // setValue would risk clobbering them. The `getValue() !== code` guard + // keeps the caret intact when the change originated from typing inside + // Monaco (which round-trips code back via `$bindable`, re-firing this + // effect with `code === getValue()`). + let lastExternalCodeSync = code + $effect(() => { + if (!syncExternalCode) return + if (code === lastExternalCodeSync) return + lastExternalCodeSync = code + if (!editor) return + untrack(() => { + if (editor!.getValue() !== code) { + editor!.setValue(code ?? '') + } + }) + }) $effect(() => { filePath = computePath(path) }) diff --git a/frontend/src/lib/components/FakeMonacoPlaceHolder.svelte b/frontend/src/lib/components/FakeMonacoPlaceHolder.svelte index c7f068a83f..3f486494a3 100644 --- a/frontend/src/lib/components/FakeMonacoPlaceHolder.svelte +++ b/frontend/src/lib/components/FakeMonacoPlaceHolder.svelte @@ -1,8 +1,8 @@ -
+
@@ -30,6 +45,7 @@ defaultLang="yaml" defaultOriginal={beforeYaml} defaultModified={afterYaml} + {inlineDiff} readOnly /> {/await} @@ -37,7 +53,7 @@ {#await import('$lib/components/FlowGraphDiffViewer.svelte')} {:then Module} - + {/await} {/if}
diff --git a/frontend/src/lib/components/FlowGraphDiffViewer.svelte b/frontend/src/lib/components/FlowGraphDiffViewer.svelte index 3639f0955b..caab746502 100644 --- a/frontend/src/lib/components/FlowGraphDiffViewer.svelte +++ b/frontend/src/lib/components/FlowGraphDiffViewer.svelte @@ -2,12 +2,12 @@ import type { OpenFlow } from '$lib/gen' import YAML from 'yaml' import FlowGraphV2 from './graph/FlowGraphV2.svelte' - import { Alert, Button } from './common' + import { Alert } from './common' import { computeFlowModuleDiff } from './flows/flowDiff' import { Pane, Splitpanes } from 'svelte-splitpanes' + import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte' - import { DiffIcon, Minus, Plus, SquareSplitHorizontal } from 'lucide-svelte' import type { Viewport } from '@xyflow/svelte' const SIDE_BY_SIDE_MIN_WIDTH = 700 @@ -15,13 +15,54 @@ interface Props { beforeYaml: string afterYaml: string + /** When true, render an empty surface placeholder for the "before" + * pane in side-by-side mode (use for added items where there's no + * prior flow to show). */ + beforeMissing?: boolean + /** Same as `beforeMissing` but for the "after" pane (use for removed + * items). */ + afterMissing?: boolean + /** Render the unified single-pane diff when true, side-by-side + * otherwise. When undefined, the component renders its own + * Unified / Side-by-side toggle in the corner (legacy behavior for + * the standalone comparison page). A narrow viewer still falls back + * to unified automatically. */ + inlineDiff?: boolean | undefined } - let { beforeYaml, afterYaml }: Props = $props() + let { + beforeYaml, + afterYaml, + beforeMissing = false, + afterMissing = false, + inlineDiff = undefined + }: Props = $props() + + // Local toggle state, used only when no inlineDiff prop is supplied. + let localViewMode = $state<'sidebyside' | 'unified'>('sidebyside') + const showLocalToggle = $derived(inlineDiff === undefined) + const effectiveInlineDiff = $derived( + inlineDiff !== undefined ? inlineDiff : localViewMode === 'unified' + ) let viewerWidth = $state(SIDE_BY_SIDE_MIN_WIDTH) let beforePaneSize = $state(50) - let viewMode = $state<'sidebyside' | 'unified'>('sidebyside') + // Track the content area's rendered height so unified-mode graphs can + // grow to fill the diff box (otherwise FlowGraphV2 sits at its + // content-fit height + small floor, leaving empty space below). + let contentAreaHeight = $state(0) + + // Each FlowGraphV2 sizes itself to its own content (clamped to minHeight). + // In side-by-side mode we want both graphs to share the same height, so + // we track each side's reported height and feed back the max as minHeight + // to both. The width-graph then stays at its computed size; the shorter + // graph grows to match. + let beforeContentHeight = $state(0) + let afterContentHeight = $state(0) + const SHARED_MIN_HEIGHT = 400 + const sharedMinHeight = $derived( + Math.max(SHARED_MIN_HEIGHT, beforeContentHeight, afterContentHeight) + ) // Shared viewport for synchronizing both graphs in side-by-side mode let sharedViewport = $state({ x: 0, y: 0, zoom: 1 }) @@ -29,7 +70,10 @@ let beforeGraph: FlowGraphV2 | undefined = $state(undefined) let afterGraph: FlowGraphV2 | undefined = $state(undefined) - function parseFlow(yaml: string, label: 'before' | 'after'): { + function parseFlow( + yaml: string, + label: 'before' | 'after' + ): { flow: OpenFlow | undefined error: string | undefined } { @@ -49,14 +93,27 @@ } } - let beforeParsed = $derived.by(() => parseFlow(beforeYaml, 'before')) - let afterParsed = $derived.by(() => parseFlow(afterYaml, 'after')) + // For added/removed items, the caller passes empty YAML and sets the + // corresponding *Missing flag. We swap in an empty OpenFlow stub on + // that side so the unified diff path still has something to compare + // against (every module on the present side becomes added / removed). + // The side-by-side rendering uses the flag directly to draw a + // placeholder pane instead. + const EMPTY_FLOW: OpenFlow = { summary: '', value: { modules: [] } } + + let beforeParsed = $derived.by(() => + beforeMissing ? { flow: EMPTY_FLOW, error: undefined } : parseFlow(beforeYaml, 'before') + ) + let afterParsed = $derived.by(() => + afterMissing ? { flow: EMPTY_FLOW, error: undefined } : parseFlow(afterYaml, 'after') + ) let parseError = $derived(beforeParsed.error ?? afterParsed.error) let beforeFlow: OpenFlow | undefined = $derived(beforeParsed.flow) let afterFlow: OpenFlow | undefined = $derived(afterParsed.flow) - // Determine if we should render side-by-side or unified (user controlled via toggle) - let isSideBySide = $derived(viewMode === 'sidebyside') + // Side-by-side unless the caller asked for unified, OR the viewer pane + // is too narrow to comfortably split (fallback to unified for legibility). + const isSideBySide = $derived(!effectiveInlineDiff && viewerWidth >= SIDE_BY_SIDE_MIN_WIDTH) // Build timeline using history-based approach // In side-by-side view, mark removed modules as 'shadowed' in the After graph @@ -72,14 +129,6 @@ sharedViewport = viewport } } - - $effect(() => { - if (viewerWidth < SIDE_BY_SIDE_MIN_WIDTH) { - viewMode = 'unified' - } else { - viewMode = 'sidebyside' - } - }) {#if parseError} @@ -88,10 +137,12 @@ {:else if beforeFlow && afterFlow}
- -
-
- + {#if showLocalToggle} + +
+ {#snippet children({ item })}
- + {/if} + +
{#if isSideBySide} - -
- +
{/if} -
- - -
{#if isSideBySide} -
-
- - {#snippet leftHeader()} - Before - {/snippet} - -
+
+ {#if beforeMissing} + + Before (no prior version) + + {:else} +
+ (beforeContentHeight = h)} + > + {#snippet leftHeader()} + Before + {/snippet} + +
+ {/if}
-
-
- - {#snippet leftHeader()} - After - {/snippet} - -
+
+ {#if afterMissing} + + After (flow deleted) + + {:else} +
+ (afterContentHeight = h)} + > + {#snippet leftHeader()} + After + {/snippet} + +
+ {/if}
@@ -219,7 +299,7 @@ editMode={false} download={false} scroll={false} - minHeight={400} + minHeight={Math.max(contentAreaHeight, SHARED_MIN_HEIGHT)} triggerNode={false} />
@@ -231,3 +311,31 @@

Loading graphs...

{/if} + + diff --git a/frontend/src/lib/components/FlowStatusViewerInner.svelte b/frontend/src/lib/components/FlowStatusViewerInner.svelte index 809eff58d9..d0bf66d61b 100644 --- a/frontend/src/lib/components/FlowStatusViewerInner.svelte +++ b/frontend/src/lib/components/FlowStatusViewerInner.svelte @@ -28,6 +28,7 @@ import ModuleStatus from './ModuleStatus.svelte' import { clone, isScriptPreview, msToSec, readFieldsRecursively, truncateRev } from '$lib/utils' import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' + import { appendViewToken } from '$lib/viewToken' import JobArgs from './JobArgs.svelte' import { ChevronDown, Download, ExternalLink, Hourglass } from 'lucide-svelte' import { deepEqual } from 'fast-equals' @@ -1839,7 +1840,9 @@ style="min-height: {minTabHeight}px" > {#if !hideDownloadLogs && !isReplay && job?.id} - {@const logsApiPath = `/w/${workspace}/jobs_u/get_flow_all_logs/${job.id}`} + {@const logsApiPath = appendViewToken( + `/w/${workspace}/jobs_u/get_flow_all_logs/${job.id}` + )} {@const logsName = `windmill_flow_logs_${job.id}.txt`}
{#if shouldDownloadViaClient()} diff --git a/frontend/src/lib/components/GfmMarkdown.svelte b/frontend/src/lib/components/GfmMarkdown.svelte index 83cce90fbe..377951b97b 100644 --- a/frontend/src/lib/components/GfmMarkdown.svelte +++ b/frontend/src/lib/components/GfmMarkdown.svelte @@ -1,19 +1,12 @@
diff --git a/frontend/src/lib/components/GraphqlSchemaViewer.svelte b/frontend/src/lib/components/GraphqlSchemaViewer.svelte index 1ab4c68f00..205d8de416 100644 --- a/frontend/src/lib/components/GraphqlSchemaViewer.svelte +++ b/frontend/src/lib/components/GraphqlSchemaViewer.svelte @@ -2,20 +2,19 @@ import { BROWSER } from 'esm-env' import { editor as meditor, KeyMod, KeyCode } from 'monaco-editor' + import { editorFontSize } from '$lib/editorFontSize.svelte' import { onDestroy, onMount } from 'svelte' let divEl: HTMLDivElement | null = $state(null) let editor: meditor.IStandaloneCodeEditor - interface Props { - code?: string; - class?: string; + code?: string + class?: string } - let { code = '', class: className = '' }: Props = $props(); - + let { code = '', class: className = '' }: Props = $props() async function loadMonaco() { editor = meditor.create(divEl as HTMLDivElement, { @@ -25,6 +24,7 @@ automaticLayout: true, scrollBeyondLastLine: false, lineNumbers: 'off', + fontSize: editorFontSize.regular, minimap: { enabled: false } }) @@ -43,6 +43,13 @@ } }) + $effect(() => { + const fontSize = editorFontSize.regular + if (editor) { + editor.updateOptions({ fontSize }) + } + }) + onDestroy(() => { try { editor && editor.dispose() diff --git a/frontend/src/lib/components/InstanceSettings.svelte b/frontend/src/lib/components/InstanceSettings.svelte index 7342f1d3fa..9581357e84 100644 --- a/frontend/src/lib/components/InstanceSettings.svelte +++ b/frontend/src/lib/components/InstanceSettings.svelte @@ -13,6 +13,7 @@ import { createEventDispatcher } from 'svelte' import { setLicense } from '$lib/enterpriseUtils' import AuthSettings from './AuthSettings.svelte' + import oauthConnectRegistry from '$oauth_connect_registry' import InstanceSetting from './InstanceSetting.svelte' import { writable, type Writable } from 'svelte/store' import { ExternalLink, Loader2 } from 'lucide-svelte' @@ -54,7 +55,9 @@ let initialValues: Record = $state({}) let baseUrlIsFallback = $state(false) - let snowflakeAccountIdentifier = $state('') + // Per-instance OAuth providers (Snowflake, ServiceNow, …): instance name + // keyed by provider, used to build their per-instance connect_config URLs. + let instanceInputs: Record = $state({}) let version: string = $state('') let loading = $state(true) @@ -147,12 +150,8 @@ $values = nvalues loading = false - // populate snowflake account identifier from db - const account_identifier = - oauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier - if (account_identifier) { - snowflakeAccountIdentifier = account_identifier - } + // populate per-instance OAuth provider inputs (snowflake, servicenow, …) from db + loadInstanceInputs(oauths) } export async function saveSettings() { @@ -162,13 +161,7 @@ } } - if ( - oauths?.snowflake_oauth && - oauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier !== - snowflakeAccountIdentifier - ) { - setupSnowflakeUrls() - } + setupTemplatedOauthUrls() // Remove empty or invalid entries for critical error channels $values.critical_error_channels = $values.critical_error_channels.filter((entry: any) => { @@ -283,19 +276,54 @@ } } - function setupSnowflakeUrls() { - // strip all whitespaces from account identifier - snowflakeAccountIdentifier = snowflakeAccountIdentifier.replace(/\s/g, '') + // Per-instance OAuth providers (Snowflake, ServiceNow, …) keyed by name -> + // their registry connect_config_template. Adding a new one needs only a + // registry entry — no code here. + const connectConfigTemplates: Record = Object.fromEntries( + Object.entries(oauthConnectRegistry) + .filter(([, cfg]) => cfg && typeof cfg === 'object' && 'connect_config_template' in cfg) + .map(([name, cfg]) => [name, (cfg as any).connect_config_template]) + ) - const connect_config = { - scopes: [], - auth_url: `https://${snowflakeAccountIdentifier}.snowflakecomputing.com/oauth/authorize`, - token_url: `https://${snowflakeAccountIdentifier}.snowflakecomputing.com/oauth/token-request`, - req_body_auth: false, - extra_params: { account_identifier: snowflakeAccountIdentifier }, - extra_params_callback: {} + function normalizeInstanceInput(tmpl: any, raw: string): string { + let v = (raw ?? '').replace(/\s/g, '') + if (tmpl.strip_suffix) { + // accept a full host/URL or a bare name -> reduce to the bare instance + v = v.replace(/^https?:\/\//, '').replace(/\/.*$/, '') + if (v.endsWith(tmpl.strip_suffix)) { + v = v.slice(0, -tmpl.strip_suffix.length) + } + } + return v + } + + // Build each per-instance provider's connect_config from the admin-entered + // instance name + its registry template (substituting {instance} into the + // URLs). Replaces the old per-provider setup functions. + function setupTemplatedOauthUrls() { + for (const [name, tmpl] of Object.entries(connectConfigTemplates)) { + if (!oauths?.[name]) continue + const key = tmpl.extra_params_key ?? 'instance' + const v = normalizeInstanceInput(tmpl, instanceInputs[name] ?? '') + instanceInputs[name] = v + if (oauths[name].connect_config?.extra_params?.[key] === v) continue + oauths[name].connect_config = { + scopes: [], + auth_url: tmpl.auth_url.replaceAll('{instance}', v), + token_url: tmpl.token_url.replaceAll('{instance}', v), + req_body_auth: tmpl.req_body_auth ?? false, + extra_params: { [key]: v }, + extra_params_callback: {} + } + } + } + + // Recover the instance-name inputs from a saved oauths config (for load/discard). + function loadInstanceInputs(savedOauths: Record) { + for (const [name, tmpl] of Object.entries(connectConfigTemplates)) { + const key = tmpl.extra_params_key ?? 'instance' + instanceInputs[name] = savedOauths?.[name]?.connect_config?.extra_params?.[key] ?? '' } - oauths['snowflake_oauth'].connect_config = connect_config } let sendingStats = $state(false) @@ -510,9 +538,7 @@ if (category === 'Auth/OAuth/SAML') { oauths = JSON.parse(JSON.stringify(initialOauths)) requirePreexistingUserForOauth = initialRequirePreexistingUserForOauth - const account_identifier = - initialOauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier - snowflakeAccountIdentifier = account_identifier ?? '' + loadInstanceInputs(initialOauths) } else if (category === 'Registries') { const v = initialValues['workspace_registries'] $values['workspace_registries'] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined @@ -524,9 +550,7 @@ $values = JSON.parse(JSON.stringify(initialValues)) oauths = JSON.parse(JSON.stringify(initialOauths)) requirePreexistingUserForOauth = initialRequirePreexistingUserForOauth - const account_identifier = - initialOauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier - snowflakeAccountIdentifier = account_identifier ?? '' + loadInstanceInputs(initialOauths) if (yamlMode) { syncFormToYaml() } @@ -535,13 +559,7 @@ export async function saveCategorySettings(category: string) { // Category-specific pre-processing if (category === 'Auth/OAuth/SAML') { - if ( - oauths?.snowflake_oauth && - oauths?.snowflake_oauth?.connect_config?.extra_params?.account_identifier !== - snowflakeAccountIdentifier - ) { - setupSnowflakeUrls() - } + setupTemplatedOauthUrls() } if (category === 'Alerts' && $values?.critical_error_channels) { @@ -1116,7 +1134,7 @@ {:else if category == 'Auth/OAuth/SAML'} request a share link). + if (status === 403 || status === 404 || errorIteration == 5) { notfound = true job = undefined clearCurrentId() @@ -754,6 +764,13 @@ params.set('token', token.token) } + // Share read link: SSE/EventSource can't set the X-View-Token header, + // so carry the token as a query param instead. + const viewToken = getViewToken() + if (viewToken) { + params.set('view_token', viewToken) + } + const sseUrl = `/api/w/${workspace}/jobs_u/getupdate_sse/${id}?${params.toString()}` currentEventSource = new EventSource(sseUrl) diff --git a/frontend/src/lib/components/LocalDraftBanner.svelte b/frontend/src/lib/components/LocalDraftBanner.svelte new file mode 100644 index 0000000000..2fb2de4c11 --- /dev/null +++ b/frontend/src/lib/components/LocalDraftBanner.svelte @@ -0,0 +1,102 @@ + + + + +{#if show} +
+
+ + + You have unsaved changes + +
+
+ + {#if !disabled} + + {/if} +
+
+{/if} diff --git a/frontend/src/lib/components/LogViewer.svelte b/frontend/src/lib/components/LogViewer.svelte index ca90e3968f..b5a31265be 100644 --- a/frontend/src/lib/components/LogViewer.svelte +++ b/frontend/src/lib/components/LogViewer.svelte @@ -17,6 +17,7 @@ import { base } from '$lib/base' import { withExternalDomain } from '$lib/externalDomain' import { downloadViaClient, shouldDownloadViaClient } from '$lib/utils/downloadFile' + import { appendViewToken } from '$lib/viewToken' import { workspaceStore } from '$lib/stores' import { AnsiUp } from 'ansi_up' import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte' @@ -241,7 +242,7 @@ fetchedSkippedJobId = undefined } }) - let logsApiPath = $derived(`/w/${$workspaceStore}/jobs_u/get_logs/${jobId}`) + let logsApiPath = $derived(appendViewToken(`/w/${$workspaceStore}/jobs_u/get_logs/${jobId}`)) let downloadHref = $derived(withExternalDomain(`${base}/api${logsApiPath}`)) let downloadName = $derived(`windmill_logs_${jobId}.txt`) let truncatedContent = $derived( diff --git a/frontend/src/lib/components/Path.svelte b/frontend/src/lib/components/Path.svelte index 8306c8a410..0a64225d14 100644 --- a/frontend/src/lib/components/Path.svelte +++ b/frontend/src/lib/components/Path.svelte @@ -5,7 +5,7 @@ @@ -696,7 +706,7 @@ class={twMerge(inputBorderClass({ forceFocus: isFocus }), 'rounded-md overflow-auto pl-2', clazz)} > {#if !editor} - + {/if}
!deepEqual(states[ws].draft, initialStates[ws])) ) const anyDirty = $derived(dirtyWorkspaces.length > 0) + // Banner is scoped to the selected workspace — the diff/discard only + // operate on it, so showing it for an unrelated dirty workspace would be + // misleading. The cross-workspace `otherDirty` alert below still covers + // that case. + const selectedDirty = $derived(!!selected && dirtyWorkspaces.includes(selected)) const otherDirty = $derived( dirtyWorkspaces.length == 1 ? dirtyWorkspaces.filter((ws) => ws !== $workspaceStore) @@ -183,11 +188,6 @@ if (previousMeta.remoteRev === undefined && previousMeta.remoteDraftRev === undefined) { UserDraft.saveMeta('variable', p, { remoteRev: v.edited_at }, { workspace: ws }) } - notifyRestoredFromLocal(false, true, { - onResetToDeployed: () => { - UserDraft.discard('variable', p, s, { workspace: ws }) - } - }) } } ensureHandle(ws, s) @@ -327,6 +327,20 @@ title={edit ? `Update variable at ${initialPath}` : 'Add a variable'} on:close={drawer?.closeDrawer} > + {#snippet banner()} + (selected ? initialStates[selected] : undefined)} + getCurrent={() => current} + onDiscard={() => { + if (!selected) return + UserDraft.discard('variable', editPath ?? '', initialStates[selected], { + workspace: selected + }) + }} + disabled={!can_write} + /> + {/snippet}
{#if !can_write} diff --git a/frontend/src/lib/components/VariableForm.svelte b/frontend/src/lib/components/VariableForm.svelte index 0ffa4d8ba6..c688759874 100644 --- a/frontend/src/lib/components/VariableForm.svelte +++ b/frontend/src/lib/components/VariableForm.svelte @@ -75,7 +75,7 @@ disabled={edit && $userStore?.operator} /> {#if variable.is_secret} - + Every secret is encrypted at rest and in transit with a key specific to this workspace. In addition, any read of a secret variable generates an audit log whose operation name is: variables.decrypt_secret diff --git a/frontend/src/lib/components/WorkspaceFairnessEvents.svelte b/frontend/src/lib/components/WorkspaceFairnessEvents.svelte new file mode 100644 index 0000000000..4fbaf8f076 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceFairnessEvents.svelte @@ -0,0 +1,106 @@ + + +
+ {#snippet action()} +
+ + + + + + + + + + {#each events as e} + + + + + + + {/each} + +
TimeEventWorkspaceDetails
+ {displayDate(e.timestamp, true)} + + {#if e.operation === 'workspace_fairness.capped'} + capped + {:else if e.operation === 'workspace_fairness.uncapped'} + uncapped + {:else} + {e.operation} + {/if} + {e.workspace_id ?? '—'}{formatParameters(e.parameters)}
+ + {/if} + diff --git a/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte b/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte new file mode 100644 index 0000000000..2ee01b4607 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemDiffViewer.svelte @@ -0,0 +1,162 @@ + + + +{#if kind === 'flow'} +
+ +
+{:else if hasContent} +
+ + + + +
+ {#if contentTab === 'content'} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} + + {/await} + {:else} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} + + {/await} + {/if} +
+
+{:else} + {#await import('$lib/components/DiffEditor.svelte')} +
+ {:then Module} +
+ +
+ {/await} +{/if} diff --git a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte index abee584afa..dda620dad5 100644 --- a/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte +++ b/frontend/src/lib/components/WorkspaceItemDrillPicker.svelte @@ -17,8 +17,10 @@ Clicking a row drills *down*; the chevron-left in the header walks one level import { ChevronLeft, ChevronRight, Folder, Layers, Loader2, User } from 'lucide-svelte' import TextInput from '$lib/components/text_input/TextInput.svelte' import RowIcon from '$lib/components/common/table/RowIcon.svelte' + import WorkspaceItemRow from '$lib/components/WorkspaceItemRow.svelte' import SearchItems from '$lib/components/SearchItems.svelte' import { onMount, untrack } from 'svelte' + import { generateRandomString } from '$lib/utils' import { dirKey, getCachedItems, @@ -30,6 +32,8 @@ Clicking a row drills *down*; the chevron-left in the header walks one level type WorkspaceItem, type WorkspaceItemKind } from './workspacePicker' + import { listGlobalDrafts } from '$lib/components/copilot/chat/global/userDraftAdapter' + import { isGlobalAiEnabled } from '$lib/components/copilot/chat/global/gate' type Kind = WorkspaceItemKind type Item = WorkspaceItem @@ -62,7 +66,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level let searchInput: TextInput | undefined = $state() let pickerRoot: HTMLElement | undefined = $state() - const instanceId = crypto.randomUUID() + const instanceId = generateRandomString(8) const listboxId = `pkr-list-${instanceId}` const idFor = (key: string) => `pkr-${instanceId}-${key.replace(/[^a-zA-Z0-9-]/g, '_')}` @@ -72,8 +76,16 @@ Clicking a row drills *down*; the chevron-left in the header walks one level // Sibling-popover open: melt-ui's `openFocus` runs once during the close→open // transition; the picker may not be mounted yet. Retry after settle. + // Also kicks off the initial scope's fetch — drill/goUp do the same from + // their respective branches, so `ensureLoaded` is always a callback + // reaction to user navigation, never a reactive consequence. onMount(() => { const t = setTimeout(focus, 50) + const initial = untrack(() => scope) + if (initial) { + if (initial.kind === 'all') for (const k of kinds) ensureLoaded(k) + else ensureLoaded(initial.kind) + } return () => clearTimeout(t) }) @@ -82,6 +94,22 @@ Clicking a row drills *down*; the chevron-left in the header walks one level let scope = $state(untrack(() => initialScope)) let filter = $state('') + /** + * Canonical entry point for changing the picker's scope. Triggers the + * fetch for the kind(s) the new scope needs at the same point in time. + * Replaces the older "react to `scope` change via `$effect`" wiring, + * which had a subtle bug: `ensureLoaded` reads `loaded[kind]`, so the + * effect ended up subscribed to the signal it fills — every fetch + * result re-fired it. With explicit callbacks the fetch is tied to + * the user's action, never to a reactive consequence of that action. + */ + function setScope(next: Scope) { + scope = next + if (!next) return + if (next.kind === 'all') for (const k of kinds) ensureLoaded(k) + else ensureLoaded(next.kind) + } + /** Tracks whether the last user action was mouse movement (true) or * keyboard nav (false). When false, row `mouseenter` events are ignored * — prevents the cursor from stealing the keyboard-driven highlight as @@ -90,10 +118,11 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * mounts under a stationary cursor doesn't clobber `initialHighlight`. */ let mouseActive = $state(false) - // Seed from cache so kinds already fetched in this session render on the - // first frame. Read once at mount: melt-ui mounts a fresh picker per - // popover open, so workspace changes are picked up at the next open - // without needing this seed to be reactive. + // Seed from the last fetched snapshot so kinds already fetched in this + // session render on the first frame. Each entry is replaced once + // `loadKind` returns fresh data — stale-while-revalidate, so deploys and + // AI-created drafts surface on the next open without explicit cache + // busting. let loaded = $state>>( (() => { if (!$workspaceStore) return {} @@ -109,8 +138,15 @@ Clicking a row drills *down*; the chevron-left in the header walks one level async function ensureLoaded(kind: Kind) { if (!$workspaceStore) return - if (loaded[kind]) return - loadingKind[kind] = true + // Always re-fetch. If we have nothing cached, show a spinner; if we do, + // keep displaying it and quietly swap to fresh data when it lands. + // `loaded[kind]` is read inside `untrack(...)` because this function is + // reachable from the search `$effect` below — without the untrack, + // that effect would subscribe to the signal `ensureLoaded` fills, and + // each `loaded[kind] = items` (proxy `set` notifies even when the ref + // is unchanged from cache) would refire it → runaway loop. Drill + // navigation goes through `setScope` directly so it isn't affected. + if (!untrack(() => loaded[kind])) loadingKind[kind] = true try { const items = await loadKind($workspaceStore, kind) loaded[kind] = items @@ -119,13 +155,31 @@ Clicking a row drills *down*; the chevron-left in the header walks one level } } - // Fetch the scope's kind on entry to a non-root level. The `'all'` scope - // needs every kind loaded since it merges items across them. - $effect(() => { - if (!scope) return - if (scope.kind === 'all') for (const k of kinds) ensureLoaded(k) - else ensureLoaded(scope.kind) - }) + // Chat tools and session editor previews write drafts through + // `UserDraft` (workspace-scoped, localStorage-backed). Merge those into + // the picker so users can navigate to in-flight items that haven't been + // deployed yet. Filter to kinds the picker actually displays. + // + // Gated on the same dev flag as the rest of the sessions feature: without + // it there are no sessions, so the only UserDrafts present are the + // standalone editors' autosaves — surfacing those in the breadcrumb picker + // would be surprising (they'd appear as navigable items that 404 on the + // backend draft fetch). When the flag is off this is a no-op. + const KIND_TO_DRAFT_TYPE = { flow: 'flow', script: 'script', app: 'app' } as const + function aiDraftsForKind(k: Kind): Item[] { + if (!isGlobalAiEnabled()) return [] + if (!$workspaceStore) return [] + const targetType = KIND_TO_DRAFT_TYPE[k] + return listGlobalDrafts($workspaceStore) + .filter((d) => d.type === targetType) + .map((d) => ({ + path: d.path, + summary: d.summary ?? '', + kind: k, + // `raw_app` lives on the draft envelope for legacy/raw-app distinction. + raw_app: k === 'app' ? !!(d.value as { files?: unknown })?.files : undefined + })) + } // Searching is global → load every kind. $effect(() => { @@ -140,6 +194,17 @@ Clicking a row drills *down*; the chevron-left in the header walks one level leaves: Item[] } + /** Merge AI-created in-memory drafts into a kind's list. The AI may have + * scaffolded a script/flow/app via chat tools without the user saving + * yet — those drafts should be navigable from the picker. Existing items + * (same path) win to keep the backend's metadata (summary etc.). */ + function withAiDrafts(items: Item[], k: Kind): Item[] { + const ai = aiDraftsForKind(k) + if (ai.length === 0) return items + const known = new Set(items.map((it) => it.path)) + return items.concat(ai.filter((d) => !known.has(d.path))) + } + /** Inject the currently-edited item into a kind's list at its live path, * dropping the saved entry when a draft rename is in progress. Other kinds * pass through untouched. */ @@ -207,7 +272,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * cached. */ function buildIfActive(k: Kind, list: Item[] | undefined): DirNode[] { if (!kinds.includes(k)) return [] - const items = withCurrent(list ?? [], k) + const items = withAiDrafts(withCurrent(list ?? [], k), k) if (items.length === 0) return [] return buildTreeFromItems(items) } @@ -219,7 +284,7 @@ Clicking a row drills *down*; the chevron-left in the header walks one level * one folder hierarchy. Each leaf still carries its real kind, so the row * icon and `editPathFor` routing still work; folders contain a mix. */ const allTree = $derived.by(() => { - const merged = kinds.flatMap((k) => withCurrent(loaded[k] ?? [], k)) + const merged = kinds.flatMap((k) => withAiDrafts(withCurrent(loaded[k] ?? [], k), k)) return merged.length === 0 ? [] : buildTreeFromItems(merged) }) @@ -255,7 +320,10 @@ Clicking a row drills *down*; the chevron-left in the header walks one level let allItems = $derived( kinds.flatMap((k) => - withCurrent(loaded[k] ?? [], k).map((it) => ({ ...it, _key: `${k}:${it.path}` })) + withAiDrafts(withCurrent(loaded[k] ?? [], k), k).map((it) => ({ + ...it, + _key: `${k}:${it.path}` + })) ) ) @@ -383,9 +451,9 @@ Clicking a row drills *down*; the chevron-left in the header walks one level function drill(entry: Entry) { if (entry.type === 'kind') { - scope = { kind: entry.kind } + setScope({ kind: entry.kind }) } else if (entry.type === 'dir') { - scope = { kind: entry.kind, dir: entry.node.fullPath } + setScope({ kind: entry.kind, dir: entry.node.fullPath }) } else { pick(entry.item) } @@ -397,13 +465,13 @@ Clicking a row drills *down*; the chevron-left in the header walks one level // just left, so the user sees where they came from. if (!scope.dir) { const leaving = kindKey(scope.kind) - scope = undefined + setScope(undefined) highlightedKey = leaving return } const leaving = dirKey(scope.kind, scope.dir) const parent = parentDirPath(scope.dir) - scope = parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind } + setScope(parent ? { kind: scope.kind, dir: parent } : { kind: scope.kind }) highlightedKey = leaving } @@ -528,32 +596,18 @@ Clicking a row drills *down*; the chevron-left in the header walks one level {#snippet leafRow(it: Item, secondary: string, baseClass: string)} {@const key = leafKey(it)} - {@const isHl = key === highlightedKey} - {@const isCur = isCurrent(it)} - + /> {/snippet} diff --git a/frontend/src/lib/components/WorkspaceItemRow.svelte b/frontend/src/lib/components/WorkspaceItemRow.svelte new file mode 100644 index 0000000000..8ddd3fa317 --- /dev/null +++ b/frontend/src/lib/components/WorkspaceItemRow.svelte @@ -0,0 +1,148 @@ + + + + + +{#if href} + + +
+ {#if summary} +
{summary}
+
{secondary}
+ {:else} +
{secondary}
+ {/if} +
+ {#if extras} +
+ {@render extras()} +
+ {/if} +
+{:else} + +{/if} diff --git a/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte b/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte index e8ae0f8335..ff9c4263a8 100644 --- a/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte +++ b/frontend/src/lib/components/apps/components/display/AppMarkdown.svelte @@ -5,10 +5,8 @@ import type { AppViewerContext, ComponentCustomCSS, RichConfigurations } from '../../types' import { initCss } from '../../utils' import RunnableWrapper from '../helpers/RunnableWrapper.svelte' - import { Markdown, type Plugin } from 'svelte-exmarkdown' - import { gfmPlugin } from 'svelte-exmarkdown/gfm' - import rehypeRaw from 'rehype-raw' - import { rehypeGithubAlerts } from 'rehype-github-alerts' + import { Markdown } from 'svelte-exmarkdown' + import { markdownPlugins as plugins } from '$lib/components/markdownPlugins' import { classNames } from '$lib/utils' import { components } from '../../editor/component' import ResolveConfig from '../helpers/ResolveConfig.svelte' @@ -31,11 +29,6 @@ configuration }: Props = $props() - const plugins: Plugin[] = [ - gfmPlugin(), - { rehypePlugin: [rehypeRaw] }, - { rehypePlugin: [rehypeGithubAlerts] } - ] const { app, worldStore, mode } = getContext('AppViewerContext') const resolvedConfig = $state( diff --git a/frontend/src/lib/components/apps/editor/AppEditor.svelte b/frontend/src/lib/components/apps/editor/AppEditor.svelte index 4e62110eff..cd9acdbffa 100644 --- a/frontend/src/lib/components/apps/editor/AppEditor.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditor.svelte @@ -3,7 +3,7 @@ const bubble = createBubbler() import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte' - import { onMount, setContext, untrack } from 'svelte' + import { getContext, onMount, setContext, untrack } from 'svelte' import { twMerge } from 'tailwind-merge' import { Pane, Splitpanes } from 'svelte-splitpanes' @@ -79,20 +79,29 @@ gotoFn = (path: string, opt?: Record) => window.history.pushState(null, '', path), unsavedConfirmationModal, onSavedNewAppPath, + onNavigate, initialRevs }: AppEditorProps = $props() migrateApp(untrack(() => app)) + // Inside a session pane the AIChatManager is injected via context. Sessions + // have their own state machinery (sessionRuntime + per-fork backend), and + // the user-facing $workspaceStore stays on the main workspace even when + // the session is editing in a fork — so a UserDraft handle here would + // share its LS key with the regular /apps/edit route and clobber both + // sides' autosaves. Skip UserDraft entirely in that case. + const inSessionPane = !!getContext('aiChatManager') + const appDraftPath = newApp ? '' : (path ?? '') - const appDraftHandle = UserDraft.use('app', appDraftPath) + const appDraftHandle = inSessionPane ? undefined : UserDraft.use('app', appDraftPath) // Prefer the persisted autosave over the prop when both exist (e.g. // /apps/add reload: the route always initializes `app` to an empty // template, but the user's last session is sitting in LS under the // empty-path entry). The route is responsible for wiping the entry // (`UserDraft.remove`) when it wants to force a fresh start — // `?nodraft=true`, template/hub loads, etc. - const stateApp = $state(untrack(() => appDraftHandle.draft ?? app)) + const stateApp = $state(untrack(() => appDraftHandle?.draft ?? app)) const appStore = writable(stateApp) // Captured once on mount: the load-time revs are only used as the // seed meta on the very first persist of this entry. After that the @@ -112,6 +121,7 @@ let firstMirror = true $effect(() => { readFieldsRecursively(stateApp) + if (!appDraftHandle) return untrack(() => { // Resolve the meta to attach BEFORE the wipe — the wipe clears // in-memory meta and would otherwise force-seed `initialRevs` @@ -165,7 +175,7 @@ groups: $userStore?.groups, username: $userStore?.username, name: $userStore?.name, - query: urlParamsToObject(new URL(window.location.href).searchParams), + query: urlParamsToObject(new URL(window.location.href).searchParams, { stripReserved: true }), hash: window.location.hash.substring(1), workspace: $workspaceStore, mode: 'editor', @@ -884,6 +894,7 @@ rightPanelHidden={rightPanelSize === 0} bottomPanelHidden={runnablePanelSize === 0} {onSavedNewAppPath} + {onNavigate} onShowLeftPanel={() => showLeftPanel()} onShowRightPanel={() => showRightPanel()} onShowBottomPanel={() => showBottomPanel()} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index 7a55f28860..5bbd0d17c7 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -64,7 +64,7 @@ import DebugPanel from './contextPanel/DebugPanel.svelte' import EditorHeader from '$lib/components/EditorHeader.svelte' - import { editPathFor, invalidate as invalidatePicker } from '$lib/components/workspacePicker' + import { editPathFor } from '$lib/components/workspacePicker' import { invalidateWorkspacePaths } from '$lib/components/PathNameAutocomplete.svelte' import { goto } from '$app/navigation' import HideButton from './settingsPanel/HideButton.svelte' @@ -110,6 +110,7 @@ onHideRightPanel?: () => void onHideLeftPanel?: () => void onHideBottomPanel?: () => void + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void } let { @@ -130,7 +131,8 @@ onShowBottomPanel, onHideLeftPanel, onHideRightPanel, - onHideBottomPanel + onHideBottomPanel, + onNavigate = undefined }: Props = $props() /** Mirror of the path the user is editing in the pen popover. Initialized @@ -170,6 +172,14 @@ const { history, jobsDrawerOpen, refreshComponents } = getContext('AppEditorContext') + // Sessions inject an AIChatManager via context; AppEditor skips its + // UserDraft handle in that case, so the cleanup calls here must skip too + // (otherwise we'd wipe a non-session tab's autosave at the same path). The + // session-side equivalent is the View's `onDeploy` → + // `runtime.syncPreviewWithDeployed`, which discards the fork draft + reloads + // the preview to the deployed version. + const inSessionPane = !!getContext('aiChatManager') + const loading = $state({ publish: false, save: false, @@ -229,7 +239,7 @@ } closeSaveDrawer() sendUserToast('App deployed successfully') - UserDraft.remove('app', path) + if (!inSessionPane) UserDraft.remove('app', path) onSavedNewAppPath?.(path) } catch (e) { sendUserToast('Error creating app', e) @@ -313,7 +323,6 @@ preserve_on_behalf_of: preserveOnBehalfOf || undefined } }) - invalidatePicker($workspaceStore!, 'app') invalidateWorkspacePaths($workspaceStore!) savedApp = { summary: $summary, @@ -330,7 +339,7 @@ closeSaveDrawer() sendUserToast('App deployed successfully') - UserDraft.remove('app', $appPath) + if (!inSessionPane) UserDraft.remove('app', $appPath) if ($appPath !== npath) { onSavedNewAppPath?.(npath) } @@ -406,7 +415,7 @@ // The initial draft was promoted to a real path on the backend — // drop the autosave keyed on the prior (possibly empty) path so // a future "+ App" click opens on a clean slate. - UserDraft.remove('app', $appPath) + if (!inSessionPane) UserDraft.remove('app', $appPath) onSavedNewAppPath?.(newEditedPath) } catch (e) { sendUserToast('Error saving initial draft', e) @@ -497,7 +506,7 @@ } sendUserToast('Draft saved') - UserDraft.remove('app', path) + if (!inSessionPane) UserDraft.remove('app', path) loading.saveDraft = false if (newApp || savedApp.draft_only) { onSavedNewAppPath?.(newEditedPath || path) @@ -1006,7 +1015,7 @@ bind:path={newEditedPath} savedPath={$appPath || newPath || undefined} kind="app" - onNavigate={(item) => goto(editPathFor(item))} + onNavigate={(item) => (onNavigate ? onNavigate(item) : goto(editPathFor(item)))} />
{#if $app} diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte index 649e67ef0d..2294554f3e 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeaderDeploy.svelte @@ -35,7 +35,8 @@ newEditedPath = $bindable(), newPath, hideSecretUrl = false, - preserveOnBehalfOf = $bindable(false) + preserveOnBehalfOf = $bindable(false), + rawApp = false }: { policy: any setPublishState: () => void @@ -51,6 +52,11 @@ newPath: string hideSecretUrl?: boolean preserveOnBehalfOf?: boolean + // Raw apps need cross-origin isolation (wm_coep) to be embeddable. Classic + // (low-code) apps must NOT get the flag — it would force COEP on the + // document and break no-CORP cross-origin subresources (external images, + // {@html} embeds, CDN imports). + rawApp?: boolean } = $props() let isDeployer = $derived($userStore?.groups?.includes(WM_DEPLOYERS_GROUP) ?? false) @@ -91,6 +97,17 @@ isCloudHosted() || globalWorkspacedRoute ? $workspaceStore + '/' : '' }${customPath}` ) + + // When embedding a raw app in an iframe inside another Windmill app (or any + // cross-origin-isolated page), the embedded document must set COEP. The + // `wm_coep` flag opts the public app into the cross-origin isolation headers. + // Only raw apps get it — for classic (low-code) apps COEP would break + // no-CORP cross-origin subresources, so their snippet stays a plain iframe. + let embedMode = $state(false) + function toEmbedSnippet(url: string): string { + const finalUrl = rawApp ? `${url}${url.includes('?') ? '&' : '?'}wm_coep=on` : url + return `` + } async function getSecretUrl() { secretUrl = await AppService.getPublicSecretOfApp({ workspace: $workspaceStore!, @@ -122,7 +139,7 @@ }) $effect(() => { - appPath && appPath != '' && secretUrl == undefined && untrack(() => getSecretUrl()) + appPath && appPath != '' && savedApp && secretUrl == undefined && untrack(() => getSecretUrl()) }) @@ -247,18 +264,40 @@ policy.execution_mode = e.detail ? 'anonymous' : 'publisher' setPublishState() }} - disabled={appPath == ''} + disabled={!savedApp} />
- {#if appPath == ''} + {#if !savedApp} {:else if secretUrlHref} - +
+ (embedMode = e.detail)} + options={{ left: 'URL', right: 'Embed' }} + /> +
+ {:else} {/if}
- Share this url directly or embed it using an iframe (if requiring login, top-level domain of - embedding app must be the same as the one of Windmill) + {#if embedMode} + Paste this iframe snippet into another app. + {#if rawApp} + The wm_coep flag Sets the cross-origin isolation headers (COEP) so the app can be embedded inside + another Windmill app or any cross-origin-isolated page. Without it the browser blocks + the iframe. lets it load inside a cross-origin-isolated page. + {/if} + (if requiring login, top-level domain of embedding app must be the same as the one of Windmill) + {:else} + Share this url directly, or switch to Embed to get an iframe snippet. + {/if}
@@ -305,7 +344,10 @@
Custom public URL
- +
{dirtyCustomPath ? customPathError : ''} diff --git a/frontend/src/lib/components/apps/editor/PublicApp.svelte b/frontend/src/lib/components/apps/editor/PublicApp.svelte index 48c3d5578d..fef6ba8fa3 100644 --- a/frontend/src/lib/components/apps/editor/PublicApp.svelte +++ b/frontend/src/lib/components/apps/editor/PublicApp.svelte @@ -145,7 +145,7 @@ name: $userStore?.name, groups: $userStore?.groups, username: $userStore?.username, - query: urlParamsToObject(page.url.searchParams), + query: urlParamsToObject(page.url.searchParams, { stripReserved: true }), hash: page.url.hash.substring(1) }} workspace={effectiveWorkspace} diff --git a/frontend/src/lib/components/apps/types.ts b/frontend/src/lib/components/apps/types.ts index 2fe6536aa8..64b08d621e 100644 --- a/frontend/src/lib/components/apps/types.ts +++ b/frontend/src/lib/components/apps/types.ts @@ -164,6 +164,8 @@ export interface AppEditorProps { gotoFn?: (path: string, opt?: Record | undefined) => void unsavedConfirmationModal?: import('svelte').Snippet<[any]> onSavedNewAppPath?: (path: string) => void + /** Override breadcrumb-picker navigation. Defaults to goto(editPathFor(item)). */ + onNavigate?: (item: import('$lib/components/workspacePicker').WorkspaceItem) => void /** * Backend revs at the load that produced `app`. Used as the seed * `UserDraft` meta on the first local autosave: until the handle has diff --git a/frontend/src/lib/components/common/EditableInput.svelte b/frontend/src/lib/components/common/EditableInput.svelte index e772247c0a..e85625c58f 100644 --- a/frontend/src/lib/components/common/EditableInput.svelte +++ b/frontend/src/lib/components/common/EditableInput.svelte @@ -78,6 +78,15 @@ this component just proposes new values. }) } + // External trigger (e.g. from a Melt dropdown menu item). Melt's focus trap + // stays active for a brief window after the menu closes — focusing our + // input during that window causes checkFocusIn to slam focus back out, which + // fires onblur=save and instantly closes the edit. A 50ms defer is enough + // for Melt's trap to release. + export function edit() { + setTimeout(startEditing, 50) + } + function save() { // Re-entry guard: Enter calls `save()` and sets `editing = false`, // which unmounts the `` and synchronously fires its `blur` diff --git a/frontend/src/lib/components/common/button/model.ts b/frontend/src/lib/components/common/button/model.ts index b950bd1bff..8126ef8667 100644 --- a/frontend/src/lib/components/common/button/model.ts +++ b/frontend/src/lib/components/common/button/model.ts @@ -17,7 +17,7 @@ export namespace ButtonType { * @deprecated Use `UnifiedSize` instead */ export type Size = 'xs3' | 'xs2' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' - export type UnifiedSize = 'xs' | 'sm' | 'md' | 'lg' + export type UnifiedSize = '2xs' | 'xs' | 'sm' | 'md' | 'lg' export type ExtendedSize = 'xs2' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' /** * @deprecated Use `Variant` instead @@ -221,6 +221,7 @@ export namespace ButtonType { // New unified sizing system export const UnifiedSizingClasses: Record = { + '2xs': 'px-1', // Compact horizontal padding xs: 'px-2', sm: 'px-2', // Regular horizontal padding md: 'px-4', @@ -228,6 +229,7 @@ export namespace ButtonType { } export const UnifiedIconOnlySizingClasses: Record = { + '2xs': 'px-1', xs: 'px-1', sm: 'px-2', // Square padding for icon-only (same as width padding) md: 'px-2', @@ -235,6 +237,7 @@ export namespace ButtonType { } export const UnifiedMinHeightClasses: Record = { + '2xs': 'min-h-5', xs: 'min-h-5', sm: 'min-h-7', md: 'min-h-8', @@ -242,6 +245,7 @@ export namespace ButtonType { } export const UnifiedHeightClasses: Record = { + '2xs': 'h-5', xs: 'h-5', sm: 'h-7', md: 'h-8', @@ -249,6 +253,7 @@ export namespace ButtonType { } export const UnifiedIconSizes: Record = { + '2xs': 12, xs: 12, sm: 13, md: 14, @@ -256,6 +261,7 @@ export namespace ButtonType { } export const UnifiedFontSizes: Record = { + '2xs': 'font-normal', xs: 'font-normal', sm: 'font-normal', md: 'font-medium', diff --git a/frontend/src/lib/components/common/drawer/DrawerContent.svelte b/frontend/src/lib/components/common/drawer/DrawerContent.svelte index ad89c2c725..6b76873c49 100644 --- a/frontend/src/lib/components/common/drawer/DrawerContent.svelte +++ b/frontend/src/lib/components/common/drawer/DrawerContent.svelte @@ -22,6 +22,8 @@ id?: string | undefined actions?: import('svelte').Snippet titleExtra?: import('svelte').Snippet + /** Rendered fixed below the header, above the scrollable content. */ + banner?: import('svelte').Snippet children?: import('svelte').Snippet } @@ -40,6 +42,7 @@ id, actions, titleExtra, + banner, children }: Props = $props() @@ -83,6 +86,10 @@ {/if}
+ {#if banner} + {@render banner()} + {/if} +
+ export type TabItem = { + /** Stable identifier; used as the `[key]` for dnd and the activeId equality check. */ + id: string + label: string + /** Optional lucide-svelte (or compatible) component rendered at 12px before the label. */ + icon?: any + /** Optional class applied to the icon (e.g. `text-accent` to tint it). */ + iconClass?: string + /** Optional class applied to the label text (e.g. `text-accent` to tint it). */ + labelClass?: string + /** Defaults to true. Set false to hide the × close button. */ + closable?: boolean + /** Pinned tabs are rendered outside the drag zone — 'left' or 'right' of the draggable group. */ + pinned?: 'left' | 'right' + } + + // Per-instance dnd zone `type` so sibling bars (mirrored single-view) don't + // share svelte-dnd-action's item pool — otherwise a drag in one ghosts the + // matching tab in the other. + let dndZoneSeq = 0 + + + + +{#snippet tabButton(tab: TabItem)} + {@const isActive = tab.id === activeId} + {@const Icon = tab.icon} + + +{/snippet} + +
+
+
+ +
+
+ {#each pinnedLeft as tab (tab.id)} + {@render tabButton(tab)} + {/each} + +
+ {#each dndMiddle as tab (tab.id)} +
+ {@render tabButton(tab)} +
+ {/each} +
+ + {#each pinnedRight as tab (tab.id)} + {@render tabButton(tab)} + {/each} + + + +
+
+
+ +
+
+
+
+ + {#if trailing} +
+ {@render trailing()} +
+ {/if} +
+ + diff --git a/frontend/src/lib/components/copilot/CronGen.svelte b/frontend/src/lib/components/copilot/CronGen.svelte index 02a45aa767..6a8dc6892e 100644 --- a/frontend/src/lib/components/copilot/CronGen.svelte +++ b/frontend/src/lib/components/copilot/CronGen.svelte @@ -1,7 +1,7 @@
- -

+

{userQuestion.question}

@@ -71,15 +154,40 @@ {/each} + +
+ (activeIndex = customAnswerIndex) + }} + /> +
diff --git a/frontend/src/lib/components/copilot/chat/ChatMode.svelte b/frontend/src/lib/components/copilot/chat/ChatMode.svelte index 8bb0ab5c0d..89b714b80f 100644 --- a/frontend/src/lib/components/copilot/chat/ChatMode.svelte +++ b/frontend/src/lib/components/copilot/chat/ChatMode.svelte @@ -1,54 +1,40 @@ -
- aiChatManager.allowedModes[k] - ).length < 2} - class="max-w-full" +{#if hasMultiple} + + allowedModeList.map((mode) => ({ + displayName: modeLabel(mode), + selected: aiChatManager.mode === mode, + action: () => aiChatManager.changeMode(mode) + }))} + placement="bottom-start" + fixedHeight={false} + customWidth={170} > - {#snippet trigger()} - -
- - {aiChatManager.mode.charAt(0).toUpperCase() + aiChatManager.mode.slice(1)} mode - - {#if Object.keys(aiChatManager.allowedModes).filter((k) => aiChatManager.allowedModes[k]).length > 1} -
- -
- {/if} -
- - {/snippet} - {#snippet content({ close })} - -
- {#each Object.values(AIMode) as possibleMode} - {#if aiChatManager.allowedModes[possibleMode]} - - {/if} - {/each} -
- - {/snippet} -
-
+ {#snippet buttonReplacement()} + + {/snippet} + +{:else} + +{/if} diff --git a/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte b/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte index 4df548192a..65abb0e1c5 100644 --- a/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte +++ b/frontend/src/lib/components/copilot/chat/DatatableCreationPolicy.svelte @@ -1,7 +1,9 @@ -
- - {#snippet trigger()} -
- {providerModel.model} - {#if multipleModels} -
- -
- {/if} -
+{#if multipleModels} + + $copilotInfo.aiModels.map((m) => ({ + displayName: m.model, + selected: m.model === providerModel.model, + action: () => { + $copilotSessionModel = m + storeLocalSetting(COPILOT_SESSION_MODEL_SETTING_NAME, m.model) + storeLocalSetting(COPILOT_SESSION_PROVIDER_SETTING_NAME, m.provider) + } + }))} + placement="bottom-end" + fixedHeight={false} + > + {#snippet buttonReplacement()} + {/snippet} - {#snippet content({ close })} -
- {#each $copilotInfo.aiModels as providerModel} - - {/each} -
- {/snippet} -
-
+ +{:else} + +{/if} diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index f6e5950ac6..b6d01c6b38 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -1,8 +1,10 @@ {#if activeUserQuestion} {:else} -
+