Compare commits
108 Commits
v1.606.0
...
experiment
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
741526b7b8 | ||
|
|
73deef44ed | ||
|
|
7249b82dba | ||
|
|
bb21486394 | ||
|
|
0797e89aa0 | ||
|
|
a384b4c23d | ||
|
|
7385726741 | ||
|
|
3b8a99e174 | ||
|
|
ac1a4b4495 | ||
|
|
161c114067 | ||
|
|
389499e576 | ||
|
|
af14b09415 | ||
|
|
22b222031e | ||
|
|
7848d361a5 | ||
|
|
ddfbe026c0 | ||
|
|
8cf456d74c | ||
|
|
1e4fe01293 | ||
|
|
27b3ce7e77 | ||
|
|
287b7e7d97 | ||
|
|
9e617a3979 | ||
|
|
8c41045e04 | ||
|
|
3f3df4163f | ||
|
|
2854922fa8 | ||
|
|
203f6785c4 | ||
|
|
2a64c208a1 | ||
|
|
3777c05a27 | ||
|
|
41d45f9c86 | ||
|
|
9a2ec7b11d | ||
|
|
0bb211fbda | ||
|
|
1526d3ae2b | ||
|
|
09adc58a67 | ||
|
|
8e4a6cbc18 | ||
|
|
51f8913901 | ||
|
|
5c1c682dca | ||
|
|
6f35279126 | ||
|
|
d884ddb7eb | ||
|
|
3cd14a3adf | ||
|
|
687175c6a8 | ||
|
|
baf060df74 | ||
|
|
d4ff12df67 | ||
|
|
0e91a86458 | ||
|
|
4ddde07fb7 | ||
|
|
95df7b9a6a | ||
|
|
32059499d5 | ||
|
|
73e86d9fc8 | ||
|
|
ceb798838c | ||
|
|
bb22fcb3a4 | ||
|
|
c143e78d7f | ||
|
|
00fc86d099 | ||
|
|
a08c52ec8f | ||
|
|
96dabee225 | ||
|
|
6ee56d2ca9 | ||
|
|
f55dac6958 | ||
|
|
f33b79936b | ||
|
|
fa3440001c | ||
|
|
8daeccc89f | ||
|
|
1438b26310 | ||
|
|
75dab4886c | ||
|
|
9359ad820d | ||
|
|
30da9e69f8 | ||
|
|
c1ec159471 | ||
|
|
3ec94395dc | ||
|
|
c04eb371cc | ||
|
|
1c8c7949fe | ||
|
|
4f8110eb98 | ||
|
|
83cf1d3d90 | ||
|
|
1b9d1c56c7 | ||
|
|
4d8721c163 | ||
|
|
ff77154638 | ||
|
|
2eac74cef4 | ||
|
|
437025a078 | ||
|
|
03daa341eb | ||
|
|
73c4ce3012 | ||
|
|
b3cb41efa4 | ||
|
|
8dd5e81a32 | ||
|
|
857d031b2b | ||
|
|
c7a6a05925 | ||
|
|
86ebf9e25a | ||
|
|
437bad4cb7 | ||
|
|
dcee9fe7b1 | ||
|
|
cf44c7a7ee | ||
|
|
1819713450 | ||
|
|
7c898579f5 | ||
|
|
b6ef536098 | ||
|
|
d8d6c39131 | ||
|
|
548d1af654 | ||
|
|
21ebaa4196 | ||
|
|
cd88a03791 | ||
|
|
a0bab87e54 | ||
|
|
d5fa3d8dec | ||
|
|
483b7d699f | ||
|
|
74dccc73d3 | ||
|
|
62c1fd4ee7 | ||
|
|
303b673a75 | ||
|
|
86714f2d03 | ||
|
|
2a4a288160 | ||
|
|
54db9eeed3 | ||
|
|
1d5d28ae7a | ||
|
|
62eeec63ac | ||
|
|
2296b2cc8a | ||
|
|
15842ad638 | ||
|
|
0c78aebe6a | ||
|
|
d4f1d7fbb5 | ||
|
|
40d00734f3 | ||
|
|
a7335d6914 | ||
|
|
e74dc02804 | ||
|
|
da54a67822 | ||
|
|
739b347c1b |
2
.github/DockerfileBackendTests
vendored
2
.github/DockerfileBackendTests
vendored
@@ -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.4.18/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.24/uv-installer.sh | sh && mv /usr/local/cargo/bin/uv /usr/local/bin/uv
|
||||
|
||||
ENV TZ=Etc/UTC
|
||||
|
||||
|
||||
2
.github/workflows/backend-test.yml
vendored
2
.github/workflows/backend-test.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
||||
bun-version: 1.1.43
|
||||
- uses: astral-sh/setup-uv@v6.2.1
|
||||
with:
|
||||
version: "0.6.2"
|
||||
version: "0.9.24"
|
||||
- uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
cache-workspaces: backend
|
||||
|
||||
33
.github/workflows/docker-image.yml
vendored
33
.github/workflows/docker-image.yml
vendored
@@ -167,39 +167,6 @@ jobs:
|
||||
${{ steps.meta-ee-public.outputs.labels }}
|
||||
org.opencontainers.image.licenses=Windmill-Enterprise-License
|
||||
|
||||
# disabled until we make it 100% reliable and add more meaningful tests
|
||||
# playwright:
|
||||
# runs-on: [self-hosted, new]
|
||||
# needs: [build]
|
||||
# services:
|
||||
# postgres:
|
||||
# image: postgres
|
||||
# env:
|
||||
# POSTGRES_DB: windmill
|
||||
# POSTGRES_USER: admin
|
||||
# POSTGRES_PASSWORD: changeme
|
||||
# ports:
|
||||
# - 5432:5432
|
||||
# options: >-
|
||||
# --health-cmd pg_isready
|
||||
# --health-interval 10s
|
||||
# --health-timeout 5s
|
||||
# --health-retries 5
|
||||
# steps:
|
||||
# - uses: actions/checkout@v4
|
||||
# - name: "Docker"
|
||||
# run: echo "::set-output name=id::$(docker run --network=host --rm -d -p 8000:8000 --privileged -it -e DATABASE_URL=postgres://admin:changeme@localhost:5432/windmill -e BASE_INTERNAL_URL=http://localhost:8000 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest)"
|
||||
# id: docker-container
|
||||
# - uses: actions/setup-node@v3
|
||||
# with:
|
||||
# node-version: 16
|
||||
# - name: "Playwright run"
|
||||
# timeout-minutes: 2
|
||||
# run: cd frontend && npm ci @playwright/test && npx playwright install && export BASE_URL=http://localhost:8000 && npm run test
|
||||
# - name: "Clean up"
|
||||
# run: docker kill ${{ steps.docker-container.outputs.id }}
|
||||
# if: always()
|
||||
|
||||
attach_amd64_binary_to_release:
|
||||
needs: [build, build_ee]
|
||||
runs-on: ubicloud
|
||||
|
||||
177
CHANGELOG.md
177
CHANGELOG.md
@@ -1,5 +1,182 @@
|
||||
# Changelog
|
||||
|
||||
## [1.613.4](https://github.com/windmill-labs/windmill/compare/v1.613.3...v1.613.4) (2026-01-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update git sync CLI to 1.613.2 ([7848d36](https://github.com/windmill-labs/windmill/commit/7848d361a546ef8a2ef9fa20a0399f2e8473f685))
|
||||
|
||||
## [1.613.3](https://github.com/windmill-labs/windmill/compare/v1.613.2...v1.613.3) (2026-01-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** normalize paths in wmill-lock for cross-platform compatibility ([#7645](https://github.com/windmill-labs/windmill/issues/7645)) ([8cf456d](https://github.com/windmill-labs/windmill/commit/8cf456d74c79921806edbcd0c9fde462f7202188))
|
||||
* update git sync CLI to 1.613.2 ([1e4fe01](https://github.com/windmill-labs/windmill/commit/1e4fe01293e65ac130b368fbef45a1571ee2b6d7))
|
||||
|
||||
## [1.613.2](https://github.com/windmill-labs/windmill/compare/v1.613.1...v1.613.2) (2026-01-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* azure read s3 proxy ([#7641](https://github.com/windmill-labs/windmill/issues/7641)) ([9e617a3](https://github.com/windmill-labs/windmill/commit/9e617a3979622a58ae022f9a74e2dde87a43c60e))
|
||||
* **cli:** skip branch-specific files when type is not configured ([#7643](https://github.com/windmill-labs/windmill/issues/7643)) ([287b7e7](https://github.com/windmill-labs/windmill/commit/287b7e7d971469db979e6951ee14764f6b91ed67))
|
||||
|
||||
## [1.613.1](https://github.com/windmill-labs/windmill/compare/v1.613.0...v1.613.1) (2026-01-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix microsoft SSO setting ([3f3df41](https://github.com/windmill-labs/windmill/commit/3f3df4163f9b6d99bc2c0f0284134b8b78f1ef6d))
|
||||
* isolate SvelteKit-specific imports for library usage ([203f678](https://github.com/windmill-labs/windmill/commit/203f6785c4ba9f7ace643259bf4e4a8f164288f3))
|
||||
|
||||
## [1.613.0](https://github.com/windmill-labs/windmill/compare/v1.612.2...v1.613.0) (2026-01-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **api:** add include_args query parameter to job list endpoints ([96dabee](https://github.com/windmill-labs/windmill/commit/96dabee22591adff5d6221e8628f7a1571b8d5a8))
|
||||
* **cli:** add workspace list command to show remote workspaces ([a08c52e](https://github.com/windmill-labs/windmill/commit/a08c52ec8f5323c645457b1dd7b32ef703fd86c4))
|
||||
* DuckDB support write to Azure ([#7618](https://github.com/windmill-labs/windmill/issues/7618)) ([73e86d9](https://github.com/windmill-labs/windmill/commit/73e86d9fc867aedb7221cf3da9df8ab573734d0f))
|
||||
* **mcp:** handle server oauth ([#7585](https://github.com/windmill-labs/windmill/issues/7585)) ([09adc58](https://github.com/windmill-labs/windmill/commit/09adc58a678da2d59d20e27a6b528b79843f122f))
|
||||
* otel REST tracing ([#7571](https://github.com/windmill-labs/windmill/issues/7571)) ([95df7b9](https://github.com/windmill-labs/windmill/commit/95df7b9a6a8ffcbca92b3249a61d97c32c9dbc4f))
|
||||
* **raw-apps:** add ctx input type for secure backend-resolved user context ([#7621](https://github.com/windmill-labs/windmill/issues/7621)) ([c143e78](https://github.com/windmill-labs/windmill/commit/c143e78d7fdd866b2e30c036ef48e907dda6ad6c))
|
||||
* **raw-apps:** add public URL and custom path support for raw apps ([#7630](https://github.com/windmill-labs/windmill/issues/7630)) ([baf060d](https://github.com/windmill-labs/windmill/commit/baf060df7474620b144e4a438274866bdfc41881))
|
||||
* **raw-apps:** enable hash-based routing with URL sync for shareable URLs ([#7624](https://github.com/windmill-labs/windmill/issues/7624)) ([3205949](https://github.com/windmill-labs/windmill/commit/32059499d5fa9aed1f8149f427732d1f0500dce5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **cli:** recognize branch-specific folder files in getTypeStrFromPath ([6f35279](https://github.com/windmill-labs/windmill/commit/6f35279126b875d09af23d4618e86f87be064679))
|
||||
* **cli:** recognize branch-specific settings and encryption_key files ([5c1c682](https://github.com/windmill-labs/windmill/commit/5c1c682dcaa1a4ce80ee4de78b80c9ace395092a))
|
||||
* **frontend:** improve raw app history ([#7625](https://github.com/windmill-labs/windmill/issues/7625)) ([687175c](https://github.com/windmill-labs/windmill/commit/687175c6a85f47c707bb429008c93ec0981c50e2))
|
||||
* **frontend:** set editor font size to the same default as text ([#7631](https://github.com/windmill-labs/windmill/issues/7631)) ([d884ddb](https://github.com/windmill-labs/windmill/commit/d884ddb7eb611f17a8e0ed41998953fb47b6cc21))
|
||||
* S3 advanced custom permissions ([#7632](https://github.com/windmill-labs/windmill/issues/7632)) ([1526d3a](https://github.com/windmill-labs/windmill/commit/1526d3ae2b3139bbfa23aef012bbe7f9b2132732))
|
||||
|
||||
## [1.612.2](https://github.com/windmill-labs/windmill/compare/v1.612.1...v1.612.2) (2026-01-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add HIDE_WORKERS_FOR_NON_ADMINS env var and workspace-scoped custom_tags endpoint ([#7613](https://github.com/windmill-labs/windmill/issues/7613)) ([f33b799](https://github.com/windmill-labs/windmill/commit/f33b79936b8666242f2235d4fa6d4e7488123194))
|
||||
* **mcp:** fix empty args format + sanitize tool name ([#7615](https://github.com/windmill-labs/windmill/issues/7615)) ([f55dac6](https://github.com/windmill-labs/windmill/commit/f55dac69582000f0bfdae6dbed2d33f2e48087b2))
|
||||
|
||||
## [1.612.1](https://github.com/windmill-labs/windmill/compare/v1.612.0...v1.612.1) (2026-01-19)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix runs page initialization ([1438b26](https://github.com/windmill-labs/windmill/commit/1438b263102ccf22612f98e59596c7e51083df71))
|
||||
* update git sync CLI to 1.612.0 ([8daeccc](https://github.com/windmill-labs/windmill/commit/8daeccc89fc0405143a2b553520f9a42272e3c26))
|
||||
|
||||
## [1.612.0](https://github.com/windmill-labs/windmill/compare/v1.611.0...v1.612.0) (2026-01-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **cli:** add branch-specific items for folders and settings ([#7611](https://github.com/windmill-labs/windmill/issues/7611)) ([3ec9439](https://github.com/windmill-labs/windmill/commit/3ec94395dcc6a179a4d5dde3a5b88aeb1053ada3))
|
||||
* move job metrics from ee to ce ([#7608](https://github.com/windmill-labs/windmill/issues/7608)) ([c04eb37](https://github.com/windmill-labs/windmill/commit/c04eb371ccd805e8d0d0a03b4ef654c7a8131ccd))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** fix centered page shift when scroll ([#7610](https://github.com/windmill-labs/windmill/issues/7610)) ([c1ec159](https://github.com/windmill-labs/windmill/commit/c1ec159471d3fabb9cb7b9023d662726a9cf1f93))
|
||||
* **frontend:** improve ai settings page ([#7606](https://github.com/windmill-labs/windmill/issues/7606)) ([9359ad8](https://github.com/windmill-labs/windmill/commit/9359ad820ded8a4a94195ee76bbe6210f6e8eb9f))
|
||||
* **frontend:** improve loading centered modal ui ([#7605](https://github.com/windmill-labs/windmill/issues/7605)) ([30da9e6](https://github.com/windmill-labs/windmill/commit/30da9e69f88ba4621bb3ee35287f914389930bb6))
|
||||
|
||||
## [1.611.0](https://github.com/windmill-labs/windmill/compare/v1.610.1...v1.611.0) (2026-01-19)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add HashiCorp Vault secret storage integration ([#7599](https://github.com/windmill-labs/windmill/issues/7599)) ([1b9d1c5](https://github.com/windmill-labs/windmill/commit/1b9d1c56c7e49042677326eb397e10d34a3ddcdf))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **flow-chat:** handle SSE timeout and fix temp message race condition ([4f8110e](https://github.com/windmill-labs/windmill/commit/4f8110eb9852b78b48aabbed114c75cbf0d1a2ef))
|
||||
|
||||
## [1.610.1](https://github.com/windmill-labs/windmill/compare/v1.610.0...v1.610.1) (2026-01-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* resolve BlobPart type incompatibility between Deno and Node.js ([2eac74c](https://github.com/windmill-labs/windmill/commit/2eac74cef4aa5a987fb16110388f99e912951db8))
|
||||
* use type cast instead of slice() for BlobPart compatibility ([ff77154](https://github.com/windmill-labs/windmill/commit/ff771546380ef26dfa443f9d459853048bc8029c))
|
||||
|
||||
## [1.610.0](https://github.com/windmill-labs/windmill/compare/v1.609.0...v1.610.0) (2026-01-17)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add private npm registry proxy support for ATA in webide ([#7597](https://github.com/windmill-labs/windmill/issues/7597)) ([b3cb41e](https://github.com/windmill-labs/windmill/commit/b3cb41efa4520fd3243a6dbab7c985923dd538e3))
|
||||
* add workspace success handler with 60s TTL caching ([#7598](https://github.com/windmill-labs/windmill/issues/7598)) ([73c4ce3](https://github.com/windmill-labs/windmill/commit/73c4ce30127af587510b16fbc7fa223436f6845c))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* handle missing storage key in S3Object for write_s3_file ([03daa34](https://github.com/windmill-labs/windmill/commit/03daa341eb91118c27e92b1b51731400a0dce30c))
|
||||
* improve job deletion performance and batching ([8dd5e81](https://github.com/windmill-labs/windmill/commit/8dd5e81a32b97814eab3b72964807bdfe0ea8b49))
|
||||
|
||||
## [1.609.0](https://github.com/windmill-labs/windmill/compare/v1.608.0...v1.609.0) (2026-01-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* cli branch override ([#7592](https://github.com/windmill-labs/windmill/issues/7592)) ([dcee9fe](https://github.com/windmill-labs/windmill/commit/dcee9fe7b163993836691988a552a5bc6042b9a2))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Fix MS SQL S3 Mode ([#7595](https://github.com/windmill-labs/windmill/issues/7595)) ([c7a6a05](https://github.com/windmill-labs/windmill/commit/c7a6a05925681bb1b2cec8d2c11037bc3d339798))
|
||||
* transparency issue of instance setting save button ([#7594](https://github.com/windmill-labs/windmill/issues/7594)) ([86ebf9e](https://github.com/windmill-labs/windmill/commit/86ebf9e25a03db99453269832bce030438c677c3))
|
||||
|
||||
## [1.608.0](https://github.com/windmill-labs/windmill/compare/v1.607.1...v1.608.0) (2026-01-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add streamJob to raw apps ([1819713](https://github.com/windmill-labs/windmill/commit/1819713450acacc7f4342593869b12ffa3519fe1))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* S3 secondary storage client and UI fixes ([#7587](https://github.com/windmill-labs/windmill/issues/7587)) ([b6ef536](https://github.com/windmill-labs/windmill/commit/b6ef536098775c24dd1aa40f3a186d5b04ea53a2))
|
||||
|
||||
## [1.607.1](https://github.com/windmill-labs/windmill/compare/v1.607.0...v1.607.1) (2026-01-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* fix wmill app dev with workspace scripts ([d5fa3d8](https://github.com/windmill-labs/windmill/commit/d5fa3d8dec78148becdc826ab83defe39a06af7e))
|
||||
* improve raw app builder malformed files ([483b7d6](https://github.com/windmill-labs/windmill/commit/483b7d699f01f2bf91c23f9e37534f648a0a4e7e))
|
||||
|
||||
## [1.607.0](https://github.com/windmill-labs/windmill/compare/v1.606.1...v1.607.0) (2026-01-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow resume urls at flow level for pre-generation ([#7582](https://github.com/windmill-labs/windmill/issues/7582)) ([86714f2](https://github.com/windmill-labs/windmill/commit/86714f2d03302a876e07d5ea3390be9fd2513387))
|
||||
* **flow:** add diff viewer in deployment history ([#7575](https://github.com/windmill-labs/windmill/issues/7575)) ([62c1fd4](https://github.com/windmill-labs/windmill/commit/62c1fd4ee749cc1677f1a050c2aa61773a727fef))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **frontend:** detect [windmill] log marker anywhere in content, not just at start ([#7583](https://github.com/windmill-labs/windmill/issues/7583)) ([303b673](https://github.com/windmill-labs/windmill/commit/303b673a7556d38c5aab84795e93105c90f5247b))
|
||||
* **frontend:** remove workspace invites ([#7579](https://github.com/windmill-labs/windmill/issues/7579)) ([1d5d28a](https://github.com/windmill-labs/windmill/commit/1d5d28ae7a19c03a2c5d3b2bfbc99323c2afd170))
|
||||
* remove audit logs page overflow scrollbars ([#7572](https://github.com/windmill-labs/windmill/issues/7572)) ([0c78aeb](https://github.com/windmill-labs/windmill/commit/0c78aebe6ac2bf44cf931ad833961b3911fce908))
|
||||
|
||||
## [1.606.1](https://github.com/windmill-labs/windmill/compare/v1.606.0...v1.606.1) (2026-01-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* bump uv 0.6.2 -> 0.9.24 ([#7559](https://github.com/windmill-labs/windmill/issues/7559)) ([e74dc02](https://github.com/windmill-labs/windmill/commit/e74dc02804d0bd720963d571f58fd3aa97eb2396))
|
||||
* Fix number ordering in postgres' db manager ([#7570](https://github.com/windmill-labs/windmill/issues/7570)) ([a7335d6](https://github.com/windmill-labs/windmill/commit/a7335d6914ce0e331f2309368d7c947986159e77))
|
||||
* **frontend:** improve context for ai chat in raw app builder ([#7566](https://github.com/windmill-labs/windmill/issues/7566)) ([da54a67](https://github.com/windmill-labs/windmill/commit/da54a678221b7851625eb4ba52504099eb69b100))
|
||||
* improve debugger behavior ([40d0073](https://github.com/windmill-labs/windmill/commit/40d00734f33b3aa7cef31f6abc29c40e975f48f8))
|
||||
|
||||
## [1.606.0](https://github.com/windmill-labs/windmill/compare/v1.605.0...v1.606.0) (2026-01-14)
|
||||
|
||||
|
||||
|
||||
@@ -190,7 +190,7 @@ 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.6.2/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.24/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
|
||||
|
||||
23
backend/.sqlx/query-002d68d7c4437522a6dae95af007a356217bbae06b8453f0c32046f0cbf20dcb.json
generated
Normal file
23
backend/.sqlx/query-002d68d7c4437522a6dae95af007a356217bbae06b8453f0c32046f0cbf20dcb.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "created_by",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "002d68d7c4437522a6dae95af007a356217bbae06b8453f0c32046f0cbf20dcb"
|
||||
}
|
||||
23
backend/.sqlx/query-020c031c3de6c85577e30421ada9d39a5a47ca1b6cf3dbfd6988aa0694d7364c.json
generated
Normal file
23
backend/.sqlx/query-020c031c3de6c85577e30421ada9d39a5a47ca1b6cf3dbfd6988aa0694d7364c.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value FROM variable WHERE path = $1 AND workspace_id = $2 AND is_secret = true",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "020c031c3de6c85577e30421ada9d39a5a47ca1b6cf3dbfd6988aa0694d7364c"
|
||||
}
|
||||
20
backend/.sqlx/query-052d42b46d5faba6b41f1fdcbf6a012db51b9e5a255ec0da9a8a0999d668d336.json
generated
Normal file
20
backend/.sqlx/query-052d42b46d5faba6b41f1fdcbf6a012db51b9e5a255ec0da9a8a0999d668d336.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) as count FROM variable WHERE is_secret = true AND value != 'CLEARED'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "052d42b46d5faba6b41f1fdcbf6a012db51b9e5a255ec0da9a8a0999d668d336"
|
||||
}
|
||||
26
backend/.sqlx/query-0600f2a9179f83502c6b13e8e4284f85ca82636f274f5dce47da5a8320a60088.json
generated
Normal file
26
backend/.sqlx/query-0600f2a9179f83502c6b13e8e4284f85ca82636f274f5dce47da5a8320a60088.json
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_id, path FROM variable WHERE is_secret = true",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0600f2a9179f83502c6b13e8e4284f85ca82636f274f5dce47da5a8320a60088"
|
||||
}
|
||||
20
backend/.sqlx/query-085bbcfcebc9e56984295046c48722f3510548eaf525c499ad0e24cdf8332b22.json
generated
Normal file
20
backend/.sqlx/query-085bbcfcebc9e56984295046c48722f3510548eaf525c499ad0e24cdf8332b22.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT success_handler_extra_args FROM workspace_settings WHERE workspace_id = 'test-workspace'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "success_handler_extra_args",
|
||||
"type_info": "Json"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "085bbcfcebc9e56984295046c48722f3510548eaf525c499ad0e24cdf8332b22"
|
||||
}
|
||||
@@ -177,6 +177,16 @@
|
||||
"ordinal": 34,
|
||||
"name": "teams_team_guid",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 35,
|
||||
"name": "success_handler",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 36,
|
||||
"name": "success_handler_extra_args",
|
||||
"type_info": "Json"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -219,6 +229,8 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\nWITH lockable_counters AS (\n SELECT concurrency_id, job_uuids\n FROM concurrency_counter\n WHERE job_uuids != '{}'::jsonb\n FOR UPDATE SKIP LOCKED\n),\nall_job_uuids AS (\n SELECT DISTINCT jsonb_object_keys(job_uuids) AS job_uuid\n FROM lockable_counters\n),\norphaned_job_uuids AS (\n SELECT job_uuid\n FROM all_job_uuids\n WHERE job_uuid NOT IN (\n SELECT id::text \n FROM v2_job_queue \n FOR SHARE SKIP LOCKED\n )\n),\norphaned_array AS (\n SELECT ARRAY(SELECT job_uuid FROM orphaned_job_uuids) AS orphaned_keys\n),\nbefore_update AS (\n SELECT lc.concurrency_id, lc.job_uuids, oa.orphaned_keys\n FROM lockable_counters lc, orphaned_array oa\n WHERE lc.job_uuids ?| oa.orphaned_keys\n),\naffected_rows AS (\n UPDATE concurrency_counter \n SET job_uuids = job_uuids - orphaned_array.orphaned_keys\n FROM orphaned_array\n WHERE concurrency_counter.concurrency_id IN (\n SELECT concurrency_id FROM before_update\n )\n RETURNING concurrency_id, job_uuids AS updated_job_uuids\n),\nexpanded_orphaned AS (\n SELECT bu.concurrency_id, \n bu.job_uuids AS original_job_uuids,\n unnest(bu.orphaned_keys) AS orphaned_key\n FROM before_update bu\n)\nSELECT \n eo.concurrency_id,\n eo.orphaned_key,\n eo.original_job_uuids,\n ar.updated_job_uuids\nFROM expanded_orphaned eo\nJOIN affected_rows ar ON eo.concurrency_id = ar.concurrency_id\nWHERE eo.original_job_uuids ? eo.orphaned_key\nORDER BY eo.concurrency_id, eo.orphaned_key\n",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "concurrency_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "orphaned_key",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "original_job_uuids",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "updated_job_uuids",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0a1c10bd2232b0770a7816e1bd8d758dc393f797890d597e5996146247f512ac"
|
||||
}
|
||||
23
backend/.sqlx/query-0c8a3eb810c96230ba3a5466c55bf24a94eb8a52ceb82cc29dade173ad87569d.json
generated
Normal file
23
backend/.sqlx/query-0c8a3eb810c96230ba3a5466c55bf24a94eb8a52ceb82cc29dade173ad87569d.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path FROM variable WHERE path = ANY($1) AND workspace_id = $2 AND is_secret = true",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "0c8a3eb810c96230ba3a5466c55bf24a94eb8a52ceb82cc29dade173ad87569d"
|
||||
}
|
||||
20
backend/.sqlx/query-10a40902f5aba38aea23f2b0776b54752362c9cc7deb179f75c7f1795866d13a.json
generated
Normal file
20
backend/.sqlx/query-10a40902f5aba38aea23f2b0776b54752362c9cc7deb179f75c7f1795866d13a.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT success_handler FROM workspace_settings WHERE workspace_id = 'test-workspace'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "success_handler",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "10a40902f5aba38aea23f2b0776b54752362c9cc7deb179f75c7f1795866d13a"
|
||||
}
|
||||
29
backend/.sqlx/query-146f0e42ada3068a5cdae0ffdbb54b63f8c06c9143b16ce399170c1b5a6b911e.json
generated
Normal file
29
backend/.sqlx/query-146f0e42ada3068a5cdae0ffdbb54b63f8c06c9143b16ce399170c1b5a6b911e.json
generated
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value, is_secret FROM variable WHERE path = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "is_secret",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "146f0e42ada3068a5cdae0ffdbb54b63f8c06c9143b16ce399170c1b5a6b911e"
|
||||
}
|
||||
@@ -177,6 +177,16 @@
|
||||
"ordinal": 34,
|
||||
"name": "teams_team_guid",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 35,
|
||||
"name": "success_handler",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 36,
|
||||
"name": "success_handler_extra_args",
|
||||
"type_info": "Json"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -219,6 +229,8 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
|
||||
23
backend/.sqlx/query-18aad20ed9cb2dde46f9d899dc4aa6f80ecf1628bd2c073d7a237dea9b8e0c65.json
generated
Normal file
23
backend/.sqlx/query-18aad20ed9cb2dde46f9d899dc4aa6f80ecf1628bd2c073d7a237dea9b8e0c65.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT is_secret FROM variable WHERE path = $1 AND workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "is_secret",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "18aad20ed9cb2dde46f9d899dc4aa6f80ecf1628bd2c073d7a237dea9b8e0c65"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT app_id, value FROM app_version WHERE id = $1",
|
||||
"query": "SELECT app_id, value, raw_app FROM app_version WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -12,6 +12,11 @@
|
||||
"ordinal": 1,
|
||||
"name": "value",
|
||||
"type_info": "Json"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "raw_app",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -20,9 +25,10 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ea9bbb972217bab4d7e8f4c08e331899161e80c239d56eb657d73bbf4272939b"
|
||||
"hash": "19cca1d42f37e860dc54470fea8dd9a35c412d82d27ce369ccb0ba38b9791669"
|
||||
}
|
||||
21
backend/.sqlx/query-1b4f7485c015338536d781838448c96ce686fce217be21ec15a8900b772f02a3.json
generated
Normal file
21
backend/.sqlx/query-1b4f7485c015338536d781838448c96ce686fce217be21ec15a8900b772f02a3.json
generated
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO mcp_oauth_server_code\n (code, client_id, user_email, workspace_id, scopes, redirect_uri, code_challenge, code_challenge_method)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"TextArray",
|
||||
"Text",
|
||||
"Varchar",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1b4f7485c015338536d781838448c96ce686fce217be21ec15a8900b772f02a3"
|
||||
}
|
||||
14
backend/.sqlx/query-1ccc9db6c6cb51bebeb90d003e12c8f7922450515d1eaee8b3abee5b31f72527.json
generated
Normal file
14
backend/.sqlx/query-1ccc9db6c6cb51bebeb90d003e12c8f7922450515d1eaee8b3abee5b31f72527.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM concurrency_key WHERE ended_at <= now() - ($1::bigint::text || ' s')::interval",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1ccc9db6c6cb51bebeb90d003e12c8f7922450515d1eaee8b3abee5b31f72527"
|
||||
}
|
||||
23
backend/.sqlx/query-1d8c2f54118b352dc13058dbb9b6e3f6ca4961b68d7e409386e655a61c54e0d0.json
generated
Normal file
23
backend/.sqlx/query-1d8c2f54118b352dc13058dbb9b6e3f6ca4961b68d7e409386e655a61c54e0d0.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM usr WHERE workspace_id = $1 AND email = $2 AND NOT disabled)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "1d8c2f54118b352dc13058dbb9b6e3f6ca4961b68d7e409386e655a61c54e0d0"
|
||||
}
|
||||
34
backend/.sqlx/query-2922c242228b2188b8abcda02b37d6fd220659dcd9e16d4bb110202321bc06cf.json
generated
Normal file
34
backend/.sqlx/query-2922c242228b2188b8abcda02b37d6fd220659dcd9e16d4bb110202321bc06cf.json
generated
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT client_id, client_name, redirect_uris FROM mcp_oauth_server_client WHERE client_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "client_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "client_name",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "redirect_uris",
|
||||
"type_info": "TextArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "2922c242228b2188b8abcda02b37d6fd220659dcd9e16d4bb110202321bc06cf"
|
||||
}
|
||||
21
backend/.sqlx/query-2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8.json
generated
Normal file
21
backend/.sqlx/query-2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8.json
generated
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO mcp_oauth_refresh_token\n (refresh_token, access_token, client_id, user_email, workspace_id, scopes, token_family, expires_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, now() + ($8 || ' seconds')::interval)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"TextArray",
|
||||
"Uuid",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2c231a2cd267d8d6d28a22d166a50cc6b4df813a15c613eb1960eff202c517f8"
|
||||
}
|
||||
12
backend/.sqlx/query-2c2ed5c91a4893966e94305df8b9f8ac904c8d0781a83fdea66c918320fb5601.json
generated
Normal file
12
backend/.sqlx/query-2c2ed5c91a4893966e94305df8b9f8ac904c8d0781a83fdea66c918320fb5601.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE workspace_settings\n SET success_handler = NULL\n WHERE workspace_id = 'test-workspace'\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2c2ed5c91a4893966e94305df8b9f8ac904c8d0781a83fdea66c918320fb5601"
|
||||
}
|
||||
14
backend/.sqlx/query-2d43c1889b492df6f77380e739e41e20fe6bea0553cc39e30b7aa8ae59963f95.json
generated
Normal file
14
backend/.sqlx/query-2d43c1889b492df6f77380e739e41e20fe6bea0553cc39e30b7aa8ae59963f95.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', 1234567890, 'f/test/success_handler', $1, 'deno', 'script', 'test-user', '{}', 'Success handler script', 'Handles successful job completions', '')\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "2d43c1889b492df6f77380e739e41e20fe6bea0553cc39e30b7aa8ae59963f95"
|
||||
}
|
||||
29
backend/.sqlx/query-2fcddda99dd0aacf5007ed459cb27caa754424e062427edf5ddcb95f9d96888e.json
generated
Normal file
29
backend/.sqlx/query-2fcddda99dd0aacf5007ed459cb27caa754424e062427edf5ddcb95f9d96888e.json
generated
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT path, value FROM variable\n WHERE path LIKE ('u/' || $1 || '/%')\n AND workspace_id = $2\n AND is_secret = true\n AND value LIKE '$vault:%'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "value",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "2fcddda99dd0aacf5007ed459cb27caa754424e062427edf5ddcb95f9d96888e"
|
||||
}
|
||||
23
backend/.sqlx/query-306e0156ee1541710c1c6512ecb4f61baeb3ae6f31ba3fd57a3ec485108a7f49.json
generated
Normal file
23
backend/.sqlx/query-306e0156ee1541710c1c6512ecb4f61baeb3ae6f31ba3fd57a3ec485108a7f49.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM v2_job_completed\n WHERE id IN (\n SELECT id FROM v2_job_completed\n WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval\n ORDER BY completed_at ASC\n LIMIT $2\n FOR UPDATE SKIP LOCKED\n )\n RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "306e0156ee1541710c1c6512ecb4f61baeb3ae6f31ba3fd57a3ec485108a7f49"
|
||||
}
|
||||
64
backend/.sqlx/query-369f8ecde50af034f06d339ecef8fc55a0113b4156274d03d5af643c3da73fa4.json
generated
Normal file
64
backend/.sqlx/query-369f8ecde50af034f06d339ecef8fc55a0113b4156274d03d5af643c3da73fa4.json
generated
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM mcp_oauth_server_code\n WHERE code = $1 AND expires_at > now()\n RETURNING code, client_id, user_email, workspace_id, scopes, redirect_uri,\n code_challenge, code_challenge_method",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "code",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "client_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "user_email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "scopes",
|
||||
"type_info": "TextArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "redirect_uri",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "code_challenge",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "code_challenge_method",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "369f8ecde50af034f06d339ecef8fc55a0113b4156274d03d5af643c3da73fa4"
|
||||
}
|
||||
16
backend/.sqlx/query-37e0c601a463819748078b92d1c87f63348a92908e2ca52f5a092149191e6200.json
generated
Normal file
16
backend/.sqlx/query-37e0c601a463819748078b92d1c87f63348a92908e2ca52f5a092149191e6200.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE variable SET value = $1 WHERE path = $2 AND workspace_id = $3 AND is_secret = true",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "37e0c601a463819748078b92d1c87f63348a92908e2ca52f5a092149191e6200"
|
||||
}
|
||||
12
backend/.sqlx/query-3a71ab02239d8dd4e2f440a35155459a7d5091c125a0998e79d89f8c560a3fd3.json
generated
Normal file
12
backend/.sqlx/query-3a71ab02239d8dd4e2f440a35155459a7d5091c125a0998e79d89f8c560a3fd3.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO group_ (workspace_id, name, summary, extra_perms)\n VALUES ('test-workspace', 'success_handler', 'The group the success handler acts on behalf of', '{\"u/test-user\": true}')\n ON CONFLICT DO NOTHING\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3a71ab02239d8dd4e2f440a35155459a7d5091c125a0998e79d89f8c560a3fd3"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\nWITH rows_to_delete AS (\n SELECT concurrency_id\n FROM concurrency_counter\n \n WHERE job_uuids = '{}'::jsonb\n FOR UPDATE SKIP LOCKED\n)\nDELETE FROM concurrency_counter\nWHERE concurrency_id IN (SELECT concurrency_id FROM rows_to_delete) RETURNING concurrency_id",
|
||||
"query": "\nWITH rows_to_delete AS (\n SELECT concurrency_id\n FROM concurrency_counter\n\n WHERE job_uuids = '{}'::jsonb\n FOR UPDATE SKIP LOCKED\n)\nDELETE FROM concurrency_counter\nWHERE concurrency_id IN (SELECT concurrency_id FROM rows_to_delete) RETURNING concurrency_id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -16,5 +16,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "c6ef0acdf20bd71dd26de981fb49f178ba8a1b8c1e01e0fec1dfd6a54ea7a894"
|
||||
"hash": "45d0552a169b63241a0ecc0e7ec6a533d7eec520376b99b2fd8000f0f0acba7b"
|
||||
}
|
||||
14
backend/.sqlx/query-4977b4027861ca234e95559a8b550bc8849f7ed60a471d46f3be3d39664a48ca.json
generated
Normal file
14
backend/.sqlx/query-4977b4027861ca234e95559a8b550bc8849f7ed60a471d46f3be3d39664a48ca.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE\n workspace_settings\n SET\n success_handler = NULL,\n success_handler_extra_args = NULL\n WHERE\n workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "4977b4027861ca234e95559a8b550bc8849f7ed60a471d46f3be3d39664a48ca"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n j.id AS \"id!\", j.workspace_id AS \"workspace_id!\", j.parent_job, j.flow_step_id IS NOT NULL AS \"is_flow_step?\",\n COALESCE(s.flow_status, s.workflow_as_code_status) AS \"flow_status: Box<str>\", r.ping AS last_ping, j.same_worker AS \"same_worker?\"\n FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)\n WHERE q.running = true AND q.suspend = 0 AND q.suspend_until IS null AND q.scheduled_for <= now()\n AND (j.kind = 'flow' OR j.kind = 'flowpreview' OR j.kind = 'flownode')\n AND r.ping IS NOT NULL AND r.ping < NOW() - ($1 || ' seconds')::interval\n AND q.canceled_by IS NULL\n \n ",
|
||||
"query": "\n SELECT\n j.id AS \"id!\", j.workspace_id AS \"workspace_id!\", j.parent_job, j.flow_step_id IS NOT NULL AS \"is_flow_step?\",\n COALESCE(s.flow_status, s.workflow_as_code_status) AS \"flow_status: Box<str>\", r.ping AS last_ping, j.same_worker AS \"same_worker?\"\n FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)\n WHERE q.running = true AND q.suspend = 0 AND q.suspend_until IS null AND q.scheduled_for <= now()\n AND (j.kind = 'flow' OR j.kind = 'flowpreview' OR j.kind = 'flownode')\n AND r.ping IS NOT NULL AND r.ping < NOW() - ($1 || ' seconds')::interval\n AND q.canceled_by IS NULL\n\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -54,5 +54,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "70a6880960d17218bc5bf05287e2a6d9a6393c6bb1783ab8903d87dd099e236b"
|
||||
"hash": "4d80985dd0794a01a2af18ae7abf4a3ab8ba3d162ed5d04735caea7295da0b20"
|
||||
}
|
||||
20
backend/.sqlx/query-5769af6cfc749881b3f21d42d2c79b4c3e6788ba521ef5736f46d6ec8447ad8f.json
generated
Normal file
20
backend/.sqlx/query-5769af6cfc749881b3f21d42d2c79b4c3e6788ba521ef5736f46d6ec8447ad8f.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM mcp_oauth_server_code WHERE expires_at <= now() RETURNING code",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "code",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5769af6cfc749881b3f21d42d2c79b4c3e6788ba521ef5736f46d6ec8447ad8f"
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM v2_job_completed c\n WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval \n RETURNING c.id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5820d34be1a7f7b72e656c692f53146f45ad4a6e584e917a0a86280d8f473c10"
|
||||
}
|
||||
38
backend/.sqlx/query-598805056a3b102c9c2fd69d8330fa78b06106236522e48ab5fe009cac059468.json
generated
Normal file
38
backend/.sqlx/query-598805056a3b102c9c2fd69d8330fa78b06106236522e48ab5fe009cac059468.json
generated
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\nWITH lockable_counters AS (\n SELECT concurrency_id, job_uuids\n FROM concurrency_counter\n WHERE job_uuids != '{}'::jsonb\n FOR UPDATE SKIP LOCKED\n),\nall_job_uuids AS (\n SELECT DISTINCT jsonb_object_keys(job_uuids) AS job_uuid\n FROM lockable_counters\n),\norphaned_job_uuids AS (\n SELECT job_uuid\n FROM all_job_uuids\n WHERE job_uuid NOT IN (\n SELECT id::text\n FROM v2_job_queue\n FOR SHARE SKIP LOCKED\n )\n),\norphaned_array AS (\n SELECT ARRAY(SELECT job_uuid FROM orphaned_job_uuids) AS orphaned_keys\n),\nbefore_update AS (\n SELECT lc.concurrency_id, lc.job_uuids, oa.orphaned_keys\n FROM lockable_counters lc, orphaned_array oa\n WHERE lc.job_uuids ?| oa.orphaned_keys\n),\naffected_rows AS (\n UPDATE concurrency_counter\n SET job_uuids = job_uuids - orphaned_array.orphaned_keys\n FROM orphaned_array\n WHERE concurrency_counter.concurrency_id IN (\n SELECT concurrency_id FROM before_update\n )\n RETURNING concurrency_id, job_uuids AS updated_job_uuids\n),\nexpanded_orphaned AS (\n SELECT bu.concurrency_id,\n bu.job_uuids AS original_job_uuids,\n unnest(bu.orphaned_keys) AS orphaned_key\n FROM before_update bu\n)\nSELECT\n eo.concurrency_id,\n eo.orphaned_key,\n eo.original_job_uuids,\n ar.updated_job_uuids\nFROM expanded_orphaned eo\nJOIN affected_rows ar ON eo.concurrency_id = ar.concurrency_id\nWHERE eo.original_job_uuids ? eo.orphaned_key\nORDER BY eo.concurrency_id, eo.orphaned_key\n",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "concurrency_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "orphaned_key",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "original_job_uuids",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "updated_job_uuids",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "598805056a3b102c9c2fd69d8330fa78b06106236522e48ab5fe009cac059468"
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
|
||||
|
||||
29
backend/.sqlx/query-5b5cb5339208847bd542f8d903d028803a286e69443a151cd1e3d16da7e8e4f7.json
generated
Normal file
29
backend/.sqlx/query-5b5cb5339208847bd542f8d903d028803a286e69443a151cd1e3d16da7e8e4f7.json
generated
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO otel_traces (\n trace_id, span_id, trace_state, parent_span_id, flags,\n name, kind, start_time_unix_nano, end_time_unix_nano,\n attributes, dropped_attributes_count,\n events, dropped_events_count,\n links, dropped_links_count,\n status\n ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea",
|
||||
"Bytea",
|
||||
"Text",
|
||||
"Bytea",
|
||||
"Int4",
|
||||
"Text",
|
||||
"Int4",
|
||||
"Int8",
|
||||
"Int8",
|
||||
"Jsonb",
|
||||
"Int4",
|
||||
"Jsonb",
|
||||
"Int4",
|
||||
"Jsonb",
|
||||
"Int4",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5b5cb5339208847bd542f8d903d028803a286e69443a151cd1e3d16da7e8e4f7"
|
||||
}
|
||||
89
backend/.sqlx/query-5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd.json
generated
Normal file
89
backend/.sqlx/query-5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd.json
generated
Normal file
@@ -0,0 +1,89 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE mcp_oauth_refresh_token\n SET used_at = now()\n WHERE refresh_token = $1\n AND client_id = $2\n AND used_at IS NULL\n AND NOT revoked\n AND expires_at > now()\n RETURNING id, refresh_token, access_token, client_id, user_email, workspace_id,\n scopes, token_family, created_at, expires_at, used_at, revoked",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "refresh_token",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "access_token",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "client_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "user_email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "scopes",
|
||||
"type_info": "TextArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "token_family",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "created_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 9,
|
||||
"name": "expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "used_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "revoked",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "5c9ed4d8d16c77c0c6b42e9ee211168573162745060788fbca188ed405c423cd"
|
||||
}
|
||||
14
backend/.sqlx/query-5ffb1c49d8d001253a71c6b9bd90e58416d59a9a855afd1ec0a814937583461f.json
generated
Normal file
14
backend/.sqlx/query-5ffb1c49d8d001253a71c6b9bd90e58416d59a9a855afd1ec0a814937583461f.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE mcp_oauth_refresh_token SET revoked = TRUE WHERE token_family = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "5ffb1c49d8d001253a71c6b9bd90e58416d59a9a855afd1ec0a814937583461f"
|
||||
}
|
||||
16
backend/.sqlx/query-6376f88654dbbd85a68c507480fc4918958244abd7a1f81f32a0e60f7e5f9464.json
generated
Normal file
16
backend/.sqlx/query-6376f88654dbbd85a68c507480fc4918958244abd7a1f81f32a0e60f7e5f9464.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO mcp_oauth_server_client (client_id, client_name, redirect_uris)\n VALUES ($1, $2, $3)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6376f88654dbbd85a68c507480fc4918958244abd7a1f81f32a0e60f7e5f9464"
|
||||
}
|
||||
14
backend/.sqlx/query-66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a.json
generated
Normal file
14
backend/.sqlx/query-66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM token WHERE token = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "66e0968fe9f757755945a7010153821cf73ace9d6692750ccc4cca37701ed77a"
|
||||
}
|
||||
46
backend/.sqlx/query-66e66da2ed6eace5d7ec2a41a7b11ae255f5dc212d1ff41c2905b303c8c13b18.json
generated
Normal file
46
backend/.sqlx/query-66e66da2ed6eace5d7ec2a41a7b11ae255f5dc212d1ff41c2905b303c8c13b18.json
generated
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH job_info AS (\n SELECT id, kind::text AS kind, parent_job\n FROM v2_job\n WHERE id = $1\n )\n SELECT\n q.id AS \"id!\",\n s.flow_status,\n q.suspend AS \"suspend!\",\n j.runnable_path AS script_path,\n (ji.kind IN ('flow', 'flowpreview')) AS \"is_flow_level!\"\n FROM job_info ji\n JOIN v2_job_queue q ON q.id = CASE\n WHEN ji.kind IN ('flow', 'flowpreview') THEN ji.id\n ELSE ji.parent_job\n END\n JOIN v2_job j ON j.id = q.id\n JOIN v2_job_status s ON s.id = q.id\n FOR UPDATE OF q\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id!",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flow_status",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "suspend!",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "script_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "is_flow_level!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "66e66da2ed6eace5d7ec2a41a7b11ae255f5dc212d1ff41c2905b303c8c13b18"
|
||||
}
|
||||
32
backend/.sqlx/query-6dd7e38c902f6b8d397aa3e9e698fe1541bd3f22634af341849303de84c0034d.json
generated
Normal file
32
backend/.sqlx/query-6dd7e38c902f6b8d397aa3e9e698fe1541bd3f22634af341849303de84c0034d.json
generated
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_id, path, value FROM variable WHERE is_secret = true",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "value",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "6dd7e38c902f6b8d397aa3e9e698fe1541bd3f22634af341849303de84c0034d"
|
||||
}
|
||||
12
backend/.sqlx/query-6eaa0af9c5e0a8d64c3202753c9c3cef39361b0987a22b7d2b2346652f81177e.json
generated
Normal file
12
backend/.sqlx/query-6eaa0af9c5e0a8d64c3202753c9c3cef39361b0987a22b7d2b2346652f81177e.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE workspace_settings\n SET success_handler = 'script/f/test/success_handler',\n success_handler_extra_args = '{\"key\": \"value\"}'::json\n WHERE workspace_id = 'test-workspace'\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6eaa0af9c5e0a8d64c3202753c9c3cef39361b0987a22b7d2b2346652f81177e"
|
||||
}
|
||||
38
backend/.sqlx/query-71de038f332593e2177f33284854b6c0bfe9bb4999f2c4e9082a4c5f3b341496.json
generated
Normal file
38
backend/.sqlx/query-71de038f332593e2177f33284854b6c0bfe9bb4999f2c4e9082a4c5f3b341496.json
generated
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id,\n runnable_path,\n permissioned_as_email,\n parent_job\n FROM v2_job\n WHERE workspace_id = 'test-workspace'\n AND permissioned_as_email = 'success_handler@windmill.dev'\n ORDER BY created_at DESC\n LIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "runnable_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "permissioned_as_email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "parent_job",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "71de038f332593e2177f33284854b6c0bfe9bb4999f2c4e9082a4c5f3b341496"
|
||||
}
|
||||
19
backend/.sqlx/query-7e4aa6b19b110bca423b3a3f428826d92b9808c64ef989fef2142bc8e02d6630.json
generated
Normal file
19
backend/.sqlx/query-7e4aa6b19b110bca423b3a3f428826d92b9808c64ef989fef2142bc8e02d6630.json
generated
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO token (token, email, label, expiration, scopes, workspace_id)\n VALUES ($1, $2, $3, now() + ($4 || ' seconds')::interval, $5, $6)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "7e4aa6b19b110bca423b3a3f428826d92b9808c64ef989fef2142bc8e02d6630"
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT q.id, f.flow_status, q.suspend, j.runnable_path AS script_path\n FROM v2_job_queue q\n JOIN v2_job j USING (id)\n JOIN v2_job_status f USING (id)\n WHERE id = ( SELECT parent_job FROM v2_job WHERE id = $1 )\n FOR UPDATE\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flow_status",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "suspend",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "script_path",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "83785ee2f7dcc7f2252b0e8bcc8322dfd7689d615a34b63e29c9c6699b7e5514"
|
||||
}
|
||||
12
backend/.sqlx/query-8f5722afde37d22c56851da23895daa1653e14b6d12013f6e3bb8bf042447a6a.json
generated
Normal file
12
backend/.sqlx/query-8f5722afde37d22c56851da23895daa1653e14b6d12013f6e3bb8bf042447a6a.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE variable SET value = 'ROUND_TRIP_CLEARED' WHERE is_secret = true",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8f5722afde37d22c56851da23895daa1653e14b6d12013f6e3bb8bf042447a6a"
|
||||
}
|
||||
16
backend/.sqlx/query-9044c71dc8aab7f5d17bf27c4702e67f3eee46d7dbccb09cadeaafa0534952d6.json
generated
Normal file
16
backend/.sqlx/query-9044c71dc8aab7f5d17bf27c4702e67f3eee46d7dbccb09cadeaafa0534952d6.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE\n workspace_settings\n SET\n success_handler = $1,\n success_handler_extra_args = $2\n WHERE\n workspace_id = $3\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Json",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9044c71dc8aab7f5d17bf27c4702e67f3eee46d7dbccb09cadeaafa0534952d6"
|
||||
}
|
||||
22
backend/.sqlx/query-90d93fd3bd91e468c1e796e41e31e4f15a825b442346c7386836127bc5723c93.json
generated
Normal file
22
backend/.sqlx/query-90d93fd3bd91e468c1e796e41e31e4f15a825b442346c7386836127bc5723c93.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT json_build_object(\n 'trace_id', encode(trace_id, 'hex'), -- BYTEA to hex string\n 'span_id', encode(span_id, 'hex'), -- BYTEA to hex string\n 'parent_span_id', encode(parent_span_id, 'hex'), -- BYTEA to hex string\n 'trace_state', trace_state,\n 'flags', flags,\n 'name', name,\n 'kind', kind,\n 'start_time_unix_nano', start_time_unix_nano,\n 'end_time_unix_nano', end_time_unix_nano,\n 'attributes', attributes,\n 'dropped_attributes_count', dropped_attributes_count,\n 'events', events,\n 'dropped_events_count', dropped_events_count,\n 'links', links,\n 'dropped_links_count', dropped_links_count,\n 'status', status\n ) as \"span!\"\n FROM otel_traces\n WHERE trace_id = $1\n ORDER BY start_time_unix_nano",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "span!",
|
||||
"type_info": "Json"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bytea"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "90d93fd3bd91e468c1e796e41e31e4f15a825b442346c7386836127bc5723c93"
|
||||
}
|
||||
12
backend/.sqlx/query-93ef241a4f624cb76d680418d313cd956e4c807c3a0031913dbfded80f0e5881.json
generated
Normal file
12
backend/.sqlx/query-93ef241a4f624cb76d680418d313cd956e4c807c3a0031913dbfded80f0e5881.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE variable SET value = 'CLEARED' WHERE is_secret = true",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "93ef241a4f624cb76d680418d313cd956e4c807c3a0031913dbfded80f0e5881"
|
||||
}
|
||||
22
backend/.sqlx/query-99398d6d6aa04235226f1a5d0f100aea034d7ee2c86aa8fa5ccec0e3560965fd.json
generated
Normal file
22
backend/.sqlx/query-99398d6d6aa04235226f1a5d0f100aea034d7ee2c86aa8fa5ccec0e3560965fd.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT token_family FROM mcp_oauth_refresh_token\n WHERE refresh_token = $1 AND used_at IS NOT NULL",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "token_family",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "99398d6d6aa04235226f1a5d0f100aea034d7ee2c86aa8fa5ccec0e3560965fd"
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM concurrency_key WHERE ended_at <= now() - ($1::bigint::text || ' s')::interval ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "9da0cea2a5d0464ca78cfeccf6cedf2b1c0e6e6cb3c9183a937a68465debdb06"
|
||||
}
|
||||
12
backend/.sqlx/query-a2bc410e1066ddc89ca4557ca0e29339e41cbc7a7ef6ad97b3f0eb93de04c359.json
generated
Normal file
12
backend/.sqlx/query-a2bc410e1066ddc89ca4557ca0e29339e41cbc7a7ef6ad97b3f0eb93de04c359.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE workspace_settings\n SET success_handler = 'flow/f/test/success_flow'\n WHERE workspace_id = 'test-workspace'\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a2bc410e1066ddc89ca4557ca0e29339e41cbc7a7ef6ad97b3f0eb93de04c359"
|
||||
}
|
||||
28
backend/.sqlx/query-a87e7b098b523176916daf5b1685b218dfbfe752aa730b421e9229dadd6ae587.json
generated
Normal file
28
backend/.sqlx/query-a87e7b098b523176916daf5b1685b218dfbfe752aa730b421e9229dadd6ae587.json
generated
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT kind::text as \"kind!\", parent_job\n FROM v2_job\n WHERE id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "kind!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "parent_job",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a87e7b098b523176916daf5b1685b218dfbfe752aa730b421e9229dadd6ae587"
|
||||
}
|
||||
32
backend/.sqlx/query-ade0696ec69bec2258d2e3ff86bfba6ed3b1d573c298f9d7fe8056ba7e32ed81.json
generated
Normal file
32
backend/.sqlx/query-ade0696ec69bec2258d2e3ff86bfba6ed3b1d573c298f9d7fe8056ba7e32ed81.json
generated
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_id, path, value FROM variable WHERE is_secret = true AND value IS NOT NULL AND value != ''",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "value",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ade0696ec69bec2258d2e3ff86bfba6ed3b1d573c298f9d7fe8056ba7e32ed81"
|
||||
}
|
||||
20
backend/.sqlx/query-b030efec7c7f770bf2ed5331a469aae723925270d9d5124cc4cb2c03db61dea8.json
generated
Normal file
20
backend/.sqlx/query-b030efec7c7f770bf2ed5331a469aae723925270d9d5124cc4cb2c03db61dea8.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) as count FROM variable WHERE is_secret = true AND value = 'CLEARED'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "b030efec7c7f770bf2ed5331a469aae723925270d9d5124cc4cb2c03db61dea8"
|
||||
}
|
||||
32
backend/.sqlx/query-b107d602c60c08f0edcaa99695f8546bf30074bad903959f7df030e0ac70a86f.json
generated
Normal file
32
backend/.sqlx/query-b107d602c60c08f0edcaa99695f8546bf30074bad903959f7df030e0ac70a86f.json
generated
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_id, path, value FROM variable WHERE is_secret = true AND value != 'CLEARED' ORDER BY workspace_id, path",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "value",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b107d602c60c08f0edcaa99695f8546bf30074bad903959f7df030e0ac70a86f"
|
||||
}
|
||||
32
backend/.sqlx/query-b10d2ab53bd8d24b0bbcede8211de229d507784fbcdf46c309907df123e35018.json
generated
Normal file
32
backend/.sqlx/query-b10d2ab53bd8d24b0bbcede8211de229d507784fbcdf46c309907df123e35018.json
generated
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT workspace_id, path, value FROM variable WHERE is_secret = true ORDER BY workspace_id, path",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "value",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b10d2ab53bd8d24b0bbcede8211de229d507784fbcdf46c309907df123e35018"
|
||||
}
|
||||
16
backend/.sqlx/query-bb289fd24f443f0f8917ec55bc9fc3113e37dd8009425e558b5f5d1e1543b513.json
generated
Normal file
16
backend/.sqlx/query-bb289fd24f443f0f8917ec55bc9fc3113e37dd8009425e558b5f5d1e1543b513.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE variable SET value = $1 WHERE path = $2 AND workspace_id = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bb289fd24f443f0f8917ec55bc9fc3113e37dd8009425e558b5f5d1e1543b513"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n auto_invite_domain,\n auto_invite_operator,\n auto_add,\n customer_id,\n plan,\n webhook,\n deploy_to,\n ai_config,\n error_handler,\n error_handler_extra_args,\n error_handler_muted_on_cancel,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_add_instance_groups,\n auto_add_instance_groups_roles\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n auto_invite_domain,\n auto_invite_operator,\n auto_add,\n customer_id,\n plan,\n webhook,\n deploy_to,\n ai_config,\n error_handler,\n error_handler_extra_args,\n error_handler_muted_on_cancel,\n success_handler,\n success_handler_extra_args,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_add_instance_groups,\n auto_add_instance_groups_roles\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -115,66 +115,76 @@
|
||||
},
|
||||
{
|
||||
"ordinal": 22,
|
||||
"name": "success_handler",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"name": "success_handler_extra_args",
|
||||
"type_info": "Json"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"name": "large_file_storage",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 23,
|
||||
"ordinal": 25,
|
||||
"name": "datatable",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 24,
|
||||
"ordinal": 26,
|
||||
"name": "ducklake",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 25,
|
||||
"ordinal": 27,
|
||||
"name": "git_sync",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 26,
|
||||
"ordinal": 28,
|
||||
"name": "deploy_ui",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 27,
|
||||
"ordinal": 29,
|
||||
"name": "default_app",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 28,
|
||||
"ordinal": 30,
|
||||
"name": "default_scripts",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 29,
|
||||
"ordinal": 31,
|
||||
"name": "mute_critical_alerts",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 30,
|
||||
"ordinal": 32,
|
||||
"name": "color",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 31,
|
||||
"ordinal": 33,
|
||||
"name": "operator_settings",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 32,
|
||||
"ordinal": 34,
|
||||
"name": "git_app_installations",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 33,
|
||||
"ordinal": 35,
|
||||
"name": "auto_add_instance_groups",
|
||||
"type_info": "TextArray"
|
||||
},
|
||||
{
|
||||
"ordinal": 34,
|
||||
"ordinal": 36,
|
||||
"name": "auto_add_instance_groups_roles",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
@@ -217,10 +227,12 @@
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "3c53de373b9f1034b5f43002bf4715e12ddc641f4ca52efe0335719fa9461bb0"
|
||||
"hash": "d0dfd217a4f53aaee7b83d5b5267f74e5ad617830dd835a45e17fd378bcbeada"
|
||||
}
|
||||
15
backend/.sqlx/query-dbc05cbf6720cc85e01118bcdcbb478724ac0ea8f3e043da2de9d932216cc730.json
generated
Normal file
15
backend/.sqlx/query-dbc05cbf6720cc85e01118bcdcbb478724ac0ea8f3e043da2de9d932216cc730.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)\n VALUES ('test-workspace', $1, 'f/test/simple_script', $2, 'deno', 'script', 'test-user', '{}', 'Simple test script', 'A simple test script', '')\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "dbc05cbf6720cc85e01118bcdcbb478724ac0ea8f3e043da2de9d932216cc730"
|
||||
}
|
||||
12
backend/.sqlx/query-e3f2459d9ac3534531d5724bb2d67be6f33f8ca02ef4768bccd32a33a70a4300.json
generated
Normal file
12
backend/.sqlx/query-e3f2459d9ac3534531d5724bb2d67be6f33f8ca02ef4768bccd32a33a70a4300.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE workspace_settings\n SET success_handler = 'script/f/test/success_handler'\n WHERE workspace_id = 'test-workspace'\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e3f2459d9ac3534531d5724bb2d67be6f33f8ca02ef4768bccd32a33a70a4300"
|
||||
}
|
||||
23
backend/.sqlx/query-fb321fba5f4508d6cff5bae5a7c5a120d12b43a2f6056c3fff1c7c099b5f248a.json
generated
Normal file
23
backend/.sqlx/query-fb321fba5f4508d6cff5bae5a7c5a120d12b43a2f6056c3fff1c7c099b5f248a.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT app_version.raw_app FROM app\n JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)]\n WHERE app.path = $1 AND app.workspace_id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "raw_app",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fb321fba5f4508d6cff5bae5a7c5a120d12b43a2f6056c3fff1c7c099b5f248a"
|
||||
}
|
||||
1392
backend/Cargo.lock
generated
1392
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.606.0"
|
||||
version = "1.613.4"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
@@ -18,6 +18,7 @@ members = [
|
||||
"./windmill-indexer",
|
||||
"./windmill-macros",
|
||||
"./windmill-oauth",
|
||||
"./windmill-local",
|
||||
"./parsers/windmill-parser",
|
||||
"./parsers/windmill-parser-ts",
|
||||
"./parsers/windmill-parser-go",
|
||||
@@ -35,7 +36,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.606.0"
|
||||
version = "1.613.4"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -161,6 +162,7 @@ k8s-openapi.workspace = true
|
||||
libloading.workspace = true
|
||||
bitflags.workspace = true
|
||||
globset.workspace = true
|
||||
opentelemetry-proto.workspace = true
|
||||
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
@@ -219,6 +221,8 @@ memchr = "2.7.4"
|
||||
axum = { version = "^0.7", features = ["multipart", "macros"] }
|
||||
headers = "^0"
|
||||
hyper = { version = "^1", features = ["full"] }
|
||||
hyper-tls = "^0.6"
|
||||
hyper-util = { version = "^0.1", features = ["client-legacy", "http1", "tokio"] }
|
||||
tokio = { version = "=1.46.1", features = ["full", "tracing", "time"] }
|
||||
tokio-stream = { version = "0.1.17" }
|
||||
tower = "^0"
|
||||
@@ -233,6 +237,7 @@ thiserror = "^2"
|
||||
anyhow = "^1"
|
||||
chrono = { version = "^0.4", features = ["serde"] }
|
||||
chrono-tz = "^0.10.1"
|
||||
derive_more = { version = "1", features = ["deref", "deref_mut"], default-features = false }
|
||||
tracing = "^0"
|
||||
tracing-subscriber = { version = "^0", features = ["env-filter", "json"] }
|
||||
tracing-appender = "^0"
|
||||
@@ -333,6 +338,7 @@ futures-core = "^0"
|
||||
lazy_static = "1.4.0"
|
||||
serde_derive = "1.0.147"
|
||||
const_format = { version = "0.2.35", features = ["rust_1_64", "rust_1_51"] }
|
||||
const-str = "0.5"
|
||||
constant_time_eq = "0.3.1"
|
||||
dyn-iter = "0.2.0"
|
||||
rsa = "^0"
|
||||
@@ -405,6 +411,7 @@ async-once-cell = "0.5.4"
|
||||
aws-smithy-types-convert = { version = "^0", features = ["convert-chrono"] }
|
||||
crc = "^3"
|
||||
tar = "^0"
|
||||
flate2 = "^1"
|
||||
http = "^1"
|
||||
async-stream = "^0"
|
||||
|
||||
@@ -414,6 +421,7 @@ opentelemetry_sdk = { version = "0.27.1", features = ["rt-tokio"] }
|
||||
opentelemetry-otlp = { version = "0.27.0", features = ["grpc-tonic", "tls"] }
|
||||
opentelemetry-appender-tracing = "0.27.0"
|
||||
opentelemetry-semantic-conventions = { version = "0.27.0", features = ["semconv_experimental"] }
|
||||
opentelemetry-proto = { version = "0.29.0", features = ["with-serde", "gen-tonic"] }
|
||||
|
||||
bollard = "0.18.1"
|
||||
|
||||
@@ -455,3 +463,6 @@ oracle = { version = "0.6.3", features = ["chrono"] }
|
||||
rumqttc = { version = "0.24.0", features = ["use-native-tls"]}
|
||||
strum = { version = "0.27", features = ["derive"] }
|
||||
strum_macros = "^0"
|
||||
hudsucker = { version = "0.22", features = ["rcgen-ca", "native-tls-client"] }
|
||||
hyper-http-proxy = { version = "1", default-features = false, features = ["native-tls"] }
|
||||
rcgen = "0.13"
|
||||
|
||||
@@ -1 +1 @@
|
||||
6262e373ede45b5b8d45505a63ff73968c04336a
|
||||
0bfcfe263622f48c086331b628c1239d5cea0b37
|
||||
|
||||
@@ -13,21 +13,7 @@ from typing import Dict, List, Any, Optional
|
||||
|
||||
IMPORTS = """
|
||||
use std::borrow::Cow;
|
||||
use serde::{Deserialize, Serialize};
|
||||
"""
|
||||
|
||||
ENDPOINT_STRUCT = """
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct EndpointTool {
|
||||
pub name: Cow<'static, str>,
|
||||
pub description: Cow<'static, str>,
|
||||
pub instructions: Cow<'static, str>,
|
||||
pub path: Cow<'static, str>,
|
||||
pub method: Cow<'static, str>,
|
||||
pub path_params_schema: Option<serde_json::Value>,
|
||||
pub query_params_schema: Option<serde_json::Value>,
|
||||
pub body_schema: Option<serde_json::Value>,
|
||||
}
|
||||
use windmill_mcp::server::EndpointTool;
|
||||
"""
|
||||
|
||||
def load_openapi_spec(file_path: str) -> Dict[str, Any]:
|
||||
@@ -341,13 +327,11 @@ export const mcpEndpointTools: EndpointTool[] = [
|
||||
def generate_rust_code(tools: List[Dict[str, Any]], spec: Dict[str, Any], base_path: str = "") -> str:
|
||||
"""Generate the complete Rust code with MCP tools."""
|
||||
if not tools:
|
||||
return """// No MCP tools found in the OpenAPI specification
|
||||
|
||||
return f"""// No MCP tools found in the OpenAPI specification
|
||||
{IMPORTS}
|
||||
{ENDPOINT_STRUCT}
|
||||
pub fn all_tools() -> Vec<EndpointTool> {
|
||||
pub fn all_tools() -> Vec<EndpointTool> {{
|
||||
vec![]
|
||||
}
|
||||
}}
|
||||
"""
|
||||
|
||||
tool_definitions = []
|
||||
@@ -386,9 +370,7 @@ pub fn all_tools() -> Vec<EndpointTool> {
|
||||
|
||||
rust_code = f"""// Auto-generated MCP tools from OpenAPI specification
|
||||
// This file is generated by generate_mcp_tools.py - DO NOT EDIT MANUALLY
|
||||
|
||||
{IMPORTS}
|
||||
{ENDPOINT_STRUCT}
|
||||
pub fn all_tools() -> Vec<EndpointTool> {{
|
||||
vec![
|
||||
{tool_definitions_str}
|
||||
@@ -405,7 +387,7 @@ def main():
|
||||
project_dir = backend_dir.parent
|
||||
|
||||
openapi_file = backend_dir / "windmill-api" / "openapi.yaml"
|
||||
rust_output_file = backend_dir / "windmill-api" / "src" / "mcp" / "tools" / "auto_generated_endpoints.rs"
|
||||
rust_output_file = backend_dir / "windmill-api" / "src" / "mcp" / "auto_generated_endpoints.rs"
|
||||
ts_output_file = project_dir / "frontend" / "src" / "lib" / "mcpEndpointTools.ts"
|
||||
|
||||
if not openapi_file.exists():
|
||||
|
||||
3
backend/migrations/20260112000000_otel_traces.down.sql
Normal file
3
backend/migrations/20260112000000_otel_traces.down.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
DROP INDEX IF EXISTS otel_traces_time_idx;
|
||||
DROP INDEX IF EXISTS otel_traces_trace_time_idx;
|
||||
DROP TABLE IF EXISTS otel_traces;
|
||||
36
backend/migrations/20260112000000_otel_traces.up.sql
Normal file
36
backend/migrations/20260112000000_otel_traces.up.sql
Normal file
@@ -0,0 +1,36 @@
|
||||
-- OpenTelemetry Span storage (all fields from proto::Span).
|
||||
-- See: https://github.com/open-telemetry/opentelemetry-proto/blob/main/opentelemetry/proto/trace/v1/trace.proto
|
||||
|
||||
CREATE TABLE IF NOT EXISTS otel_traces (
|
||||
-- Identity fields (BYTEA for efficient storage and querying)
|
||||
trace_id BYTEA NOT NULL, -- 16 bytes (proto: bytes)
|
||||
span_id BYTEA NOT NULL, -- 8 bytes (proto: bytes)
|
||||
trace_state TEXT NOT NULL DEFAULT '', -- W3C trace-context (proto: string)
|
||||
parent_span_id BYTEA NOT NULL DEFAULT '', -- 8 bytes, empty if root span (proto: bytes)
|
||||
flags INTEGER NOT NULL DEFAULT 0, -- W3C trace flags (proto: fixed32)
|
||||
-- Core fields
|
||||
name TEXT NOT NULL, -- operation name (proto: string)
|
||||
kind INTEGER NOT NULL, -- SpanKind enum (proto: int32)
|
||||
start_time_unix_nano BIGINT NOT NULL, -- (proto: fixed64, postgres has no u64)
|
||||
end_time_unix_nano BIGINT NOT NULL, -- (proto: fixed64, postgres has no u64)
|
||||
-- Attributes
|
||||
attributes JSONB NOT NULL DEFAULT '[]', -- (proto: repeated KeyValue)
|
||||
dropped_attributes_count INTEGER NOT NULL DEFAULT 0, -- (proto: uint32)
|
||||
-- Events
|
||||
events JSONB NOT NULL DEFAULT '[]', -- (proto: repeated Event)
|
||||
dropped_events_count INTEGER NOT NULL DEFAULT 0, -- (proto: uint32)
|
||||
-- Links
|
||||
links JSONB NOT NULL DEFAULT '[]', -- (proto: repeated Link)
|
||||
dropped_links_count INTEGER NOT NULL DEFAULT 0, -- (proto: uint32)
|
||||
-- Status
|
||||
status JSONB, -- (proto: optional Status message)
|
||||
PRIMARY KEY (trace_id, span_id)
|
||||
);
|
||||
|
||||
-- Query spans by trace_id, ordered by time
|
||||
CREATE INDEX IF NOT EXISTS otel_traces_trace_time_idx ON otel_traces (trace_id, start_time_unix_nano);
|
||||
|
||||
-- Time-based cleanup (retention policy)
|
||||
CREATE INDEX IF NOT EXISTS otel_traces_time_idx ON otel_traces (start_time_unix_nano);
|
||||
|
||||
-- trace_id = job_id.as_bytes()
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Remove success_handler columns from workspace_settings
|
||||
ALTER TABLE workspace_settings
|
||||
DROP COLUMN IF EXISTS success_handler,
|
||||
DROP COLUMN IF EXISTS success_handler_extra_args;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Add success_handler columns to workspace_settings
|
||||
ALTER TABLE workspace_settings
|
||||
ADD COLUMN IF NOT EXISTS success_handler TEXT,
|
||||
ADD COLUMN IF NOT EXISTS success_handler_extra_args JSON;
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP TABLE IF EXISTS mcp_oauth_refresh_token;
|
||||
DROP TABLE IF EXISTS mcp_oauth_server_code;
|
||||
DROP TABLE IF EXISTS mcp_oauth_server_client;
|
||||
44
backend/migrations/20260120100000_mcp_oauth_server.up.sql
Normal file
44
backend/migrations/20260120100000_mcp_oauth_server.up.sql
Normal file
@@ -0,0 +1,44 @@
|
||||
-- OAuth server: clients that have registered with Windmill to access MCP
|
||||
-- Only public clients are supported (PKCE required, no client secrets)
|
||||
CREATE TABLE mcp_oauth_server_client (
|
||||
client_id VARCHAR(255) PRIMARY KEY,
|
||||
client_name VARCHAR(255) NOT NULL,
|
||||
redirect_uris TEXT[] NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
-- OAuth server: authorization codes (short-lived, single-use)
|
||||
CREATE TABLE mcp_oauth_server_code (
|
||||
code VARCHAR(64) PRIMARY KEY,
|
||||
client_id VARCHAR(255) NOT NULL REFERENCES mcp_oauth_server_client(client_id) ON DELETE CASCADE,
|
||||
user_email VARCHAR(255) NOT NULL,
|
||||
workspace_id VARCHAR(50) NOT NULL,
|
||||
scopes TEXT[] NOT NULL,
|
||||
redirect_uri TEXT NOT NULL,
|
||||
code_challenge VARCHAR(128), -- PKCE
|
||||
code_challenge_method VARCHAR(10), -- 'S256' or 'plain'
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '10 minutes'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_mcp_oauth_server_code_expires ON mcp_oauth_server_code(expires_at);
|
||||
|
||||
-- MCP OAuth refresh tokens for token rotation
|
||||
CREATE TABLE mcp_oauth_refresh_token (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
refresh_token VARCHAR(64) NOT NULL UNIQUE,
|
||||
access_token VARCHAR(64) NOT NULL,
|
||||
client_id VARCHAR(255) NOT NULL REFERENCES mcp_oauth_server_client(client_id) ON DELETE CASCADE,
|
||||
user_email VARCHAR(255) NOT NULL,
|
||||
workspace_id VARCHAR(50) NOT NULL,
|
||||
scopes TEXT[] NOT NULL,
|
||||
token_family UUID NOT NULL, -- Groups tokens from same auth flow for theft detection
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used_at TIMESTAMPTZ DEFAULT NULL, -- For rotation tracking (single-use)
|
||||
revoked BOOLEAN NOT NULL DEFAULT FALSE -- For theft detection
|
||||
);
|
||||
|
||||
CREATE INDEX idx_mcp_oauth_refresh_token_token ON mcp_oauth_refresh_token(refresh_token);
|
||||
CREATE INDEX idx_mcp_oauth_refresh_token_expires ON mcp_oauth_refresh_token(expires_at);
|
||||
CREATE INDEX idx_mcp_oauth_refresh_token_family ON mcp_oauth_refresh_token(token_family);
|
||||
@@ -0,0 +1 @@
|
||||
-- Add down migration script here
|
||||
@@ -0,0 +1,10 @@
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER ROLE custom_instance_user CREATEROLE;
|
||||
EXCEPTION
|
||||
WHEN others THEN
|
||||
RAISE NOTICE 'Error in custom_instance_user migration: %', SQLERRM;
|
||||
-- Continue without failing the migration
|
||||
END
|
||||
$$;
|
||||
@@ -380,5 +380,6 @@ pub static SHORT_IMPORTS_MAP: PyMap = phf_map! {
|
||||
"taiga" => "python-taiga",
|
||||
"docx" => "python-docx",
|
||||
"vt" => "vt-py",
|
||||
"grpc" => "grpcio",
|
||||
// Add new entry here ^
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ use windmill_common::{
|
||||
EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
|
||||
HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING,
|
||||
INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JWT_SECRET_SETTING,
|
||||
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING,
|
||||
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, OTEL_TRACING_PROXY_SETTING,
|
||||
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
|
||||
NPM_CONFIG_REGISTRY_SETTING, NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING,
|
||||
PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING, POWERSHELL_REPO_URL_SETTING,
|
||||
@@ -99,9 +99,9 @@ use crate::monitor::{
|
||||
reload_bunfig_install_scopes_setting, reload_critical_alert_mute_ui_setting,
|
||||
reload_critical_error_channels_setting, reload_extra_pip_index_url_setting,
|
||||
reload_hub_api_secret_setting, reload_hub_base_url_setting, reload_job_default_timeout_setting,
|
||||
reload_jwt_secret_setting, reload_license_key, reload_npm_config_registry_setting,
|
||||
reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting,
|
||||
reload_smtp_config, reload_worker_config, MonitorIteration,
|
||||
reload_jwt_secret_setting, reload_license_key, reload_otel_tracing_proxy_setting,
|
||||
reload_npm_config_registry_setting, reload_pip_index_url_setting, reload_retention_period_setting,
|
||||
reload_scim_token_setting, reload_smtp_config, reload_worker_config, MonitorIteration,
|
||||
};
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
@@ -452,6 +452,7 @@ async fn windmill_main() -> anyhow::Result<()> {
|
||||
.unwrap_or(DEFAULT_NUM_WORKERS as i32)
|
||||
};
|
||||
|
||||
// TODO: maybe gate behind debug_assertions?
|
||||
if num_workers > 1 && !std::env::var("WORKER_GROUP").is_ok_and(|x| x == "native") {
|
||||
println!(
|
||||
"We STRONGLY recommend using at most 1 worker per container, use at your own risks"
|
||||
@@ -802,6 +803,10 @@ Windmill Community Edition {GIT_VERSION}
|
||||
#[cfg(not(all(feature = "tantivy", feature = "parquet")))]
|
||||
let log_indexer_f = async { Ok(()) as anyhow::Result<()> };
|
||||
|
||||
// Resubscribe for OTEL tracing proxy before workers_f captures killpill_rx
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
let otel_killpill_rx = killpill_rx.resubscribe();
|
||||
|
||||
let server_f = async {
|
||||
if !is_agent {
|
||||
if let Some(db) = conn.as_sql() {
|
||||
@@ -1156,6 +1161,13 @@ Windmill Community Edition {GIT_VERSION}
|
||||
KEEP_JOB_DIR_SETTING => {
|
||||
load_keep_job_dir(&conn).await;
|
||||
},
|
||||
OTEL_TRACING_PROXY_SETTING => {
|
||||
reload_otel_tracing_proxy_setting(&conn).await;
|
||||
if worker_mode {
|
||||
tracing::info!("OTEL tracing proxy setting changed, restarting worker");
|
||||
send_delayed_killpill(&tx, 4, "OTEL tracing proxy setting change").await;
|
||||
}
|
||||
},
|
||||
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING => {
|
||||
load_require_preexisting_user(&db).await;
|
||||
},
|
||||
@@ -1329,7 +1341,8 @@ Windmill Community Edition {GIT_VERSION}
|
||||
|
||||
// update min version explicitly.
|
||||
// for sql connection it is the part of monitor_db.
|
||||
windmill_common::worker::update_min_version(conn).await;
|
||||
// TODO: pass worker names for min keep-alive alerts (for HTTP connection)
|
||||
windmill_common::min_version::update_min_version(conn, false, vec![], false).await;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1364,6 +1377,42 @@ Windmill Community Edition {GIT_VERSION}
|
||||
Ok(()) as anyhow::Result<()>
|
||||
};
|
||||
|
||||
let otel_tracing_proxy_f = async {
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
{
|
||||
// Start OTEL tracing proxy for HTTP request interception
|
||||
// Only enabled when: setting is on, worker mode (not server), and single worker (to avoid race conditions)
|
||||
if worker_mode
|
||||
&& num_workers == 1
|
||||
&& windmill_worker::OTEL_TRACING_PROXY_SETTINGS
|
||||
.read()
|
||||
.await
|
||||
.enabled
|
||||
{
|
||||
if let Some(db) = conn.as_sql() {
|
||||
tracing::info!(
|
||||
"Starting OTEL tracing proxy (port will be dynamically assigned)"
|
||||
);
|
||||
if let Err(e) =
|
||||
windmill_worker::start_otel_tracing_proxy(db.clone(), otel_killpill_rx)
|
||||
.await
|
||||
{
|
||||
tracing::error!("OTEL tracing proxy error: {}", e);
|
||||
}
|
||||
}
|
||||
} else if windmill_worker::OTEL_TRACING_PROXY_SETTINGS
|
||||
.read()
|
||||
.await
|
||||
.enabled
|
||||
&& num_workers > 1
|
||||
{
|
||||
tracing::warn!("OTEL tracing proxy is enabled but num_workers > 1. Disabling to avoid race conditions. Set NUM_WORKERS=1 to enable.");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(()) as anyhow::Result<()>
|
||||
};
|
||||
|
||||
if server_mode {
|
||||
if let Some(db) = conn.as_sql() {
|
||||
schedule_stats(&db, &HTTP_CLIENT).await;
|
||||
@@ -1378,6 +1427,7 @@ Windmill Community Edition {GIT_VERSION}
|
||||
monitor_f,
|
||||
server_f,
|
||||
metrics_f,
|
||||
otel_tracing_proxy_f,
|
||||
indexer_f,
|
||||
log_indexer_f
|
||||
)?;
|
||||
|
||||
@@ -53,7 +53,7 @@ use windmill_common::{
|
||||
HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
|
||||
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING,
|
||||
LICENSE_KEY_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPM_CONFIG_REGISTRY_SETTING,
|
||||
NUGET_CONFIG_SETTING, OTEL_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
|
||||
OTEL_TRACING_PROXY_SETTING, NUGET_CONFIG_SETTING, OTEL_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
|
||||
POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
|
||||
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING,
|
||||
SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, TIMEOUT_WAIT_RESULT_SETTING,
|
||||
@@ -81,10 +81,10 @@ use windmill_common::{
|
||||
use windmill_common::{client::AuthedClient, global_settings::APP_WORKSPACED_ROUTE_SETTING};
|
||||
use windmill_queue::{cancel_job, get_queued_job_v2, SameWorkerPayload};
|
||||
use windmill_worker::{
|
||||
handle_job_error, JobCompletedSender, SameWorkerSender, BUNFIG_INSTALL_SCOPES,
|
||||
INSTANCE_PYTHON_VERSION, JOB_DEFAULT_TIMEOUT, KEEP_JOB_DIR, MAVEN_REPOS, NO_DEFAULT_MAVEN,
|
||||
NPM_CONFIG_REGISTRY, NUGET_CONFIG, PIP_EXTRA_INDEX_URL, PIP_INDEX_URL, POWERSHELL_REPO_PAT,
|
||||
POWERSHELL_REPO_URL,
|
||||
handle_job_error, JobCompletedSender, OtelTracingProxySettings, SameWorkerSender, BUNFIG_INSTALL_SCOPES,
|
||||
INSTANCE_PYTHON_VERSION, JOB_DEFAULT_TIMEOUT, KEEP_JOB_DIR, MAVEN_REPOS,
|
||||
OTEL_TRACING_PROXY_SETTINGS, NO_DEFAULT_MAVEN, NPM_CONFIG_REGISTRY, NUGET_CONFIG, PIP_EXTRA_INDEX_URL,
|
||||
PIP_INDEX_URL, POWERSHELL_REPO_PAT, POWERSHELL_REPO_URL,
|
||||
};
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
@@ -155,6 +155,20 @@ lazy_static::lazy_static! {
|
||||
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<u64>().ok());
|
||||
|
||||
/// Batch size for job cleanup deletion queries. Default: 10000.
|
||||
/// Larger values delete more jobs per batch but hold locks longer.
|
||||
static ref JOB_CLEANUP_BATCH_SIZE: i64 = std::env::var("JOB_CLEANUP_BATCH_SIZE")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<i64>().ok())
|
||||
.unwrap_or(20000);
|
||||
|
||||
/// Maximum number of batches to process per cleanup iteration. Default: 10.
|
||||
/// Set to 0 for unlimited (process until no expired jobs remain).
|
||||
static ref JOB_CLEANUP_MAX_BATCHES: i32 = std::env::var("JOB_CLEANUP_MAX_BATCHES")
|
||||
.ok()
|
||||
.and_then(|x| x.parse::<i32>().ok())
|
||||
.unwrap_or(20);
|
||||
}
|
||||
|
||||
pub async fn initial_load(
|
||||
@@ -306,6 +320,7 @@ pub async fn initial_load(
|
||||
reload_maven_repos_setting(&conn).await;
|
||||
reload_no_default_maven_setting(&conn).await;
|
||||
reload_ruby_repos_setting(&conn).await;
|
||||
reload_otel_tracing_proxy_setting(&conn).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -764,6 +779,35 @@ pub async fn load_keep_job_dir(conn: &Connection) {
|
||||
};
|
||||
}
|
||||
|
||||
pub async fn reload_otel_tracing_proxy_setting(conn: &Connection) {
|
||||
match load_value_from_global_settings_with_conn(conn, OTEL_TRACING_PROXY_SETTING, true).await {
|
||||
Ok(Some(settings)) => {
|
||||
match serde_json::from_value::<OtelTracingProxySettings>(settings) {
|
||||
Ok(new_settings) => {
|
||||
let mut current = OTEL_TRACING_PROXY_SETTINGS.write().await;
|
||||
if current.enabled != new_settings.enabled
|
||||
|| current.enabled_languages != new_settings.enabled_languages
|
||||
{
|
||||
tracing::info!(
|
||||
"OTEL tracing proxy settings changed: enabled={}, languages={:?}",
|
||||
new_settings.enabled,
|
||||
new_settings.enabled_languages
|
||||
);
|
||||
*current = new_settings;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error parsing OTEL tracing proxy settings: {e:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error loading OTEL tracing proxy setting: {e:#}");
|
||||
}
|
||||
_ => (),
|
||||
};
|
||||
}
|
||||
|
||||
pub async fn load_require_preexisting_user(db: &DB) {
|
||||
let value =
|
||||
load_value_from_global_settings(db, REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING).await;
|
||||
@@ -815,6 +859,22 @@ pub async fn delete_expired_items(db: &DB) -> () {
|
||||
Err(e) => tracing::error!("Error deleting pip_resolution: {}", e.to_string()),
|
||||
}
|
||||
|
||||
// Clean up expired MCP OAuth refresh tokens
|
||||
let mcp_refresh_tokens_r: std::result::Result<Vec<i64>, _> = sqlx::query_scalar(
|
||||
"DELETE FROM mcp_oauth_refresh_token WHERE expires_at <= now() RETURNING id",
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await;
|
||||
|
||||
match mcp_refresh_tokens_r {
|
||||
Ok(ids) => {
|
||||
if ids.len() > 0 {
|
||||
tracing::info!("deleted {} expired MCP OAuth refresh tokens", ids.len())
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Error deleting MCP OAuth refresh tokens: {}", e.to_string()),
|
||||
}
|
||||
|
||||
let deleted_cache = sqlx::query_scalar!(
|
||||
"DELETE FROM resource WHERE resource_type = 'cache' AND to_timestamp((value->>'expire')::int) < now() RETURNING path",
|
||||
)
|
||||
@@ -908,99 +968,170 @@ pub async fn delete_expired_items(db: &DB) -> () {
|
||||
Err(e) => tracing::error!("Error deleting expired blacklisted agent tokens: {:?}", e),
|
||||
}
|
||||
|
||||
let job_retention_secs = *JOB_RETENTION_SECS.read().await;
|
||||
if job_retention_secs > 0 {
|
||||
match db.begin().await {
|
||||
Ok(mut tx) => {
|
||||
let deleted_jobs = sqlx::query_scalar!(
|
||||
"DELETE FROM v2_job_completed c
|
||||
WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval
|
||||
RETURNING c.id",
|
||||
job_retention_secs
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await;
|
||||
|
||||
match deleted_jobs {
|
||||
Ok(deleted_jobs) => {
|
||||
if deleted_jobs.len() > 0 {
|
||||
tracing::info!(
|
||||
"deleted {} jobs completed JOB_RETENTION_SECS {} ago: {:?}",
|
||||
deleted_jobs.len(),
|
||||
job_retention_secs,
|
||||
deleted_jobs,
|
||||
);
|
||||
if let Err(e) = sqlx::query!(
|
||||
"DELETE FROM job_stats WHERE job_id = ANY($1)",
|
||||
&deleted_jobs
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error deleting job stats: {:?}", e);
|
||||
}
|
||||
match sqlx::query_scalar!(
|
||||
"DELETE FROM job_logs WHERE job_id = ANY($1) RETURNING log_file_index",
|
||||
&deleted_jobs
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
{
|
||||
Ok(log_file_index) => {
|
||||
let paths = log_file_index
|
||||
.into_iter()
|
||||
.filter_map(|opt| opt)
|
||||
.flat_map(|inner_vec| inner_vec.into_iter())
|
||||
.collect();
|
||||
delete_log_files_from_disk_and_store(paths, TMP_DIR, "").await;
|
||||
}
|
||||
Err(e) => tracing::error!("Error deleting job stats: {:?}", e),
|
||||
}
|
||||
if let Err(e) = sqlx::query!(
|
||||
"DELETE FROM concurrency_key WHERE ended_at <= now() - ($1::bigint::text || ' s')::interval ",
|
||||
job_retention_secs
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error deleting custom concurrency key: {:?}", e);
|
||||
}
|
||||
|
||||
if let Err(e) =
|
||||
sqlx::query!("DELETE FROM v2_job WHERE id = ANY($1)", &deleted_jobs)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error deleting job: {:?}", e);
|
||||
}
|
||||
|
||||
// should already be deleted but just in case
|
||||
if let Err(e) = sqlx::query!(
|
||||
"DELETE FROM job_result_stream_v2 WHERE job_id = ANY($1)",
|
||||
&deleted_jobs
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error deleting job result stream: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error deleting expired jobs: {:?}", e)
|
||||
}
|
||||
}
|
||||
|
||||
match tx.commit().await {
|
||||
Ok(_) => (),
|
||||
Err(err) => tracing::error!("Error deleting expired jobs: {:?}", err),
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Error deleting expired jobs: {:?}", err)
|
||||
match sqlx::query_scalar!(
|
||||
"DELETE FROM mcp_oauth_server_code WHERE expires_at <= now() RETURNING code",
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await
|
||||
{
|
||||
Ok(deleted_codes) => {
|
||||
if deleted_codes.len() > 0 {
|
||||
tracing::info!(
|
||||
"deleted {} expired MCP OAuth authorization codes",
|
||||
deleted_codes.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Error deleting expired MCP OAuth authorization codes: {:?}", e),
|
||||
}
|
||||
|
||||
let job_retention_secs = *JOB_RETENTION_SECS.read().await;
|
||||
if job_retention_secs > 0 {
|
||||
let batch_size = *JOB_CLEANUP_BATCH_SIZE;
|
||||
let max_batches = *JOB_CLEANUP_MAX_BATCHES;
|
||||
let cleanup_start = Instant::now();
|
||||
let mut total_deleted = 0u64;
|
||||
let mut batch_num = 0i32;
|
||||
|
||||
// Process batches until no more expired jobs or max batches reached
|
||||
loop {
|
||||
if max_batches > 0 && batch_num >= max_batches {
|
||||
tracing::debug!(
|
||||
"Job cleanup: reached max batches limit ({}), will continue next iteration",
|
||||
max_batches
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// Each batch runs in its own transaction to avoid long-running locks
|
||||
let batch_result = delete_expired_jobs_batch(db, job_retention_secs, batch_size).await;
|
||||
|
||||
match batch_result {
|
||||
Ok(deleted_count) => {
|
||||
if deleted_count == 0 {
|
||||
// No more expired jobs to delete
|
||||
break;
|
||||
}
|
||||
total_deleted += deleted_count as u64;
|
||||
batch_num += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error in job cleanup batch {}: {:?}", batch_num, e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if total_deleted > 0 {
|
||||
tracing::info!(
|
||||
"Job cleanup completed: deleted {} jobs in {} batches, took {:?}",
|
||||
total_deleted,
|
||||
batch_num,
|
||||
cleanup_start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
// Clean up concurrency keys separately (not tied to specific job IDs)
|
||||
if let Err(e) = sqlx::query!(
|
||||
"DELETE FROM concurrency_key WHERE ended_at <= now() - ($1::bigint::text || ' s')::interval",
|
||||
job_retention_secs
|
||||
)
|
||||
.execute(db)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error deleting custom concurrency key: {:?}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a batch of expired jobs with LIMIT and SKIP LOCKED for high-scale environments.
|
||||
/// Uses a single transaction per batch to minimize lock duration.
|
||||
/// Returns the number of jobs deleted in this batch.
|
||||
async fn delete_expired_jobs_batch(
|
||||
db: &DB,
|
||||
job_retention_secs: i64,
|
||||
batch_size: i64,
|
||||
) -> error::Result<usize> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Use FOR UPDATE SKIP LOCKED to avoid contention between replicas
|
||||
// ORDER BY completed_at ensures we delete oldest jobs first
|
||||
let deleted_jobs: Vec<Uuid> = sqlx::query_scalar!(
|
||||
"DELETE FROM v2_job_completed
|
||||
WHERE id IN (
|
||||
SELECT id FROM v2_job_completed
|
||||
WHERE completed_at <= now() - ($1::bigint::text || ' s')::interval
|
||||
ORDER BY completed_at ASC
|
||||
LIMIT $2
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
RETURNING id",
|
||||
job_retention_secs,
|
||||
batch_size
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let deleted_count = deleted_jobs.len();
|
||||
|
||||
if deleted_count > 0 {
|
||||
tracing::debug!(
|
||||
"Deleting batch of {} expired jobs (retention: {}s)",
|
||||
deleted_count,
|
||||
job_retention_secs
|
||||
);
|
||||
|
||||
// Delete related records for this batch
|
||||
if let Err(e) = sqlx::query!(
|
||||
"DELETE FROM job_stats WHERE job_id = ANY($1)",
|
||||
&deleted_jobs
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error deleting job stats: {:?}", e);
|
||||
}
|
||||
|
||||
match sqlx::query_scalar!(
|
||||
"DELETE FROM job_logs WHERE job_id = ANY($1) RETURNING log_file_index",
|
||||
&deleted_jobs
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await
|
||||
{
|
||||
Ok(log_file_index) => {
|
||||
let paths = log_file_index
|
||||
.into_iter()
|
||||
.filter_map(|opt| opt)
|
||||
.flat_map(|inner_vec| inner_vec.into_iter())
|
||||
.collect();
|
||||
delete_log_files_from_disk_and_store(paths, TMP_DIR, "").await;
|
||||
}
|
||||
Err(e) => tracing::error!("Error deleting job logs: {:?}", e),
|
||||
}
|
||||
|
||||
if let Err(e) = sqlx::query!("DELETE FROM v2_job WHERE id = ANY($1)", &deleted_jobs)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error deleting job: {:?}", e);
|
||||
}
|
||||
|
||||
// Should already be deleted but just in case
|
||||
if let Err(e) = sqlx::query!(
|
||||
"DELETE FROM job_result_stream_v2 WHERE job_id = ANY($1)",
|
||||
&deleted_jobs
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
{
|
||||
tracing::error!("Error deleting job result stream: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(deleted_count)
|
||||
}
|
||||
|
||||
async fn delete_log_files_from_disk_and_store(
|
||||
@@ -1732,7 +1863,7 @@ pub async fn monitor_db(
|
||||
|
||||
let update_min_worker_version_f = async {
|
||||
#[cfg(not(feature = "test_job_debouncing"))]
|
||||
windmill_common::worker::update_min_version(conn).await;
|
||||
windmill_common::min_version::update_min_version(conn, _worker_mode, WORKERS_NAMES.read().await.clone(), initial_load).await;
|
||||
};
|
||||
|
||||
join!(
|
||||
@@ -2262,7 +2393,7 @@ async fn handle_zombie_jobs(db: &Pool<Postgres>, base_internal_url: &str, node_n
|
||||
sqlx::query_scalar!("SELECT j.id
|
||||
FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)
|
||||
WHERE r.ping < now() - ($1 || ' seconds')::interval
|
||||
AND q.running = true AND j.kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow') AND j.same_worker = false",
|
||||
AND q.running = true AND j.kind NOT IN ('flow', 'flowpreview', 'flownode', 'singlestepflow') AND j.same_worker = false",
|
||||
ZOMBIE_JOB_TIMEOUT.as_str())
|
||||
.fetch_all(db)
|
||||
.await
|
||||
@@ -2396,8 +2527,8 @@ orphaned_job_uuids AS (
|
||||
SELECT job_uuid
|
||||
FROM all_job_uuids
|
||||
WHERE job_uuid NOT IN (
|
||||
SELECT id::text
|
||||
FROM v2_job_queue
|
||||
SELECT id::text
|
||||
FROM v2_job_queue
|
||||
FOR SHARE SKIP LOCKED
|
||||
)
|
||||
),
|
||||
@@ -2410,7 +2541,7 @@ before_update AS (
|
||||
WHERE lc.job_uuids ?| oa.orphaned_keys
|
||||
),
|
||||
affected_rows AS (
|
||||
UPDATE concurrency_counter
|
||||
UPDATE concurrency_counter
|
||||
SET job_uuids = job_uuids - orphaned_array.orphaned_keys
|
||||
FROM orphaned_array
|
||||
WHERE concurrency_counter.concurrency_id IN (
|
||||
@@ -2419,12 +2550,12 @@ affected_rows AS (
|
||||
RETURNING concurrency_id, job_uuids AS updated_job_uuids
|
||||
),
|
||||
expanded_orphaned AS (
|
||||
SELECT bu.concurrency_id,
|
||||
SELECT bu.concurrency_id,
|
||||
bu.job_uuids AS original_job_uuids,
|
||||
unnest(bu.orphaned_keys) AS orphaned_key
|
||||
FROM before_update bu
|
||||
)
|
||||
SELECT
|
||||
SELECT
|
||||
eo.concurrency_id,
|
||||
eo.orphaned_key,
|
||||
eo.original_job_uuids,
|
||||
@@ -2453,7 +2584,7 @@ async fn cleanup_concurrency_counters_empty_keys(db: &DB) -> error::Result<()> {
|
||||
WITH rows_to_delete AS (
|
||||
SELECT concurrency_id
|
||||
FROM concurrency_counter
|
||||
|
||||
|
||||
WHERE job_uuids = '{}'::jsonb
|
||||
FOR UPDATE SKIP LOCKED
|
||||
)
|
||||
@@ -2489,7 +2620,7 @@ async fn handle_zombie_flows(db: &DB) -> error::Result<()> {
|
||||
AND (j.kind = 'flow' OR j.kind = 'flowpreview' OR j.kind = 'flownode')
|
||||
AND r.ping IS NOT NULL AND r.ping < NOW() - ($1 || ' seconds')::interval
|
||||
AND q.canceled_by IS NULL
|
||||
|
||||
|
||||
"#,
|
||||
FLOW_ZOMBIE_TRANSITION_TIMEOUT.as_str()
|
||||
)
|
||||
@@ -2820,8 +2951,8 @@ RETURNING key,job_id
|
||||
|
||||
async fn cleanup_debounce_keys_for_completed_jobs(db: &DB) -> error::Result<()> {
|
||||
// If min version doesn't support runnable settings, clean up debounce keys for completed jobs
|
||||
if !*windmill_common::worker::MIN_VERSION_SUPPORTS_RUNNABLE_SETTINGS_V0
|
||||
.read()
|
||||
if !windmill_common::min_version::MIN_VERSION_SUPPORTS_RUNNABLE_SETTINGS_V0
|
||||
.met()
|
||||
.await
|
||||
{
|
||||
let result = sqlx::query!(
|
||||
|
||||
@@ -1065,6 +1065,8 @@ TABLE: workspace_settings
|
||||
- ai_config (jsonb)
|
||||
- error_handler_extra_args (json)
|
||||
- error_handler_muted_on_cancel (boolean)
|
||||
- success_handler (text)
|
||||
- success_handler_extra_args (json)
|
||||
- large_file_storage (jsonb)
|
||||
- git_sync (jsonb)
|
||||
- default_app (character)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::{future::Future, str::FromStr, sync::Arc};
|
||||
use std::{future::Future, str::FromStr};
|
||||
|
||||
use futures::Stream;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use sqlx::{postgres::PgListener, Pool, Postgres};
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
use windmill_api_client::types::NewScript;
|
||||
#[cfg(feature = "python")]
|
||||
@@ -498,11 +497,11 @@ pub async fn initialize_tracing() {
|
||||
}
|
||||
|
||||
pub async fn test_for_versions<F: Future<Output = ()>>(
|
||||
version_flags: impl Iterator<Item = Arc<RwLock<bool>>>,
|
||||
constraints: impl Iterator<Item = &'static windmill_common::min_version::VersionConstraint>,
|
||||
test: impl Fn() -> F,
|
||||
) {
|
||||
for version_flag in version_flags {
|
||||
*version_flag.write().await = true;
|
||||
for constraint in constraints {
|
||||
*windmill_common::min_version::MIN_VERSION.write().await = constraint.version().clone();
|
||||
test().await;
|
||||
}
|
||||
}
|
||||
|
||||
31
backend/tests/fixtures/secret_backend.sql
vendored
Normal file
31
backend/tests/fixtures/secret_backend.sql
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
-- Fixture for secret backend migration tests
|
||||
-- Sets up test secrets in the variable table
|
||||
|
||||
-- Create a second workspace for testing workspace isolation
|
||||
INSERT INTO workspace (id, name, owner)
|
||||
VALUES ('test-workspace-2', 'test-workspace-2', 'test-user')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO workspace_settings (workspace_id)
|
||||
VALUES ('test-workspace-2')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
INSERT INTO workspace_key(workspace_id, kind, key)
|
||||
VALUES ('test-workspace-2', 'cloud', 'test-key-2')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Insert test secrets for workspace 1
|
||||
-- Note: The 'value' column stores encrypted values in production,
|
||||
-- but for tests we'll use plain text that the migration will handle
|
||||
INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms)
|
||||
VALUES
|
||||
('test-workspace', 'u/test-user/db_password', 'encrypted-db-pass-123', true, 'Database password', '{}'),
|
||||
('test-workspace', 'u/test-user/api_key', 'encrypted-api-key-abc', true, 'API key for external service', '{}'),
|
||||
('test-workspace', 'u/test-user/public_var', 'not-a-secret', false, 'A non-secret variable', '{}')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Insert test secrets for workspace 2 (to test isolation)
|
||||
INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms)
|
||||
VALUES
|
||||
('test-workspace-2', 'u/test-user/other_secret', 'encrypted-other-secret', true, 'Secret in workspace 2', '{}')
|
||||
ON CONFLICT DO NOTHING;
|
||||
@@ -3,16 +3,15 @@ mod common;
|
||||
mod job_payload {
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use windmill_common::flow_status::RestartedFrom;
|
||||
use windmill_common::flows::{FlowModule, FlowModuleValue, FlowValue};
|
||||
use windmill_common::jobs::JobPayload;
|
||||
use windmill_common::scripts::{ScriptHash, ScriptLang};
|
||||
use windmill_common::flow_status::RestartedFrom;
|
||||
|
||||
use crate::common::*;
|
||||
use windmill_common::worker::{
|
||||
MIN_VERSION_IS_AT_LEAST_1_427, MIN_VERSION_IS_AT_LEAST_1_432, MIN_VERSION_IS_AT_LEAST_1_440,
|
||||
use windmill_common::min_version::{
|
||||
MIN_VERSION, MIN_VERSION_IS_AT_LEAST_1_427, MIN_VERSION_IS_AT_LEAST_1_432,
|
||||
MIN_VERSION_IS_AT_LEAST_1_440,
|
||||
};
|
||||
|
||||
pub async fn initialize_tracing() {
|
||||
@@ -28,18 +27,15 @@ mod job_payload {
|
||||
});
|
||||
}
|
||||
|
||||
use lazy_static::lazy_static;
|
||||
|
||||
use windmill_common::cache;
|
||||
use windmill_common::flows::FlowNodeId;
|
||||
use windmill_common::min_version::VersionConstraint;
|
||||
|
||||
lazy_static! {
|
||||
static ref VERSION_FLAGS: [Arc<RwLock<bool>>; 3] = [
|
||||
MIN_VERSION_IS_AT_LEAST_1_427.clone(),
|
||||
MIN_VERSION_IS_AT_LEAST_1_432.clone(),
|
||||
MIN_VERSION_IS_AT_LEAST_1_440.clone(),
|
||||
];
|
||||
}
|
||||
const VERSION_FLAGS: [&VersionConstraint; 3] = [
|
||||
&MIN_VERSION_IS_AT_LEAST_1_427,
|
||||
&MIN_VERSION_IS_AT_LEAST_1_432,
|
||||
&MIN_VERSION_IS_AT_LEAST_1_440,
|
||||
];
|
||||
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base", "hello"))]
|
||||
@@ -71,7 +67,7 @@ mod job_payload {
|
||||
|
||||
assert_eq!(result, json!("Hello foo!"));
|
||||
};
|
||||
test_for_versions(VERSION_FLAGS.iter().cloned(), test).await;
|
||||
test_for_versions(VERSION_FLAGS.iter().copied(), test).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -116,7 +112,7 @@ mod job_payload {
|
||||
.unwrap();
|
||||
assert_eq!(job.preprocessed, Some(true));
|
||||
};
|
||||
test_for_versions(VERSION_FLAGS.iter().cloned(), test).await;
|
||||
test_for_versions(VERSION_FLAGS.iter().copied(), test).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -183,7 +179,7 @@ mod job_payload {
|
||||
|
||||
assert_eq!(result, json!("Hello foo!"));
|
||||
};
|
||||
test_for_versions(VERSION_FLAGS.iter().cloned(), test).await;
|
||||
test_for_versions(VERSION_FLAGS.iter().copied(), test).await;
|
||||
let test = || async {
|
||||
let result = RunJob::from(JobPayload::FlowScript {
|
||||
id: flow_scripts[1],
|
||||
@@ -206,7 +202,7 @@ mod job_payload {
|
||||
json!("Did you just say \"You know nothing Jean Neige\"??!")
|
||||
);
|
||||
};
|
||||
test_for_versions(VERSION_FLAGS.iter().cloned(), test).await;
|
||||
test_for_versions(VERSION_FLAGS.iter().copied(), test).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -252,7 +248,7 @@ mod job_payload {
|
||||
|
||||
assert_eq!(result, json!("Did you just say \"Hello tests!\"??!"));
|
||||
};
|
||||
test_for_versions(VERSION_FLAGS.iter().cloned(), test).await;
|
||||
test_for_versions(VERSION_FLAGS.iter().copied(), test).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -282,19 +278,19 @@ mod job_payload {
|
||||
|
||||
#[sqlx::test(fixtures("base", "hello"))]
|
||||
async fn test_dependencies_payload_min_1_427(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
*MIN_VERSION_IS_AT_LEAST_1_427.write().await = true;
|
||||
*MIN_VERSION.write().await = MIN_VERSION_IS_AT_LEAST_1_427.version().clone();
|
||||
test_dependencies_payload(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
#[sqlx::test(fixtures("base", "hello"))]
|
||||
async fn test_dependencies_payload_min_1_432(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
*MIN_VERSION_IS_AT_LEAST_1_432.write().await = true;
|
||||
*MIN_VERSION.write().await = MIN_VERSION_IS_AT_LEAST_1_432.version().clone();
|
||||
test_dependencies_payload(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
#[sqlx::test(fixtures("base", "hello"))]
|
||||
async fn test_dependencies_payload_min_1_440(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
*MIN_VERSION_IS_AT_LEAST_1_440.write().await = true;
|
||||
*MIN_VERSION.write().await = MIN_VERSION_IS_AT_LEAST_1_440.version().clone();
|
||||
test_dependencies_payload(db).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -323,7 +319,7 @@ mod job_payload {
|
||||
&json!("Successful lock file generation")
|
||||
);
|
||||
};
|
||||
test_for_versions(VERSION_FLAGS.iter().cloned(), test).await;
|
||||
test_for_versions(VERSION_FLAGS.iter().copied(), test).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -383,7 +379,7 @@ mod job_payload {
|
||||
|
||||
assert_eq!(result, json!("Hello Jean Neige!"));
|
||||
};
|
||||
test_for_versions(VERSION_FLAGS.iter().cloned(), test).await;
|
||||
test_for_versions(VERSION_FLAGS.iter().copied(), test).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -414,7 +410,7 @@ mod job_payload {
|
||||
json!({ "lock": "", "status": "Successful lock file generation" })
|
||||
);
|
||||
};
|
||||
test_for_versions(VERSION_FLAGS.iter().cloned(), test).await;
|
||||
test_for_versions(VERSION_FLAGS.iter().copied(), test).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -447,7 +443,7 @@ mod job_payload {
|
||||
);
|
||||
};
|
||||
// Test the not "lite" flow.
|
||||
test_for_versions(VERSION_FLAGS.iter().cloned(), test).await;
|
||||
test_for_versions(VERSION_FLAGS.iter().copied(), test).await;
|
||||
// Deploy the flow to produce the "lite" version.
|
||||
let _ = RunJob::from(JobPayload::FlowDependencies {
|
||||
path: "f/system/hello_with_nodes_flow".to_string(),
|
||||
@@ -460,7 +456,7 @@ mod job_payload {
|
||||
.json_result()
|
||||
.unwrap();
|
||||
// Test the "lite" flow.
|
||||
test_for_versions(VERSION_FLAGS.iter().cloned(), test).await;
|
||||
test_for_versions(VERSION_FLAGS.iter().copied(), test).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -520,7 +516,7 @@ mod job_payload {
|
||||
);
|
||||
};
|
||||
// Test the not "lite" flow.
|
||||
test_for_versions(VERSION_FLAGS.iter().cloned(), test).await;
|
||||
test_for_versions(VERSION_FLAGS.iter().copied(), test).await;
|
||||
// Deploy the flow to produce the "lite" version.
|
||||
let _ = RunJob::from(JobPayload::FlowDependencies {
|
||||
path: "f/system/hello_with_preprocessor".to_string(),
|
||||
@@ -533,7 +529,7 @@ mod job_payload {
|
||||
.json_result()
|
||||
.unwrap();
|
||||
// Test the "lite" flow.
|
||||
test_for_versions(VERSION_FLAGS.iter().cloned(), test).await;
|
||||
test_for_versions(VERSION_FLAGS.iter().copied(), test).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -577,7 +573,7 @@ mod job_payload {
|
||||
);
|
||||
};
|
||||
// Test the not "lite" flow.
|
||||
test_for_versions(VERSION_FLAGS.iter().cloned(), test).await;
|
||||
test_for_versions(VERSION_FLAGS.iter().copied(), test).await;
|
||||
// Deploy the flow to produce the "lite" version.
|
||||
let _ = RunJob::from(JobPayload::FlowDependencies {
|
||||
path: "f/system/hello_with_nodes_flow".to_string(),
|
||||
@@ -590,7 +586,7 @@ mod job_payload {
|
||||
.json_result()
|
||||
.unwrap();
|
||||
// Test the "lite" flow.
|
||||
test_for_versions(VERSION_FLAGS.iter().cloned(), test).await;
|
||||
test_for_versions(VERSION_FLAGS.iter().copied(), test).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -638,7 +634,7 @@ mod job_payload {
|
||||
|
||||
assert_eq!(result, json!("Hello Jean Neige!"));
|
||||
};
|
||||
test_for_versions(VERSION_FLAGS.iter().cloned(), test).await;
|
||||
test_for_versions(VERSION_FLAGS.iter().copied(), test).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
900
backend/tests/list_jobs.rs
Normal file
900
backend/tests/list_jobs.rs
Normal file
@@ -0,0 +1,900 @@
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
use windmill_common::{
|
||||
jobs::{JobPayload, RawCode},
|
||||
scripts::ScriptLang,
|
||||
};
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ListJobsResponse {
|
||||
#[serde(rename = "type")]
|
||||
#[allow(dead_code)]
|
||||
typ: String,
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
args: Option<serde_json::Value>,
|
||||
#[serde(flatten)]
|
||||
_extra: std::collections::HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Response struct for queue/list endpoint (no type field)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct QueueJobResponse {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
args: Option<serde_json::Value>,
|
||||
#[serde(flatten)]
|
||||
_extra: std::collections::HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Response struct for completed/list endpoint
|
||||
#[cfg(feature = "python")]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CompletedJobResponse {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
args: Option<serde_json::Value>,
|
||||
#[serde(flatten)]
|
||||
_extra: std::collections::HashMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Test that list_jobs returns jobs without args by default
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_list_jobs_without_include_args(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = windmill_api_client::create_client(
|
||||
&format!("http://localhost:{port}"),
|
||||
"SECRET_TOKEN".to_string(),
|
||||
);
|
||||
|
||||
// Push a job to the queue with specific args
|
||||
let job_id = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: "def main(x): return x * 2".to_string(),
|
||||
path: None,
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings:
|
||||
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}))
|
||||
.arg("x", json!(42))
|
||||
.push(&db)
|
||||
.await;
|
||||
|
||||
// Call list_jobs without include_args
|
||||
let response = client
|
||||
.client()
|
||||
.get(format!("{}/w/test-workspace/jobs/list", client.baseurl()))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert!(response.status().is_success(), "list_jobs should succeed");
|
||||
|
||||
let jobs: Vec<ListJobsResponse> = response.json().await?;
|
||||
|
||||
// Find the job we created
|
||||
let job = jobs
|
||||
.iter()
|
||||
.find(|j| j.id == job_id.to_string())
|
||||
.expect("should find the created job");
|
||||
|
||||
// Args should be None when include_args is not set
|
||||
assert!(
|
||||
job.args.is_none(),
|
||||
"args should not be included when include_args is not set"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that list_jobs returns jobs with args when include_args=true
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_list_jobs_with_include_args(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = windmill_api_client::create_client(
|
||||
&format!("http://localhost:{port}"),
|
||||
"SECRET_TOKEN".to_string(),
|
||||
);
|
||||
|
||||
// Push a job to the queue with specific args
|
||||
let job_id = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: "def main(x): return x * 2".to_string(),
|
||||
path: None,
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings:
|
||||
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}))
|
||||
.arg("x", json!(42))
|
||||
.push(&db)
|
||||
.await;
|
||||
|
||||
// Call list_jobs with include_args=true
|
||||
let response = client
|
||||
.client()
|
||||
.get(format!(
|
||||
"{}/w/test-workspace/jobs/list?include_args=true",
|
||||
client.baseurl()
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
response.status().is_success(),
|
||||
"list_jobs with include_args should succeed"
|
||||
);
|
||||
|
||||
let jobs: Vec<ListJobsResponse> = response.json().await?;
|
||||
|
||||
// Find the job we created
|
||||
let job = jobs
|
||||
.iter()
|
||||
.find(|j| j.id == job_id.to_string())
|
||||
.expect("should find the created job");
|
||||
|
||||
// Args should be present when include_args=true
|
||||
assert!(
|
||||
job.args.is_some(),
|
||||
"args should be included when include_args=true"
|
||||
);
|
||||
|
||||
let args = job.args.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
args.get("x"),
|
||||
Some(&json!(42)),
|
||||
"args should contain the correct value"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that list_jobs returns completed jobs with args when include_args=true
|
||||
#[cfg(feature = "python")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_list_jobs_completed_with_include_args(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = windmill_api_client::create_client(
|
||||
&format!("http://localhost:{port}"),
|
||||
"SECRET_TOKEN".to_string(),
|
||||
);
|
||||
|
||||
// Run a job to completion
|
||||
let completed_job = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: "def main(x): return x * 2".to_string(),
|
||||
path: None,
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings:
|
||||
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}))
|
||||
.arg("x", json!(42))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
|
||||
let job_id = completed_job.id;
|
||||
|
||||
// Call list_jobs with include_args=true
|
||||
let response = client
|
||||
.client()
|
||||
.get(format!(
|
||||
"{}/w/test-workspace/jobs/list?include_args=true",
|
||||
client.baseurl()
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
response.status().is_success(),
|
||||
"list_jobs with include_args should succeed"
|
||||
);
|
||||
|
||||
let jobs: Vec<ListJobsResponse> = response.json().await?;
|
||||
|
||||
// Find the completed job
|
||||
let job = jobs
|
||||
.iter()
|
||||
.find(|j| j.id == job_id.to_string())
|
||||
.expect("should find the completed job");
|
||||
|
||||
// Args should be present when include_args=true
|
||||
assert!(
|
||||
job.args.is_some(),
|
||||
"args should be included for completed jobs when include_args=true"
|
||||
);
|
||||
|
||||
let args = job.args.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
args.get("x"),
|
||||
Some(&json!(42)),
|
||||
"args should contain the correct value for completed jobs"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that list_jobs returns both queued and completed jobs with args
|
||||
#[cfg(feature = "python")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_list_jobs_mixed_queue_and_completed(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = windmill_api_client::create_client(
|
||||
&format!("http://localhost:{port}"),
|
||||
"SECRET_TOKEN".to_string(),
|
||||
);
|
||||
|
||||
// Run a job to completion first
|
||||
let completed_job = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: "def main(completed_arg): return completed_arg".to_string(),
|
||||
path: None,
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings:
|
||||
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}))
|
||||
.arg("completed_arg", json!("completed_value"))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
|
||||
let completed_job_id = completed_job.id;
|
||||
|
||||
// Push another job to the queue (don't run it)
|
||||
let queued_job_id = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: "def main(queued_arg): return queued_arg".to_string(),
|
||||
path: None,
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings:
|
||||
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}))
|
||||
.arg("queued_arg", json!("queued_value"))
|
||||
.push(&db)
|
||||
.await;
|
||||
|
||||
// Call list_jobs with include_args=true
|
||||
let response = client
|
||||
.client()
|
||||
.get(format!(
|
||||
"{}/w/test-workspace/jobs/list?include_args=true",
|
||||
client.baseurl()
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
response.status().is_success(),
|
||||
"list_jobs with include_args should succeed"
|
||||
);
|
||||
|
||||
let jobs: Vec<ListJobsResponse> = response.json().await?;
|
||||
|
||||
// Find the completed job
|
||||
let completed = jobs
|
||||
.iter()
|
||||
.find(|j| j.id == completed_job_id.to_string())
|
||||
.expect("should find the completed job");
|
||||
|
||||
assert!(
|
||||
completed.args.is_some(),
|
||||
"completed job should have args when include_args=true"
|
||||
);
|
||||
assert_eq!(
|
||||
completed.args.as_ref().unwrap().get("completed_arg"),
|
||||
Some(&json!("completed_value")),
|
||||
"completed job should have correct args"
|
||||
);
|
||||
|
||||
// Find the queued job
|
||||
let queued = jobs
|
||||
.iter()
|
||||
.find(|j| j.id == queued_job_id.to_string())
|
||||
.expect("should find the queued job");
|
||||
|
||||
assert!(
|
||||
queued.args.is_some(),
|
||||
"queued job should have args when include_args=true"
|
||||
);
|
||||
assert_eq!(
|
||||
queued.args.as_ref().unwrap().get("queued_arg"),
|
||||
Some(&json!("queued_value")),
|
||||
"queued job should have correct args"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test list_jobs with multiple queued jobs and include_args
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_list_jobs_multiple_queued_with_include_args(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = windmill_api_client::create_client(
|
||||
&format!("http://localhost:{port}"),
|
||||
"SECRET_TOKEN".to_string(),
|
||||
);
|
||||
|
||||
// Push two jobs with different args
|
||||
let job1_id = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: "def main(x): return x".to_string(),
|
||||
path: None,
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings:
|
||||
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}))
|
||||
.arg("x", json!(1))
|
||||
.push(&db)
|
||||
.await;
|
||||
|
||||
let job2_id = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: "def main(y): return y".to_string(),
|
||||
path: None,
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings:
|
||||
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}))
|
||||
.arg("y", json!(2))
|
||||
.push(&db)
|
||||
.await;
|
||||
|
||||
// Test with include_args=true
|
||||
let response = client
|
||||
.client()
|
||||
.get(format!(
|
||||
"{}/w/test-workspace/jobs/list?include_args=true",
|
||||
client.baseurl()
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert!(response.status().is_success(), "list_jobs should succeed");
|
||||
|
||||
let jobs: Vec<ListJobsResponse> = response.json().await?;
|
||||
|
||||
// Find both jobs
|
||||
let job1 = jobs.iter().find(|j| j.id == job1_id.to_string())
|
||||
.expect("should find job1");
|
||||
let job2 = jobs.iter().find(|j| j.id == job2_id.to_string())
|
||||
.expect("should find job2");
|
||||
|
||||
// Both should have args
|
||||
assert!(job1.args.is_some(), "job1 args should be included");
|
||||
assert!(job2.args.is_some(), "job2 args should be included");
|
||||
|
||||
// Check the args are correct
|
||||
assert_eq!(
|
||||
job1.args.as_ref().unwrap().get("x"),
|
||||
Some(&json!(1)),
|
||||
"job1 should have correct args"
|
||||
);
|
||||
assert_eq!(
|
||||
job2.args.as_ref().unwrap().get("y"),
|
||||
Some(&json!(2)),
|
||||
"job2 should have correct args"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests for /queue/list endpoint
|
||||
// ============================================================================
|
||||
|
||||
/// Test that queue/list returns jobs without args by default
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_queue_list_without_include_args(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = windmill_api_client::create_client(
|
||||
&format!("http://localhost:{port}"),
|
||||
"SECRET_TOKEN".to_string(),
|
||||
);
|
||||
|
||||
// Push a job to the queue with specific args
|
||||
let job_id = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: "def main(x): return x * 2".to_string(),
|
||||
path: None,
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings:
|
||||
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}))
|
||||
.arg("x", json!(42))
|
||||
.push(&db)
|
||||
.await;
|
||||
|
||||
// Call queue/list without include_args
|
||||
let response = client
|
||||
.client()
|
||||
.get(format!(
|
||||
"{}/w/test-workspace/jobs/queue/list",
|
||||
client.baseurl()
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
response.status().is_success(),
|
||||
"queue/list should succeed"
|
||||
);
|
||||
|
||||
let jobs: Vec<QueueJobResponse> = response.json().await?;
|
||||
|
||||
// Find the job we created
|
||||
let job = jobs
|
||||
.iter()
|
||||
.find(|j| j.id == job_id.to_string())
|
||||
.expect("should find the created job");
|
||||
|
||||
// Args should be None when include_args is not set
|
||||
assert!(
|
||||
job.args.is_none(),
|
||||
"args should not be included when include_args is not set"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that queue/list returns jobs with args when include_args=true
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_queue_list_with_include_args(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = windmill_api_client::create_client(
|
||||
&format!("http://localhost:{port}"),
|
||||
"SECRET_TOKEN".to_string(),
|
||||
);
|
||||
|
||||
// Push a job to the queue with specific args
|
||||
let job_id = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: "def main(x): return x * 2".to_string(),
|
||||
path: None,
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings:
|
||||
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}))
|
||||
.arg("x", json!(42))
|
||||
.push(&db)
|
||||
.await;
|
||||
|
||||
// Call queue/list with include_args=true
|
||||
let response = client
|
||||
.client()
|
||||
.get(format!(
|
||||
"{}/w/test-workspace/jobs/queue/list?include_args=true",
|
||||
client.baseurl()
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
response.status().is_success(),
|
||||
"queue/list with include_args should succeed"
|
||||
);
|
||||
|
||||
let jobs: Vec<QueueJobResponse> = response.json().await?;
|
||||
|
||||
// Find the job we created
|
||||
let job = jobs
|
||||
.iter()
|
||||
.find(|j| j.id == job_id.to_string())
|
||||
.expect("should find the created job");
|
||||
|
||||
// Args should be present when include_args=true
|
||||
assert!(
|
||||
job.args.is_some(),
|
||||
"args should be included when include_args=true"
|
||||
);
|
||||
|
||||
let args = job.args.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
args.get("x"),
|
||||
Some(&json!(42)),
|
||||
"args should contain the correct value"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test queue/list with multiple jobs and include_args=true
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_queue_list_multiple_jobs_with_include_args(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = windmill_api_client::create_client(
|
||||
&format!("http://localhost:{port}"),
|
||||
"SECRET_TOKEN".to_string(),
|
||||
);
|
||||
|
||||
// Push two jobs with different args
|
||||
let job1_id = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: "def main(a): return a".to_string(),
|
||||
path: None,
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings:
|
||||
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}))
|
||||
.arg("a", json!("value_a"))
|
||||
.push(&db)
|
||||
.await;
|
||||
|
||||
let job2_id = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: "def main(b): return b".to_string(),
|
||||
path: None,
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings:
|
||||
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}))
|
||||
.arg("b", json!("value_b"))
|
||||
.push(&db)
|
||||
.await;
|
||||
|
||||
// Call queue/list with include_args=true
|
||||
let response = client
|
||||
.client()
|
||||
.get(format!(
|
||||
"{}/w/test-workspace/jobs/queue/list?include_args=true",
|
||||
client.baseurl()
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
response.status().is_success(),
|
||||
"queue/list should succeed"
|
||||
);
|
||||
|
||||
let jobs: Vec<QueueJobResponse> = response.json().await?;
|
||||
|
||||
// Find both jobs
|
||||
let job1 = jobs
|
||||
.iter()
|
||||
.find(|j| j.id == job1_id.to_string())
|
||||
.expect("should find job1");
|
||||
let job2 = jobs
|
||||
.iter()
|
||||
.find(|j| j.id == job2_id.to_string())
|
||||
.expect("should find job2");
|
||||
|
||||
// Both should have args
|
||||
assert!(job1.args.is_some(), "job1 args should be included");
|
||||
assert!(job2.args.is_some(), "job2 args should be included");
|
||||
|
||||
// Check the args are correct
|
||||
assert_eq!(
|
||||
job1.args.as_ref().unwrap().get("a"),
|
||||
Some(&json!("value_a")),
|
||||
"job1 should have correct args"
|
||||
);
|
||||
assert_eq!(
|
||||
job2.args.as_ref().unwrap().get("b"),
|
||||
Some(&json!("value_b")),
|
||||
"job2 should have correct args"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tests for /completed/list endpoint
|
||||
// ============================================================================
|
||||
|
||||
/// Test that completed/list returns jobs without args by default
|
||||
#[cfg(feature = "python")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_completed_list_without_include_args(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = windmill_api_client::create_client(
|
||||
&format!("http://localhost:{port}"),
|
||||
"SECRET_TOKEN".to_string(),
|
||||
);
|
||||
|
||||
// Run a job to completion
|
||||
let completed_job = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: "def main(x): return x * 2".to_string(),
|
||||
path: None,
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings:
|
||||
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}))
|
||||
.arg("x", json!(42))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
|
||||
let job_id = completed_job.id;
|
||||
|
||||
// Call completed/list without include_args
|
||||
let response = client
|
||||
.client()
|
||||
.get(format!(
|
||||
"{}/w/test-workspace/jobs/completed/list",
|
||||
client.baseurl()
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
response.status().is_success(),
|
||||
"completed/list should succeed"
|
||||
);
|
||||
|
||||
let jobs: Vec<CompletedJobResponse> = response.json().await?;
|
||||
|
||||
// Find the completed job
|
||||
let job = jobs
|
||||
.iter()
|
||||
.find(|j| j.id == job_id.to_string())
|
||||
.expect("should find the completed job");
|
||||
|
||||
// Args should be None when include_args is not set
|
||||
assert!(
|
||||
job.args.is_none(),
|
||||
"args should not be included when include_args is not set"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that completed/list returns jobs with args when include_args=true
|
||||
#[cfg(feature = "python")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_completed_list_with_include_args(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = windmill_api_client::create_client(
|
||||
&format!("http://localhost:{port}"),
|
||||
"SECRET_TOKEN".to_string(),
|
||||
);
|
||||
|
||||
// Run a job to completion
|
||||
let completed_job = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: "def main(x): return x * 2".to_string(),
|
||||
path: None,
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings:
|
||||
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}))
|
||||
.arg("x", json!(42))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
|
||||
let job_id = completed_job.id;
|
||||
|
||||
// Call completed/list with include_args=true
|
||||
let response = client
|
||||
.client()
|
||||
.get(format!(
|
||||
"{}/w/test-workspace/jobs/completed/list?include_args=true",
|
||||
client.baseurl()
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
response.status().is_success(),
|
||||
"completed/list with include_args should succeed"
|
||||
);
|
||||
|
||||
let jobs: Vec<CompletedJobResponse> = response.json().await?;
|
||||
|
||||
// Find the completed job
|
||||
let job = jobs
|
||||
.iter()
|
||||
.find(|j| j.id == job_id.to_string())
|
||||
.expect("should find the completed job");
|
||||
|
||||
// Args should be present when include_args=true
|
||||
assert!(
|
||||
job.args.is_some(),
|
||||
"args should be included when include_args=true"
|
||||
);
|
||||
|
||||
let args = job.args.as_ref().unwrap();
|
||||
assert_eq!(
|
||||
args.get("x"),
|
||||
Some(&json!(42)),
|
||||
"args should contain the correct value"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test completed/list with multiple jobs and include_args=true
|
||||
#[cfg(feature = "python")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_completed_list_multiple_jobs_with_include_args(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let client = windmill_api_client::create_client(
|
||||
&format!("http://localhost:{port}"),
|
||||
"SECRET_TOKEN".to_string(),
|
||||
);
|
||||
|
||||
// Run first job to completion
|
||||
let completed_job1 = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: "def main(a): return a".to_string(),
|
||||
path: None,
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings:
|
||||
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}))
|
||||
.arg("a", json!("completed_a"))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
|
||||
let job1_id = completed_job1.id;
|
||||
|
||||
// Run second job to completion
|
||||
let completed_job2 = RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: "def main(b): return b".to_string(),
|
||||
path: None,
|
||||
language: ScriptLang::Python3,
|
||||
lock: None,
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
concurrency_settings:
|
||||
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
|
||||
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
|
||||
}))
|
||||
.arg("b", json!("completed_b"))
|
||||
.run_until_complete(&db, false, port)
|
||||
.await;
|
||||
|
||||
let job2_id = completed_job2.id;
|
||||
|
||||
// Call completed/list with include_args=true
|
||||
let response = client
|
||||
.client()
|
||||
.get(format!(
|
||||
"{}/w/test-workspace/jobs/completed/list?include_args=true",
|
||||
client.baseurl()
|
||||
))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
response.status().is_success(),
|
||||
"completed/list should succeed"
|
||||
);
|
||||
|
||||
let jobs: Vec<CompletedJobResponse> = response.json().await?;
|
||||
|
||||
// Find both completed jobs
|
||||
let job1 = jobs
|
||||
.iter()
|
||||
.find(|j| j.id == job1_id.to_string())
|
||||
.expect("should find job1");
|
||||
let job2 = jobs
|
||||
.iter()
|
||||
.find(|j| j.id == job2_id.to_string())
|
||||
.expect("should find job2");
|
||||
|
||||
// Both should have args
|
||||
assert!(job1.args.is_some(), "job1 args should be included");
|
||||
assert!(job2.args.is_some(), "job2 args should be included");
|
||||
|
||||
// Check the args are correct
|
||||
assert_eq!(
|
||||
job1.args.as_ref().unwrap().get("a"),
|
||||
Some(&json!("completed_a")),
|
||||
"job1 should have correct args"
|
||||
);
|
||||
assert_eq!(
|
||||
job2.args.as_ref().unwrap().get("b"),
|
||||
Some(&json!("completed_b")),
|
||||
"job2 should have correct args"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
557
backend/tests/secret_backend_integration.rs
Normal file
557
backend/tests/secret_backend_integration.rs
Normal file
@@ -0,0 +1,557 @@
|
||||
//! Integration tests for HashiCorp Vault secret backend.
|
||||
//!
|
||||
//! These tests require:
|
||||
//! 1. A PostgreSQL database (handled by sqlx test framework)
|
||||
//! 2. A running HashiCorp Vault instance
|
||||
//! 3. The RUN_VAULT_TESTS=1 environment variable to be set
|
||||
//!
|
||||
//! Environment variables:
|
||||
//! - RUN_VAULT_TESTS=1 - Required to run the tests
|
||||
//! - VAULT_ADDR - Vault server address (default: http://127.0.0.1:8200)
|
||||
//! - VAULT_TOKEN - Static token for static token tests (default: test-root-token)
|
||||
//! - BASE_URL - Windmill instance URL for JWT tests (default: http://localhost:8000)
|
||||
//!
|
||||
//! Run tests (static token mode):
|
||||
//! ```bash
|
||||
//! RUN_VAULT_TESTS=1 VAULT_TOKEN=your-token cargo test -p windmill \
|
||||
//! secret_backend_integration --features private,enterprise -- --nocapture
|
||||
//! ```
|
||||
//!
|
||||
//! Run tests (JWT mode - requires Windmill instance running for JWKS endpoint):
|
||||
//! ```bash
|
||||
//! RUN_VAULT_TESTS=1 BASE_URL=http://localhost:8000 cargo test -p windmill \
|
||||
//! secret_backend_integration --features private,enterprise,openidconnect -- --nocapture
|
||||
//! ```
|
||||
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
mod tests {
|
||||
use sqlx::{Pool, Postgres};
|
||||
use std::collections::HashMap;
|
||||
use windmill_common::secret_backend::{
|
||||
migrate_secrets_to_database, migrate_secrets_to_vault, test_vault_connection,
|
||||
SecretBackend, VaultBackend, VaultSettings,
|
||||
};
|
||||
|
||||
/// Check if vault tests should run (requires RUN_VAULT_TESTS=1 env var)
|
||||
fn should_run_vault_tests() -> bool {
|
||||
std::env::var("RUN_VAULT_TESTS")
|
||||
.map(|v| v == "1" || v.to_lowercase() == "true")
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Set up BASE_URL for JWT tests (required for OIDC issuer URL generation)
|
||||
async fn setup_base_url() {
|
||||
let base_url = std::env::var("BASE_URL")
|
||||
.unwrap_or_else(|_| "http://localhost:8000".to_string());
|
||||
let mut url = windmill_common::BASE_URL.write().await;
|
||||
*url = base_url;
|
||||
}
|
||||
|
||||
/// Skip test if RUN_VAULT_TESTS is not set
|
||||
macro_rules! skip_if_no_vault {
|
||||
() => {
|
||||
if !should_run_vault_tests() {
|
||||
println!("Skipping test: RUN_VAULT_TESTS=1 not set");
|
||||
println!("To run vault tests: RUN_VAULT_TESTS=1 cargo test ...");
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn vault_settings_static_token() -> VaultSettings {
|
||||
VaultSettings {
|
||||
address: std::env::var("VAULT_ADDR")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:8200".to_string()),
|
||||
mount_path: "windmill".to_string(),
|
||||
jwt_role: None, // Static token mode
|
||||
namespace: None,
|
||||
token: Some(
|
||||
std::env::var("VAULT_TOKEN").unwrap_or_else(|_| "test-root-token".to_string()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn vault_settings_jwt() -> VaultSettings {
|
||||
VaultSettings {
|
||||
address: std::env::var("VAULT_ADDR")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:8200".to_string()),
|
||||
mount_path: "windmill".to_string(),
|
||||
jwt_role: Some("windmill-secrets".to_string()), // JWT mode
|
||||
namespace: None,
|
||||
token: None, // No static token - use JWT
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Static Token Tests ====================
|
||||
|
||||
/// Test Vault connection with static token
|
||||
#[sqlx::test(fixtures("base", "secret_backend"))]
|
||||
async fn test_vault_connection_static_token(db: Pool<Postgres>) {
|
||||
skip_if_no_vault!();
|
||||
|
||||
let settings = vault_settings_static_token();
|
||||
println!("Testing Vault connection with static token...");
|
||||
println!(" Address: {}", settings.address);
|
||||
|
||||
let result = test_vault_connection(&settings, Some(&db)).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to connect to Vault: {:?}",
|
||||
result.err()
|
||||
);
|
||||
println!("✓ Successfully connected to Vault with static token");
|
||||
}
|
||||
|
||||
/// Test basic CRUD operations with static token
|
||||
#[sqlx::test(fixtures("base", "secret_backend"))]
|
||||
async fn test_vault_crud_static_token(_db: Pool<Postgres>) {
|
||||
skip_if_no_vault!();
|
||||
|
||||
let settings = vault_settings_static_token();
|
||||
let backend = VaultBackend::new(settings);
|
||||
|
||||
let workspace_id = "test-crud-static";
|
||||
let path = "test-secret";
|
||||
let value = "my-super-secret-value-123";
|
||||
|
||||
println!("Testing CRUD with static token...");
|
||||
|
||||
// Create
|
||||
println!(" Creating secret...");
|
||||
backend
|
||||
.set_secret(workspace_id, path, value)
|
||||
.await
|
||||
.expect("Failed to create secret");
|
||||
println!(" ✓ Created");
|
||||
|
||||
// Read
|
||||
println!(" Reading secret...");
|
||||
let read_value = backend
|
||||
.get_secret(workspace_id, path)
|
||||
.await
|
||||
.expect("Failed to read secret");
|
||||
assert_eq!(read_value, value);
|
||||
println!(" ✓ Read (value matches)");
|
||||
|
||||
// Update
|
||||
println!(" Updating secret...");
|
||||
let new_value = "updated-secret-value-456";
|
||||
backend
|
||||
.set_secret(workspace_id, path, new_value)
|
||||
.await
|
||||
.expect("Failed to update secret");
|
||||
let updated = backend
|
||||
.get_secret(workspace_id, path)
|
||||
.await
|
||||
.expect("Failed to read updated secret");
|
||||
assert_eq!(updated, new_value);
|
||||
println!(" ✓ Updated");
|
||||
|
||||
// Delete
|
||||
println!(" Deleting secret...");
|
||||
backend
|
||||
.delete_secret(workspace_id, path)
|
||||
.await
|
||||
.expect("Failed to delete secret");
|
||||
let result = backend.get_secret(workspace_id, path).await;
|
||||
assert!(result.is_err(), "Secret should be deleted");
|
||||
println!(" ✓ Deleted");
|
||||
|
||||
println!("✓ CRUD operations successful with static token");
|
||||
}
|
||||
|
||||
// ==================== JWT Auth Tests ====================
|
||||
|
||||
/// Test Vault connection with JWT authentication
|
||||
#[sqlx::test(fixtures("base", "secret_backend"))]
|
||||
async fn test_vault_connection_jwt(db: Pool<Postgres>) {
|
||||
skip_if_no_vault!();
|
||||
setup_base_url().await;
|
||||
|
||||
let settings = vault_settings_jwt();
|
||||
println!("Testing Vault connection with JWT auth...");
|
||||
println!(" Address: {}", settings.address);
|
||||
println!(" JWT Role: {:?}", settings.jwt_role);
|
||||
println!(" BASE_URL: {}", windmill_common::BASE_URL.read().await.clone());
|
||||
|
||||
let result = test_vault_connection(&settings, Some(&db)).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to connect to Vault with JWT: {:?}",
|
||||
result.err()
|
||||
);
|
||||
println!("✓ Successfully connected to Vault with JWT auth");
|
||||
}
|
||||
|
||||
/// Test basic CRUD operations with JWT authentication
|
||||
#[cfg(feature = "openidconnect")]
|
||||
#[sqlx::test(fixtures("base", "secret_backend"))]
|
||||
async fn test_vault_crud_jwt(db: Pool<Postgres>) {
|
||||
skip_if_no_vault!();
|
||||
setup_base_url().await;
|
||||
|
||||
let settings = vault_settings_jwt();
|
||||
let backend = VaultBackend::new_with_db(settings, db.clone());
|
||||
|
||||
let workspace_id = "test-crud-jwt";
|
||||
let path = "jwt-test-secret";
|
||||
let value = "jwt-authenticated-secret-value";
|
||||
|
||||
println!("Testing CRUD with JWT auth...");
|
||||
|
||||
// Create
|
||||
println!(" Creating secret...");
|
||||
backend
|
||||
.set_secret(workspace_id, path, value)
|
||||
.await
|
||||
.expect("Failed to create secret with JWT");
|
||||
println!(" ✓ Created");
|
||||
|
||||
// Read
|
||||
println!(" Reading secret...");
|
||||
let read_value = backend
|
||||
.get_secret(workspace_id, path)
|
||||
.await
|
||||
.expect("Failed to read secret with JWT");
|
||||
assert_eq!(read_value, value);
|
||||
println!(" ✓ Read (value matches)");
|
||||
|
||||
// Delete (cleanup)
|
||||
println!(" Deleting secret...");
|
||||
backend
|
||||
.delete_secret(workspace_id, path)
|
||||
.await
|
||||
.expect("Failed to delete secret with JWT");
|
||||
println!(" ✓ Deleted");
|
||||
|
||||
println!("✓ CRUD operations successful with JWT auth");
|
||||
}
|
||||
|
||||
// ==================== Migration Tests ====================
|
||||
|
||||
/// Test migration from database to Vault
|
||||
#[sqlx::test(fixtures("base", "secret_backend"))]
|
||||
async fn test_migrate_db_to_vault(db: Pool<Postgres>) {
|
||||
skip_if_no_vault!();
|
||||
|
||||
let settings = vault_settings_static_token();
|
||||
|
||||
// Verify Vault connection
|
||||
test_vault_connection(&settings, Some(&db))
|
||||
.await
|
||||
.expect("Failed to connect to Vault");
|
||||
|
||||
// Check initial state
|
||||
let secrets_before = sqlx::query!(
|
||||
"SELECT workspace_id, path, value FROM variable WHERE is_secret = true ORDER BY workspace_id, path"
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
.expect("Failed to query secrets");
|
||||
|
||||
println!(
|
||||
"Found {} secrets in database before migration:",
|
||||
secrets_before.len()
|
||||
);
|
||||
for s in &secrets_before {
|
||||
println!(" - {}/{}: {} chars", s.workspace_id, s.path, s.value.len());
|
||||
}
|
||||
|
||||
// Run migration
|
||||
println!("\nMigrating secrets to Vault...");
|
||||
let report = migrate_secrets_to_vault(&db, &settings)
|
||||
.await
|
||||
.expect("Migration to Vault failed");
|
||||
|
||||
println!("Migration report:");
|
||||
println!(" Total secrets: {}", report.total_secrets);
|
||||
println!(" Migrated: {}", report.migrated_count);
|
||||
println!(" Failed: {}", report.failed_count);
|
||||
|
||||
if !report.failures.is_empty() {
|
||||
println!(" Failures:");
|
||||
for f in &report.failures {
|
||||
println!(" - {}/{}: {}", f.workspace_id, f.path, f.error);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(report.failed_count, 0, "Migration had failures");
|
||||
assert!(report.migrated_count > 0, "No secrets were migrated");
|
||||
|
||||
// Verify secrets in Vault
|
||||
println!("\nVerifying secrets in Vault...");
|
||||
let vault_backend = VaultBackend::new(settings.clone());
|
||||
|
||||
for secret in &secrets_before {
|
||||
let result = vault_backend
|
||||
.get_secret(&secret.workspace_id, &secret.path)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to read secret {}/{} from Vault: {:?}",
|
||||
secret.workspace_id,
|
||||
secret.path,
|
||||
result.err()
|
||||
);
|
||||
println!(
|
||||
" ✓ {}/{} exists in Vault",
|
||||
secret.workspace_id, secret.path
|
||||
);
|
||||
}
|
||||
|
||||
println!("\n✓ Migration to Vault completed successfully");
|
||||
}
|
||||
|
||||
/// Test migration from Vault back to database
|
||||
#[sqlx::test(fixtures("base", "secret_backend"))]
|
||||
async fn test_migrate_vault_to_db(db: Pool<Postgres>) {
|
||||
skip_if_no_vault!();
|
||||
|
||||
let settings = vault_settings_static_token();
|
||||
|
||||
test_vault_connection(&settings, Some(&db))
|
||||
.await
|
||||
.expect("Failed to connect to Vault");
|
||||
|
||||
// First migrate TO Vault
|
||||
println!("Setting up: migrating secrets to Vault first...");
|
||||
let to_vault = migrate_secrets_to_vault(&db, &settings)
|
||||
.await
|
||||
.expect("Initial migration to Vault failed");
|
||||
assert!(to_vault.migrated_count > 0, "No secrets to test with");
|
||||
println!(" Migrated {} secrets to Vault", to_vault.migrated_count);
|
||||
|
||||
// Clear database values
|
||||
println!("\nClearing database secret values...");
|
||||
sqlx::query!("UPDATE variable SET value = 'CLEARED' WHERE is_secret = true")
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to clear values");
|
||||
|
||||
// Migrate back from Vault
|
||||
println!("\nMigrating secrets from Vault to database...");
|
||||
let report = migrate_secrets_to_database(&db, &settings)
|
||||
.await
|
||||
.expect("Migration to database failed");
|
||||
|
||||
println!("Migration report:");
|
||||
println!(" Total secrets: {}", report.total_secrets);
|
||||
println!(" Migrated: {}", report.migrated_count);
|
||||
println!(" Failed: {}", report.failed_count);
|
||||
|
||||
assert_eq!(report.failed_count, 0, "Migration had failures");
|
||||
assert!(report.migrated_count > 0, "No secrets were migrated");
|
||||
|
||||
// Verify restored
|
||||
let restored = sqlx::query!(
|
||||
"SELECT COUNT(*) as count FROM variable WHERE is_secret = true AND value != 'CLEARED'"
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.expect("Failed to count restored");
|
||||
|
||||
assert!(
|
||||
restored.count.unwrap_or(0) > 0,
|
||||
"No secrets were restored in database"
|
||||
);
|
||||
|
||||
println!("\n✓ Migration to database completed successfully");
|
||||
}
|
||||
|
||||
// ==================== Variable Rename Tests ====================
|
||||
|
||||
/// Test renaming a variable path in Vault
|
||||
#[sqlx::test(fixtures("base", "secret_backend"))]
|
||||
async fn test_variable_rename(db: Pool<Postgres>) {
|
||||
skip_if_no_vault!();
|
||||
let _ = &db; // suppress unused warning
|
||||
|
||||
let settings = vault_settings_static_token();
|
||||
let backend = VaultBackend::new(settings);
|
||||
|
||||
let workspace_id = "test-workspace";
|
||||
let old_path = "u/test-user/old_secret_name";
|
||||
let new_path = "u/test-user/new_secret_name";
|
||||
let value = "secret-value-for-rename-test";
|
||||
|
||||
println!("Testing variable rename in Vault...");
|
||||
|
||||
// Create secret at old path
|
||||
println!(" Creating secret at old path: {}", old_path);
|
||||
backend
|
||||
.set_secret(workspace_id, old_path, value)
|
||||
.await
|
||||
.expect("Failed to create secret");
|
||||
|
||||
// Verify it exists
|
||||
let read_value = backend
|
||||
.get_secret(workspace_id, old_path)
|
||||
.await
|
||||
.expect("Failed to read secret at old path");
|
||||
assert_eq!(read_value, value);
|
||||
println!(" ✓ Secret exists at old path");
|
||||
|
||||
// Simulate rename: read from old, write to new, delete old
|
||||
println!(" Renaming: {} -> {}", old_path, new_path);
|
||||
let secret_value = backend
|
||||
.get_secret(workspace_id, old_path)
|
||||
.await
|
||||
.expect("Failed to read for rename");
|
||||
|
||||
backend
|
||||
.set_secret(workspace_id, new_path, &secret_value)
|
||||
.await
|
||||
.expect("Failed to write to new path");
|
||||
|
||||
backend
|
||||
.delete_secret(workspace_id, old_path)
|
||||
.await
|
||||
.expect("Failed to delete old path");
|
||||
|
||||
// Verify old path is gone
|
||||
let old_result = backend.get_secret(workspace_id, old_path).await;
|
||||
assert!(old_result.is_err(), "Old path should not exist");
|
||||
println!(" ✓ Old path deleted");
|
||||
|
||||
// Verify new path exists with correct value
|
||||
let new_value = backend
|
||||
.get_secret(workspace_id, new_path)
|
||||
.await
|
||||
.expect("Failed to read new path");
|
||||
assert_eq!(new_value, value);
|
||||
println!(" ✓ New path exists with correct value");
|
||||
|
||||
// Cleanup
|
||||
backend
|
||||
.delete_secret(workspace_id, new_path)
|
||||
.await
|
||||
.expect("Failed to cleanup");
|
||||
|
||||
println!("\n✓ Variable rename completed successfully");
|
||||
}
|
||||
|
||||
// ==================== Full Round Trip Test ====================
|
||||
|
||||
/// Test full round-trip: DB -> Vault -> DB with verification
|
||||
#[sqlx::test(fixtures("base", "secret_backend"))]
|
||||
async fn test_full_round_trip(db: Pool<Postgres>) {
|
||||
skip_if_no_vault!();
|
||||
|
||||
let settings = vault_settings_static_token();
|
||||
|
||||
test_vault_connection(&settings, Some(&db))
|
||||
.await
|
||||
.expect("Failed to connect to Vault");
|
||||
|
||||
// Get original secrets
|
||||
let original: HashMap<(String, String), String> = sqlx::query!(
|
||||
"SELECT workspace_id, path, value FROM variable WHERE is_secret = true"
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
.expect("Failed to query")
|
||||
.into_iter()
|
||||
.map(|r| ((r.workspace_id, r.path), r.value))
|
||||
.collect();
|
||||
|
||||
println!("Original secrets: {} entries", original.len());
|
||||
|
||||
// Step 1: DB -> Vault
|
||||
println!("\n=== Step 1: Migrate DB -> Vault ===");
|
||||
let to_vault = migrate_secrets_to_vault(&db, &settings)
|
||||
.await
|
||||
.expect("Migration to Vault failed");
|
||||
println!("Migrated {} secrets to Vault", to_vault.migrated_count);
|
||||
assert_eq!(to_vault.failed_count, 0);
|
||||
|
||||
// Step 2: Clear DB
|
||||
println!("\n=== Step 2: Clear database values ===");
|
||||
sqlx::query!("UPDATE variable SET value = 'ROUND_TRIP_CLEARED' WHERE is_secret = true")
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to clear");
|
||||
|
||||
// Step 3: Vault -> DB
|
||||
println!("\n=== Step 3: Migrate Vault -> DB ===");
|
||||
let to_db = migrate_secrets_to_database(&db, &settings)
|
||||
.await
|
||||
.expect("Migration to database failed");
|
||||
println!("Migrated {} secrets to database", to_db.migrated_count);
|
||||
assert_eq!(to_db.failed_count, 0);
|
||||
|
||||
// Step 4: Verify
|
||||
println!("\n=== Step 4: Verify round-trip integrity ===");
|
||||
let restored: HashMap<(String, String), String> = sqlx::query!(
|
||||
"SELECT workspace_id, path, value FROM variable WHERE is_secret = true"
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
.expect("Failed to query")
|
||||
.into_iter()
|
||||
.map(|r| ((r.workspace_id, r.path), r.value))
|
||||
.collect();
|
||||
|
||||
for ((ws, path), _) in &original {
|
||||
let restored_value = restored
|
||||
.get(&(ws.clone(), path.clone()))
|
||||
.expect(&format!("Secret {}/{} not found after round-trip", ws, path));
|
||||
|
||||
assert_ne!(
|
||||
restored_value, "ROUND_TRIP_CLEARED",
|
||||
"Secret {}/{} was not restored",
|
||||
ws, path
|
||||
);
|
||||
println!(" ✓ {}/{}: restored", ws, path);
|
||||
}
|
||||
|
||||
println!("\n✓ Full round-trip completed successfully!");
|
||||
}
|
||||
|
||||
// ==================== Workspace Isolation Test ====================
|
||||
|
||||
/// Test that workspace isolation is maintained
|
||||
#[sqlx::test(fixtures("base", "secret_backend"))]
|
||||
async fn test_workspace_isolation(db: Pool<Postgres>) {
|
||||
skip_if_no_vault!();
|
||||
|
||||
let settings = vault_settings_static_token();
|
||||
let backend = VaultBackend::new(settings.clone());
|
||||
|
||||
// First migrate secrets to Vault
|
||||
migrate_secrets_to_vault(&db, &settings)
|
||||
.await
|
||||
.expect("Migration failed");
|
||||
|
||||
println!("Testing workspace isolation...");
|
||||
|
||||
// Try to access workspace-2 secret from workspace-1 path (should fail)
|
||||
let cross_access = backend
|
||||
.get_secret("test-workspace", "u/test-user/other_secret")
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
cross_access.is_err(),
|
||||
"Cross-workspace access should fail!"
|
||||
);
|
||||
println!("✓ Cross-workspace access correctly denied");
|
||||
|
||||
// Verify own workspace access works
|
||||
let ws1 = backend
|
||||
.get_secret("test-workspace", "u/test-user/db_password")
|
||||
.await;
|
||||
assert!(ws1.is_ok(), "Same-workspace access should work");
|
||||
println!("✓ Same-workspace access works");
|
||||
|
||||
println!("\n✓ Workspace isolation verified!");
|
||||
}
|
||||
}
|
||||
|
||||
// OSS version - just a placeholder to avoid compilation errors
|
||||
#[cfg(not(all(feature = "private", feature = "enterprise")))]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn test_vault_requires_enterprise() {
|
||||
println!("Vault integration tests require Enterprise Edition features");
|
||||
println!("Run with: cargo test --features private,enterprise,openidconnect");
|
||||
}
|
||||
}
|
||||
323
backend/tests/secret_backend_migration.rs
Normal file
323
backend/tests/secret_backend_migration.rs
Normal file
@@ -0,0 +1,323 @@
|
||||
//! Integration tests for secret backend migration between database and HashiCorp Vault.
|
||||
//!
|
||||
//! These tests require:
|
||||
//! 1. A PostgreSQL database (handled by sqlx test framework)
|
||||
//! 2. A running HashiCorp Vault instance at http://127.0.0.1:8200
|
||||
//!
|
||||
//! To run these tests:
|
||||
//! ```bash
|
||||
//! # Start Vault in dev mode
|
||||
//! podman run -d --name vault-test -p 8200:8200 \
|
||||
//! -e VAULT_DEV_ROOT_TOKEN_ID=test-root-token \
|
||||
//! docker.io/hashicorp/vault:latest
|
||||
//!
|
||||
//! # Enable KV v2 secrets engine
|
||||
//! curl -s -H "X-Vault-Token: test-root-token" -X POST \
|
||||
//! --data '{"type":"kv-v2"}' \
|
||||
//! http://127.0.0.1:8200/v1/sys/mounts/windmill
|
||||
//!
|
||||
//! # Run the tests
|
||||
//! cargo test -p windmill secret_backend_migration -- --ignored --nocapture
|
||||
//! ```
|
||||
|
||||
use sqlx::{Pool, Postgres};
|
||||
use windmill_common::error::Result;
|
||||
use windmill_common::secret_backend::{
|
||||
vault_oss::{migrate_secrets_to_database, migrate_secrets_to_vault, test_vault_connection, VaultBackend},
|
||||
SecretBackend, VaultSettings,
|
||||
};
|
||||
|
||||
mod common;
|
||||
|
||||
fn test_vault_settings() -> VaultSettings {
|
||||
VaultSettings {
|
||||
address: std::env::var("VAULT_ADDR").unwrap_or_else(|_| "http://127.0.0.1:8200".to_string()),
|
||||
mount_path: "windmill".to_string(),
|
||||
jwt_role: Some("windmill-secrets".to_string()),
|
||||
namespace: None,
|
||||
token: Some(
|
||||
std::env::var("VAULT_TOKEN").unwrap_or_else(|_| "test-root-token".to_string()),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Test that we can connect to Vault
|
||||
#[sqlx::test(fixtures("base", "secret_backend"))]
|
||||
#[ignore = "requires running Vault instance"]
|
||||
async fn test_vault_connection_works(db: Pool<Postgres>) {
|
||||
let settings = test_vault_settings();
|
||||
|
||||
let result = test_vault_connection(&settings, Some(&db)).await;
|
||||
assert!(result.is_ok(), "Failed to connect to Vault: {:?}", result.err());
|
||||
println!("✓ Successfully connected to Vault at {}", settings.address);
|
||||
}
|
||||
|
||||
/// Test migration from database to Vault
|
||||
#[sqlx::test(fixtures("base", "secret_backend"))]
|
||||
#[ignore = "requires running Vault instance"]
|
||||
async fn test_migrate_db_to_vault(db: Pool<Postgres>) {
|
||||
let settings = test_vault_settings();
|
||||
|
||||
// First verify we can connect to Vault
|
||||
test_vault_connection(&settings, Some(&db))
|
||||
.await
|
||||
.expect("Failed to connect to Vault");
|
||||
|
||||
// Check initial state - secrets should exist in database
|
||||
let secrets_before = sqlx::query!(
|
||||
"SELECT workspace_id, path, value FROM variable WHERE is_secret = true ORDER BY workspace_id, path"
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
.expect("Failed to query secrets");
|
||||
|
||||
println!("Found {} secrets in database before migration:", secrets_before.len());
|
||||
for s in &secrets_before {
|
||||
println!(" - {}/{}: {} chars", s.workspace_id, s.path, s.value.len());
|
||||
}
|
||||
|
||||
// Run migration to Vault
|
||||
println!("\nMigrating secrets to Vault...");
|
||||
let report = migrate_secrets_to_vault(&db, &settings)
|
||||
.await
|
||||
.expect("Migration to Vault failed");
|
||||
|
||||
println!("Migration report:");
|
||||
println!(" Total secrets: {}", report.total_secrets);
|
||||
println!(" Migrated: {}", report.migrated_count);
|
||||
println!(" Failed: {}", report.failed_count);
|
||||
|
||||
if !report.failures.is_empty() {
|
||||
println!(" Failures:");
|
||||
for f in &report.failures {
|
||||
println!(" - {}/{}: {}", f.workspace_id, f.path, f.error);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(report.failed_count, 0, "Migration had failures");
|
||||
assert!(report.migrated_count > 0, "No secrets were migrated");
|
||||
|
||||
// Verify secrets are in Vault
|
||||
println!("\nVerifying secrets in Vault...");
|
||||
let vault_backend = VaultBackend::new(settings.clone());
|
||||
|
||||
for secret in &secrets_before {
|
||||
let result: Result<String> = vault_backend
|
||||
.get_secret(&secret.workspace_id, &secret.path)
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to read secret {}/{} from Vault: {:?}",
|
||||
secret.workspace_id,
|
||||
secret.path,
|
||||
result.err()
|
||||
);
|
||||
println!(" ✓ {}/{} exists in Vault", secret.workspace_id, secret.path);
|
||||
}
|
||||
|
||||
println!("\n✓ Migration to Vault completed successfully");
|
||||
}
|
||||
|
||||
/// Test migration from Vault to database
|
||||
#[sqlx::test(fixtures("base", "secret_backend"))]
|
||||
#[ignore = "requires running Vault instance"]
|
||||
async fn test_migrate_vault_to_db(db: Pool<Postgres>) {
|
||||
let settings = test_vault_settings();
|
||||
|
||||
// First verify we can connect to Vault
|
||||
test_vault_connection(&settings, Some(&db))
|
||||
.await
|
||||
.expect("Failed to connect to Vault");
|
||||
|
||||
// First, migrate secrets TO Vault so we have something to migrate back
|
||||
println!("Setting up: migrating secrets to Vault first...");
|
||||
let to_vault_report = migrate_secrets_to_vault(&db, &settings)
|
||||
.await
|
||||
.expect("Initial migration to Vault failed");
|
||||
assert!(to_vault_report.migrated_count > 0, "No secrets to test with");
|
||||
println!(" Migrated {} secrets to Vault", to_vault_report.migrated_count);
|
||||
|
||||
// Clear the database values to simulate fresh migration back
|
||||
println!("\nClearing database secret values...");
|
||||
sqlx::query!("UPDATE variable SET value = 'CLEARED' WHERE is_secret = true")
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to clear database values");
|
||||
|
||||
// Verify they were cleared
|
||||
let cleared = sqlx::query!(
|
||||
"SELECT COUNT(*) as count FROM variable WHERE is_secret = true AND value = 'CLEARED'"
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.expect("Failed to count cleared");
|
||||
println!(" Cleared {} secret values in database", cleared.count.unwrap_or(0));
|
||||
|
||||
// Now migrate from Vault back to database
|
||||
println!("\nMigrating secrets from Vault to database...");
|
||||
let report = migrate_secrets_to_database(&db, &settings)
|
||||
.await
|
||||
.expect("Migration to database failed");
|
||||
|
||||
println!("Migration report:");
|
||||
println!(" Total secrets: {}", report.total_secrets);
|
||||
println!(" Migrated: {}", report.migrated_count);
|
||||
println!(" Failed: {}", report.failed_count);
|
||||
|
||||
if !report.failures.is_empty() {
|
||||
println!(" Failures:");
|
||||
for f in &report.failures {
|
||||
println!(" - {}/{}: {}", f.workspace_id, f.path, f.error);
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(report.failed_count, 0, "Migration had failures");
|
||||
assert!(report.migrated_count > 0, "No secrets were migrated");
|
||||
|
||||
// Verify secrets are restored in database
|
||||
println!("\nVerifying secrets in database...");
|
||||
let secrets_after = sqlx::query!(
|
||||
"SELECT workspace_id, path, value FROM variable WHERE is_secret = true AND value != 'CLEARED' ORDER BY workspace_id, path"
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
.expect("Failed to query restored secrets");
|
||||
|
||||
assert!(
|
||||
!secrets_after.is_empty(),
|
||||
"No secrets were restored in database"
|
||||
);
|
||||
|
||||
for s in &secrets_after {
|
||||
println!(" ✓ {}/{}: {} chars", s.workspace_id, s.path, s.value.len());
|
||||
}
|
||||
|
||||
println!("\n✓ Migration to database completed successfully");
|
||||
}
|
||||
|
||||
/// Test full round-trip migration: DB -> Vault -> DB
|
||||
#[sqlx::test(fixtures("base", "secret_backend"))]
|
||||
#[ignore = "requires running Vault instance"]
|
||||
async fn test_full_round_trip_migration(db: Pool<Postgres>) {
|
||||
let settings = test_vault_settings();
|
||||
|
||||
// Verify Vault connection
|
||||
test_vault_connection(&settings, Some(&db))
|
||||
.await
|
||||
.expect("Failed to connect to Vault");
|
||||
|
||||
// Get original secrets
|
||||
let original_secrets: std::collections::HashMap<(String, String), String> = sqlx::query!(
|
||||
"SELECT workspace_id, path, value FROM variable WHERE is_secret = true"
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
.expect("Failed to query original secrets")
|
||||
.into_iter()
|
||||
.map(|r| ((r.workspace_id, r.path), r.value))
|
||||
.collect();
|
||||
|
||||
println!("Original secrets: {} entries", original_secrets.len());
|
||||
|
||||
// Step 1: Migrate to Vault
|
||||
println!("\n=== Step 1: Migrate DB -> Vault ===");
|
||||
let to_vault = migrate_secrets_to_vault(&db, &settings)
|
||||
.await
|
||||
.expect("Migration to Vault failed");
|
||||
println!("Migrated {} secrets to Vault", to_vault.migrated_count);
|
||||
assert_eq!(to_vault.failed_count, 0);
|
||||
|
||||
// Step 2: Clear database values
|
||||
println!("\n=== Step 2: Clear database values ===");
|
||||
sqlx::query!("UPDATE variable SET value = 'ROUND_TRIP_CLEARED' WHERE is_secret = true")
|
||||
.execute(&db)
|
||||
.await
|
||||
.expect("Failed to clear values");
|
||||
|
||||
// Step 3: Migrate back from Vault
|
||||
println!("\n=== Step 3: Migrate Vault -> DB ===");
|
||||
let to_db = migrate_secrets_to_database(&db, &settings)
|
||||
.await
|
||||
.expect("Migration to database failed");
|
||||
println!("Migrated {} secrets to database", to_db.migrated_count);
|
||||
assert_eq!(to_db.failed_count, 0);
|
||||
|
||||
// Step 4: Verify round-trip integrity
|
||||
println!("\n=== Step 4: Verify round-trip integrity ===");
|
||||
let restored_secrets: std::collections::HashMap<(String, String), String> = sqlx::query!(
|
||||
"SELECT workspace_id, path, value FROM variable WHERE is_secret = true"
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await
|
||||
.expect("Failed to query restored secrets")
|
||||
.into_iter()
|
||||
.map(|r| ((r.workspace_id, r.path), r.value))
|
||||
.collect();
|
||||
|
||||
// Compare original and restored
|
||||
for ((ws, path), _original_value) in &original_secrets {
|
||||
let restored_value = restored_secrets
|
||||
.get(&(ws.clone(), path.clone()))
|
||||
.expect(&format!("Secret {}/{} not found after round-trip", ws, path));
|
||||
|
||||
// Note: Values might differ slightly due to encryption/decryption
|
||||
// but they should not be the cleared value
|
||||
assert_ne!(
|
||||
restored_value, "ROUND_TRIP_CLEARED",
|
||||
"Secret {}/{} was not restored",
|
||||
ws, path
|
||||
);
|
||||
println!(" ✓ {}/{}: restored ({} chars)", ws, path, restored_value.len());
|
||||
}
|
||||
|
||||
println!("\n✓ Full round-trip migration completed successfully!");
|
||||
println!(" Original secrets: {}", original_secrets.len());
|
||||
println!(" Restored secrets: {}", restored_secrets.len());
|
||||
}
|
||||
|
||||
/// Test that workspace isolation is maintained during migration
|
||||
#[sqlx::test(fixtures("base", "secret_backend"))]
|
||||
#[ignore = "requires running Vault instance"]
|
||||
async fn test_workspace_isolation(db: Pool<Postgres>) {
|
||||
let settings = test_vault_settings();
|
||||
|
||||
test_vault_connection(&settings, Some(&db))
|
||||
.await
|
||||
.expect("Failed to connect to Vault");
|
||||
|
||||
// Migrate all secrets to Vault
|
||||
let report = migrate_secrets_to_vault(&db, &settings)
|
||||
.await
|
||||
.expect("Migration failed");
|
||||
|
||||
println!("Migrated {} secrets across workspaces", report.migrated_count);
|
||||
|
||||
// Verify workspace isolation in Vault
|
||||
let vault_backend = VaultBackend::new(settings.clone());
|
||||
|
||||
// Try to access test-workspace-2 secret from test-workspace path (should fail)
|
||||
let cross_workspace_result: Result<String> = vault_backend
|
||||
.get_secret("test-workspace", "u/test-user/other_secret")
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
cross_workspace_result.is_err(),
|
||||
"Cross-workspace access should fail - workspace isolation violated!"
|
||||
);
|
||||
println!("✓ Cross-workspace access correctly denied");
|
||||
|
||||
// Verify each workspace's secrets are accessible from their own workspace
|
||||
let ws1_result: Result<String> = vault_backend
|
||||
.get_secret("test-workspace", "u/test-user/db_password")
|
||||
.await;
|
||||
assert!(ws1_result.is_ok(), "test-workspace secret should be accessible");
|
||||
println!("✓ test-workspace secrets accessible");
|
||||
|
||||
let ws2_result: Result<String> = vault_backend
|
||||
.get_secret("test-workspace-2", "u/test-user/other_secret")
|
||||
.await;
|
||||
assert!(ws2_result.is_ok(), "test-workspace-2 secret should be accessible");
|
||||
println!("✓ test-workspace-2 secrets accessible");
|
||||
|
||||
println!("\n✓ Workspace isolation verified!");
|
||||
}
|
||||
241
backend/tests/success_handler.rs
Normal file
241
backend/tests/success_handler.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
use sqlx::{Pool, Postgres};
|
||||
|
||||
mod common;
|
||||
use common::*;
|
||||
|
||||
/// Test that the workspace success handler cache works correctly with 60s TTL
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_success_handler_cache(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
// First, create a success handler script
|
||||
let _server = ApiServer::start(db.clone()).await?;
|
||||
|
||||
// Set up a success handler in workspace_settings
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE workspace_settings
|
||||
SET success_handler = 'script/f/test/success_handler',
|
||||
success_handler_extra_args = '{"key": "value"}'::json
|
||||
WHERE workspace_id = 'test-workspace'
|
||||
"#
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Verify the success handler was set
|
||||
let result = sqlx::query_scalar!(
|
||||
r#"SELECT success_handler FROM workspace_settings WHERE workspace_id = 'test-workspace'"#
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
assert_eq!(result, Some("script/f/test/success_handler".to_string()));
|
||||
|
||||
// Verify extra args were set
|
||||
let extra_args = sqlx::query_scalar!(
|
||||
r#"SELECT success_handler_extra_args FROM workspace_settings WHERE workspace_id = 'test-workspace'"#
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
assert!(extra_args.is_some());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that success handler can be set and removed via database operations
|
||||
#[cfg(feature = "deno_core")]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_success_handler_settings(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
|
||||
let _server = ApiServer::start(db.clone()).await?;
|
||||
|
||||
// Initially success_handler should be NULL
|
||||
let initial = sqlx::query_scalar!(
|
||||
r#"SELECT success_handler FROM workspace_settings WHERE workspace_id = 'test-workspace'"#
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(initial.is_none());
|
||||
|
||||
// Set success handler
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE workspace_settings
|
||||
SET success_handler = 'flow/f/test/success_flow'
|
||||
WHERE workspace_id = 'test-workspace'
|
||||
"#
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let after_set = sqlx::query_scalar!(
|
||||
r#"SELECT success_handler FROM workspace_settings WHERE workspace_id = 'test-workspace'"#
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert_eq!(after_set, Some("flow/f/test/success_flow".to_string()));
|
||||
|
||||
// Remove success handler
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE workspace_settings
|
||||
SET success_handler = NULL
|
||||
WHERE workspace_id = 'test-workspace'
|
||||
"#
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let after_remove = sqlx::query_scalar!(
|
||||
r#"SELECT success_handler FROM workspace_settings WHERE workspace_id = 'test-workspace'"#
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
assert!(after_remove.is_none());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that a successful job completion triggers the success handler
|
||||
#[cfg(all(feature = "deno_core", feature = "enterprise", feature = "private"))]
|
||||
#[sqlx::test(fixtures("base"))]
|
||||
async fn test_success_handler_triggered_on_success(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
use serde_json::json;
|
||||
use windmill_common::jobs::JobPayload;
|
||||
use windmill_common::runnable_settings::{ConcurrencySettings, DebouncingSettings};
|
||||
use windmill_common::scripts::{ScriptHash, ScriptLang};
|
||||
|
||||
initialize_tracing().await;
|
||||
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
|
||||
// Create a simple success handler script
|
||||
// Note: lock must be non-null for script to be considered "deployed"
|
||||
let success_handler_code = r#"
|
||||
export async function main(path: string, email: string, job_id: string, is_flow: boolean, workspace_id: string, result: any) {
|
||||
console.log("Success handler called for job:", job_id);
|
||||
return { handled: true, original_path: path };
|
||||
}
|
||||
"#;
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)
|
||||
VALUES ('test-workspace', 1234567890, 'f/test/success_handler', $1, 'deno', 'script', 'test-user', '{}', 'Success handler script', 'Handles successful job completions', '')
|
||||
"#,
|
||||
success_handler_code
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Create a simple test script that we'll run (needs to be JobKind::Script, not Preview)
|
||||
// Note: lock must be non-null for script to be considered "deployed"
|
||||
let test_script_code = "export function main() { return 'success'; }";
|
||||
let test_script_hash: i64 = 9876543210;
|
||||
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)
|
||||
VALUES ('test-workspace', $1, 'f/test/simple_script', $2, 'deno', 'script', 'test-user', '{}', 'Simple test script', 'A simple test script', '')
|
||||
"#,
|
||||
test_script_hash,
|
||||
test_script_code
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Set up the success handler in workspace_settings
|
||||
sqlx::query!(
|
||||
r#"
|
||||
UPDATE workspace_settings
|
||||
SET success_handler = 'script/f/test/success_handler'
|
||||
WHERE workspace_id = 'test-workspace'
|
||||
"#
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Create the success_handler group
|
||||
sqlx::query!(
|
||||
r#"
|
||||
INSERT INTO group_ (workspace_id, name, summary, extra_perms)
|
||||
VALUES ('test-workspace', 'success_handler', 'The group the success handler acts on behalf of', '{"u/test-user": true}')
|
||||
ON CONFLICT DO NOTHING
|
||||
"#
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
// Run a script using ScriptHash (produces JobKind::Script, not Preview)
|
||||
let completed_job = RunJob::from(JobPayload::ScriptHash {
|
||||
hash: ScriptHash(test_script_hash),
|
||||
path: "f/test/simple_script".to_string(),
|
||||
cache_ttl: None,
|
||||
cache_ignore_s3_path: None,
|
||||
dedicated_worker: None,
|
||||
language: ScriptLang::Deno,
|
||||
priority: None,
|
||||
apply_preprocessor: false,
|
||||
concurrency_settings: ConcurrencySettings::default(),
|
||||
debouncing_settings: DebouncingSettings::default(),
|
||||
})
|
||||
.run_until_complete(&db, false, server.addr.port())
|
||||
.await;
|
||||
|
||||
let result = completed_job.json_result().unwrap();
|
||||
assert_eq!(result, json!("success"));
|
||||
|
||||
let main_job_id = completed_job.id;
|
||||
|
||||
// Wait a short time for the success handler job to be created
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Verify the success handler job was created (query by email since trigger is not set)
|
||||
let success_handler_job = sqlx::query!(
|
||||
r#"
|
||||
SELECT
|
||||
id,
|
||||
runnable_path,
|
||||
permissioned_as_email,
|
||||
parent_job
|
||||
FROM v2_job
|
||||
WHERE workspace_id = 'test-workspace'
|
||||
AND permissioned_as_email = 'success_handler@windmill.dev'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"#
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
success_handler_job.is_some(),
|
||||
"Success handler job should have been created"
|
||||
);
|
||||
|
||||
let handler_job = success_handler_job.unwrap();
|
||||
|
||||
// Verify the success handler job has correct parameters
|
||||
assert_eq!(
|
||||
handler_job.runnable_path.as_deref(),
|
||||
Some("f/test/success_handler"),
|
||||
"Success handler should run the configured script"
|
||||
);
|
||||
assert_eq!(
|
||||
handler_job.permissioned_as_email.as_str(),
|
||||
"success_handler@windmill.dev",
|
||||
"Success handler should run as success_handler user"
|
||||
);
|
||||
assert_eq!(
|
||||
handler_job.parent_job,
|
||||
Some(main_job_id),
|
||||
"Success handler should have main job as parent"
|
||||
);
|
||||
// Note: root_job may be None when it equals parent_job (optimization in push function)
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -10,7 +10,7 @@ path = "src/lib.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
private = ["windmill-audit/private"]
|
||||
private = ["windmill-audit/private", "windmill-common/private"]
|
||||
enterprise = ["windmill-queue/enterprise", "windmill-audit/enterprise", "windmill-git-sync/enterprise", "windmill-common/enterprise", "windmill-worker/enterprise"]
|
||||
stripe = []
|
||||
agent_worker_server = []
|
||||
@@ -157,6 +157,8 @@ google-cloud-googleapis = { workspace = true , optional = true }
|
||||
tonic = { workspace = true, optional = true }
|
||||
deno_error = { workspace = true, optional = true }
|
||||
deno_core = { workspace = true, optional = true }
|
||||
tar.workspace = true
|
||||
flate2.workspace = true
|
||||
backon = {workspace = true, optional = true}
|
||||
|
||||
[build-dependencies]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.606.0
|
||||
version: 1.613.4
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -1411,6 +1411,97 @@ paths:
|
||||
items:
|
||||
$ref: "#/components/schemas/GlobalSetting"
|
||||
|
||||
/min_keep_alive_version:
|
||||
get:
|
||||
summary: get minimum worker version required to stay alive
|
||||
operationId: getMinKeepAliveVersion
|
||||
tags:
|
||||
- setting
|
||||
responses:
|
||||
"200":
|
||||
description: minimum keep-alive version
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/.well-known/jwks.json:
|
||||
get:
|
||||
summary: get JWKS for Vault JWT authentication
|
||||
operationId: getJwks
|
||||
tags:
|
||||
- setting
|
||||
responses:
|
||||
"200":
|
||||
description: JSON Web Key Set
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/JwksResponse"
|
||||
|
||||
/settings/test_secret_backend:
|
||||
post:
|
||||
summary: test secret backend connection (HashiCorp Vault)
|
||||
operationId: testSecretBackend
|
||||
tags:
|
||||
- setting
|
||||
requestBody:
|
||||
description: Vault settings to test
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/VaultSettings"
|
||||
responses:
|
||||
"200":
|
||||
description: connection successful
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/settings/migrate_secrets_to_vault:
|
||||
post:
|
||||
summary: migrate secrets from database to HashiCorp Vault
|
||||
operationId: migrateSecretsToVault
|
||||
tags:
|
||||
- setting
|
||||
requestBody:
|
||||
description: Vault settings for migration target
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/VaultSettings"
|
||||
responses:
|
||||
"200":
|
||||
description: migration report
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SecretMigrationReport"
|
||||
|
||||
/settings/migrate_secrets_to_database:
|
||||
post:
|
||||
summary: migrate secrets from HashiCorp Vault to database
|
||||
operationId: migrateSecretsToDatabase
|
||||
tags:
|
||||
- setting
|
||||
requestBody:
|
||||
description: Vault settings for migration source
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/VaultSettings"
|
||||
responses:
|
||||
"200":
|
||||
description: migration report
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SecretMigrationReport"
|
||||
|
||||
/users/email:
|
||||
get:
|
||||
summary: get current user email (if logged in)
|
||||
@@ -2276,6 +2367,10 @@ paths:
|
||||
$ref: "#/components/schemas/ScriptArgs"
|
||||
error_handler_muted_on_cancel:
|
||||
type: boolean
|
||||
success_handler:
|
||||
type: string
|
||||
success_handler_extra_args:
|
||||
$ref: "#/components/schemas/ScriptArgs"
|
||||
large_file_storage:
|
||||
$ref: "#/components/schemas/LargeFileStorage"
|
||||
ducklake:
|
||||
@@ -2992,6 +3087,34 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/edit_success_handler:
|
||||
post:
|
||||
summary: edit success handler
|
||||
operationId: editSuccessHandler
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
requestBody:
|
||||
description: WorkspaceSuccessHandler
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
success_handler:
|
||||
type: string
|
||||
success_handler_extra_args:
|
||||
$ref: "#/components/schemas/ScriptArgs"
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/edit_large_file_storage_config:
|
||||
post:
|
||||
summary: edit large file storage settings
|
||||
@@ -3397,23 +3520,6 @@ paths:
|
||||
default_app_path:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workspaces/get_large_file_storage_config:
|
||||
get:
|
||||
summary: get large file storage config
|
||||
operationId: getLargeFileStorageConfig
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/LargeFileStorage"
|
||||
|
||||
/w/{workspace}/workspaces/usage:
|
||||
get:
|
||||
summary: get usage
|
||||
@@ -4977,6 +5083,141 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/npm_proxy/metadata/{package}:
|
||||
get:
|
||||
summary: get npm package metadata from private registry
|
||||
operationId: getNpmPackageMetadata
|
||||
tags:
|
||||
- npm_proxy
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: package
|
||||
description: npm package name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: package metadata
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
tags:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
versions:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/npm_proxy/resolve/{package}:
|
||||
get:
|
||||
summary: resolve npm package version from private registry
|
||||
operationId: resolveNpmPackageVersion
|
||||
tags:
|
||||
- npm_proxy
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: package
|
||||
description: npm package name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: tag
|
||||
description: version tag or reference
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: resolved version
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
/w/{workspace}/npm_proxy/filetree/{package}/{version}:
|
||||
get:
|
||||
summary: get npm package file tree from private registry
|
||||
operationId: getNpmPackageFiletree
|
||||
tags:
|
||||
- npm_proxy
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: package
|
||||
description: npm package name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: version
|
||||
description: package version
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: package file tree
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
default:
|
||||
type: string
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/npm_proxy/file/{package}/{version}/{filepath}:
|
||||
get:
|
||||
summary: get specific file from npm package in private registry
|
||||
operationId: getNpmPackageFile
|
||||
tags:
|
||||
- npm_proxy
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: package
|
||||
description: npm package name
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: version
|
||||
description: package version
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- name: filepath
|
||||
description: file path within package
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: file content
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/embeddings/query_resource_types:
|
||||
get:
|
||||
summary: query resource types by similarity
|
||||
@@ -5676,11 +5917,6 @@ paths:
|
||||
tags:
|
||||
- worker
|
||||
parameters:
|
||||
- name: workspace
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
required: false
|
||||
- name: show_workspace_restriction
|
||||
in: query
|
||||
schema:
|
||||
@@ -5696,6 +5932,24 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/workers/custom_tags:
|
||||
get:
|
||||
summary: get custom tags available for this workspace
|
||||
operationId: getCustomTagsForWorkspace
|
||||
tags:
|
||||
- worker
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: list of custom tags for workspace
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
|
||||
/workers/get_default_tags:
|
||||
get:
|
||||
summary: get all instance default tags
|
||||
@@ -9087,6 +9341,25 @@ paths:
|
||||
items:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/jobs/get_otel_traces/{id}:
|
||||
get:
|
||||
summary: get OpenTelemetry traces for a job
|
||||
operationId: getJobOtelTraces
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- $ref: "#/components/parameters/JobId"
|
||||
responses:
|
||||
"200":
|
||||
description: list of OTEL Span objects (compatible with OpenTelemetry Span proto)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
|
||||
/w/{workspace}/trigger/{trigger_kind}/resume_suspended_trigger_jobs/{trigger_path}:
|
||||
post:
|
||||
summary: resume all suspended jobs for a specific trigger
|
||||
@@ -10030,6 +10303,11 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: flow_level
|
||||
in: query
|
||||
description: 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.
|
||||
schema:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: url endpoints
|
||||
@@ -16425,6 +16703,83 @@ components:
|
||||
# -- INLINE END --
|
||||
# Do not change line above
|
||||
|
||||
VaultSettings:
|
||||
type: object
|
||||
required:
|
||||
- address
|
||||
- mount_path
|
||||
properties:
|
||||
address:
|
||||
type: string
|
||||
description: HashiCorp Vault server address (e.g., https://vault.company.com:8200)
|
||||
mount_path:
|
||||
type: string
|
||||
description: KV v2 secrets engine mount path (e.g., windmill)
|
||||
jwt_role:
|
||||
type: string
|
||||
description: Vault JWT auth role name for Windmill (optional, if not provided token auth is used)
|
||||
namespace:
|
||||
type: string
|
||||
description: Vault Enterprise namespace (optional)
|
||||
token:
|
||||
type: string
|
||||
description: Static Vault token for testing/development (optional, if provided this is used instead of JWT authentication)
|
||||
|
||||
SecretMigrationFailure:
|
||||
type: object
|
||||
required:
|
||||
- workspace_id
|
||||
- path
|
||||
- error
|
||||
properties:
|
||||
workspace_id:
|
||||
type: string
|
||||
description: Workspace ID where the secret is located
|
||||
path:
|
||||
type: string
|
||||
description: Path of the secret that failed to migrate
|
||||
error:
|
||||
type: string
|
||||
description: Error message
|
||||
|
||||
SecretMigrationReport:
|
||||
type: object
|
||||
required:
|
||||
- total_secrets
|
||||
- migrated_count
|
||||
- failed_count
|
||||
- failures
|
||||
properties:
|
||||
total_secrets:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Total number of secrets found
|
||||
migrated_count:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Number of secrets successfully migrated
|
||||
failed_count:
|
||||
type: integer
|
||||
format: int64
|
||||
description: Number of secrets that failed to migrate
|
||||
failures:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/SecretMigrationFailure"
|
||||
description: Details of any failures encountered during migration
|
||||
|
||||
JwksResponse:
|
||||
type: object
|
||||
required:
|
||||
- keys
|
||||
properties:
|
||||
keys:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
description: Array of JSON Web Keys for JWT verification
|
||||
|
||||
FlowConversation:
|
||||
type: object
|
||||
required:
|
||||
@@ -17225,6 +17580,7 @@ components:
|
||||
- visible_to_owner
|
||||
- tag
|
||||
|
||||
|
||||
ExportableCompletedJob:
|
||||
type: object
|
||||
description: Completed job with full data for export/import operations
|
||||
@@ -20614,6 +20970,10 @@ components:
|
||||
type: boolean
|
||||
custom_path:
|
||||
type: string
|
||||
raw_app:
|
||||
type: boolean
|
||||
bundle_secret:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- workspace_id
|
||||
@@ -20626,6 +20986,7 @@ components:
|
||||
- policy
|
||||
- execution_mode
|
||||
- extra_perms
|
||||
- raw_app
|
||||
|
||||
AppWithLastVersionWDraft:
|
||||
allOf:
|
||||
|
||||
@@ -6,7 +6,7 @@ use http::{HeaderMap, Method};
|
||||
use quick_cache::sync::Cache;
|
||||
use reqwest::{Client, RequestBuilder};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
use serde_json::{json, value::RawValue};
|
||||
use std::collections::HashMap;
|
||||
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::ai_providers::{AIProvider, ProviderConfig, ProviderModel};
|
||||
@@ -125,6 +125,15 @@ struct AIOAuthResource {
|
||||
user: Option<String>,
|
||||
}
|
||||
|
||||
/// Platform for Anthropic API
|
||||
#[derive(Deserialize, Debug, Clone, Default, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum AnthropicPlatform {
|
||||
#[default]
|
||||
Standard,
|
||||
GoogleVertexAi,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct AIStandardResource {
|
||||
#[serde(alias = "baseUrl")]
|
||||
@@ -137,6 +146,9 @@ struct AIStandardResource {
|
||||
aws_access_key_id: Option<String>,
|
||||
#[serde(alias = "awsSecretAccessKey")]
|
||||
aws_secret_access_key: Option<String>,
|
||||
/// Platform for Anthropic API (standard or google_vertex_ai)
|
||||
#[serde(default)]
|
||||
platform: AnthropicPlatform,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
@@ -161,6 +173,7 @@ struct AIRequestConfig {
|
||||
pub region: Option<String>,
|
||||
pub aws_access_key_id: Option<String>,
|
||||
pub aws_secret_access_key: Option<String>,
|
||||
pub platform: AnthropicPlatform,
|
||||
}
|
||||
|
||||
impl AIRequestConfig {
|
||||
@@ -179,9 +192,11 @@ impl AIRequestConfig {
|
||||
region,
|
||||
aws_access_key_id,
|
||||
aws_secret_access_key,
|
||||
platform,
|
||||
) = match resource {
|
||||
AIResource::Standard(resource) => {
|
||||
let region = resource.region.clone();
|
||||
let platform = resource.platform.clone();
|
||||
let base_url = provider
|
||||
.get_base_url(resource.base_url, resource.region, db)
|
||||
.await?;
|
||||
@@ -216,6 +231,7 @@ impl AIRequestConfig {
|
||||
region,
|
||||
aws_access_key_id,
|
||||
aws_secret_access_key,
|
||||
platform,
|
||||
)
|
||||
}
|
||||
AIResource::OAuth(resource) => {
|
||||
@@ -227,7 +243,17 @@ impl AIRequestConfig {
|
||||
let token = Self::get_token_using_oauth(resource, db, w_id).await?;
|
||||
let base_url = provider.get_base_url(None, None, db).await?;
|
||||
|
||||
(None, Some(token), None, base_url, user, None, None, None)
|
||||
(
|
||||
None,
|
||||
Some(token),
|
||||
None,
|
||||
base_url,
|
||||
user,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
AnthropicPlatform::Standard,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
@@ -240,6 +266,7 @@ impl AIRequestConfig {
|
||||
region,
|
||||
aws_access_key_id,
|
||||
aws_secret_access_key,
|
||||
platform,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -294,8 +321,18 @@ impl AIRequestConfig {
|
||||
|
||||
let is_azure = provider.is_azure_openai(base_url);
|
||||
let is_anthropic = matches!(provider, AIProvider::Anthropic);
|
||||
let is_anthropic_vertex = is_anthropic && self.platform == AnthropicPlatform::GoogleVertexAi;
|
||||
let is_anthropic_sdk = headers.get("X-Anthropic-SDK").is_some();
|
||||
let is_bedrock = matches!(provider, AIProvider::AWSBedrock);
|
||||
let is_google_ai = matches!(provider, AIProvider::GoogleAI);
|
||||
|
||||
// GoogleAI uses OpenAI-compatible endpoint in the proxy (for the chat), but not for the ai agent
|
||||
let base_url = if is_google_ai {
|
||||
format!("{}/openai", base_url)
|
||||
} else {
|
||||
base_url.to_string()
|
||||
};
|
||||
let base_url = base_url.as_str();
|
||||
|
||||
// Check if using IAM credentials for Bedrock (instead of bearer token)
|
||||
let use_iam_auth =
|
||||
@@ -317,6 +354,10 @@ impl AIRequestConfig {
|
||||
let bedrock_base_url = base_url.replace("bedrock-runtime.", "bedrock.");
|
||||
let bedrock_url = format!("{}/{}", bedrock_base_url, path);
|
||||
(bedrock_url, body)
|
||||
} else if is_anthropic_vertex && method != Method::GET {
|
||||
let (model, transformed_body) = transform_anthropic_for_vertex(&body)?;
|
||||
let vertex_url = format!("{}/{}:streamRawPredict", base_url, model);
|
||||
(vertex_url, transformed_body)
|
||||
} else if is_azure {
|
||||
let azure_url = AIProvider::build_azure_openai_url(base_url, path);
|
||||
(azure_url, body)
|
||||
@@ -336,7 +377,12 @@ impl AIRequestConfig {
|
||||
.header("content-type", "application/json");
|
||||
|
||||
for (header_name, header_value) in headers.iter() {
|
||||
// Forward anthropic-* headers, but skip anthropic-version for Vertex AI
|
||||
// (Vertex AI requires anthropic_version in the request body, not as a header)
|
||||
if header_name.to_string().starts_with("anthropic-") {
|
||||
if is_anthropic_vertex && header_name.as_str() == "anthropic-version" {
|
||||
continue;
|
||||
}
|
||||
request = request.header(header_name, header_value);
|
||||
}
|
||||
}
|
||||
@@ -360,13 +406,14 @@ impl AIRequestConfig {
|
||||
}
|
||||
} else {
|
||||
// For non-IAM auth, use bearer token or API key
|
||||
if let Some(api_key) = self.api_key {
|
||||
if let Some(api_key) = self.api_key.clone() {
|
||||
if is_azure {
|
||||
request = request.header("api-key", api_key.clone())
|
||||
} else {
|
||||
request = request.header("authorization", format!("Bearer {}", api_key.clone()))
|
||||
}
|
||||
if is_anthropic {
|
||||
// For standard Anthropic API, also add X-API-Key header
|
||||
if is_anthropic && !is_anthropic_vertex {
|
||||
request = request.header("X-API-Key", api_key);
|
||||
}
|
||||
}
|
||||
@@ -438,6 +485,81 @@ pub struct AIConfig {
|
||||
pub max_tokens_per_model: Option<HashMap<String, i32>>,
|
||||
}
|
||||
|
||||
/// Anthropic API version for Google Vertex AI
|
||||
const ANTHROPIC_VERSION_VERTEX: &str = "vertex-2023-10-16";
|
||||
|
||||
/// Transforms an Anthropic request for Google Vertex AI:
|
||||
/// - Extracts the model from the body (needed for the URL)
|
||||
/// - Adds anthropic_version to the body
|
||||
fn transform_anthropic_for_vertex(body: &Bytes) -> Result<(String, Bytes)> {
|
||||
let mut json_body: HashMap<String, serde_json::Value> = serde_json::from_slice(body)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to parse Anthropic request: {}", e)))?;
|
||||
|
||||
// Extract and remove model from body
|
||||
let model = json_body
|
||||
.remove("model")
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.ok_or_else(|| Error::BadRequest("Missing 'model' field in Anthropic request".to_string()))?;
|
||||
|
||||
// Add anthropic_version to body (required for Vertex AI)
|
||||
json_body.insert(
|
||||
"anthropic_version".to_string(),
|
||||
serde_json::Value::String(ANTHROPIC_VERSION_VERTEX.to_string()),
|
||||
);
|
||||
|
||||
let transformed_body = serde_json::to_vec(&json_body)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to serialize Vertex request: {}", e)))?;
|
||||
|
||||
Ok((model, Bytes::from(transformed_body)))
|
||||
}
|
||||
|
||||
// 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<String>, // code after cursor
|
||||
temperature: Option<f32>,
|
||||
max_tokens: Option<u32>,
|
||||
stop: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// 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 <CURSOR/> 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!(
|
||||
"<PREFIX>\n{}\n<CURSOR/>\n<SUFFIX>\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))
|
||||
}
|
||||
@@ -516,10 +638,10 @@ async fn global_proxy(
|
||||
async fn proxy(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, ai_path)): Path<(String, String)>,
|
||||
Path((w_id, mut ai_path)): Path<(String, String)>,
|
||||
method: Method,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
mut body: Bytes,
|
||||
) -> impl IntoResponse {
|
||||
let provider = headers
|
||||
.get("X-Provider")
|
||||
@@ -600,6 +722,19 @@ async fn proxy(
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Extract model and streaming flag for Bedrock transformation (only for POST requests)
|
||||
let (model, is_streaming) =
|
||||
if matches!(provider, AIProvider::AWSBedrock) && method == Method::POST {
|
||||
|
||||
@@ -129,7 +129,7 @@ pub async fn handle_resume_action(
|
||||
captures.name("approver").map(|m| m.as_str().to_string()),
|
||||
);
|
||||
|
||||
let approver = QueryApprover { approver };
|
||||
let approver = QueryApprover { approver, flow_level: None };
|
||||
|
||||
// Convert job_id and resume_id to appropriate types
|
||||
let job_uuid = Uuid::from_str(job_id)
|
||||
@@ -191,7 +191,7 @@ pub async fn get_approval_form_details(
|
||||
let res = get_resume_urls_internal(
|
||||
axum::Extension(db.clone()),
|
||||
Path((w_id.to_string(), job_id, resume_id)),
|
||||
Query(QueryApprover { approver: approver.map(|a| a.to_string()) }),
|
||||
Query(QueryApprover { approver: approver.map(|a| a.to_string()), flow_level: None }),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ pub fn unauthed_service() -> Router {
|
||||
.route("/download_s3_file/*path", get(download_s3_file_from_app))
|
||||
.route("/public_app/:secret", get(get_public_app_by_secret))
|
||||
.route("/public_resource/*path", get(get_public_resource))
|
||||
.route("/get_data/v/*id", get(get_raw_app_data))
|
||||
}
|
||||
pub fn global_service() -> Router {
|
||||
Router::new()
|
||||
@@ -175,6 +176,9 @@ pub struct AppWithLastVersion {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub custom_path: Option<String>,
|
||||
pub raw_app: bool,
|
||||
#[sqlx(skip)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub bundle_secret: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, FromRow)]
|
||||
@@ -770,7 +774,7 @@ async fn get_public_app_by_secret(
|
||||
"SELECT app.id, app.path, app.summary, app.versions, app.policy, app.custom_path,
|
||||
null as extra_perms, coalesce(app_version_lite.value::json, app_version.value::json) as value,
|
||||
app_version.created_at, app_version.created_by, app_version.raw_app
|
||||
FROM app, app_version
|
||||
FROM app, app_version
|
||||
LEFT JOIN app_version_lite ON app_version_lite.id = app_version.id
|
||||
WHERE app.id = $1 AND app.workspace_id = $2 AND app_version.id = app.versions[array_upper(app.versions, 1)]")
|
||||
.bind(&id)
|
||||
@@ -778,36 +782,37 @@ async fn get_public_app_by_secret(
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
let app = not_found_if_none(app_o, "App", id.to_string())?;
|
||||
let mut app = not_found_if_none(app_o, "App", id.to_string())?;
|
||||
|
||||
let policy = serde_json::from_str::<Policy>(app.policy.0.get()).map_err(to_anyhow)?;
|
||||
|
||||
if matches!(policy.execution_mode, ExecutionMode::Anonymous) {
|
||||
return Ok(Json(app));
|
||||
}
|
||||
|
||||
if opt_authed.is_none() {
|
||||
{
|
||||
if !matches!(policy.execution_mode, ExecutionMode::Anonymous) {
|
||||
if opt_authed.is_none() {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are not logged in".to_string(),
|
||||
));
|
||||
} else {
|
||||
let authed = opt_authed.unwrap();
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let is_visible = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
|
||||
id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if !is_visible.unwrap_or(false) {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let authed = opt_authed.unwrap();
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let is_visible = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM app WHERE id = $1 AND workspace_id = $2)",
|
||||
id,
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
if !is_visible.unwrap_or(false) {
|
||||
return Err(Error::NotAuthorized(
|
||||
"App visibility does not allow public access and you are logged in but you have no read-access to that app".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Compute bundle_secret for raw apps
|
||||
if app.raw_app {
|
||||
app.bundle_secret = Some(compute_bundle_secret(&db, &w_id, &app.versions).await?);
|
||||
}
|
||||
|
||||
Ok(Json(app))
|
||||
@@ -892,6 +897,15 @@ async fn get_secret_id(
|
||||
|
||||
const BUNDLE_SECRET_PREFIX: &str = "bundle_";
|
||||
|
||||
pub async fn compute_bundle_secret(db: &DB, w_id: &str, versions: &[i64]) -> Result<String> {
|
||||
let version_id = versions
|
||||
.last()
|
||||
.ok_or_else(|| Error::internal_err("App has no versions".to_string()))?;
|
||||
let mc = build_crypt(db, w_id).await?;
|
||||
let hx = hex::encode(mc.encrypt_str_to_bytes(format!("{}{}", BUNDLE_SECRET_PREFIX, version_id)));
|
||||
Ok(hx)
|
||||
}
|
||||
|
||||
async fn get_latest_version_secret_id(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
@@ -1303,6 +1317,18 @@ async fn delete_app(
|
||||
));
|
||||
}
|
||||
|
||||
// Check if it's a raw app before deletion
|
||||
let is_raw_app = sqlx::query_scalar!(
|
||||
"SELECT app_version.raw_app FROM app
|
||||
JOIN app_version ON app_version.id = app.versions[array_upper(app.versions, 1)]
|
||||
WHERE app.path = $1 AND app.workspace_id = $2",
|
||||
path,
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
sqlx::query!(
|
||||
@@ -1333,16 +1359,26 @@ async fn delete_app(
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
|
||||
let deployed_object = if is_raw_app {
|
||||
DeployedObject::RawApp {
|
||||
path: path.to_string(),
|
||||
parent_path: Some(path.to_string()),
|
||||
version: 0, // dummy version as it will not get inserted in db
|
||||
}
|
||||
} else {
|
||||
DeployedObject::App {
|
||||
path: path.to_string(),
|
||||
parent_path: Some(path.to_string()),
|
||||
version: 0, // dummy version as it will not get inserted in db
|
||||
}
|
||||
};
|
||||
|
||||
handle_deployment_metadata(
|
||||
&authed.email,
|
||||
&authed.username,
|
||||
&db,
|
||||
&w_id,
|
||||
DeployedObject::App {
|
||||
path: path.to_string(),
|
||||
parent_path: Some(path.to_string()),
|
||||
version: 0, // dummy version as it will not get inserted in db
|
||||
},
|
||||
deployed_object,
|
||||
Some(format!("App '{}' deleted", path)),
|
||||
true,
|
||||
)
|
||||
@@ -2227,6 +2263,7 @@ async fn upload_s3_file_from_app(
|
||||
);
|
||||
let (_, s3_resource_opt) = get_workspace_s3_resource_and_check_paths(
|
||||
&db_with_opt_authed,
|
||||
Some(&on_behalf_authed),
|
||||
&w_id,
|
||||
None,
|
||||
&[(&file_key, S3Permission::WRITE)],
|
||||
@@ -2258,6 +2295,7 @@ async fn upload_s3_file_from_app(
|
||||
DbWithOptAuthed::from_authed(&on_behalf_authed, db.clone(), None);
|
||||
let (_, s3_resource_opt) = get_workspace_s3_resource_and_check_paths(
|
||||
&db_with_opt_authed,
|
||||
Some(&on_behalf_authed),
|
||||
&w_id,
|
||||
None,
|
||||
&[(&file_key, S3Permission::WRITE)],
|
||||
@@ -2297,6 +2335,7 @@ async fn upload_s3_file_from_app(
|
||||
let db_with_opt_authed = DbWithOptAuthed::from_authed(&authed, db.clone(), None);
|
||||
let (_, s3_resource) = get_workspace_s3_resource_and_check_paths(
|
||||
&db_with_opt_authed,
|
||||
Some(&authed),
|
||||
&w_id,
|
||||
None,
|
||||
&[(&file_key, S3Permission::WRITE)],
|
||||
@@ -2337,7 +2376,7 @@ async fn upload_s3_file_from_app(
|
||||
])
|
||||
.into();
|
||||
|
||||
upload_file_from_req(s3_client, &file_key, request, options).await?;
|
||||
let _put_result = upload_file_from_req(s3_client, &file_key, request, options).await?;
|
||||
|
||||
let delete_token = jwt::encode_with_internal_secret(S3DeleteTokenClaims {
|
||||
file_key: file_key.clone(),
|
||||
@@ -2407,6 +2446,7 @@ async fn delete_s3_file_from_app(
|
||||
let db_with_opt_authed = DbWithOptAuthed::from_authed(&on_behalf_authed, db.clone(), None);
|
||||
let (_, s3_resource) = get_workspace_s3_resource_and_check_paths(
|
||||
&db_with_opt_authed,
|
||||
Some(&on_behalf_authed),
|
||||
&w_id,
|
||||
None,
|
||||
&[(&path.to_string(), S3Permission::DELETE)],
|
||||
|
||||
6
backend/windmill-api/src/azure_proxy_oss.rs
Normal file
6
backend/windmill-api/src/azure_proxy_oss.rs
Normal file
@@ -0,0 +1,6 @@
|
||||
// OSS stub for Azure proxy functionality
|
||||
// The actual implementation is in azure_proxy_ee.rs (Enterprise Edition)
|
||||
|
||||
#[cfg(all(feature = "private", feature = "parquet"))]
|
||||
#[allow(unused)]
|
||||
pub use crate::azure_proxy_ee::*;
|
||||
@@ -224,6 +224,7 @@ async fn get_concurrent_intervals(
|
||||
concurrency_key: Some(_),
|
||||
allow_wildcards: None,
|
||||
trigger_kind: _,
|
||||
include_args: _,
|
||||
} => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@@ -34,9 +34,10 @@ use windmill_audit::audit_oss::audit_log;
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::runnable_settings::RunnableSettingsTrait;
|
||||
use windmill_common::utils::query_elems_from_hub;
|
||||
use windmill_common::worker::{
|
||||
to_raw_value, CLOUD_HOSTED, MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2,
|
||||
use windmill_common::min_version::{
|
||||
MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2,
|
||||
};
|
||||
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
|
||||
use windmill_common::HUB_BASE_URL;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
@@ -1476,14 +1477,14 @@ async fn archive_flow_by_path(
|
||||
/// Validates that flow debouncing configuration is supported by all workers
|
||||
/// Returns an error if debouncing is configured but workers are behind required version
|
||||
async fn guard_flow_from_debounce_data(nf: &NewFlow) -> Result<()> {
|
||||
if !*MIN_VERSION_SUPPORTS_DEBOUNCING.read().await
|
||||
if !MIN_VERSION_SUPPORTS_DEBOUNCING.met().await
|
||||
&& !nf.parse_flow_value()?.debouncing_settings.is_default()
|
||||
{
|
||||
tracing::warn!(
|
||||
"Flow debouncing configuration rejected: workers are behind minimum required version for debouncing feature"
|
||||
);
|
||||
Err(Error::WorkersAreBehind { feature: "Debouncing".into(), min_version: "1.566.0".into() })
|
||||
} else if !*MIN_VERSION_SUPPORTS_DEBOUNCING_V2.read().await
|
||||
} else if !MIN_VERSION_SUPPORTS_DEBOUNCING_V2.met().await
|
||||
&& !nf
|
||||
.parse_flow_value()?
|
||||
.debouncing_settings
|
||||
|
||||
@@ -12,7 +12,7 @@ use windmill_common::s3_helpers::StorageResourceType;
|
||||
#[cfg(all(feature = "parquet", not(feature = "private")))]
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
#[cfg(all(feature = "parquet", not(feature = "private")))]
|
||||
use object_store::{ObjectStore, PutMultipartOpts};
|
||||
use object_store::{ObjectStore, PutMultipartOpts, PutResult};
|
||||
#[cfg(all(feature = "parquet", not(feature = "private")))]
|
||||
use std::sync::Arc;
|
||||
#[cfg(not(feature = "private"))]
|
||||
@@ -85,7 +85,7 @@ pub async fn upload_file_from_req(
|
||||
_file_key: &str,
|
||||
_req: axum::extract::Request,
|
||||
_options: PutMultipartOpts,
|
||||
) -> error::Result<()> {
|
||||
) -> error::Result<PutResult> {
|
||||
Err(error::Error::internal_err(
|
||||
"Not implemented in Windmill's Open Source repository".to_string(),
|
||||
))
|
||||
@@ -152,6 +152,7 @@ pub struct DeleteS3FileQuery {
|
||||
#[cfg(not(feature = "private"))]
|
||||
pub async fn get_workspace_s3_resource_and_check_paths<'c>(
|
||||
_db_with_opt_authed: &DbWithOptAuthed<'c, ApiAuthed>,
|
||||
_authed_api: Option<&ApiAuthed>,
|
||||
_w_id: &str,
|
||||
_storage: Option<String>,
|
||||
_paths: &[(&str, windmill_common::s3_helpers::S3Permission)],
|
||||
|
||||
@@ -64,7 +64,9 @@ use crate::{
|
||||
concurrency_groups::join_concurrency_key,
|
||||
db::{ApiAuthed, DB},
|
||||
triggers::trigger_helpers::RunnableId,
|
||||
users::{get_scope_tags, require_owner_of_path, require_path_read_access_for_preview, OptAuthed},
|
||||
users::{
|
||||
get_scope_tags, require_owner_of_path, require_path_read_access_for_preview, OptAuthed,
|
||||
},
|
||||
utils::{check_scopes, content_plain, require_super_admin},
|
||||
};
|
||||
use anyhow::Context;
|
||||
@@ -341,6 +343,7 @@ pub fn workspaced_service() -> Router {
|
||||
"/send_email_with_instance_smtp",
|
||||
post(send_email_with_instance_smtp),
|
||||
)
|
||||
.route("/get_otel_traces/:id", get(get_otel_traces))
|
||||
}
|
||||
|
||||
pub fn workspace_unauthed_service() -> Router {
|
||||
@@ -1765,6 +1768,8 @@ pub struct ListableCompletedJob {
|
||||
pub priority: Option<i16>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub labels: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub args: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
@@ -1852,6 +1857,7 @@ pub struct ListQueueQuery {
|
||||
pub allow_wildcards: Option<bool>,
|
||||
pub trigger_kind: Option<JobTriggerKind>,
|
||||
pub trigger_path: Option<String>,
|
||||
pub include_args: Option<bool>,
|
||||
}
|
||||
|
||||
impl From<ListCompletedQuery> for ListQueueQuery {
|
||||
@@ -1885,6 +1891,7 @@ impl From<ListCompletedQuery> for ListQueueQuery {
|
||||
allow_wildcards: lcq.allow_wildcards,
|
||||
trigger_kind: lcq.trigger_kind,
|
||||
trigger_path: lcq.trigger_path,
|
||||
include_args: lcq.include_args,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2074,6 +2081,16 @@ async fn list_queue_jobs(
|
||||
Query(pagination): Query<Pagination>,
|
||||
Query(lq): Query<ListQueueQuery>,
|
||||
) -> error::JsonResult<Vec<ListableQueuedJob>> {
|
||||
let include_args = lq.include_args.unwrap_or(false);
|
||||
|
||||
if include_args && *CLOUD_HOSTED {
|
||||
return Err(error::Error::BadRequest(
|
||||
"include_args is not supported on cloud hosted Windmill".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let args_field = if include_args { "v2_job.args" } else { "null as args" };
|
||||
|
||||
let sql = list_queue_jobs_query(
|
||||
&w_id,
|
||||
&lq,
|
||||
@@ -2086,7 +2103,7 @@ async fn list_queue_jobs(
|
||||
"v2_job_queue.scheduled_for",
|
||||
"v2_job.runnable_id as script_hash",
|
||||
"v2_job.runnable_path as script_path",
|
||||
"null as args",
|
||||
args_field,
|
||||
"v2_job.kind as job_kind",
|
||||
"CASE WHEN v2_job.trigger_kind = 'schedule' THEN v2_job.trigger END as schedule_path",
|
||||
"v2_job.permissioned_as",
|
||||
@@ -2413,6 +2430,14 @@ async fn list_jobs(
|
||||
Query(pagination): Query<Pagination>,
|
||||
Query(lq): Query<ListCompletedQuery>,
|
||||
) -> error::JsonResult<Vec<Job>> {
|
||||
let include_args = lq.include_args.unwrap_or(false);
|
||||
|
||||
if include_args && *CLOUD_HOSTED {
|
||||
return Err(error::Error::BadRequest(
|
||||
"include_args is not supported on cloud hosted Windmill".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (per_page, offset) = paginate(pagination);
|
||||
let lqc = lq.clone();
|
||||
|
||||
@@ -2425,13 +2450,36 @@ async fn list_jobs(
|
||||
"cannot specify both success and running".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Create dynamic field arrays when include_args is true
|
||||
let cj_fields: Vec<&str>;
|
||||
let qj_fields: Vec<&str>;
|
||||
let cj_fields_ref: &[&str];
|
||||
let qj_fields_ref: &[&str];
|
||||
|
||||
if include_args {
|
||||
cj_fields = UnifiedJob::completed_job_fields()
|
||||
.iter()
|
||||
.map(|f| if *f == "null as args" { "v2_job.args" } else { *f })
|
||||
.collect();
|
||||
qj_fields = UnifiedJob::queued_job_fields()
|
||||
.iter()
|
||||
.map(|f| if *f == "null as args" { "v2_job.args" } else { *f })
|
||||
.collect();
|
||||
cj_fields_ref = &cj_fields;
|
||||
qj_fields_ref = &qj_fields;
|
||||
} else {
|
||||
cj_fields_ref = UnifiedJob::completed_job_fields();
|
||||
qj_fields_ref = UnifiedJob::queued_job_fields();
|
||||
}
|
||||
|
||||
let sqlc = if lq.running.is_none() {
|
||||
Some(list_completed_jobs_query(
|
||||
&w_id,
|
||||
Some(per_page),
|
||||
0,
|
||||
&ListCompletedQuery { order_desc: Some(true), ..lqc },
|
||||
UnifiedJob::completed_job_fields(),
|
||||
cj_fields_ref,
|
||||
true,
|
||||
get_scope_tags(&authed),
|
||||
))
|
||||
@@ -2449,7 +2497,7 @@ async fn list_jobs(
|
||||
let mut sqlq = list_queue_jobs_query(
|
||||
&w_id,
|
||||
&ListQueueQuery { order_desc: Some(true), ..lq.into() },
|
||||
UnifiedJob::queued_job_fields(),
|
||||
qj_fields_ref,
|
||||
Pagination { per_page: None, page: None },
|
||||
true,
|
||||
get_scope_tags(&authed),
|
||||
@@ -2553,22 +2601,28 @@ async fn resume_suspended_job_internal(
|
||||
let value = value.unwrap_or(serde_json::Value::Null);
|
||||
verify_suspended_secret(&w_id, &db, job_id, resume_id, &approver, secret).await?;
|
||||
|
||||
let parent_flow_info = get_suspended_parent_flow_info(job_id, &db).await?;
|
||||
let parent_flow = GetQuery::new()
|
||||
.without_logs()
|
||||
.without_code()
|
||||
.without_flow()
|
||||
.fetch(&db, &parent_flow_info.id, &w_id)
|
||||
.await?;
|
||||
let flow_status = parent_flow
|
||||
.flow_status()
|
||||
.ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?;
|
||||
// Get flow info - works for both step-level (job_id is a step) and flow-level (job_id is the flow)
|
||||
let (flow_info, is_flow_level) = get_flow_info_for_resume(job_id, &db).await?;
|
||||
|
||||
let trigger_email = match &parent_flow {
|
||||
Job::CompletedJob(job) => &job.email,
|
||||
Job::QueuedJob(job) => &job.email,
|
||||
};
|
||||
conditionally_require_authed_user(authed.clone(), flow_status, trigger_email)?;
|
||||
// For step-level resumes, verify user auth and flow status
|
||||
// For flow-level resumes (pre-approvals), the flow might not be at a suspended step yet
|
||||
if !is_flow_level {
|
||||
let parent_flow = GetQuery::new()
|
||||
.without_logs()
|
||||
.without_code()
|
||||
.without_flow()
|
||||
.fetch(&db, &flow_info.id, &w_id)
|
||||
.await?;
|
||||
let flow_status = parent_flow
|
||||
.flow_status()
|
||||
.ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?;
|
||||
|
||||
let trigger_email = match &parent_flow {
|
||||
Job::CompletedJob(job) => &job.email,
|
||||
Job::QueuedJob(job) => &job.email,
|
||||
};
|
||||
conditionally_require_authed_user(authed.clone(), flow_status, trigger_email)?;
|
||||
}
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
r#"
|
||||
@@ -2584,7 +2638,7 @@ async fn resume_suspended_job_internal(
|
||||
return Err(anyhow::anyhow!("resume request already sent").into());
|
||||
}
|
||||
|
||||
let approver = if authed.as_ref().is_none()
|
||||
let approver_value = if authed.as_ref().is_none()
|
||||
|| (approver
|
||||
.approver
|
||||
.clone()
|
||||
@@ -2599,9 +2653,9 @@ async fn resume_suspended_job_internal(
|
||||
insert_resume_job(
|
||||
resume_id,
|
||||
job_id,
|
||||
&parent_flow_info,
|
||||
&flow_info,
|
||||
value,
|
||||
approver.clone(),
|
||||
approver_value.clone(),
|
||||
approved,
|
||||
&mut tx,
|
||||
)
|
||||
@@ -2610,15 +2664,20 @@ async fn resume_suspended_job_internal(
|
||||
if !approved {
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_queue SET suspend = 0 WHERE id = $1",
|
||||
parent_flow_info.id
|
||||
flow_info.id
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
} else if is_flow_level {
|
||||
// For flow-level resumes, decrement the suspend counter if the flow is currently suspended
|
||||
// The approval will be matched when the worker checks for resumes (both step-level and flow-level)
|
||||
resume_immediately_for_flow_level(&flow_info, &mut tx).await?;
|
||||
} else {
|
||||
resume_immediately_if_relevant(parent_flow_info, job_id, &mut tx).await?;
|
||||
// For step-level resumes, try to resume immediately if the step is waiting
|
||||
resume_immediately_if_relevant(flow_info, job_id, &mut tx).await?;
|
||||
}
|
||||
|
||||
let approver = approver.unwrap_or_else(|| "anonymous".to_string());
|
||||
let approver = approver_value.unwrap_or_else(|| "anonymous".to_string());
|
||||
|
||||
let audit_author = match authed {
|
||||
Some(authed) => (&authed).into(),
|
||||
@@ -2709,6 +2768,26 @@ async fn resume_immediately_if_relevant<'c>(
|
||||
)
|
||||
}
|
||||
|
||||
/// For flow-level resumes, decrement the suspend counter if the flow is currently suspended.
|
||||
/// Unlike step-level resumes, we don't check if the job_id matches - we just need the flow
|
||||
/// to be in a suspended state.
|
||||
async fn resume_immediately_for_flow_level<'c>(
|
||||
flow: &FlowInfo,
|
||||
tx: &mut Transaction<'c, Postgres>,
|
||||
) -> error::Result<()> {
|
||||
if flow.suspend > 0 {
|
||||
let new_suspend = flow.suspend - 1;
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_queue SET suspend = $1 WHERE id = $2",
|
||||
new_suspend,
|
||||
flow.id,
|
||||
)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn insert_resume_job<'c>(
|
||||
resume_id: u32,
|
||||
job_id: Uuid,
|
||||
@@ -2745,23 +2824,46 @@ struct FlowInfo {
|
||||
script_path: Option<String>,
|
||||
}
|
||||
|
||||
async fn get_suspended_parent_flow_info(job_id: Uuid, db: &DB) -> error::Result<FlowInfo> {
|
||||
let flow = sqlx::query_as!(
|
||||
FlowInfo,
|
||||
/// Get flow info from either a step job (by looking up its parent) or a flow job directly.
|
||||
/// Returns (FlowInfo, is_flow_level) where is_flow_level indicates if job_id was a flow job.
|
||||
async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowInfo, bool)> {
|
||||
// Single query that determines if job_id is a flow or step, and fetches the appropriate flow info
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
SELECT q.id, f.flow_status, q.suspend, j.runnable_path AS script_path
|
||||
FROM v2_job_queue q
|
||||
JOIN v2_job j USING (id)
|
||||
JOIN v2_job_status f USING (id)
|
||||
WHERE id = ( SELECT parent_job FROM v2_job WHERE id = $1 )
|
||||
FOR UPDATE
|
||||
"#,
|
||||
WITH job_info AS (
|
||||
SELECT id, kind::text AS kind, parent_job
|
||||
FROM v2_job
|
||||
WHERE id = $1
|
||||
)
|
||||
SELECT
|
||||
q.id AS "id!",
|
||||
s.flow_status,
|
||||
q.suspend AS "suspend!",
|
||||
j.runnable_path AS script_path,
|
||||
(ji.kind IN ('flow', 'flowpreview')) AS "is_flow_level!"
|
||||
FROM job_info ji
|
||||
JOIN v2_job_queue q ON q.id = CASE
|
||||
WHEN ji.kind IN ('flow', 'flowpreview') THEN ji.id
|
||||
ELSE ji.parent_job
|
||||
END
|
||||
JOIN v2_job j ON j.id = q.id
|
||||
JOIN v2_job_status s ON s.id = q.id
|
||||
FOR UPDATE OF q
|
||||
"#,
|
||||
job_id,
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?;
|
||||
Ok(flow)
|
||||
.ok_or_else(|| anyhow::anyhow!("job not found or parent flow not in queue: {}", job_id))?;
|
||||
|
||||
let flow_info = FlowInfo {
|
||||
id: result.id,
|
||||
flow_status: result.flow_status,
|
||||
suspend: result.suspend,
|
||||
script_path: result.script_path,
|
||||
};
|
||||
|
||||
Ok((flow_info, result.is_flow_level))
|
||||
}
|
||||
|
||||
async fn get_suspended_flow_info<'c>(
|
||||
@@ -2828,6 +2930,9 @@ pub struct SuspendedJobFlow {
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct QueryApprover {
|
||||
pub approver: Option<String>,
|
||||
/// If true, generate/verify 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.
|
||||
pub flow_level: Option<bool>,
|
||||
}
|
||||
|
||||
pub async fn get_suspended_job_flow(
|
||||
@@ -3084,8 +3189,17 @@ pub async fn get_resume_urls_internal(
|
||||
Query(approver): Query<QueryApprover>,
|
||||
) -> error::JsonResult<ResumeUrls> {
|
||||
let key = get_workspace_key(&w_id, &db).await?;
|
||||
let signature = create_signature(key, job_id, resume_id, approver.approver.clone())?;
|
||||
let approver = approver
|
||||
|
||||
// If flow_level is true, use the parent flow ID for the signature and URLs
|
||||
// This allows pre-approvals that can be consumed by any later suspend step
|
||||
let target_job_id = if approver.flow_level.unwrap_or(false) {
|
||||
get_flow_id_for_job(&db, job_id).await?
|
||||
} else {
|
||||
job_id
|
||||
};
|
||||
|
||||
let signature = create_signature(key, target_job_id, resume_id, approver.approver.clone())?;
|
||||
let approver_query = approver
|
||||
.approver
|
||||
.as_ref()
|
||||
.map(|x| format!("?approver={}", encode(x)))
|
||||
@@ -3095,19 +3209,58 @@ pub async fn get_resume_urls_internal(
|
||||
let base_url = base_url_str.as_str();
|
||||
let res = ResumeUrls {
|
||||
approvalPage: format!(
|
||||
"{base_url}/approve/{w_id}/{job_id}/{resume_id}/{signature}{approver}"
|
||||
"{base_url}/approve/{w_id}/{target_job_id}/{resume_id}/{signature}{approver_query}"
|
||||
),
|
||||
cancel: build_resume_url(
|
||||
"cancel", &w_id, &job_id, &resume_id, &signature, &approver, &base_url,
|
||||
"cancel",
|
||||
&w_id,
|
||||
&target_job_id,
|
||||
&resume_id,
|
||||
&signature,
|
||||
&approver_query,
|
||||
&base_url,
|
||||
),
|
||||
resume: build_resume_url(
|
||||
"resume", &w_id, &job_id, &resume_id, &signature, &approver, &base_url,
|
||||
"resume",
|
||||
&w_id,
|
||||
&target_job_id,
|
||||
&resume_id,
|
||||
&signature,
|
||||
&approver_query,
|
||||
&base_url,
|
||||
),
|
||||
};
|
||||
|
||||
Ok(Json(res))
|
||||
}
|
||||
|
||||
/// Get the flow ID for a job. If the job is a flow, returns the job_id.
|
||||
/// If the job is a step in a flow, returns the parent flow ID.
|
||||
async fn get_flow_id_for_job(db: &DB, job_id: Uuid) -> error::Result<Uuid> {
|
||||
// First check if the job is a flow itself (kind = 'flow' or 'flowpreview')
|
||||
let job_info = sqlx::query!(
|
||||
r#"
|
||||
SELECT kind::text as "kind!", parent_job
|
||||
FROM v2_job
|
||||
WHERE id = $1
|
||||
"#,
|
||||
job_id
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("job not found: {}", job_id))?;
|
||||
|
||||
// If it's a flow job, return the job_id itself
|
||||
if job_info.kind == "flow" || job_info.kind == "flowpreview" {
|
||||
return Ok(job_id);
|
||||
}
|
||||
|
||||
// Otherwise, return the parent flow ID
|
||||
job_info
|
||||
.parent_job
|
||||
.ok_or_else(|| anyhow::anyhow!("job {} has no parent flow", job_id).into())
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow, Debug, Serialize)]
|
||||
pub struct JobExtended<T: JobCommon> {
|
||||
#[sqlx(flatten)]
|
||||
@@ -3353,6 +3506,7 @@ pub struct UnifiedJob {
|
||||
pub running: Option<bool>,
|
||||
pub script_hash: Option<ScriptHash>,
|
||||
pub script_path: Option<String>,
|
||||
pub args: Option<serde_json::Value>,
|
||||
pub duration_ms: Option<i64>,
|
||||
pub success: Option<bool>,
|
||||
pub deleted: bool,
|
||||
@@ -3473,6 +3627,7 @@ impl UnifiedJob {
|
||||
|
||||
impl<'a> From<UnifiedJob> for Job {
|
||||
fn from(uj: UnifiedJob) -> Self {
|
||||
let args = uj.args.and_then(|v| serde_json::from_value(v).ok());
|
||||
match uj.typ.as_ref() {
|
||||
"CompletedJob" => Job::CompletedJob(JobExtended::new(
|
||||
uj.self_wait_time_ms,
|
||||
@@ -3489,7 +3644,7 @@ impl<'a> From<UnifiedJob> for Job {
|
||||
success: uj.success.unwrap(),
|
||||
script_hash: uj.script_hash,
|
||||
script_path: uj.script_path,
|
||||
args: None,
|
||||
args: args.clone(),
|
||||
result: None,
|
||||
result_columns: None,
|
||||
logs: None,
|
||||
@@ -3529,7 +3684,7 @@ impl<'a> From<UnifiedJob> for Job {
|
||||
script_hash: uj.script_hash,
|
||||
script_path: uj.script_path,
|
||||
script_entrypoint_override: None,
|
||||
args: None,
|
||||
args,
|
||||
logs: None,
|
||||
canceled: uj.canceled,
|
||||
canceled_by: uj.canceled_by,
|
||||
@@ -5015,7 +5170,7 @@ pub fn result_to_response(result: Box<RawValue>, success: bool) -> error::Result
|
||||
if success {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
},
|
||||
Json(result),
|
||||
)
|
||||
@@ -5029,7 +5184,7 @@ pub fn result_to_response(result: Box<RawValue>, success: bool) -> error::Result
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
if !success {
|
||||
Ok(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
Ok(StatusCode::UNPROCESSABLE_ENTITY)
|
||||
} else if result_value.is_some() {
|
||||
Ok(StatusCode::OK)
|
||||
} else {
|
||||
@@ -5079,7 +5234,7 @@ pub fn result_to_response(result: Box<RawValue>, success: bool) -> error::Result
|
||||
if success {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
},
|
||||
Json(result),
|
||||
)
|
||||
@@ -8238,6 +8393,7 @@ pub struct ListCompletedQuery {
|
||||
pub allow_wildcards: Option<bool>,
|
||||
pub trigger_kind: Option<JobTriggerKind>,
|
||||
pub trigger_path: Option<String>,
|
||||
pub include_args: Option<bool>,
|
||||
}
|
||||
|
||||
async fn list_completed_jobs(
|
||||
@@ -8247,8 +8403,18 @@ async fn list_completed_jobs(
|
||||
Query(pagination): Query<Pagination>,
|
||||
Query(lq): Query<ListCompletedQuery>,
|
||||
) -> error::JsonResult<Vec<ListableCompletedJob>> {
|
||||
let include_args = lq.include_args.unwrap_or(false);
|
||||
|
||||
if include_args && *CLOUD_HOSTED {
|
||||
return Err(error::Error::BadRequest(
|
||||
"include_args is not supported on cloud hosted Windmill".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let (per_page, offset) = paginate(pagination);
|
||||
|
||||
let args_field = if include_args { "v2_job.args" } else { "null as args" };
|
||||
|
||||
let sql = list_completed_jobs_query(
|
||||
&w_id,
|
||||
Some(per_page),
|
||||
@@ -8284,6 +8450,7 @@ async fn list_completed_jobs(
|
||||
"v2_job.tag",
|
||||
"v2_job.priority",
|
||||
"v2_job_completed.result->'wm_labels' as labels",
|
||||
args_field,
|
||||
"'CompletedJob' as type",
|
||||
],
|
||||
false,
|
||||
@@ -8417,7 +8584,7 @@ async fn get_completed_job_result(
|
||||
&db,
|
||||
suspended_job,
|
||||
resume_id,
|
||||
&QueryApprover { approver },
|
||||
&QueryApprover { approver, flow_level: None },
|
||||
secret,
|
||||
)
|
||||
.await?
|
||||
@@ -8683,3 +8850,63 @@ async fn delete_completed_job<'a>(
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn get_otel_traces(
|
||||
OptAuthed(opt_authed): OptAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, id)): Path<(String, Uuid)>,
|
||||
) -> error::Result<Json<Vec<serde_json::Value>>> {
|
||||
// Check job exists and user has permission to view it
|
||||
let job = sqlx::query_scalar!(
|
||||
"SELECT created_by FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
id,
|
||||
w_id
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
match job {
|
||||
Some(created_by) => {
|
||||
if opt_authed.is_none() && created_by != "anonymous" {
|
||||
return Err(Error::BadRequest(
|
||||
"As a non logged in user, you can only see jobs ran by anonymous users"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
return Err(Error::NotFound(format!("Job {} not found", id)));
|
||||
}
|
||||
}
|
||||
|
||||
let trace_id = id.as_bytes().as_slice();
|
||||
|
||||
let traces = sqlx::query_scalar!(
|
||||
r#"SELECT json_build_object(
|
||||
'trace_id', encode(trace_id, 'hex'), -- BYTEA to hex string
|
||||
'span_id', encode(span_id, 'hex'), -- BYTEA to hex string
|
||||
'parent_span_id', encode(parent_span_id, 'hex'), -- BYTEA to hex string
|
||||
'trace_state', trace_state,
|
||||
'flags', flags,
|
||||
'name', name,
|
||||
'kind', kind,
|
||||
'start_time_unix_nano', start_time_unix_nano,
|
||||
'end_time_unix_nano', end_time_unix_nano,
|
||||
'attributes', attributes,
|
||||
'dropped_attributes_count', dropped_attributes_count,
|
||||
'events', events,
|
||||
'dropped_events_count', dropped_events_count,
|
||||
'links', links,
|
||||
'dropped_links_count', dropped_links_count,
|
||||
'status', status
|
||||
) as "span!"
|
||||
FROM otel_traces
|
||||
WHERE trace_id = $1
|
||||
ORDER BY start_time_unix_nano"#,
|
||||
trace_id
|
||||
)
|
||||
.fetch_all(&db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(traces))
|
||||
}
|
||||
|
||||
@@ -78,6 +78,9 @@ pub mod args;
|
||||
mod assets;
|
||||
mod audit;
|
||||
pub mod auth;
|
||||
#[cfg(all(feature = "private", feature = "parquet"))]
|
||||
pub mod azure_proxy_ee;
|
||||
mod azure_proxy_oss;
|
||||
mod bedrock;
|
||||
mod capture;
|
||||
mod concurrency_groups;
|
||||
@@ -106,6 +109,7 @@ mod inkeep_oss;
|
||||
mod inputs;
|
||||
mod integration;
|
||||
mod live_migrations;
|
||||
mod npm_proxy;
|
||||
#[cfg(feature = "http_trigger")]
|
||||
mod openapi;
|
||||
#[cfg(all(feature = "private", feature = "parquet"))]
|
||||
@@ -147,6 +151,7 @@ pub mod scim_ee;
|
||||
mod scim_oss;
|
||||
mod scopes;
|
||||
mod scripts;
|
||||
mod secret_backend_ext;
|
||||
mod service_logs;
|
||||
mod settings;
|
||||
mod slack_approvals;
|
||||
@@ -355,7 +360,7 @@ pub async fn run_server(
|
||||
{
|
||||
let smtp_server = Arc::new(SmtpServer {
|
||||
db: db.clone(),
|
||||
user_db: user_db,
|
||||
user_db: user_db.clone(),
|
||||
auth_cache: auth_cache.clone(),
|
||||
base_internal_url: _base_internal_url.clone(),
|
||||
});
|
||||
@@ -409,12 +414,15 @@ pub async fn run_server(
|
||||
let (mcp_router, mcp_cancellation_token) = {
|
||||
#[cfg(feature = "mcp")]
|
||||
if server_mode || mcp_mode {
|
||||
let (mcp_router, mcp_cancellation_token) = setup_mcp_server().await?;
|
||||
let mcp_middleware = axum::middleware::from_fn(extract_and_store_workspace_id);
|
||||
(
|
||||
mcp_router.layer(mcp_middleware),
|
||||
Some(mcp_cancellation_token),
|
||||
)
|
||||
use mcp::add_www_authenticate_header;
|
||||
let (mcp_router, mcp_cancellation_token) =
|
||||
setup_mcp_server(db.clone(), user_db).await?;
|
||||
// Apply middleware: auth check inside WWW-Authenticate wrapper so 401s get the header
|
||||
let mcp_router = mcp_router
|
||||
.route_layer(from_extractor::<ApiAuthed>())
|
||||
.layer(axum::middleware::from_fn(add_www_authenticate_header))
|
||||
.layer(axum::middleware::from_fn(extract_and_store_workspace_id));
|
||||
(mcp_router, Some(mcp_cancellation_token))
|
||||
} else {
|
||||
(Router::new(), None)
|
||||
}
|
||||
@@ -494,7 +502,18 @@ pub async fn run_server(
|
||||
#[cfg(not(feature = "oauth2"))]
|
||||
Router::new()
|
||||
})
|
||||
.nest("/mcp/oauth/server", {
|
||||
#[cfg(feature = "mcp")]
|
||||
{
|
||||
// Only /approve requires authentication (called by frontend)
|
||||
mcp::oauth_server::workspaced_authed_service()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
Router::new()
|
||||
})
|
||||
.nest("/ai", ai::workspaced_service())
|
||||
.nest("/npm_proxy", npm_proxy::workspaced_service())
|
||||
.nest("/raw_apps", raw_apps::workspaced_service())
|
||||
.nest("/resources", resources::workspaced_service())
|
||||
.nest("/schedules", schedule::workspaced_service())
|
||||
@@ -504,6 +523,7 @@ pub async fn run_server(
|
||||
users::workspaced_service().layer(Extension(argon2.clone())),
|
||||
)
|
||||
.nest("/variables", variables::workspaced_service())
|
||||
.nest("/workers", workers::workspaced_service())
|
||||
.nest("/workspaces", workspaces::workspaced_service())
|
||||
.nest("/oidc", oidc_oss::workspaced_service())
|
||||
.nest("/openapi", {
|
||||
@@ -537,10 +557,20 @@ pub async fn run_server(
|
||||
.nest("/embeddings", embeddings::global_service())
|
||||
.nest("/ai", ai::global_service())
|
||||
.nest("/inkeep", inkeep_oss::global_service())
|
||||
.nest("/mcp/w/:workspace_id/sse", mcp_router)
|
||||
.nest("/mcp/w/:workspace_id/list_tools", mcp_list_tools_service)
|
||||
.route_layer(from_extractor::<ApiAuthed>())
|
||||
.route_layer(from_extractor::<users::Tokened>())
|
||||
// Workspace-scoped OAuth endpoints that don't require authentication
|
||||
// (authorize and token are called by MCP client before user is authenticated)
|
||||
.nest("/w/:workspace_id/mcp/oauth/server", {
|
||||
#[cfg(feature = "mcp")]
|
||||
{
|
||||
mcp::oauth_server::workspaced_unauthed_service()
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
Router::new()
|
||||
})
|
||||
.nest("/jobs", jobs::global_root_service())
|
||||
.nest(
|
||||
"/srch/w/:workspace_id/index",
|
||||
@@ -579,6 +609,9 @@ pub async fn run_server(
|
||||
.layer(cors.clone()),
|
||||
)
|
||||
.layer(from_extractor::<OptAuthed>())
|
||||
// Deprecated, here for backwards compatibility: user should use /mcp/w/:workspace_id/mcp instead
|
||||
.nest("/mcp/w/:workspace_id/sse", mcp_router.clone())
|
||||
.nest("/mcp/w/:workspace_id/mcp", mcp_router)
|
||||
.nest("/agent_workers", {
|
||||
#[cfg(feature = "agent_worker_server")]
|
||||
{
|
||||
@@ -681,6 +714,15 @@ pub async fn run_server(
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
Router::new()
|
||||
})
|
||||
.nest("/mcp/oauth/server", {
|
||||
#[cfg(feature = "mcp")]
|
||||
{
|
||||
mcp::oauth_server::global_service().layer(cors.clone())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
Router::new()
|
||||
})
|
||||
.nest("/r", {
|
||||
#[cfg(feature = "http_trigger")]
|
||||
{
|
||||
@@ -711,11 +753,44 @@ pub async fn run_server(
|
||||
}
|
||||
})
|
||||
.route("/version", get(git_v))
|
||||
.route("/min_keep_alive_version", get(min_keep_alive_version))
|
||||
.route("/uptodate", get(is_up_to_date))
|
||||
.route("/ee_license", get(ee_license))
|
||||
.route("/openapi.yaml", get(openapi))
|
||||
.route("/openapi.json", get(openapi_json)),
|
||||
)
|
||||
// Clients must use workspace-scoped OAuth metadata at:
|
||||
// /.well-known/oauth-authorization-server/api/w/:workspace_id/mcp/oauth/server
|
||||
// This is discovered via /.well-known/oauth-protected-resource?workspace_id=...
|
||||
.route(
|
||||
"/.well-known/oauth-authorization-server/api/w/:workspace_id/mcp/oauth/server",
|
||||
{
|
||||
#[cfg(feature = "mcp")]
|
||||
{
|
||||
get(mcp::oauth_server::workspaced_oauth_metadata)
|
||||
}
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
{
|
||||
get(|| async { axum::http::StatusCode::NOT_FOUND })
|
||||
}
|
||||
},
|
||||
)
|
||||
// RFC 9728 path-based discovery: /.well-known/oauth-protected-resource/api/mcp/w/:workspace_id/mcp
|
||||
.route(
|
||||
"/.well-known/oauth-protected-resource/api/mcp/w/:workspace_id/mcp",
|
||||
{
|
||||
#[cfg(feature = "mcp")]
|
||||
{
|
||||
get(mcp::oauth_server::protected_resource_metadata_by_path)
|
||||
}
|
||||
#[cfg(not(feature = "mcp"))]
|
||||
{
|
||||
get(|| async { axum::http::StatusCode::NOT_FOUND })
|
||||
}
|
||||
},
|
||||
)
|
||||
// JWKS endpoint for HashiCorp Vault JWT authentication (must be outside /api prefix)
|
||||
.route("/.well-known/jwks.json", get(settings::get_jwks))
|
||||
.fallback(static_assets::static_handler)
|
||||
.layer(middleware_stack);
|
||||
|
||||
@@ -830,6 +905,11 @@ async fn git_v() -> String {
|
||||
format!("CE {GIT_VERSION}")
|
||||
}
|
||||
|
||||
async fn min_keep_alive_version() -> String {
|
||||
let v = windmill_common::min_version::MIN_KEEP_ALIVE_VERSION;
|
||||
format!("{}.{}.{}", v.0, v.1, v.2)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
async fn ee_license() -> &'static str {
|
||||
""
|
||||
|
||||
@@ -10,7 +10,7 @@ use sqlx::Postgres;
|
||||
use std::time::Duration;
|
||||
use tokio::task::JoinHandle;
|
||||
use windmill_common::error::Error;
|
||||
use windmill_common::worker::MIN_VERSION_IS_AT_LEAST_1_461;
|
||||
use windmill_common::min_version::MIN_VERSION_IS_AT_LEAST_1_461;
|
||||
|
||||
use crate::db::{CustomMigrator, DB};
|
||||
use sqlx::migrate::Migrate;
|
||||
@@ -36,7 +36,7 @@ pub async fn custom_migrations(
|
||||
let db2 = db.clone();
|
||||
let v2jh = tokio::task::spawn(async move {
|
||||
loop {
|
||||
if !*MIN_VERSION_IS_AT_LEAST_1_461.read().await {
|
||||
if !MIN_VERSION_IS_AT_LEAST_1_461.met().await {
|
||||
tracing::info!("Waiting for all workers to be at least version 1.461 before applying v2 finalize migration, sleeping for 5s...");
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
continue;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user