Compare commits
60 Commits
wmill-scri
...
windmill-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3768d8bb1b | ||
|
|
ede427d108 | ||
|
|
f1a2e19dbb | ||
|
|
a4cac47085 | ||
|
|
1a73012e07 | ||
|
|
e44504c6e9 | ||
|
|
d7f4b950ce | ||
|
|
f6208af673 | ||
|
|
55ad0ff5c4 | ||
|
|
0885d8c986 | ||
|
|
69ce946241 | ||
|
|
cc67fd9e46 | ||
|
|
6620f5513c | ||
|
|
82f2a3902f | ||
|
|
167084a0eb | ||
|
|
935fb44c84 | ||
|
|
cb8b264dee | ||
|
|
9b3e558d84 | ||
|
|
36a81004dc | ||
|
|
b7475c7309 | ||
|
|
5501b7a729 | ||
|
|
2e2dd511f7 | ||
|
|
1ff14e3f45 | ||
|
|
9e8d4af458 | ||
|
|
ead1ea73af | ||
|
|
0bd756839c | ||
|
|
7f48704cfd | ||
|
|
c28314f424 | ||
|
|
4c8edd5e94 | ||
|
|
8a32322c18 | ||
|
|
0317668089 | ||
|
|
34cf0a0324 | ||
|
|
0904d7fffe | ||
|
|
520706b640 | ||
|
|
b7d14c8614 | ||
|
|
34e3115bcb | ||
|
|
60804a96c6 | ||
|
|
10c5c97d37 | ||
|
|
79d2bd51a0 | ||
|
|
e3620e074e | ||
|
|
0db21aa6b7 | ||
|
|
fe223bffa3 | ||
|
|
1341a1321d | ||
|
|
85c52e2cde | ||
|
|
6060ac3adc | ||
|
|
d578e40101 | ||
|
|
db5e03610d | ||
|
|
a26a2e8092 | ||
|
|
81eb446eee | ||
|
|
8cfaa91d43 | ||
|
|
bdfd5d5726 | ||
|
|
2048a36376 | ||
|
|
3c34d19813 | ||
|
|
7f27d996ac | ||
|
|
6d63d9973d | ||
|
|
23df390b17 | ||
|
|
5089a45881 | ||
|
|
f035b538bb | ||
|
|
47c0c363f4 | ||
|
|
54f5a19377 |
5
.github/workflows/rust-client-check.yml
vendored
5
.github/workflows/rust-client-check.yml
vendored
@@ -18,10 +18,7 @@ jobs:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: cachix/install-nix-action@v20
|
||||
with:
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
- uses: cachix/install-nix-action@v31
|
||||
- name: Check rust client builds
|
||||
run: cd rust-client && nix develop ../ --command ./dev.nu --check
|
||||
timeout-minutes: 16
|
||||
5
.github/workflows/rust_on_release.yml
vendored
5
.github/workflows/rust_on_release.yml
vendored
@@ -10,10 +10,7 @@ jobs:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: cachix/install-nix-action@v20
|
||||
with:
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
- uses: cachix/install-nix-action@v31
|
||||
- run: cd rust-client && nix develop ../ --command ./dev.nu --check --publish
|
||||
env:
|
||||
CRATES_IO_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}
|
||||
|
||||
@@ -55,11 +55,13 @@ profiles:
|
||||
- id: backend
|
||||
kind: command
|
||||
split: right
|
||||
command: ROOT="$(git rev-parse --show-toplevel)"; cd "$ROOT/backend" && cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}"
|
||||
workingDir: backend
|
||||
command: PORT=${BACKEND_PORT:-8000} cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}"
|
||||
- id: frontend
|
||||
kind: command
|
||||
split: bottom
|
||||
command: ROOT="$(git rev-parse --show-toplevel)"; cd "$ROOT/frontend" && npm run generate-backend-client && npm run dev -- --host 0.0.0.0
|
||||
workingDir: frontend
|
||||
command: npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0
|
||||
|
||||
frontendOnly:
|
||||
runtime: host
|
||||
@@ -82,7 +84,8 @@ profiles:
|
||||
- id: frontend
|
||||
kind: command
|
||||
split: right
|
||||
command: ROOT="$(git rev-parse --show-toplevel)"; cd "$ROOT/frontend" && npm run generate-backend-client && npm run dev -- --host 0.0.0.0
|
||||
workingDir: frontend
|
||||
command: npm run generate-backend-client && npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0
|
||||
|
||||
agentOnly:
|
||||
runtime: host
|
||||
|
||||
66
CHANGELOG.md
66
CHANGELOG.md
@@ -1,5 +1,71 @@
|
||||
# Changelog
|
||||
|
||||
## [1.665.0](https://github.com/windmill-labs/windmill/compare/v1.664.0...v1.665.0) (2026-03-26)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add instance setting to enforce workspace prefix for HTTP routes ([#8528](https://github.com/windmill-labs/windmill/issues/8528)) ([9b3e558](https://github.com/windmill-labs/windmill/commit/9b3e558d84f15052e9c32695a467f8ef7e4ad1f5))
|
||||
* add trashbin system for soft-deleting items ([#8519](https://github.com/windmill-labs/windmill/issues/8519)) ([69ce946](https://github.com/windmill-labs/windmill/commit/69ce946241d98ea90bc7135d44ca0c87f928be88))
|
||||
* mask sensitive values in job logs ([#8520](https://github.com/windmill-labs/windmill/issues/8520)) ([0885d8c](https://github.com/windmill-labs/windmill/commit/0885d8c986f13ac210e4db3ad38febe9be391ba4))
|
||||
* move basic git sync from EE to CE with runtime user count gating ([#8493](https://github.com/windmill-labs/windmill/issues/8493)) ([79d2bd5](https://github.com/windmill-labs/windmill/commit/79d2bd51a00654162754046308d7670242120df6))
|
||||
* runner groups for shared-process multi-script dedicated workers ([#8434](https://github.com/windmill-labs/windmill/issues/8434)) ([c28314f](https://github.com/windmill-labs/windmill/commit/c28314f424ea0e04b86565ce88e6c91e0df1a0cf))
|
||||
* SCIM user deprovisioning (active:false) + instance-level user disable ([#8484](https://github.com/windmill-labs/windmill/issues/8484)) ([0bd7568](https://github.com/windmill-labs/windmill/commit/0bd756839c0261f255111d62088bdaaecb838085))
|
||||
* show groups and notes in flow status viewer ([#8535](https://github.com/windmill-labs/windmill/issues/8535)) ([167084a](https://github.com/windmill-labs/windmill/commit/167084a0ebe73384fa0d31f0b24017a47686a072))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* auto-generate datatable SDK reference for app mode system prompt ([#8522](https://github.com/windmill-labs/windmill/issues/8522)) ([8a32322](https://github.com/windmill-labs/windmill/commit/8a32322c187ccc60ec7eafb61a9678f267a82282))
|
||||
* consider wmill.yaml environments alias in git sync ([#8532](https://github.com/windmill-labs/windmill/issues/8532)) ([b7475c7](https://github.com/windmill-labs/windmill/commit/b7475c73094a28f520f798f6cb1a0c6b4807ccb7))
|
||||
* GitHub Enterprise Server support for self-managed GitHub Apps ([#8507](https://github.com/windmill-labs/windmill/issues/8507)) ([935fb44](https://github.com/windmill-labs/windmill/commit/935fb44c848b8bf9430b5600dd3c3bedb2f89efd))
|
||||
* raw apps bundle not found during deployment error ([#8515](https://github.com/windmill-labs/windmill/issues/8515)) ([34e3115](https://github.com/windmill-labs/windmill/commit/34e3115bcbd19a8e0b6f483435586a2ab43d0a8e))
|
||||
* require admin for workspace encryption key export ([#8523](https://github.com/windmill-labs/windmill/issues/8523)) ([0317668](https://github.com/windmill-labs/windmill/commit/031766808945aefc926f0836d011c0b2a5d2243d))
|
||||
* restrict logout redirect to whitelisted domains ([#8524](https://github.com/windmill-labs/windmill/issues/8524)) ([4c8edd5](https://github.com/windmill-labs/windmill/commit/4c8edd5e944d77ed2d41c2b87171c1115c0fdcdc))
|
||||
* serve index disk storage sizes from /srch/ endpoint ([#8511](https://github.com/windmill-labs/windmill/issues/8511)) ([e3620e0](https://github.com/windmill-labs/windmill/commit/e3620e074e1bdb46b2b8d732f35a91d300589663))
|
||||
* use /apps_raw/get/ redirect URL for raw apps set as workspace default ([#8508](https://github.com/windmill-labs/windmill/issues/8508)) ([85c52e2](https://github.com/windmill-labs/windmill/commit/85c52e2cded10606cc895d0d3b717e13c69bc9b3))
|
||||
* use resource-level scope overrides during OAuth2 token refresh ([#8540](https://github.com/windmill-labs/windmill/issues/8540)) ([55ad0ff](https://github.com/windmill-labs/windmill/commit/55ad0ff5c499c33b766f47c6f32ba5d3eeb14763))
|
||||
|
||||
## [1.664.0](https://github.com/windmill-labs/windmill/compare/v1.663.0...v1.664.0) (2026-03-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add instance-level AI settings ([#8453](https://github.com/windmill-labs/windmill/issues/8453)) ([db5e036](https://github.com/windmill-labs/windmill/commit/db5e03610da325288d53afdbca94b9cbfc7ceace))
|
||||
* add selfApproval option to WAC + inline approval buttons ([#8440](https://github.com/windmill-labs/windmill/issues/8440)) ([d578e40](https://github.com/windmill-labs/windmill/commit/d578e40101a838d3dffda14157cf72ee4d5a93c0))
|
||||
* flow group nodes with collapsible groups ([#8075](https://github.com/windmill-labs/windmill/issues/8075)) ([81eb446](https://github.com/windmill-labs/windmill/commit/81eb446eee359f44374b81320690e5345fd08c15))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add GIT_SSL_CAINFO to tracing proxy env vars ([#8502](https://github.com/windmill-labs/windmill/issues/8502)) ([bdfd5d5](https://github.com/windmill-labs/windmill/commit/bdfd5d57261a4bb760fc57ad41ee56aff9b9c0af))
|
||||
* create parent dirs and accept 'python' alias in script bootstrap ([#8497](https://github.com/windmill-labs/windmill/issues/8497)) ([7f27d99](https://github.com/windmill-labs/windmill/commit/7f27d996accb3c3b471d1c50df397867d89c738a))
|
||||
|
||||
## [1.663.0](https://github.com/windmill-labs/windmill/compare/v1.662.0...v1.663.0) (2026-03-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add summary field for native triggers ([#8476](https://github.com/windmill-labs/windmill/issues/8476)) ([5089a45](https://github.com/windmill-labs/windmill/commit/5089a458819abbc6f241bc354bebb91520bd1a52))
|
||||
* add typed request body to OpenAPI spec generation ([#8481](https://github.com/windmill-labs/windmill/issues/8481)) ([37ebaf4](https://github.com/windmill-labs/windmill/commit/37ebaf4d0ac342703498733f97778a552f979f6a))
|
||||
* **cli:** better stale scripts detection [#3](https://github.com/windmill-labs/windmill/issues/3) ([#8480](https://github.com/windmill-labs/windmill/issues/8480)) ([9643006](https://github.com/windmill-labs/windmill/commit/9643006f1e90b991b334bb58caf62301bc26d09d))
|
||||
* Debounce node ([#8324](https://github.com/windmill-labs/windmill/issues/8324)) ([5d1c54d](https://github.com/windmill-labs/windmill/commit/5d1c54d9b33d6ff6f2c98481a2740d1e7629cdfa))
|
||||
* surface permissioned_as selector in trigger editor UI ([#8475](https://github.com/windmill-labs/windmill/issues/8475)) ([f035b53](https://github.com/windmill-labs/windmill/commit/f035b538bbd786445526339f88be8f33a3628105))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* clean up stale dependency map entries for renamed scripts ([#8492](https://github.com/windmill-labs/windmill/issues/8492)) ([47c0c36](https://github.com/windmill-labs/windmill/commit/47c0c363f4fc1d9af7efd07ea172e32989ce50d2))
|
||||
* **cli:** add Svelte 5 event delegation guidance and safe push to raw-app skill ([#8466](https://github.com/windmill-labs/windmill/issues/8466)) ([911df95](https://github.com/windmill-labs/windmill/commit/911df958e78d2dab9823dfa7d7e5c9824fc2d565))
|
||||
* Fix worker panic when job_isolation changed to unshare at runtime ([#8490](https://github.com/windmill-labs/windmill/issues/8490)) ([cbe47c0](https://github.com/windmill-labs/windmill/commit/cbe47c0b6c22f79452d020777e481ee26970f25b))
|
||||
* improve SQS retries ([3c8d351](https://github.com/windmill-labs/windmill/commit/3c8d351c9722a089133871019d27cf3bc3cdc159))
|
||||
* Move database manager SQL queries to backend ([#8306](https://github.com/windmill-labs/windmill/issues/8306)) ([aa30fd2](https://github.com/windmill-labs/windmill/commit/aa30fd252dcf40233d191c43a6293fb9feabf010))
|
||||
* prevent SQL injection in job query parameters ([#8494](https://github.com/windmill-labs/windmill/issues/8494)) ([54f5a19](https://github.com/windmill-labs/windmill/commit/54f5a19377e9df712e18f85f896e21b1776981ed))
|
||||
* respect NO_COLOR env variable for stdout log output ([#8483](https://github.com/windmill-labs/windmill/issues/8483)) ([f329ee7](https://github.com/windmill-labs/windmill/commit/f329ee7aaefbae0ad344743c40825440a936bd30))
|
||||
* show effective isolation level on workers page ([#8491](https://github.com/windmill-labs/windmill/issues/8491)) ([37886ed](https://github.com/windmill-labs/windmill/commit/37886edda1443293806a9b1b810196b72e076b12))
|
||||
* skip debounce arg accumulation when batch table is empty (CE) ([#8485](https://github.com/windmill-labs/windmill/issues/8485)) ([010753c](https://github.com/windmill-labs/windmill/commit/010753c73ac85237af50acadf9c08567b1bc993c))
|
||||
* stop_after_if with empty error_message prevents flow from stopping ([#8464](https://github.com/windmill-labs/windmill/issues/8464)) ([1503bf9](https://github.com/windmill-labs/windmill/commit/1503bf948e3340b8a6933d71885f8f2cb8dc1867))
|
||||
|
||||
## [1.662.0](https://github.com/windmill-labs/windmill/compare/v1.661.0...v1.662.0) (2026-03-20)
|
||||
|
||||
|
||||
|
||||
14
backend/.sqlx/query-08522e494e34f4ecae21460262bf0ed3c5a197dd744c87cb760aaf47001febbd.json
generated
Normal file
14
backend/.sqlx/query-08522e494e34f4ecae21460262bf0ed3c5a197dd744c87cb760aaf47001febbd.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM trashbin WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "08522e494e34f4ecae21460262bf0ed3c5a197dd744c87cb760aaf47001febbd"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_hash,\n service_config\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, error = NULL, updated_at = NOW()\n ",
|
||||
"query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n summary\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, summary = $8, error = NULL, updated_at = NOW()\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -21,10 +21,11 @@
|
||||
"Varchar",
|
||||
"Bool",
|
||||
"Varchar",
|
||||
"Jsonb"
|
||||
"Jsonb",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7"
|
||||
"hash": "1048d1c95270ce1f36c02bce31a2bc8a88935c613bd213b7156299811377db8e"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT $1 OFFSET $2",
|
||||
"query": "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT $1 OFFSET $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -57,6 +57,11 @@
|
||||
"ordinal": 10,
|
||||
"name": "role_source",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "disabled",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -76,8 +81,9 @@
|
||||
true,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab"
|
||||
"hash": "115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ",
|
||||
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -62,6 +62,11 @@
|
||||
"ordinal": 9,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "summary",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -91,8 +96,9 @@
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb"
|
||||
"hash": "15014ce696cf2af4f719a537a4e3ca5b322cc130a35a91f8b8854f5ebdf25ad2"
|
||||
}
|
||||
14
backend/.sqlx/query-192ddae8c3c82a8f099a4944483024d9826a328bf0416c22daf06fff5ced08f6.json
generated
Normal file
14
backend/.sqlx/query-192ddae8c3c82a8f099a4944483024d9826a328bf0416c22daf06fff5ced08f6.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM token WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "192ddae8c3c82a8f099a4944483024d9826a328bf0416c22daf06fff5ced08f6"
|
||||
}
|
||||
28
backend/.sqlx/query-1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef.json
generated
Normal file
28
backend/.sqlx/query-1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef.json
generated
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT ws.default_app AS default_app_path, av.raw_app AS \"default_app_raw: Option<bool>\"\n FROM workspace_settings ws\n LEFT JOIN app ON app.path = ws.default_app AND app.workspace_id = ws.workspace_id\n LEFT JOIN app_version av ON av.id = app.versions[array_upper(app.versions, 1)]\n WHERE ws.workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "default_app_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "default_app_raw: Option<bool>",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef"
|
||||
}
|
||||
15
backend/.sqlx/query-1d995dd5a094631ae96c16d68026fdeb22714af38162e87c02b052a5b8ec2645.json
generated
Normal file
15
backend/.sqlx/query-1d995dd5a094631ae96c16d68026fdeb22714af38162e87c02b052a5b8ec2645.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM trashbin WHERE workspace_id = $1 AND id = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1d995dd5a094631ae96c16d68026fdeb22714af38162e87c02b052a5b8ec2645"
|
||||
}
|
||||
22
backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json
generated
Normal file
22
backend/.sqlx/query-21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "count",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af"
|
||||
}
|
||||
28
backend/.sqlx/query-23b9c862d050b00aaa332527b62ef901cd3c417b9f3af03f35009213143bd443.json
generated
Normal file
28
backend/.sqlx/query-23b9c862d050b00aaa332527b62ef901cd3c417b9f3af03f35009213143bd443.json
generated
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, disabled FROM password WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "email",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "disabled",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "23b9c862d050b00aaa332527b62ef901cd3c417b9f3af03f35009213143bd443"
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT parent_job, flow_step_id FROM v2_job WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "parent_job",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "flow_step_id",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "32ca7941db013dacd2479962fa9ed5c8c64daec45ba820a6c8f7d7ab76cc40c9"
|
||||
}
|
||||
12
backend/.sqlx/query-3453c0b7dd3c4d2c9bc639f379901741955502c9345e82a9b7fbbf3d3c7ab517.json
generated
Normal file
12
backend/.sqlx/query-3453c0b7dd3c4d2c9bc639f379901741955502c9345e82a9b7fbbf3d3c7ab517.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM trashbin WHERE expires_at <= now()",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "3453c0b7dd3c4d2c9bc639f379901741955502c9345e82a9b7fbbf3d3c7ab517"
|
||||
}
|
||||
65
backend/.sqlx/query-446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7.json
generated
Normal file
65
backend/.sqlx/query-446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7.json
generated
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, workspace_id, item_kind, item_path, item_data, deleted_by, deleted_at, expires_at\n FROM trashbin\n WHERE workspace_id = $1 AND id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "item_kind",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "item_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "item_data",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "deleted_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "deleted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7"
|
||||
}
|
||||
61
backend/.sqlx/query-51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef.json
generated
Normal file
61
backend/.sqlx/query-51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef.json
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, workspace_id, item_kind, item_path, deleted_by, deleted_at, expires_at\n FROM trashbin\n WHERE workspace_id = $1 AND item_kind = $2\n ORDER BY deleted_at DESC\n LIMIT $3 OFFSET $4",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "item_kind",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "item_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "deleted_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "deleted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef"
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url FROM account WHERE workspace_id = $1 AND id = $2",
|
||||
"query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, scopes FROM account WHERE workspace_id = $1 AND id = $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -32,6 +32,11 @@
|
||||
"ordinal": 5,
|
||||
"name": "cc_token_url",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "scopes",
|
||||
"type_info": "TextArray"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -46,8 +51,9 @@
|
||||
false,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "cc269052ffc1e613d7edc31f0f7bb84f6e6301ad1afb028813105a121a69fa7e"
|
||||
"hash": "63c48fde8c0c0fff9abffc3be27e9948556b636b70b818cc31c2d50921a27366"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ",
|
||||
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -62,6 +62,11 @@
|
||||
"ordinal": 9,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "summary",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -92,8 +97,9 @@
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce"
|
||||
"hash": "6dafcc89668fb0e5740f23264b515b1724c36031da39d53ae6c329a479bdf8aa"
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "debounced_times",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "first_started_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "job_id_to_debounce",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "79b82ae996fba2e2ab53fcf84c108cb1ca21fbdba3373af54fadf1f4af324073"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url) VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, $5, $6, $7, $8, $9) RETURNING id",
|
||||
"query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url, scopes) VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, $5, $6, $7, $8, $9, $10) RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -19,12 +19,13 @@
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Text"
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "b1bd088c2e1aca3104bede7d0953369b6b17ad3ad62692ae6f2303be890e6391"
|
||||
"hash": "870e1c3f0dc1aaa07ac74a2e37721ce352ad4fb67d36c19dce09d841e36f85dd"
|
||||
}
|
||||
32
backend/.sqlx/query-87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30.json
generated
Normal file
32
backend/.sqlx/query-87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30.json
generated
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n route_path,\n workspace_id,\n http_method::TEXT AS \"http_method!\"\n FROM\n http_trigger\n WHERE\n workspaced_route IS FALSE\n AND route_path_key IN (\n SELECT\n route_path_key\n FROM\n http_trigger\n WHERE\n workspaced_route IS FALSE\n GROUP BY\n route_path_key, http_method\n HAVING COUNT(*) > 1\n )\n ORDER BY route_path_key\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "route_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "http_method!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30"
|
||||
}
|
||||
26
backend/.sqlx/query-8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f.json
generated
Normal file
26
backend/.sqlx/query-8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f.json
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO trashbin (workspace_id, item_kind, item_path, item_data, deleted_by)\n VALUES ($1, $2, $3, $4, $5) RETURNING id",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Varchar",
|
||||
"Jsonb",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f"
|
||||
}
|
||||
15
backend/.sqlx/query-8bd266705fc8272f3d8941922ad7d18161eb6f5ec1ba9f1b55feffe8b6518c67.json
generated
Normal file
15
backend/.sqlx/query-8bd266705fc8272f3d8941922ad7d18161eb6f5ec1ba9f1b55feffe8b6518c67.json
generated
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE password SET disabled = $1 WHERE email = $2",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Bool",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8bd266705fc8272f3d8941922ad7d18161eb6f5ec1ba9f1b55feffe8b6518c67"
|
||||
}
|
||||
60
backend/.sqlx/query-92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32.json
generated
Normal file
60
backend/.sqlx/query-92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32.json
generated
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT id, workspace_id, item_kind, item_path, deleted_by, deleted_at, expires_at\n FROM trashbin\n WHERE workspace_id = $1\n ORDER BY deleted_at DESC\n LIMIT $2 OFFSET $3",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "workspace_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "item_kind",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "item_path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "deleted_by",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "deleted_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "expires_at",
|
||||
"type_info": "Timestamptz"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32"
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH RECURSIVE chain AS (\n SELECT\n j.id,\n j.parent_job,\n j.flow_step_id,\n 1 AS depth\n FROM v2_job j\n WHERE j.id = $1\n UNION ALL\n SELECT\n pj.id,\n pj.parent_job,\n pj.flow_step_id,\n c.depth + 1\n FROM chain c\n JOIN v2_job pj ON pj.id = c.parent_job\n WHERE c.parent_job IS NOT NULL\n )\n SELECT\n c.id,\n c.parent_job,\n c.flow_step_id,\n EXISTS(SELECT 1 FROM v2_job_queue q WHERE q.id = c.parent_job) AS \"parent_in_queue!\",\n EXISTS(\n SELECT 1 FROM v2_job sib\n WHERE sib.parent_job = c.parent_job\n AND sib.id != c.id\n AND sib.id IN (SELECT sq.id FROM v2_job_queue sq)\n ) AS \"has_other_active_siblings!\"\n FROM chain c\n WHERE c.depth >= 1\n ORDER BY c.depth ASC\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "id",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "parent_job",
|
||||
"type_info": "Uuid"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "flow_step_id",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "parent_in_queue!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "has_other_active_siblings!",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "950f364c9fa3c680eea895558a559f29220c08e94e1822e3bcb5c6ed6aa7d2bb"
|
||||
}
|
||||
20
backend/.sqlx/query-975099ff6b07718ea94bcb5f84a4414c59199964cee53ef2ee6b35a78cf0c49a.json
generated
Normal file
20
backend/.sqlx/query-975099ff6b07718ea94bcb5f84a4414c59199964cee53ef2ee6b35a78cf0c49a.json
generated
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT value FROM global_settings WHERE name = 'ai_config'",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "value",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "975099ff6b07718ea94bcb5f84a4414c59199964cee53ef2ee6b35a78cf0c49a"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT args as \"args: sqlx::types::Json<Box<RawValue>>\"\n FROM v2_job\n WHERE id = $1",
|
||||
"query": "SELECT args as \"args: sqlx::types::Json<Box<RawValue>>\"\n FROM v2_job\n WHERE id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -18,5 +18,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "d1dcc7fc8a1e1bc4dad263ec5163a94fca9dd95cc3b26b33611eab9d2a261141"
|
||||
"hash": "97cf826b271cf064182382c924188fee392ed9cff6ae446abc86170984304a25"
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "debounced_times",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "first_started_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "job_id_to_debounce",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false ORDER BY created_at DESC LIMIT 1",
|
||||
"query": "SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false AND archived = false ORDER BY created_at DESC LIMIT 1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -19,5 +19,5 @@
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d5661c7557cf3a8dee7cf799cd364d21d38edb827d2c08b0ca7d72311b78d574"
|
||||
"hash": "a32d7ba43745226fd65328475731526e0b20ea6eeafeb937eb01cdc2cdfcb859"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2",
|
||||
"query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source, disabled\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -57,6 +57,11 @@
|
||||
"ordinal": 10,
|
||||
"name": "role_source",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "disabled",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -76,8 +81,9 @@
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce"
|
||||
"hash": "a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n )\n LIMIT $3\n OFFSET $4\n ",
|
||||
"query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at,\n nt.summary\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n )\n LIMIT $3\n OFFSET $4\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -62,6 +62,11 @@
|
||||
"ordinal": 9,
|
||||
"name": "updated_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 10,
|
||||
"name": "summary",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -94,8 +99,9 @@
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e"
|
||||
"hash": "b0775af41a9b54cce040bf37cae770e63dab12a1383a0855d105c061e4e4ca48"
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "UPDATE v2_job_status SET flow_status = (\n SELECT jsonb_set(\n flow_status,\n ARRAY['modules', (idx - 1)::text],\n $2::jsonb\n )\n FROM jsonb_array_elements(flow_status->'modules')\n WITH ORDINALITY arr(elem, idx)\n WHERE elem->>'id' = $3\n LIMIT 1\n ) WHERE id = $1 AND (\n SELECT COUNT(*) FROM jsonb_array_elements(flow_status->'modules')\n WITH ORDINALITY arr(elem, idx)\n WHERE elem->>'id' = $3\n ) > 0",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Jsonb",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "b1979a8249557d29e9055fde06191688f3ed0efd3a43e81f4ea296255248092c"
|
||||
}
|
||||
14
backend/.sqlx/query-bae31609123da68d16bea8e0f1c4624403b6f97e13f13f056501fe2f4efb0f06.json
generated
Normal file
14
backend/.sqlx/query-bae31609123da68d16bea8e0f1c4624403b6f97e13f13f056501fe2f4efb0f06.json
generated
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "DELETE FROM trashbin WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "bae31609123da68d16bea8e0f1c4624403b6f97e13f13f056501fe2f4efb0f06"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n UPDATE native_trigger\n SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, error = NULL, updated_at = NOW()\n WHERE\n workspace_id = $5\n AND service_name = $6\n AND external_id = $7\n ",
|
||||
"query": "\n UPDATE native_trigger\n SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, summary = $8, error = NULL, updated_at = NOW()\n WHERE\n workspace_id = $5\n AND service_name = $6\n AND external_id = $7\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -21,10 +21,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Text"
|
||||
"Text",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e"
|
||||
"hash": "bf224f6441c36187f1402f9f01bfe15bb9edfa1dc9052f8a829e486b7334d708"
|
||||
}
|
||||
23
backend/.sqlx/query-c1fd495abb4353b46361ec94fd4ae8d224457171b1b73fe145d28e67f1fe03af.json
generated
Normal file
23
backend/.sqlx/query-c1fd495abb4353b46361ec94fd4ae8d224457171b1b73fe145d28e67f1fe03af.json
generated
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2)",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "exists",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "c1fd495abb4353b46361ec94fd4ae8d224457171b1b73fe145d28e67f1fe03af"
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "debounced_times",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "first_started_at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "job_id_to_debounce",
|
||||
"type_info": "Uuid"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Varchar"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b"
|
||||
}
|
||||
22
backend/.sqlx/query-ccc49a2a6e11f874825365de758bdc0e1934d67d3f2b14047d434b77d370af21.json
generated
Normal file
22
backend/.sqlx/query-ccc49a2a6e11f874825365de758bdc0e1934d67d3f2b14047d434b77d370af21.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT super_admin FROM password WHERE email = $1 AND disabled = false",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "super_admin",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "ccc49a2a6e11f874825365de758bdc0e1934d67d3f2b14047d434b77d370af21"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO password (email, login_type, verified, username, name) VALUES ($1, 'saml', true, $2, $3) ON CONFLICT DO NOTHING",
|
||||
"query": "INSERT INTO password (email, login_type, verified, username, name) VALUES ($1, 'saml', true, $2, $3) ON CONFLICT (email) DO UPDATE SET disabled = false",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -12,5 +12,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "638d3c2ba1198dce5b5b0e47df59a92ff8011e19fbefcc3960d6f0fe167e55b6"
|
||||
"hash": "daa1a6bf3d4a1001da88301932a7ac9019767074158e0c027988e5b0d51a3656"
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT EXISTS(\n SELECT 1 FROM v2_job\n WHERE parent_job = $1 AND id != $2\n AND id IN (SELECT id FROM v2_job_queue)\n ) as has",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "has",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Uuid",
|
||||
"Uuid"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "ecf67b08d327c351909b7ba80218903bec93ef79a71053c00227e17c6f0415a2"
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT default_app FROM workspace_settings WHERE workspace_id = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "default_app",
|
||||
"type_info": "Varchar"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password WHERE email = $1",
|
||||
"query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -57,6 +57,11 @@
|
||||
"ordinal": 10,
|
||||
"name": "role_source",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 11,
|
||||
"name": "disabled",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -75,8 +80,9 @@
|
||||
true,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9"
|
||||
"hash": "f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493"
|
||||
}
|
||||
104
backend/.sqlx/query-f2fa27ed5020aa9c085176b25466be1bceb79c82e7b5542f17b23b6d70cc02d6.json
generated
Normal file
104
backend/.sqlx/query-f2fa27ed5020aa9c085176b25466be1bceb79c82e7b5542f17b23b6d70cc02d6.json
generated
Normal file
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT DISTINCT ON (path) path, language AS \"language: ScriptLang\", content FROM script\n WHERE workspace_id = $1\n AND archived = false\n AND dedicated_worker = true\n AND language = ANY($2::SCRIPT_LANG[])\n ORDER BY path, created_at DESC",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "path",
|
||||
"type_info": "Varchar"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "language: ScriptLang",
|
||||
"type_info": {
|
||||
"Custom": {
|
||||
"name": "script_lang",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"python3",
|
||||
"deno",
|
||||
"go",
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
"snowflake",
|
||||
"graphql",
|
||||
"powershell",
|
||||
"mssql",
|
||||
"php",
|
||||
"bunnative",
|
||||
"rust",
|
||||
"ansible",
|
||||
"csharp",
|
||||
"oracledb",
|
||||
"nu",
|
||||
"java",
|
||||
"duckdb",
|
||||
"ruby"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "content",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
{
|
||||
"Custom": {
|
||||
"name": "script_lang[]",
|
||||
"kind": {
|
||||
"Array": {
|
||||
"Custom": {
|
||||
"name": "script_lang",
|
||||
"kind": {
|
||||
"Enum": [
|
||||
"python3",
|
||||
"deno",
|
||||
"go",
|
||||
"bash",
|
||||
"postgresql",
|
||||
"nativets",
|
||||
"bun",
|
||||
"mysql",
|
||||
"bigquery",
|
||||
"snowflake",
|
||||
"graphql",
|
||||
"powershell",
|
||||
"mssql",
|
||||
"php",
|
||||
"bunnative",
|
||||
"rust",
|
||||
"ansible",
|
||||
"csharp",
|
||||
"oracledb",
|
||||
"nu",
|
||||
"java",
|
||||
"duckdb",
|
||||
"ruby"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f2fa27ed5020aa9c085176b25466be1bceb79c82e7b5542f17b23b6d70cc02d6"
|
||||
}
|
||||
22
backend/.sqlx/query-fc6c6310ae8ac5eb351d7e2af1678447d0aa3d143e94e49924ff7ac8b7abf924.json
generated
Normal file
22
backend/.sqlx/query-fc6c6310ae8ac5eb351d7e2af1678447d0aa3d143e94e49924ff7ac8b7abf924.json
generated
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "SELECT disabled FROM password WHERE email = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "disabled",
|
||||
"type_info": "Bool"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "fc6c6310ae8ac5eb351d7e2af1678447d0aa3d143e94e49924ff7ac8b7abf924"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "INSERT INTO token\n (token_hash, token_prefix, token, label, super_admin, email)\n VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
"query": "INSERT INTO token\n (token_hash, token_prefix, token, label, super_admin, email)\n VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -15,5 +15,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90"
|
||||
"hash": "fd4c5391107af34a3bf9b83b0c3f7d5ee9490240a627b20a1037444845e39c5f"
|
||||
}
|
||||
720
backend/Cargo.lock
generated
720
backend/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,13 @@
|
||||
[package]
|
||||
name = "windmill"
|
||||
version = "1.662.0"
|
||||
version = "1.665.0"
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"./windmill-ai",
|
||||
"./windmill-object-store",
|
||||
"./windmill-api",
|
||||
"./windmill-api-scripts",
|
||||
@@ -82,7 +83,7 @@ members = [
|
||||
exclude = ["./windmill-duckdb-ffi-internal"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.662.0"
|
||||
version = "1.665.0"
|
||||
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -132,8 +133,8 @@ dind = ["windmill-worker/dind"]
|
||||
websocket = ["windmill-api/websocket"]
|
||||
http_trigger = ["windmill-api/http_trigger"]
|
||||
postgres_trigger = ["windmill-api/postgres_trigger"]
|
||||
mcp = ["windmill-api/mcp", "windmill-worker/mcp"]
|
||||
bedrock = ["windmill-api/bedrock", "windmill-worker/bedrock", "windmill-common/bedrock"]
|
||||
mcp = ["windmill-ai/mcp", "windmill-api/mcp", "windmill-worker/mcp"]
|
||||
bedrock = ["windmill-ai/bedrock", "windmill-api/bedrock", "windmill-worker/bedrock", "windmill-common/bedrock"]
|
||||
mqtt_trigger = ["windmill-api/mqtt_trigger"]
|
||||
native_trigger = ["windmill-api/native_trigger"]
|
||||
sqs_trigger = ["windmill-api/sqs_trigger", "windmill-common/aws_auth", "windmill-api/openidconnect"]
|
||||
@@ -206,6 +207,7 @@ anyhow.workspace = true
|
||||
tokio.workspace = true
|
||||
tokio-stream.workspace = true
|
||||
dotenv.workspace = true
|
||||
windmill-ai = { workspace = true, default-features = false }
|
||||
windmill-queue.workspace = true
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
windmill-object-store.workspace = true
|
||||
@@ -284,6 +286,7 @@ aws-credential-types.workspace = true
|
||||
|
||||
|
||||
[workspace.dependencies]
|
||||
windmill-ai = { path = "./windmill-ai", default-features = false }
|
||||
windmill-api = { path = "./windmill-api", default-features = false }
|
||||
windmill-queue = { path = "./windmill-queue" }
|
||||
windmill-worker = { path = "./windmill-worker" }
|
||||
@@ -373,7 +376,7 @@ tower = "^0"
|
||||
tower-http = { version = "^0.6", features = ["trace", "cors", "catch-panic"] }
|
||||
tower-cookies = "^0.10"
|
||||
#stuck because of swc for now
|
||||
serde = "=1.0.219"
|
||||
serde = "=1.0.220"
|
||||
serde_json = { version = "^1", features = ["preserve_order", "raw_value"] }
|
||||
serde_yml = "0.0.12"
|
||||
uuid = { version = "^1", features = ["serde", "v4", "js"] }
|
||||
@@ -510,7 +513,7 @@ native-tls = ">=0.2, <0.2.17"
|
||||
# samael will break compilation on MacOS. Use this fork instead to make it work
|
||||
# samael = { git="https://github.com/njaremko/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = ["xmlsec"] }
|
||||
libxml = { version = "=0.3.3" }
|
||||
samael = { version="0.0.14", features = ["xmlsec"] }
|
||||
samael = { git="https://github.com/njaremko/samael", rev="f879f1942ec1b34b6d3027ce7e4724ad95d15dfa", features = ["xmlsec"] }
|
||||
gcp_auth = "0.9.0"
|
||||
rust_decimal = { version = "^1", features = ["db-postgres", "serde-float"]}
|
||||
jsonwebtoken = "8.3.0"
|
||||
@@ -587,7 +590,7 @@ tikv-jemalloc-ctl = { version = "^0.5" }
|
||||
triomphe = "^0"
|
||||
pin-project-lite = "^0"
|
||||
|
||||
tantivy = { git="https://github.com/windmill-labs/tantivy", rev="6a24621231202ccd77bec90d8787e2281fb94e4e" }
|
||||
tantivy = { git="https://github.com/windmill-labs/tantivy", rev="6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" }
|
||||
|
||||
backon = "1.3.0"
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
c04f3851c03758662e4936ff4b6e71bc56dbae7e
|
||||
6db424512b0d02f86489e85f0026581b7637d6e6
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE native_trigger DROP COLUMN IF EXISTS summary;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE native_trigger ADD COLUMN summary VARCHAR(1000);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE password DROP COLUMN IF EXISTS disabled;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE password ADD COLUMN disabled BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Revoke grants for app_bundles table
|
||||
REVOKE ALL ON app_bundles FROM windmill_user;
|
||||
REVOKE ALL ON app_bundles FROM windmill_admin;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Add grants for app_bundles table
|
||||
GRANT ALL ON app_bundles TO windmill_user;
|
||||
GRANT ALL ON app_bundles TO windmill_admin;
|
||||
1
backend/migrations/20260326000000_trashbin.down.sql
Normal file
1
backend/migrations/20260326000000_trashbin.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS trashbin;
|
||||
16
backend/migrations/20260326000000_trashbin.up.sql
Normal file
16
backend/migrations/20260326000000_trashbin.up.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE trashbin (
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
|
||||
item_kind VARCHAR(50) NOT NULL,
|
||||
item_path TEXT NOT NULL,
|
||||
item_data JSONB NOT NULL,
|
||||
deleted_by VARCHAR(255) NOT NULL,
|
||||
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '3 days'
|
||||
);
|
||||
|
||||
CREATE INDEX idx_trashbin_expires_at ON trashbin(expires_at);
|
||||
CREATE INDEX idx_trashbin_workspace_kind ON trashbin(workspace_id, item_kind);
|
||||
|
||||
GRANT ALL ON trashbin TO windmill_user;
|
||||
GRANT ALL ON trashbin TO windmill_admin;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE account DROP COLUMN IF EXISTS scopes;
|
||||
1
backend/migrations/20260326100000_account_scopes.up.sql
Normal file
1
backend/migrations/20260326100000_account_scopes.up.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE account ADD COLUMN scopes TEXT[];
|
||||
@@ -34,17 +34,18 @@ use windmill_common::ee_oss::{
|
||||
maybe_renew_license_key_on_start, LICENSE_KEY_ID, LICENSE_KEY_VALID,
|
||||
};
|
||||
|
||||
use windmill_ai::ai_cache::bump_instance_ai_config_revision;
|
||||
use windmill_common::{
|
||||
agent_workers::AgentConfig,
|
||||
global_settings::{
|
||||
APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING, BASE_URL_SETTING,
|
||||
BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
|
||||
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING,
|
||||
BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
|
||||
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
|
||||
CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
|
||||
DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
|
||||
EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
|
||||
HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING,
|
||||
INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING,
|
||||
INDEXER_SETTING, INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
|
||||
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING,
|
||||
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING,
|
||||
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
|
||||
@@ -103,10 +104,10 @@ use crate::monitor::{
|
||||
reload_base_url_setting, reload_bunfig_install_scopes_setting,
|
||||
reload_critical_alert_mute_ui_setting, reload_critical_alerts_on_token_expiry_setting,
|
||||
reload_critical_error_channels_setting, reload_extra_pip_index_url_setting,
|
||||
reload_hub_api_secret_setting, reload_hub_base_url_setting,
|
||||
reload_instance_events_webhook_setting, reload_job_default_timeout_setting,
|
||||
reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key,
|
||||
reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting,
|
||||
reload_http_route_workspaced_route_setting, reload_hub_api_secret_setting,
|
||||
reload_hub_base_url_setting, reload_instance_events_webhook_setting,
|
||||
reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting,
|
||||
reload_license_key, reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting,
|
||||
reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting,
|
||||
reload_smtp_config, reload_uv_index_strategy_setting, reload_worker_config, MonitorIteration,
|
||||
};
|
||||
@@ -1098,6 +1099,9 @@ Windmill Community Edition {GIT_VERSION}
|
||||
}
|
||||
|
||||
let addr = SocketAddr::from((server_bind_address, port));
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.context("binding main windmill server")?;
|
||||
|
||||
let (base_internal_tx, base_internal_rx) = tokio::sync::oneshot::channel::<String>();
|
||||
|
||||
@@ -1231,7 +1235,7 @@ Windmill Community Edition {GIT_VERSION}
|
||||
db.clone(),
|
||||
index_reader,
|
||||
log_index_reader,
|
||||
addr,
|
||||
listener,
|
||||
server_killpill_rx,
|
||||
base_internal_tx,
|
||||
server_mode,
|
||||
@@ -1813,6 +1817,27 @@ async fn process_notify_event(
|
||||
tracing::error!(error = %e, "Could not reload app workspaced route setting");
|
||||
}
|
||||
}
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => {
|
||||
if let Err(e) = reload_http_route_workspaced_route_setting(db).await {
|
||||
tracing::error!(error = %e, "Could not reload http route workspaced route setting");
|
||||
}
|
||||
#[cfg(feature = "http_trigger")]
|
||||
match windmill_api::triggers::http::refresh_routers(db).await {
|
||||
Ok((true, _)) => {
|
||||
tracing::info!(
|
||||
"Refreshed HTTP routers (http workspaced route setting change)"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::error!("Error refreshing HTTP routers (http workspaced route setting change): {err:#}");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
AI_CONFIG_SETTING => {
|
||||
tracing::info!("AI config setting changed, bumping instance AI cache revision");
|
||||
bump_instance_ai_config_revision();
|
||||
}
|
||||
OTEL_SETTING => {
|
||||
tracing::info!("OTEL setting changed, restarting");
|
||||
send_delayed_killpill(tx, 4, "OTEL setting change").await;
|
||||
|
||||
@@ -88,7 +88,13 @@ use windmill_common::{
|
||||
MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED,
|
||||
SERVICE_LOG_RETENTION_SECS,
|
||||
};
|
||||
use windmill_common::{client::AuthedClient, global_settings::APP_WORKSPACED_ROUTE_SETTING};
|
||||
use windmill_common::{
|
||||
client::AuthedClient,
|
||||
global_settings::{
|
||||
APP_WORKSPACED_ROUTE_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE,
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
|
||||
},
|
||||
};
|
||||
#[cfg(feature = "parquet")]
|
||||
use windmill_object_store::reload_object_store_setting;
|
||||
use windmill_queue::{cancel_job, get_queued_job_v2, SameWorkerPayload};
|
||||
@@ -296,6 +302,10 @@ pub async fn initial_load(
|
||||
if let Err(e) = reload_app_workspaced_route_setting(db).await {
|
||||
tracing::error!("Error reloading app workspaced route: {:?}", e)
|
||||
}
|
||||
|
||||
if let Err(e) = reload_http_route_workspaced_route_setting(db).await {
|
||||
tracing::error!("Error reloading http route workspaced route: {:?}", e)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "parquet")]
|
||||
@@ -1168,6 +1178,15 @@ pub async fn delete_expired_items(db: &DB) -> () {
|
||||
tracing::error!("Error deleting custom concurrency key: {:?}", e);
|
||||
}
|
||||
}
|
||||
|
||||
match windmill_common::trashbin::delete_expired_trash(db).await {
|
||||
Ok(count) => {
|
||||
if count > 0 {
|
||||
tracing::info!("deleted {} expired trash items", count);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::error!("Error deleting expired trash items: {}", e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_expiring_tokens(db: &DB) {
|
||||
@@ -3390,6 +3409,39 @@ pub async fn reload_app_workspaced_route_setting(conn: &DB) -> error::Result<()>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reload_http_route_workspaced_route_setting(conn: &DB) -> error::Result<()> {
|
||||
let http_route_workspaced_route =
|
||||
load_value_from_global_settings(conn, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING).await?;
|
||||
|
||||
let ws_route = match http_route_workspaced_route {
|
||||
Some(serde_json::Value::Bool(ws_route)) => ws_route,
|
||||
None => false,
|
||||
_ => {
|
||||
tracing::error!(
|
||||
"Expected {} to be a boolean got: {:?}. Defaulting to false",
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
|
||||
http_route_workspaced_route
|
||||
);
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
let mut l = HTTP_ROUTE_WORKSPACED_ROUTE.write().await;
|
||||
|
||||
if *l != ws_route {
|
||||
*l = ws_route;
|
||||
drop(l);
|
||||
// Bump the HTTP trigger version so the route cache is rebuilt with
|
||||
// the updated workspaced_route behavior on the next request.
|
||||
sqlx::query!("SELECT nextval('http_trigger_version_seq')")
|
||||
.fetch_one(conn)
|
||||
.await?;
|
||||
} else {
|
||||
*l = ws_route;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn reload_critical_alerts_on_db_oversize(conn: &DB) -> error::Result<()> {
|
||||
#[derive(Deserialize)]
|
||||
struct DBOversize {
|
||||
|
||||
@@ -33,7 +33,7 @@ workspace_key_kind: cloud
|
||||
|
||||
## Tables
|
||||
_sqlx_migrations: version(bigint), description(text), installed_on(ts), success(bool), checksum(bytes), execution_time(bigint)
|
||||
account: workspace_id(char), id(int), expires_at(ts), refresh_token(char), client(char), refresh_error(text), grant_type(char), cc_client_id(char), cc_client_secret(char), cc_token_url(char), mcp_server_url(text)
|
||||
account: workspace_id(char), id(int), expires_at(ts), refresh_token(char), client(char), refresh_error(text), grant_type(char), cc_client_id(char), cc_client_secret(char), cc_token_url(char), mcp_server_url(text), scopes(text[])
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
agent_token_blacklist: token(char), expires_at(ts), blacklisted_at(ts), blacklisted_by(char)
|
||||
ai_agent_memory: workspace_id(char), conversation_id(uuid), step_id(char), messages(jsonb), created_at(ts), updated_at(ts)
|
||||
@@ -151,6 +151,9 @@ script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), s
|
||||
skip_workspace_diff_tally: workspace_id(char), added_at(ts)
|
||||
sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attributes(text[]), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error(text), server_id(char), last_server_ping(ts), aws_auth_resource_type(aws_auth_resource_type), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
trashbin: id(bigint), workspace_id(char), item_kind(char), item_path(char), item_data(jsonb), deleted_by(char), deleted_at(ts), expires_at(ts)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
INDEX: idx_trashbin_expires_at (expires_at), idx_trashbin_workspace_kind (workspace_id, item_kind)
|
||||
token: token_hash(char), token_prefix(char), token(char), label(char), expiration(ts), workspace_id(char), owner(char), email(char), super_admin(bool), created_at(ts), last_used_at(ts), scopes(text[]), job(uuid)
|
||||
FK: (workspace_id) -> workspace(id)
|
||||
token_expiry_notification: token_hash(char), expiration(ts)
|
||||
|
||||
@@ -891,25 +891,34 @@ mod dedicated_worker_protocol {
|
||||
use std::process::{Command, Stdio};
|
||||
use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult};
|
||||
use windmill_worker::{
|
||||
build_loader, generate_dedicated_worker_wrapper, LoaderMode, BUN_DEDICATED_WORKER_ARGS,
|
||||
BUN_PATH, NODE_BIN_PATH,
|
||||
build_loader, compute_ts_codegen, generate_multi_script_wrapper, LoaderMode, TsScriptEntry,
|
||||
BUN_DEDICATED_WORKER_ARGS, BUN_PATH, NODE_BIN_PATH,
|
||||
};
|
||||
|
||||
const TEST_SCRIPT_PATH: &str = "f/test/script";
|
||||
|
||||
/// Creates test worker files and optionally bundles for Node.js (like production)
|
||||
/// Returns the path to the wrapper file to execute
|
||||
fn create_test_worker_files(
|
||||
dir: &std::path::Path,
|
||||
script: &str,
|
||||
arg_names: &[&str],
|
||||
bundle_for_node: bool,
|
||||
) -> std::path::PathBuf {
|
||||
let dir_str = dir.to_str().unwrap();
|
||||
// Write main.ts at root (like production single-script)
|
||||
std::fs::write(dir.join("main.ts"), script).unwrap();
|
||||
|
||||
let codegen = compute_ts_codegen(script);
|
||||
let ext = if bundle_for_node { "js" } else { "ts" };
|
||||
let scripts = [TsScriptEntry {
|
||||
import_name: "main",
|
||||
original_path: TEST_SCRIPT_PATH,
|
||||
codegen: &codegen,
|
||||
}];
|
||||
let wrapper = generate_multi_script_wrapper(&scripts, ext);
|
||||
|
||||
if bundle_for_node {
|
||||
// For Node.js: bundle to JavaScript first (like production's build_loader with LoaderMode::Node)
|
||||
let wrapper = generate_dedicated_worker_wrapper(arg_names, "./main.js", None, None);
|
||||
std::fs::write(dir.join("wrapper.mjs"), wrapper).unwrap();
|
||||
std::fs::write(dir.join("wrapper.mjs"), &wrapper).unwrap();
|
||||
|
||||
// Use the exact same build_loader function as production
|
||||
tokio::runtime::Runtime::new()
|
||||
@@ -919,7 +928,7 @@ mod dedicated_worker_protocol {
|
||||
"http://localhost:8000",
|
||||
"test_token",
|
||||
"test-workspace",
|
||||
"f/test/script",
|
||||
TEST_SCRIPT_PATH,
|
||||
LoaderMode::Node,
|
||||
&None,
|
||||
))
|
||||
@@ -945,10 +954,8 @@ mod dedicated_worker_protocol {
|
||||
std::fs::rename(&bundled_path, &output_path).unwrap();
|
||||
output_path
|
||||
} else {
|
||||
// For Bun: use TypeScript directly (like production)
|
||||
let wrapper = generate_dedicated_worker_wrapper(arg_names, "./main.ts", None, None);
|
||||
let wrapper_path = dir.join("wrapper.mjs");
|
||||
std::fs::write(&wrapper_path, wrapper).unwrap();
|
||||
std::fs::write(&wrapper_path, &wrapper).unwrap();
|
||||
wrapper_path
|
||||
}
|
||||
}
|
||||
@@ -957,14 +964,12 @@ mod dedicated_worker_protocol {
|
||||
fn run_worker_test(
|
||||
runtime: &str,
|
||||
script: &str,
|
||||
arg_names: &[&str],
|
||||
jobs: Vec<serde_json::Value>,
|
||||
) -> Vec<Result<serde_json::Value, String>> {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
// Create files and get the wrapper path (bundled for node, raw for bun)
|
||||
let wrapper_path =
|
||||
create_test_worker_files(temp_dir.path(), script, arg_names, runtime == "node");
|
||||
let wrapper_path = create_test_worker_files(temp_dir.path(), script, runtime == "node");
|
||||
let wrapper_str = wrapper_path.to_str().unwrap();
|
||||
|
||||
// Build args matching production behavior
|
||||
@@ -1008,7 +1013,8 @@ mod dedicated_worker_protocol {
|
||||
let mut results = Vec::new();
|
||||
|
||||
for job_args in jobs {
|
||||
writeln!(stdin, "{}", job_args.to_string()).unwrap();
|
||||
// Protocol: exec:<script_path>:<json_args>
|
||||
writeln!(stdin, "exec:{}:{}", TEST_SCRIPT_PATH, job_args.to_string()).unwrap();
|
||||
stdin.flush().unwrap();
|
||||
|
||||
let mut response = String::new();
|
||||
@@ -1043,12 +1049,7 @@ export function main(x: number, y: number): number {
|
||||
return x + y;
|
||||
}
|
||||
"#;
|
||||
let results = run_worker_test(
|
||||
"node",
|
||||
script,
|
||||
&["x", "y"],
|
||||
vec![serde_json::json!({"x": 5, "y": 3})],
|
||||
);
|
||||
let results = run_worker_test("node", script, vec![serde_json::json!({"x": 5, "y": 3})]);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(8)));
|
||||
@@ -1062,7 +1063,7 @@ export function main(n: number): number {
|
||||
}
|
||||
"#;
|
||||
let jobs: Vec<serde_json::Value> = (1..=5).map(|i| serde_json::json!({"n": i})).collect();
|
||||
let results = run_worker_test("node", script, &["n"], jobs);
|
||||
let results = run_worker_test("node", script, jobs);
|
||||
|
||||
assert_eq!(results.len(), 5);
|
||||
for (i, result) in results.iter().enumerate() {
|
||||
@@ -1081,7 +1082,6 @@ export function main(msg: string): never {
|
||||
let results = run_worker_test(
|
||||
"node",
|
||||
script,
|
||||
&["msg"],
|
||||
vec![serde_json::json!({"msg": "test error"})],
|
||||
);
|
||||
|
||||
@@ -1099,12 +1099,7 @@ export function main(x: number, y: number): number {
|
||||
return x + y;
|
||||
}
|
||||
"#;
|
||||
let results = run_worker_test(
|
||||
"bun",
|
||||
script,
|
||||
&["x", "y"],
|
||||
vec![serde_json::json!({"x": 5, "y": 3})],
|
||||
);
|
||||
let results = run_worker_test("bun", script, vec![serde_json::json!({"x": 5, "y": 3})]);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(8)));
|
||||
@@ -1118,7 +1113,7 @@ export function main(n: number): number {
|
||||
}
|
||||
"#;
|
||||
let jobs: Vec<serde_json::Value> = (1..=5).map(|i| serde_json::json!({"n": i})).collect();
|
||||
let results = run_worker_test("bun", script, &["n"], jobs);
|
||||
let results = run_worker_test("bun", script, jobs);
|
||||
|
||||
assert_eq!(results.len(), 5);
|
||||
for (i, result) in results.iter().enumerate() {
|
||||
@@ -1137,7 +1132,6 @@ export function main(msg: string): never {
|
||||
let results = run_worker_test(
|
||||
"bun",
|
||||
script,
|
||||
&["msg"],
|
||||
vec![serde_json::json!({"msg": "test error"})],
|
||||
);
|
||||
|
||||
@@ -1145,6 +1139,721 @@ export function main(msg: string): never {
|
||||
assert!(results[0].is_err());
|
||||
assert_eq!(results[0], Err("test error".to_string()));
|
||||
}
|
||||
|
||||
// ==================== Multi-Script (Runner Group) Tests ====================
|
||||
|
||||
/// Job to send to a specific script in a multi-script wrapper
|
||||
struct MultiScriptJob {
|
||||
script_path: String,
|
||||
args: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Creates a multi-script wrapper with multiple scripts as flat files, returns the wrapper path
|
||||
fn create_multi_script_worker_files(
|
||||
dir: &std::path::Path,
|
||||
scripts: &[(&str, &str)], // (original_path, script_content)
|
||||
) -> std::path::PathBuf {
|
||||
let mut entries_data = Vec::new();
|
||||
for (path, content) in scripts {
|
||||
let safe_name = format!("_wm_{}", path.replace('/', "__"));
|
||||
std::fs::write(dir.join(format!("{safe_name}.ts")), content).unwrap();
|
||||
entries_data.push((safe_name, path.to_string(), compute_ts_codegen(content)));
|
||||
}
|
||||
|
||||
let entries: Vec<TsScriptEntry<'_>> = entries_data
|
||||
.iter()
|
||||
.map(|(safe, path, cg)| TsScriptEntry {
|
||||
import_name: safe.as_str(),
|
||||
original_path: path.as_str(),
|
||||
codegen: cg,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let wrapper = generate_multi_script_wrapper(&entries, "ts");
|
||||
let wrapper_path = dir.join("wrapper.mjs");
|
||||
std::fs::write(&wrapper_path, &wrapper).unwrap();
|
||||
wrapper_path
|
||||
}
|
||||
|
||||
/// Helper to run a multi-script dedicated worker test
|
||||
fn run_multi_script_worker_test(
|
||||
scripts: &[(&str, &str)],
|
||||
jobs: Vec<MultiScriptJob>,
|
||||
) -> Vec<Result<serde_json::Value, String>> {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let wrapper_path = create_multi_script_worker_files(temp_dir.path(), scripts);
|
||||
let wrapper_str = wrapper_path.to_str().unwrap();
|
||||
|
||||
let mut cmd_args: Vec<&str> = BUN_DEDICATED_WORKER_ARGS.to_vec();
|
||||
cmd_args.push(wrapper_str);
|
||||
|
||||
let mut child = Command::new(BUN_PATH.as_str())
|
||||
.args(cmd_args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.current_dir(temp_dir.path())
|
||||
.spawn()
|
||||
.expect("Failed to spawn worker process");
|
||||
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
// Wait for "start" signal
|
||||
let mut start_line = String::new();
|
||||
reader.read_line(&mut start_line).unwrap();
|
||||
assert_eq!(
|
||||
parse_dedicated_worker_line(start_line.trim()),
|
||||
DedicatedWorkerResult::Start,
|
||||
"Expected 'start', got: {}",
|
||||
start_line.trim()
|
||||
);
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for job in &jobs {
|
||||
writeln!(stdin, "exec:{}:{}", job.script_path, job.args.to_string()).unwrap();
|
||||
stdin.flush().unwrap();
|
||||
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response).unwrap();
|
||||
|
||||
match parse_dedicated_worker_line(response.trim()) {
|
||||
DedicatedWorkerResult::Success(value) => results.push(Ok(value)),
|
||||
DedicatedWorkerResult::Error(err) => {
|
||||
let msg = err["message"]
|
||||
.as_str()
|
||||
.unwrap_or("Unknown error")
|
||||
.to_string();
|
||||
results.push(Err(msg));
|
||||
}
|
||||
other => panic!("Unexpected response: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(stdin, "end").unwrap();
|
||||
stdin.flush().unwrap();
|
||||
let _ = child.wait().expect("Worker process failed to exit");
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_script_routing_basic() {
|
||||
let script_add = r#"
|
||||
export function main(a: number, b: number): number {
|
||||
return a + b;
|
||||
}
|
||||
"#;
|
||||
let script_mul = r#"
|
||||
export function main(x: number, y: number): number {
|
||||
return x * y;
|
||||
}
|
||||
"#;
|
||||
let results = run_multi_script_worker_test(
|
||||
&[("f/math/add", script_add), ("f/math/mul", script_mul)],
|
||||
vec![
|
||||
MultiScriptJob {
|
||||
script_path: "f/math/add".to_string(),
|
||||
args: serde_json::json!({"a": 3, "b": 4}),
|
||||
},
|
||||
MultiScriptJob {
|
||||
script_path: "f/math/mul".to_string(),
|
||||
args: serde_json::json!({"x": 5, "y": 6}),
|
||||
},
|
||||
// Route back to add
|
||||
MultiScriptJob {
|
||||
script_path: "f/math/add".to_string(),
|
||||
args: serde_json::json!({"a": 10, "b": 20}),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(7))); // 3 + 4
|
||||
assert_eq!(results[1], Ok(serde_json::json!(30))); // 5 * 6
|
||||
assert_eq!(results[2], Ok(serde_json::json!(30))); // 10 + 20
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_script_interleaved_jobs() {
|
||||
let script_upper = r#"
|
||||
export function main(s: string): string {
|
||||
return s.toUpperCase();
|
||||
}
|
||||
"#;
|
||||
let script_len = r#"
|
||||
export function main(s: string): number {
|
||||
return s.length;
|
||||
}
|
||||
"#;
|
||||
let results = run_multi_script_worker_test(
|
||||
&[("f/str/upper", script_upper), ("f/str/len", script_len)],
|
||||
vec![
|
||||
MultiScriptJob {
|
||||
script_path: "f/str/upper".to_string(),
|
||||
args: serde_json::json!({"s": "hello"}),
|
||||
},
|
||||
MultiScriptJob {
|
||||
script_path: "f/str/len".to_string(),
|
||||
args: serde_json::json!({"s": "hello"}),
|
||||
},
|
||||
MultiScriptJob {
|
||||
script_path: "f/str/upper".to_string(),
|
||||
args: serde_json::json!({"s": "world"}),
|
||||
},
|
||||
MultiScriptJob {
|
||||
script_path: "f/str/len".to_string(),
|
||||
args: serde_json::json!({"s": "ab"}),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(results.len(), 4);
|
||||
assert_eq!(results[0], Ok(serde_json::json!("HELLO")));
|
||||
assert_eq!(results[1], Ok(serde_json::json!(5)));
|
||||
assert_eq!(results[2], Ok(serde_json::json!("WORLD")));
|
||||
assert_eq!(results[3], Ok(serde_json::json!(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_script_unknown_path_error() {
|
||||
let script = r#"
|
||||
export function main(x: number): number {
|
||||
return x;
|
||||
}
|
||||
"#;
|
||||
let results = run_multi_script_worker_test(
|
||||
&[("f/known", script)],
|
||||
vec![MultiScriptJob {
|
||||
script_path: "f/unknown".to_string(),
|
||||
args: serde_json::json!({"x": 1}),
|
||||
}],
|
||||
);
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(results[0].is_err());
|
||||
assert!(results[0]
|
||||
.as_ref()
|
||||
.unwrap_err()
|
||||
.contains("Script not found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_script_error_doesnt_break_other_scripts() {
|
||||
let script_ok = r#"
|
||||
export function main(x: number): number {
|
||||
return x * 2;
|
||||
}
|
||||
"#;
|
||||
let script_err = r#"
|
||||
export function main(msg: string): never {
|
||||
throw new Error(msg);
|
||||
}
|
||||
"#;
|
||||
let results = run_multi_script_worker_test(
|
||||
&[("f/ok", script_ok), ("f/err", script_err)],
|
||||
vec![
|
||||
MultiScriptJob {
|
||||
script_path: "f/ok".to_string(),
|
||||
args: serde_json::json!({"x": 5}),
|
||||
},
|
||||
MultiScriptJob {
|
||||
script_path: "f/err".to_string(),
|
||||
args: serde_json::json!({"msg": "boom"}),
|
||||
},
|
||||
// Should still work after error in other script
|
||||
MultiScriptJob {
|
||||
script_path: "f/ok".to_string(),
|
||||
args: serde_json::json!({"x": 10}),
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(10)));
|
||||
assert!(results[1].is_err());
|
||||
assert_eq!(results[1], Err("boom".to_string()));
|
||||
assert_eq!(results[2], Ok(serde_json::json!(20)));
|
||||
}
|
||||
|
||||
// ==================== exec_preprocess Tests ====================
|
||||
|
||||
/// Raw protocol command to send to a dedicated worker
|
||||
enum ProtocolCmd {
|
||||
Exec { path: String, args: serde_json::Value },
|
||||
ExecPreprocess { path: String, args: serde_json::Value },
|
||||
}
|
||||
|
||||
/// Run a multi-script worker test with raw protocol commands, returning all protocol lines
|
||||
fn run_raw_protocol_test(
|
||||
scripts: &[(&str, &str)],
|
||||
commands: Vec<ProtocolCmd>,
|
||||
) -> Vec<DedicatedWorkerResult> {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let wrapper_path = create_multi_script_worker_files(temp_dir.path(), scripts);
|
||||
let wrapper_str = wrapper_path.to_str().unwrap();
|
||||
|
||||
let mut cmd_args: Vec<&str> = BUN_DEDICATED_WORKER_ARGS.to_vec();
|
||||
cmd_args.push(wrapper_str);
|
||||
|
||||
let mut child = Command::new(BUN_PATH.as_str())
|
||||
.args(cmd_args)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.current_dir(temp_dir.path())
|
||||
.spawn()
|
||||
.expect("Failed to spawn worker process");
|
||||
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
let mut start_line = String::new();
|
||||
reader.read_line(&mut start_line).unwrap();
|
||||
assert_eq!(
|
||||
parse_dedicated_worker_line(start_line.trim()),
|
||||
DedicatedWorkerResult::Start,
|
||||
);
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for cmd in &commands {
|
||||
let line = match cmd {
|
||||
ProtocolCmd::Exec { path, args } => format!("exec:{}:{}", path, args),
|
||||
ProtocolCmd::ExecPreprocess { path, args } => {
|
||||
format!("exec_preprocess:{}:{}", path, args)
|
||||
}
|
||||
};
|
||||
writeln!(stdin, "{}", line).unwrap();
|
||||
stdin.flush().unwrap();
|
||||
|
||||
// exec_preprocess produces 2 response lines (preprocessed_args + success/error)
|
||||
// exec produces 1 response line (success/error)
|
||||
let expected_lines = match cmd {
|
||||
ProtocolCmd::ExecPreprocess { .. } => 2,
|
||||
ProtocolCmd::Exec { .. } => 1,
|
||||
};
|
||||
|
||||
for _ in 0..expected_lines {
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response).unwrap();
|
||||
let parsed = parse_dedicated_worker_line(response.trim());
|
||||
// If it's an error, stop reading more lines for this command
|
||||
if matches!(parsed, DedicatedWorkerResult::Error(_)) {
|
||||
results.push(parsed);
|
||||
break;
|
||||
}
|
||||
results.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(stdin, "end").unwrap();
|
||||
stdin.flush().unwrap();
|
||||
let _ = child.wait().expect("Worker process failed to exit");
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bun_exec_preprocess() {
|
||||
let script = r#"
|
||||
export function preprocessor(x: number) {
|
||||
return { x: x * 10 };
|
||||
}
|
||||
export function main(x: number): number {
|
||||
return x + 1;
|
||||
}
|
||||
"#;
|
||||
let results = run_raw_protocol_test(
|
||||
&[("f/test/pre", script)],
|
||||
vec![ProtocolCmd::ExecPreprocess {
|
||||
path: "f/test/pre".to_string(),
|
||||
args: serde_json::json!({"x": 5}),
|
||||
}],
|
||||
);
|
||||
// Should get preprocessed_args then success
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(
|
||||
results[0],
|
||||
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50}))
|
||||
);
|
||||
// main(50) => 51
|
||||
assert_eq!(
|
||||
results[1],
|
||||
DedicatedWorkerResult::Success(serde_json::json!(51))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bun_exec_preprocess_missing_preprocessor() {
|
||||
let script = r#"
|
||||
export function main(x: number): number {
|
||||
return x;
|
||||
}
|
||||
"#;
|
||||
let results = run_raw_protocol_test(
|
||||
&[("f/test/nopre", script)],
|
||||
vec![ProtocolCmd::ExecPreprocess {
|
||||
path: "f/test/nopre".to_string(),
|
||||
args: serde_json::json!({"x": 5}),
|
||||
}],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(matches!(results[0], DedicatedWorkerResult::Error(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bun_exec_preprocess_then_exec() {
|
||||
let script = r#"
|
||||
export function preprocessor(x: number) {
|
||||
return { x: x * 2 };
|
||||
}
|
||||
export function main(x: number): number {
|
||||
return x + 100;
|
||||
}
|
||||
"#;
|
||||
let results = run_raw_protocol_test(
|
||||
&[("f/test/mixed", script)],
|
||||
vec![
|
||||
ProtocolCmd::ExecPreprocess {
|
||||
path: "f/test/mixed".to_string(),
|
||||
args: serde_json::json!({"x": 5}),
|
||||
},
|
||||
ProtocolCmd::Exec {
|
||||
path: "f/test/mixed".to_string(),
|
||||
args: serde_json::json!({"x": 7}),
|
||||
},
|
||||
],
|
||||
);
|
||||
// preprocess: preprocessor(5) => {"x":10}, main(10) => 110
|
||||
// exec: main(7) => 107
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(
|
||||
results[0],
|
||||
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 10}))
|
||||
);
|
||||
assert_eq!(
|
||||
results[1],
|
||||
DedicatedWorkerResult::Success(serde_json::json!(110))
|
||||
);
|
||||
assert_eq!(
|
||||
results[2],
|
||||
DedicatedWorkerResult::Success(serde_json::json!(107))
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Argument Transformation Tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_bun_date_arg_transformation() {
|
||||
let script = r#"
|
||||
export function main(d: Date): string {
|
||||
return d instanceof Date ? d.toISOString() : typeof d;
|
||||
}
|
||||
"#;
|
||||
let results = run_worker_test(
|
||||
"bun",
|
||||
script,
|
||||
vec![serde_json::json!({"d": "2024-01-15T10:30:00.000Z"})],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(
|
||||
results[0],
|
||||
Ok(serde_json::json!("2024-01-15T10:30:00.000Z"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bun_null_and_undefined_args() {
|
||||
let script = r#"
|
||||
export function main(x?: number): string {
|
||||
return x === null ? "null" : x === undefined ? "undefined" : String(x);
|
||||
}
|
||||
"#;
|
||||
let results = run_worker_test(
|
||||
"bun",
|
||||
script,
|
||||
vec![
|
||||
serde_json::json!({"x": null}),
|
||||
serde_json::json!({"x": 42}),
|
||||
serde_json::json!({}),
|
||||
],
|
||||
);
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0], Ok(serde_json::json!("null")));
|
||||
assert_eq!(results[1], Ok(serde_json::json!("42")));
|
||||
// Missing arg should be undefined
|
||||
assert_eq!(results[2], Ok(serde_json::json!("undefined")));
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Deno Dedicated Worker Protocol Tests
|
||||
// ============================================================================
|
||||
|
||||
mod dedicated_worker_protocol_deno {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult};
|
||||
use windmill_worker::{generate_deno_dedicated_worker_wrapper, DENO_PATH};
|
||||
|
||||
const TEST_SCRIPT_PATH: &str = "f/test/script";
|
||||
|
||||
fn run_deno_worker_test(
|
||||
script: &str,
|
||||
jobs: Vec<serde_json::Value>,
|
||||
) -> Vec<Result<serde_json::Value, String>> {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(temp_dir.path().join("main.ts"), script).unwrap();
|
||||
|
||||
let wrapper = generate_deno_dedicated_worker_wrapper(script).unwrap();
|
||||
std::fs::write(temp_dir.path().join("wrapper.ts"), &wrapper).unwrap();
|
||||
|
||||
let mut child = Command::new(DENO_PATH.as_str())
|
||||
.args([
|
||||
"run",
|
||||
"--no-check",
|
||||
"--unstable-unsafe-proto",
|
||||
"--unstable-bare-node-builtins",
|
||||
"-A",
|
||||
"wrapper.ts",
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.current_dir(temp_dir.path())
|
||||
.spawn()
|
||||
.expect("Failed to spawn deno process");
|
||||
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
// Wait for "start" — deno outputs 'start\n' via console.log which adds
|
||||
// its own newline, producing double newlines. Skip empty lines.
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(
|
||||
parse_dedicated_worker_line(line.trim()),
|
||||
DedicatedWorkerResult::Start,
|
||||
"Expected 'start', got: {}",
|
||||
line.trim()
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
for job_args in jobs {
|
||||
writeln!(stdin, "exec:{}:{}", TEST_SCRIPT_PATH, job_args.to_string()).unwrap();
|
||||
stdin.flush().unwrap();
|
||||
|
||||
loop {
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response).unwrap();
|
||||
let trimmed = response.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
match parse_dedicated_worker_line(trimmed) {
|
||||
DedicatedWorkerResult::Success(value) => results.push(Ok(value)),
|
||||
DedicatedWorkerResult::Error(err) => {
|
||||
let msg = err["message"]
|
||||
.as_str()
|
||||
.unwrap_or("Unknown error")
|
||||
.to_string();
|
||||
results.push(Err(msg));
|
||||
}
|
||||
other => panic!("Unexpected response: {:?}", other),
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(stdin, "end").unwrap();
|
||||
stdin.flush().unwrap();
|
||||
let _ = child.wait().expect("Worker process failed to exit");
|
||||
results
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deno_dedicated_worker_simple() {
|
||||
let script = r#"
|
||||
export function main(x: number, y: number): number {
|
||||
return x + y;
|
||||
}
|
||||
"#;
|
||||
let results = run_deno_worker_test(script, vec![serde_json::json!({"x": 5, "y": 3})]);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(8)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deno_dedicated_worker_multiple_jobs() {
|
||||
let script = r#"
|
||||
export function main(n: number): number {
|
||||
return n * 2;
|
||||
}
|
||||
"#;
|
||||
let jobs: Vec<serde_json::Value> = (1..=5).map(|i| serde_json::json!({"n": i})).collect();
|
||||
let results = run_deno_worker_test(script, jobs);
|
||||
assert_eq!(results.len(), 5);
|
||||
for (i, result) in results.iter().enumerate() {
|
||||
assert_eq!(*result, Ok(serde_json::json!(((i + 1) * 2) as i64)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deno_dedicated_worker_error() {
|
||||
let script = r#"
|
||||
export function main(msg: string): never {
|
||||
throw new Error(msg);
|
||||
}
|
||||
"#;
|
||||
let results = run_deno_worker_test(script, vec![serde_json::json!({"msg": "test error"})]);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(results[0].is_err());
|
||||
assert_eq!(results[0], Err("test error".to_string()));
|
||||
}
|
||||
|
||||
// ==================== exec_preprocess Tests ====================
|
||||
|
||||
/// Run a raw deno protocol test, reading all output lines per command
|
||||
fn run_deno_raw_protocol_test(
|
||||
script: &str,
|
||||
commands: Vec<(&str, serde_json::Value)>, // ("exec" or "exec_preprocess", args)
|
||||
) -> Vec<DedicatedWorkerResult> {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(temp_dir.path().join("main.ts"), script).unwrap();
|
||||
|
||||
let wrapper = generate_deno_dedicated_worker_wrapper(script).unwrap();
|
||||
std::fs::write(temp_dir.path().join("wrapper.ts"), &wrapper).unwrap();
|
||||
|
||||
let mut child = Command::new(DENO_PATH.as_str())
|
||||
.args([
|
||||
"run",
|
||||
"--no-check",
|
||||
"--unstable-unsafe-proto",
|
||||
"--unstable-bare-node-builtins",
|
||||
"-A",
|
||||
"wrapper.ts",
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.current_dir(temp_dir.path())
|
||||
.spawn()
|
||||
.expect("Failed to spawn deno process");
|
||||
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
// Wait for start, skip empty lines
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
reader.read_line(&mut line).unwrap();
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(
|
||||
parse_dedicated_worker_line(line.trim()),
|
||||
DedicatedWorkerResult::Start,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for (cmd, args) in &commands {
|
||||
writeln!(stdin, "{}:{}:{}", cmd, TEST_SCRIPT_PATH, args).unwrap();
|
||||
stdin.flush().unwrap();
|
||||
|
||||
let expected_lines = if *cmd == "exec_preprocess" { 2 } else { 1 };
|
||||
|
||||
for _ in 0..expected_lines {
|
||||
loop {
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response).unwrap();
|
||||
if response.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let parsed = parse_dedicated_worker_line(response.trim());
|
||||
if matches!(parsed, DedicatedWorkerResult::Error(_)) {
|
||||
results.push(parsed);
|
||||
break;
|
||||
}
|
||||
results.push(parsed);
|
||||
break;
|
||||
}
|
||||
// If last result was an error, don't read more lines for this command
|
||||
if matches!(results.last(), Some(DedicatedWorkerResult::Error(_))) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(stdin, "end").unwrap();
|
||||
stdin.flush().unwrap();
|
||||
let _ = child.wait().expect("Worker process failed to exit");
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deno_exec_preprocess() {
|
||||
let script = r#"
|
||||
export function preprocessor(x: number) {
|
||||
return { x: x * 10 };
|
||||
}
|
||||
export function main(x: number): number {
|
||||
return x + 1;
|
||||
}
|
||||
"#;
|
||||
let results = run_deno_raw_protocol_test(
|
||||
script,
|
||||
vec![("exec_preprocess", serde_json::json!({"x": 5}))],
|
||||
);
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(
|
||||
results[0],
|
||||
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50}))
|
||||
);
|
||||
assert_eq!(
|
||||
results[1],
|
||||
DedicatedWorkerResult::Success(serde_json::json!(51))
|
||||
);
|
||||
}
|
||||
|
||||
// Note: no "missing preprocessor" test for Deno because the wrapper only generates
|
||||
// the exec_preprocess handler when the script actually has a preprocessor function.
|
||||
// Without one, exec_preprocess messages are unrecognized (by design — Rust never sends them).
|
||||
|
||||
// ==================== Argument Transformation Tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_deno_date_arg_transformation() {
|
||||
let script = r#"
|
||||
export function main(d: Date): string {
|
||||
return d instanceof Date ? d.toISOString() : typeof d;
|
||||
}
|
||||
"#;
|
||||
let results = run_deno_worker_test(
|
||||
script,
|
||||
vec![serde_json::json!({"d": "2024-01-15T10:30:00.000Z"})],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(
|
||||
results[0],
|
||||
Ok(serde_json::json!("2024-01-15T10:30:00.000Z"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -1,9 +1,470 @@
|
||||
use serde_json::json;
|
||||
#[cfg(feature = "python")]
|
||||
use sqlx::postgres::Postgres;
|
||||
#[cfg(feature = "python")]
|
||||
use sqlx::Pool;
|
||||
#[cfg(feature = "python")]
|
||||
use windmill_common::scripts::ScriptLang;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
// ============================================================================
|
||||
// Dedicated Worker Protocol Tests (Python)
|
||||
// ============================================================================
|
||||
|
||||
#[cfg(feature = "python")]
|
||||
mod dedicated_worker_protocol_python {
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult};
|
||||
use windmill_worker::{compute_py_codegen, generate_py_multi_script_wrapper, PyScriptEntry};
|
||||
|
||||
struct MultiScriptJob {
|
||||
script_path: String,
|
||||
args: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Creates a multi-script Python wrapper, writes scripts to proper module paths
|
||||
fn create_py_worker_files(
|
||||
dir: &std::path::Path,
|
||||
scripts: &[(&str, &str)], // (original_path, content)
|
||||
) -> std::path::PathBuf {
|
||||
let mut codegens = Vec::new();
|
||||
for (path, content) in scripts {
|
||||
let cg = compute_py_codegen(content, path);
|
||||
let module_dir = dir.join(&cg.dirs);
|
||||
std::fs::create_dir_all(&module_dir).unwrap();
|
||||
std::fs::write(module_dir.join(format!("{}.py", cg.module_name)), content).unwrap();
|
||||
codegens.push((path.to_string(), cg));
|
||||
}
|
||||
|
||||
let entries: Vec<PyScriptEntry<'_>> = codegens
|
||||
.iter()
|
||||
.map(|(path, cg)| PyScriptEntry { original_path: path.as_str(), codegen: cg })
|
||||
.collect();
|
||||
|
||||
let wrapper = generate_py_multi_script_wrapper(&entries, false, false);
|
||||
let wrapper_path = dir.join("wrapper.py");
|
||||
std::fs::write(&wrapper_path, &wrapper).unwrap();
|
||||
wrapper_path
|
||||
}
|
||||
|
||||
fn run_py_multi_script_test(
|
||||
scripts: &[(&str, &str)],
|
||||
jobs: Vec<MultiScriptJob>,
|
||||
) -> Vec<Result<serde_json::Value, String>> {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
create_py_worker_files(temp_dir.path(), scripts);
|
||||
|
||||
let mut child = Command::new("python3")
|
||||
.args(["-u", "-m", "wrapper"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.current_dir(temp_dir.path())
|
||||
.spawn()
|
||||
.expect("Failed to spawn python3 process");
|
||||
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
let mut start_line = String::new();
|
||||
reader.read_line(&mut start_line).unwrap();
|
||||
assert_eq!(
|
||||
parse_dedicated_worker_line(start_line.trim()),
|
||||
DedicatedWorkerResult::Start,
|
||||
"Expected 'start', got: {}",
|
||||
start_line.trim()
|
||||
);
|
||||
|
||||
let mut results = Vec::new();
|
||||
for job in &jobs {
|
||||
writeln!(stdin, "exec:{}:{}", job.script_path, job.args.to_string()).unwrap();
|
||||
stdin.flush().unwrap();
|
||||
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response).unwrap();
|
||||
|
||||
match parse_dedicated_worker_line(response.trim()) {
|
||||
DedicatedWorkerResult::Success(value) => results.push(Ok(value)),
|
||||
DedicatedWorkerResult::Error(err) => {
|
||||
let msg = err["message"]
|
||||
.as_str()
|
||||
.unwrap_or("Unknown error")
|
||||
.to_string();
|
||||
results.push(Err(msg));
|
||||
}
|
||||
other => panic!("Unexpected response: {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(stdin, "end").unwrap();
|
||||
stdin.flush().unwrap();
|
||||
let _ = child.wait().expect("Worker process failed to exit");
|
||||
results
|
||||
}
|
||||
|
||||
fn run_py_single_script_test(
|
||||
script_path: &str,
|
||||
content: &str,
|
||||
jobs: Vec<serde_json::Value>,
|
||||
) -> Vec<Result<serde_json::Value, String>> {
|
||||
run_py_multi_script_test(
|
||||
&[(script_path, content)],
|
||||
jobs.into_iter()
|
||||
.map(|args| MultiScriptJob { script_path: script_path.to_string(), args })
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_dedicated_worker_simple() {
|
||||
let results = run_py_single_script_test(
|
||||
"f/test/add",
|
||||
"def main(a: int, b: int):\n return a + b\n",
|
||||
vec![serde_json::json!({"a": 3, "b": 4})],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(7)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_dedicated_worker_multiple_jobs() {
|
||||
let results = run_py_single_script_test(
|
||||
"f/test/double",
|
||||
"def main(n: int):\n return n * 2\n",
|
||||
(1..=5).map(|i| serde_json::json!({"n": i})).collect(),
|
||||
);
|
||||
assert_eq!(results.len(), 5);
|
||||
for (i, result) in results.iter().enumerate() {
|
||||
assert_eq!(*result, Ok(serde_json::json!(((i + 1) * 2) as i64)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_multi_script_routing() {
|
||||
let results = run_py_multi_script_test(
|
||||
&[
|
||||
(
|
||||
"f/math/add",
|
||||
"def main(a: int, b: int):\n return a + b\n",
|
||||
),
|
||||
(
|
||||
"f/math/mul",
|
||||
"def main(x: int, y: int):\n return x * y\n",
|
||||
),
|
||||
],
|
||||
vec![
|
||||
MultiScriptJob {
|
||||
script_path: "f/math/add".to_string(),
|
||||
args: serde_json::json!({"a": 3, "b": 4}),
|
||||
},
|
||||
MultiScriptJob {
|
||||
script_path: "f/math/mul".to_string(),
|
||||
args: serde_json::json!({"x": 5, "y": 6}),
|
||||
},
|
||||
MultiScriptJob {
|
||||
script_path: "f/math/add".to_string(),
|
||||
args: serde_json::json!({"a": 10, "b": 20}),
|
||||
},
|
||||
],
|
||||
);
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(7)));
|
||||
assert_eq!(results[1], Ok(serde_json::json!(30)));
|
||||
assert_eq!(results[2], Ok(serde_json::json!(30)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_multi_script_error_isolation() {
|
||||
let results = run_py_multi_script_test(
|
||||
&[
|
||||
("f/ok", "def main(x: int):\n return x * 2\n"),
|
||||
("f/err", "def main(msg: str):\n raise Exception(msg)\n"),
|
||||
],
|
||||
vec![
|
||||
MultiScriptJob {
|
||||
script_path: "f/ok".to_string(),
|
||||
args: serde_json::json!({"x": 5}),
|
||||
},
|
||||
MultiScriptJob {
|
||||
script_path: "f/err".to_string(),
|
||||
args: serde_json::json!({"msg": "boom"}),
|
||||
},
|
||||
MultiScriptJob {
|
||||
script_path: "f/ok".to_string(),
|
||||
args: serde_json::json!({"x": 10}),
|
||||
},
|
||||
],
|
||||
);
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(10)));
|
||||
assert!(results[1].is_err());
|
||||
assert_eq!(results[1], Err("boom".to_string()));
|
||||
assert_eq!(results[2], Ok(serde_json::json!(20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_multi_script_unknown_path() {
|
||||
let results = run_py_multi_script_test(
|
||||
&[("f/known", "def main(x: int):\n return x\n")],
|
||||
vec![MultiScriptJob {
|
||||
script_path: "f/unknown".to_string(),
|
||||
args: serde_json::json!({"x": 1}),
|
||||
}],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(results[0].is_err());
|
||||
assert!(results[0]
|
||||
.as_ref()
|
||||
.unwrap_err()
|
||||
.contains("Script not found"));
|
||||
}
|
||||
|
||||
// ==================== exec_preprocess Tests ====================
|
||||
|
||||
/// Raw protocol command for Python
|
||||
enum ProtocolCmd {
|
||||
Exec { path: String, args: serde_json::Value },
|
||||
ExecPreprocess { path: String, args: serde_json::Value },
|
||||
}
|
||||
|
||||
/// Run a Python worker test with raw protocol commands
|
||||
fn run_py_raw_protocol_test(
|
||||
scripts: &[(&str, &str)],
|
||||
commands: Vec<ProtocolCmd>,
|
||||
) -> Vec<DedicatedWorkerResult> {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
create_py_worker_files(temp_dir.path(), scripts);
|
||||
|
||||
let mut child = Command::new("python3")
|
||||
.args(["-u", "-m", "wrapper"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.current_dir(temp_dir.path())
|
||||
.spawn()
|
||||
.expect("Failed to spawn python3 process");
|
||||
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let mut reader = BufReader::new(stdout);
|
||||
|
||||
let mut start_line = String::new();
|
||||
reader.read_line(&mut start_line).unwrap();
|
||||
assert_eq!(
|
||||
parse_dedicated_worker_line(start_line.trim()),
|
||||
DedicatedWorkerResult::Start,
|
||||
);
|
||||
|
||||
let mut results = Vec::new();
|
||||
|
||||
for cmd in &commands {
|
||||
let line = match cmd {
|
||||
ProtocolCmd::Exec { path, args } => format!("exec:{}:{}", path, args),
|
||||
ProtocolCmd::ExecPreprocess { path, args } => {
|
||||
format!("exec_preprocess:{}:{}", path, args)
|
||||
}
|
||||
};
|
||||
writeln!(stdin, "{}", line).unwrap();
|
||||
stdin.flush().unwrap();
|
||||
|
||||
let expected_lines = match cmd {
|
||||
ProtocolCmd::ExecPreprocess { .. } => 2,
|
||||
ProtocolCmd::Exec { .. } => 1,
|
||||
};
|
||||
|
||||
for _ in 0..expected_lines {
|
||||
let mut response = String::new();
|
||||
reader.read_line(&mut response).unwrap();
|
||||
let parsed = parse_dedicated_worker_line(response.trim());
|
||||
if matches!(parsed, DedicatedWorkerResult::Error(_)) {
|
||||
results.push(parsed);
|
||||
break;
|
||||
}
|
||||
results.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
writeln!(stdin, "end").unwrap();
|
||||
stdin.flush().unwrap();
|
||||
let _ = child.wait().expect("Worker process failed to exit");
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_exec_preprocess() {
|
||||
let script = r#"
|
||||
def preprocessor(x: int):
|
||||
return {"x": x * 10}
|
||||
|
||||
def main(x: int):
|
||||
return x + 1
|
||||
"#;
|
||||
let results = run_py_raw_protocol_test(
|
||||
&[("f/test/pre", script)],
|
||||
vec![ProtocolCmd::ExecPreprocess {
|
||||
path: "f/test/pre".to_string(),
|
||||
args: serde_json::json!({"x": 5}),
|
||||
}],
|
||||
);
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(
|
||||
results[0],
|
||||
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50}))
|
||||
);
|
||||
// main(50) => 51
|
||||
assert_eq!(
|
||||
results[1],
|
||||
DedicatedWorkerResult::Success(serde_json::json!(51))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_exec_preprocess_missing_preprocessor() {
|
||||
let script = "def main(x: int):\n return x\n";
|
||||
let results = run_py_raw_protocol_test(
|
||||
&[("f/test/nopre", script)],
|
||||
vec![ProtocolCmd::ExecPreprocess {
|
||||
path: "f/test/nopre".to_string(),
|
||||
args: serde_json::json!({"x": 5}),
|
||||
}],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(matches!(results[0], DedicatedWorkerResult::Error(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_exec_preprocess_then_exec() {
|
||||
let script = r#"
|
||||
def preprocessor(x: int):
|
||||
return {"x": x * 2}
|
||||
|
||||
def main(x: int):
|
||||
return x + 100
|
||||
"#;
|
||||
let results = run_py_raw_protocol_test(
|
||||
&[("f/test/mixed", script)],
|
||||
vec![
|
||||
ProtocolCmd::ExecPreprocess {
|
||||
path: "f/test/mixed".to_string(),
|
||||
args: serde_json::json!({"x": 5}),
|
||||
},
|
||||
ProtocolCmd::Exec {
|
||||
path: "f/test/mixed".to_string(),
|
||||
args: serde_json::json!({"x": 7}),
|
||||
},
|
||||
],
|
||||
);
|
||||
// preprocess: preprocessor(5) => {"x":10}, main(10) => 110
|
||||
// exec: main(7) => 107
|
||||
assert_eq!(results.len(), 3);
|
||||
assert_eq!(
|
||||
results[0],
|
||||
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 10}))
|
||||
);
|
||||
assert_eq!(
|
||||
results[1],
|
||||
DedicatedWorkerResult::Success(serde_json::json!(110))
|
||||
);
|
||||
assert_eq!(
|
||||
results[2],
|
||||
DedicatedWorkerResult::Success(serde_json::json!(107))
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Argument Transformation Tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_python_datetime_arg_transformation() {
|
||||
let script = r#"
|
||||
from datetime import datetime
|
||||
|
||||
def main(d: datetime):
|
||||
return d.isoformat()
|
||||
"#;
|
||||
let results = run_py_single_script_test(
|
||||
"f/test/dt",
|
||||
script,
|
||||
vec![serde_json::json!({"d": "2024-01-15T10:30:00+00:00"})],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(
|
||||
results[0],
|
||||
Ok(serde_json::json!("2024-01-15T10:30:00+00:00"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_bytes_arg_transformation() {
|
||||
let script = r#"
|
||||
def main(data: bytes):
|
||||
return len(data)
|
||||
"#;
|
||||
// base64 of "hello" is "aGVsbG8="
|
||||
let results = run_py_single_script_test(
|
||||
"f/test/bytes",
|
||||
script,
|
||||
vec![serde_json::json!({"data": "aGVsbG8="})],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(5)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_kwargs_filtering() {
|
||||
// Test that extra kwargs are filtered out and only declared args are passed
|
||||
let script = "def main(a: int, b: int):\n return a + b\n";
|
||||
let results = run_py_single_script_test(
|
||||
"f/test/kwargs",
|
||||
script,
|
||||
vec![serde_json::json!({"a": 1, "b": 2, "extra": 99})],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0], Ok(serde_json::json!(3)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_function_call_sentinel_removal() {
|
||||
// Test that '<function call>' sentinel values are removed from args
|
||||
let script = "def main(a: int, b: int = 10):\n return a + b\n";
|
||||
let results = run_py_single_script_test(
|
||||
"f/test/sentinel",
|
||||
script,
|
||||
vec![serde_json::json!({"a": 5, "b": "<function call>"})],
|
||||
);
|
||||
assert_eq!(results.len(), 1);
|
||||
// b should be removed (sentinel), default 10 used
|
||||
assert_eq!(results[0], Ok(serde_json::json!(15)));
|
||||
}
|
||||
|
||||
// ==================== Relative Import Tests ====================
|
||||
|
||||
#[test]
|
||||
fn test_python_dedicated_worker_with_relative_import_detection() {
|
||||
// Test that the wrapper includes 'import loader' when scripts have relative imports
|
||||
let script_with_relative = "from f.helper import util\ndef main(x: int):\n return x\n";
|
||||
let cg = compute_py_codegen(script_with_relative, "f/test/rel");
|
||||
let entries = [PyScriptEntry { original_path: "f/test/rel", codegen: &cg }];
|
||||
let wrapper = generate_py_multi_script_wrapper(&entries, false, true);
|
||||
assert!(
|
||||
wrapper.contains("import loader"),
|
||||
"wrapper should contain 'import loader' when any_relative_imports=true"
|
||||
);
|
||||
|
||||
// Without relative imports
|
||||
let script_no_relative = "def main(x: int):\n return x\n";
|
||||
let cg2 = compute_py_codegen(script_no_relative, "f/test/norel");
|
||||
let entries2 = [PyScriptEntry { original_path: "f/test/norel", codegen: &cg2 }];
|
||||
let wrapper2 = generate_py_multi_script_wrapper(&entries2, false, false);
|
||||
assert!(
|
||||
!wrapper2.contains("import loader"),
|
||||
"wrapper should NOT contain 'import loader' when any_relative_imports=false"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "python")]
|
||||
#[sqlx::test(fixtures("base", "lockfile_python"))]
|
||||
async fn test_requirements_python(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
|
||||
37
backend/windmill-ai/Cargo.toml
Normal file
37
backend/windmill-ai/Cargo.toml
Normal file
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "windmill-ai"
|
||||
version.workspace = true
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
bedrock = ["dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config"]
|
||||
mcp = ["dep:windmill-mcp"]
|
||||
|
||||
[lib]
|
||||
name = "windmill_ai"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
windmill-types.workspace = true
|
||||
windmill-parser.workspace = true
|
||||
windmill-mcp = { workspace = true, optional = true }
|
||||
|
||||
async-trait.workspace = true
|
||||
base64.workspace = true
|
||||
reqwest.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
sqlx = { workspace = true, features = ["postgres"] }
|
||||
uuid.workspace = true
|
||||
lazy_static.workspace = true
|
||||
tracing.workspace = true
|
||||
tokio.workspace = true
|
||||
|
||||
# Bedrock (optional)
|
||||
aws-config = { workspace = true, optional = true }
|
||||
aws-credential-types = { workspace = true, optional = true }
|
||||
aws-smithy-types = { workspace = true, optional = true }
|
||||
aws-sdk-bedrockruntime = { workspace = true, optional = true }
|
||||
@@ -11,13 +11,14 @@ use aws_config::BehaviorVersion;
|
||||
use aws_credential_types::provider::token::ProvideToken;
|
||||
use aws_credential_types::provider::ProvideCredentials;
|
||||
use aws_sdk_bedrockruntime::types::{
|
||||
ContentBlock, ConversationRole, ConverseStreamOutput, ImageBlock, ImageFormat, ImageSource,
|
||||
InferenceConfiguration, Message, SystemContentBlock, Tool, ToolInputSchema, ToolSpecification,
|
||||
ContentBlock, ConversationRole, ConverseStreamOutput, DocumentBlock, DocumentFormat,
|
||||
DocumentSource, ImageBlock, ImageFormat, ImageSource, InferenceConfiguration, Message,
|
||||
SystemContentBlock, Tool, ToolInputSchema, ToolSpecification,
|
||||
};
|
||||
use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::Error;
|
||||
use windmill_common::error::Error;
|
||||
|
||||
use crate::ai_types::{
|
||||
ContentPart, OpenAIContent, OpenAIFunction, OpenAIMessage, OpenAIToolCall, ToolDef,
|
||||
@@ -356,13 +357,12 @@ pub fn content_to_text(content: &OpenAIContent) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse image data URL and extract format and base64 data
|
||||
fn parse_image_data_url(url: &str) -> Result<(ImageFormat, Vec<u8>), Error> {
|
||||
/// Parse a data URL and extract MIME type and decoded bytes.
|
||||
fn parse_data_url_bytes(url: &str) -> Result<(String, Vec<u8>), Error> {
|
||||
if !url.starts_with("data:") {
|
||||
return Err(Error::internal_err("Image URL must be a data URL"));
|
||||
return Err(Error::internal_err("URL must be a data URL"));
|
||||
}
|
||||
|
||||
// Parse data:image/png;base64,<data>
|
||||
let base64_start = url
|
||||
.find("base64,")
|
||||
.ok_or_else(|| Error::internal_err("Invalid data URL format"))?;
|
||||
@@ -372,30 +372,51 @@ fn parse_image_data_url(url: &str) -> Result<(ImageFormat, Vec<u8>), Error> {
|
||||
.split(';')
|
||||
.next()
|
||||
.and_then(|s| s.strip_prefix("data:"))
|
||||
.unwrap_or("image/png");
|
||||
.unwrap_or("application/octet-stream");
|
||||
|
||||
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_data)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to decode base64 data: {}", e)))?;
|
||||
|
||||
Ok((mime_type.to_string(), bytes))
|
||||
}
|
||||
|
||||
/// Parse an image data URL and extract ImageFormat and decoded bytes.
|
||||
fn parse_image_data_url(url: &str) -> Result<(ImageFormat, Vec<u8>), Error> {
|
||||
let (mime_type, bytes) = parse_data_url_bytes(url)?;
|
||||
|
||||
// Extract format from MIME type (e.g., "image/png" -> "png")
|
||||
let format_str = mime_type
|
||||
.rsplit_once('/')
|
||||
.map(|(_, format)| format)
|
||||
.unwrap_or("png");
|
||||
|
||||
// Map to ImageFormat enum
|
||||
let format = match format_str {
|
||||
"png" => ImageFormat::Png,
|
||||
"jpeg" | "jpg" => ImageFormat::Jpeg,
|
||||
"gif" => ImageFormat::Gif,
|
||||
"webp" => ImageFormat::Webp,
|
||||
_ => ImageFormat::Png, // Default to PNG
|
||||
_ => ImageFormat::Png,
|
||||
};
|
||||
|
||||
// Decode base64
|
||||
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_data)
|
||||
.map_err(|e| Error::internal_err(format!("Failed to decode base64 image: {}", e)))?;
|
||||
|
||||
Ok((format, bytes))
|
||||
}
|
||||
|
||||
/// Map a MIME type to a Bedrock DocumentFormat.
|
||||
fn mime_to_document_format(mime_type: &str) -> DocumentFormat {
|
||||
match mime_type {
|
||||
"application/pdf" => DocumentFormat::Pdf,
|
||||
"text/csv" => DocumentFormat::Csv,
|
||||
"text/html" => DocumentFormat::Html,
|
||||
"text/plain" => DocumentFormat::Txt,
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => {
|
||||
DocumentFormat::Docx
|
||||
}
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => {
|
||||
DocumentFormat::Xlsx
|
||||
}
|
||||
_ => DocumentFormat::Pdf,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a ContentPart to Bedrock ContentBlock
|
||||
fn content_part_to_block(part: &ContentPart) -> Result<Option<ContentBlock>, Error> {
|
||||
match part {
|
||||
@@ -418,8 +439,21 @@ fn content_part_to_block(part: &ContentPart) -> Result<Option<ContentBlock>, Err
|
||||
|
||||
Ok(Some(ContentBlock::Image(image_block)))
|
||||
}
|
||||
ContentPart::File { file } => {
|
||||
let (mime_type, bytes) = parse_data_url_bytes(&file.file_data)?;
|
||||
let doc_source = DocumentSource::Bytes(bytes.into());
|
||||
let doc_block = DocumentBlock::builder()
|
||||
.format(mime_to_document_format(&mime_type))
|
||||
.name(file.filename.replace('.', "_"))
|
||||
.source(doc_source)
|
||||
.build()
|
||||
.map_err(|e| {
|
||||
Error::internal_err(format!("Failed to build document block: {}", e))
|
||||
})?;
|
||||
Ok(Some(ContentBlock::Document(doc_block)))
|
||||
}
|
||||
ContentPart::S3Object { .. } => {
|
||||
// S3Objects should be converted to ImageUrl before calling this function
|
||||
// S3Objects should be converted before calling this function
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
11
backend/windmill-ai/src/ai_cache.rs
Normal file
11
backend/windmill-ai/src/ai_cache.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
static INSTANCE_AI_CONFIG_REVISION: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
pub fn current_instance_ai_config_revision() -> u64 {
|
||||
INSTANCE_AI_CONFIG_REVISION.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub fn bump_instance_ai_config_revision() -> u64 {
|
||||
INSTANCE_AI_CONFIG_REVISION.fetch_add(1, Ordering::SeqCst) + 1
|
||||
}
|
||||
@@ -9,8 +9,11 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ai_types::{ContentPart, ExtraContent, GoogleExtraContent, OpenAIContent, OpenAIMessage, ToolDef, UrlCitation};
|
||||
use crate::error::Error;
|
||||
use crate::ai_types::{
|
||||
ContentPart, ExtraContent, GoogleExtraContent, OpenAIContent, OpenAIMessage, ToolDef,
|
||||
UrlCitation,
|
||||
};
|
||||
use windmill_common::error::Error;
|
||||
|
||||
// ============================================================================
|
||||
// Request / Content Types
|
||||
@@ -87,7 +90,10 @@ pub struct GeminiTextRequest {
|
||||
/// Tool definition — function declarations and/or Google Search grounding.
|
||||
#[derive(Serialize)]
|
||||
pub struct GeminiTool {
|
||||
#[serde(rename = "functionDeclarations", skip_serializing_if = "Option::is_none")]
|
||||
#[serde(
|
||||
rename = "functionDeclarations",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub function_declarations: Option<Vec<GeminiFunctionDeclaration>>,
|
||||
#[serde(rename = "googleSearch", skip_serializing_if = "Option::is_none")]
|
||||
pub google_search: Option<serde_json::Value>,
|
||||
@@ -115,7 +121,10 @@ pub struct GeminiToolConfig {
|
||||
#[derive(Serialize)]
|
||||
pub struct GeminiFunctionCallingConfig {
|
||||
pub mode: String,
|
||||
#[serde(rename = "allowedFunctionNames", skip_serializing_if = "Option::is_none")]
|
||||
#[serde(
|
||||
rename = "allowedFunctionNames",
|
||||
skip_serializing_if = "Option::is_none"
|
||||
)]
|
||||
pub allowed_function_names: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
@@ -341,7 +350,12 @@ pub fn convert_content_to_gemini_parts(content: &OpenAIContent) -> Vec<GeminiPar
|
||||
Some(GeminiPart::Text { text: text.clone() })
|
||||
}
|
||||
ContentPart::ImageUrl { image_url } => {
|
||||
parse_data_url(&image_url.url).map(|(mime_type, data)| {
|
||||
parse_data_url(&image_url.url).map(|(mime_type, data)| GeminiPart::InlineData {
|
||||
inline_data: GeminiInlineData { mime_type, data },
|
||||
})
|
||||
}
|
||||
ContentPart::File { file } => {
|
||||
parse_data_url(&file.file_data).map(|(mime_type, data)| {
|
||||
GeminiPart::InlineData {
|
||||
inline_data: GeminiInlineData { mime_type, data },
|
||||
}
|
||||
@@ -372,15 +386,12 @@ pub fn openai_messages_to_gemini(
|
||||
if let Some(content) = &msg.content {
|
||||
let parts = convert_content_to_gemini_parts(content);
|
||||
if !parts.is_empty() {
|
||||
system_instruction =
|
||||
Some(GeminiContentMessage { role: None, parts });
|
||||
system_instruction = Some(GeminiContentMessage { role: None, parts });
|
||||
}
|
||||
}
|
||||
}
|
||||
"tool" => {
|
||||
if let (Some(tool_call_id), Some(content)) =
|
||||
(&msg.tool_call_id, &msg.content)
|
||||
{
|
||||
if let (Some(tool_call_id), Some(content)) = (&msg.tool_call_id, &msg.content) {
|
||||
let func_name = find_gemini_function_name(messages, tool_call_id);
|
||||
let response_text = match content {
|
||||
OpenAIContent::Text(text) => text.clone(),
|
||||
@@ -435,10 +446,8 @@ pub fn openai_messages_to_gemini(
|
||||
}
|
||||
|
||||
if !parts.is_empty() {
|
||||
contents.push(GeminiContentMessage {
|
||||
role: Some(gemini_role.to_string()),
|
||||
parts,
|
||||
});
|
||||
contents
|
||||
.push(GeminiContentMessage { role: Some(gemini_role.to_string()), parts });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -469,10 +478,8 @@ pub fn openai_tools_to_gemini(
|
||||
.collect();
|
||||
|
||||
if !declarations.is_empty() {
|
||||
gemini_tools.push(GeminiTool {
|
||||
function_declarations: Some(declarations),
|
||||
google_search: None,
|
||||
});
|
||||
gemini_tools
|
||||
.push(GeminiTool { function_declarations: Some(declarations), google_search: None });
|
||||
}
|
||||
|
||||
if has_websearch {
|
||||
@@ -2,8 +2,8 @@
|
||||
* This file contains shared AI provider utilities used by both the API and worker.
|
||||
*/
|
||||
|
||||
use crate::db::DB;
|
||||
use crate::error::{Error, Result};
|
||||
use windmill_common::db::DB;
|
||||
use windmill_common::error::{Error, Result};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
|
||||
/// Deserializes an Option<String> where empty strings become None.
|
||||
@@ -6,7 +6,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::value::RawValue;
|
||||
|
||||
use crate::flow_status::AgentAction;
|
||||
use windmill_common::flow_status::AgentAction;
|
||||
use windmill_types::s3::S3Object;
|
||||
|
||||
// ============================================================================
|
||||
@@ -33,6 +33,11 @@ pub enum ContentPart {
|
||||
ImageUrl {
|
||||
image_url: ImageUrlData,
|
||||
},
|
||||
/// File content block for OpenAI Chat Completions format (PDFs, etc.)
|
||||
#[serde(rename = "file")]
|
||||
File {
|
||||
file: FileData,
|
||||
},
|
||||
#[serde(rename = "s3_object")]
|
||||
S3Object {
|
||||
s3_object: S3Object,
|
||||
@@ -44,6 +49,25 @@ pub struct ImageUrlData {
|
||||
pub url: String, // data:image/png;base64,... or https://...
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct FileData {
|
||||
pub filename: String,
|
||||
pub file_data: String, // data:application/pdf;base64,...
|
||||
}
|
||||
|
||||
/// Check if a MIME type represents a document (as opposed to an image).
|
||||
pub fn is_document_mime(mime_type: &str) -> bool {
|
||||
matches!(
|
||||
mime_type,
|
||||
"application/pdf"
|
||||
| "text/csv"
|
||||
| "text/html"
|
||||
| "text/plain"
|
||||
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(untagged)]
|
||||
pub enum OpenAIContent {
|
||||
8
backend/windmill-ai/src/lib.rs
Normal file
8
backend/windmill-ai/src/lib.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
#[cfg(feature = "bedrock")]
|
||||
pub mod ai_bedrock;
|
||||
pub mod ai_cache;
|
||||
pub mod ai_google;
|
||||
pub mod ai_providers;
|
||||
pub mod ai_types;
|
||||
pub mod query_builder;
|
||||
pub mod types;
|
||||
99
backend/windmill-ai/src/query_builder.rs
Normal file
99
backend/windmill-ai/src/query_builder.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use async_trait::async_trait;
|
||||
use windmill_common::{client::AuthedClient, error::Error};
|
||||
use windmill_types::s3::S3Object;
|
||||
|
||||
use crate::ai_types::OpenAIToolCall;
|
||||
use crate::types::*;
|
||||
|
||||
/// Arguments for building an AI request
|
||||
pub struct BuildRequestArgs<'a> {
|
||||
pub messages: &'a [OpenAIMessage],
|
||||
pub tools: Option<&'a [ToolDef]>,
|
||||
pub model: &'a str,
|
||||
pub temperature: Option<f32>,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub output_schema: Option<&'a OpenAPISchema>,
|
||||
pub output_type: &'a OutputType,
|
||||
pub system_prompt: Option<&'a str>,
|
||||
pub user_message: &'a str,
|
||||
pub attachments: Option<&'a [S3Object]>,
|
||||
pub has_websearch: bool,
|
||||
}
|
||||
|
||||
/// Response from AI provider
|
||||
pub enum ParsedResponse {
|
||||
Text {
|
||||
content: Option<String>,
|
||||
tool_calls: Vec<OpenAIToolCall>,
|
||||
events_str: Option<String>,
|
||||
annotations: Vec<UrlCitation>,
|
||||
used_websearch: bool,
|
||||
usage: Option<TokenUsage>,
|
||||
},
|
||||
Image {
|
||||
base64_data: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Trait for streaming AI events to a sink (e.g., database persistence).
|
||||
/// Implemented by the worker's StreamEventProcessor.
|
||||
#[async_trait]
|
||||
pub trait StreamEventSink: Send + Sync {
|
||||
async fn send(&self, event: StreamingEvent, events_str: &mut String) -> Result<(), Error>;
|
||||
}
|
||||
|
||||
/// Trait for building provider-specific AI requests
|
||||
#[async_trait]
|
||||
pub trait QueryBuilder: Send + Sync {
|
||||
/// Check if this provider supports tools with the given output type
|
||||
fn supports_tools_with_output_type(&self, output_type: &OutputType) -> bool;
|
||||
|
||||
/// Build the request body for the provider
|
||||
async fn build_request(
|
||||
&self,
|
||||
args: &BuildRequestArgs<'_>,
|
||||
client: &AuthedClient,
|
||||
workspace_id: &str,
|
||||
) -> Result<String, Error>;
|
||||
|
||||
/// Build the request body without usage tracking (for retry on incompatible providers)
|
||||
/// Default implementation just calls build_request (most providers don't need this)
|
||||
async fn build_request_without_usage(
|
||||
&self,
|
||||
args: &BuildRequestArgs<'_>,
|
||||
client: &AuthedClient,
|
||||
workspace_id: &str,
|
||||
) -> Result<String, Error> {
|
||||
self.build_request(args, client, workspace_id).await
|
||||
}
|
||||
|
||||
/// Whether this provider supports retry without usage tracking
|
||||
/// Only OtherQueryBuilder (OpenAI-compatible providers) needs this
|
||||
fn supports_retry_without_usage(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Parse the image response from the provider
|
||||
async fn parse_image_response(
|
||||
&self,
|
||||
response: reqwest::Response,
|
||||
) -> Result<ParsedResponse, Error>;
|
||||
|
||||
/// Parse streaming response from the provider
|
||||
async fn parse_streaming_response(
|
||||
&self,
|
||||
response: reqwest::Response,
|
||||
stream_event_sink: Box<dyn StreamEventSink>,
|
||||
) -> Result<ParsedResponse, Error>;
|
||||
|
||||
/// Get the API endpoint for this provider
|
||||
fn get_endpoint(&self, base_url: &str, model: &str, output_type: &OutputType) -> String;
|
||||
|
||||
/// Get the authentication headers for this provider
|
||||
fn get_auth_headers(
|
||||
&self,
|
||||
api_key: &str,
|
||||
base_url: &str,
|
||||
output_type: &OutputType,
|
||||
) -> Vec<(&'static str, String)>;
|
||||
}
|
||||
1667
backend/windmill-ai/src/types.rs
Normal file
1667
backend/windmill-ai/src/types.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -87,6 +87,7 @@ pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/sign", post(sign_debug_request))
|
||||
.route("/sign_expression", post(sign_expression))
|
||||
.route("/sign_multiplayer", post(sign_multiplayer))
|
||||
}
|
||||
|
||||
/// JWKS response containing the public key for debug token verification
|
||||
@@ -416,3 +417,62 @@ async fn sign_expression(
|
||||
|
||||
Ok(Json(SignedExpressionPayload { token }))
|
||||
}
|
||||
|
||||
/// JWT claims for multiplayer session tokens
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct MultiplayerTokenClaims {
|
||||
/// Workspace ID
|
||||
pub workspace_id: String,
|
||||
/// User email
|
||||
pub email: String,
|
||||
/// Issued at (Unix timestamp)
|
||||
pub iat: i64,
|
||||
/// Expiration (Unix timestamp)
|
||||
pub exp: i64,
|
||||
/// Token purpose (always "multiplayer")
|
||||
pub purpose: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SignedMultiplayerPayload {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
/// Sign a multiplayer session request.
|
||||
///
|
||||
/// Returns a JWT that the multiplayer server will verify using the public key from /api/debug/jwks.
|
||||
async fn sign_multiplayer(
|
||||
authed: ApiAuthed,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<SignedMultiplayerPayload> {
|
||||
let key_guard = DEBUG_SIGNING_KEY.read().await;
|
||||
let signing_key = key_guard.as_ref().ok_or_else(|| {
|
||||
windmill_common::error::Error::InternalErr("Debug signing key not initialized".to_string())
|
||||
})?;
|
||||
|
||||
let now_ts = Utc::now().timestamp();
|
||||
let exp = now_ts + DEBUG_TOKEN_TTL_SECS;
|
||||
|
||||
let claims = MultiplayerTokenClaims {
|
||||
workspace_id: w_id,
|
||||
email: authed.email,
|
||||
iat: now_ts,
|
||||
exp,
|
||||
purpose: "multiplayer".to_string(),
|
||||
};
|
||||
|
||||
let header = serde_json::json!({
|
||||
"alg": "EdDSA",
|
||||
"typ": "JWT"
|
||||
});
|
||||
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
|
||||
let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap());
|
||||
let message = format!("{}.{}", header_b64, claims_b64);
|
||||
|
||||
let signature = signing_key.sign(message.as_bytes());
|
||||
let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
|
||||
|
||||
let token = format!("{}.{}", message, signature_b64);
|
||||
|
||||
Ok(Json(SignedMultiplayerPayload { token }))
|
||||
}
|
||||
|
||||
@@ -1657,6 +1657,38 @@ async fn delete_flow_by_path(
|
||||
}
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Capture all related data for trashbin before deleting (CASCADE will remove flow_version, flow_node)
|
||||
let trash_flow: Option<serde_json::Value> =
|
||||
sqlx::query_scalar("SELECT to_jsonb(t) FROM flow t WHERE path = $1 AND workspace_id = $2")
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_flow_versions: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM flow_version t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_flow_nodes: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM flow_node t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_drafts: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
|
||||
path,
|
||||
@@ -1673,6 +1705,28 @@ async fn delete_flow_by_path(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(flow_data) = trash_flow {
|
||||
let mut trash_data = serde_json::json!({"row": flow_data});
|
||||
if !trash_flow_versions.is_empty() {
|
||||
trash_data["flow_versions"] = serde_json::Value::Array(trash_flow_versions);
|
||||
}
|
||||
if !trash_flow_nodes.is_empty() {
|
||||
trash_data["flow_nodes"] = serde_json::Value::Array(trash_flow_nodes);
|
||||
}
|
||||
if !trash_drafts.is_empty() {
|
||||
trash_data["drafts"] = serde_json::Value::Array(trash_drafts);
|
||||
}
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"flow",
|
||||
path,
|
||||
trash_data,
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !query.keep_captures.unwrap_or(false) {
|
||||
sqlx::query!(
|
||||
"DELETE FROM capture_config WHERE path = $1 AND workspace_id = $2 AND is_flow IS TRUE",
|
||||
|
||||
@@ -18,7 +18,6 @@ use regex::Regex;
|
||||
use windmill_api_auth::{check_scopes, ApiAuthed, AuthCache, Tokened};
|
||||
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
|
||||
use windmill_audit::ActionKind;
|
||||
use windmill_common::{error::Error, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}};
|
||||
use windmill_common::DB;
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
@@ -26,6 +25,11 @@ use windmill_common::{
|
||||
users::username_to_permissioned_as,
|
||||
utils::{not_found_if_none, paginate, Pagination},
|
||||
};
|
||||
use windmill_common::{
|
||||
error::Error,
|
||||
webhook::{WebhookMessage, WebhookShared},
|
||||
workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult},
|
||||
};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::{FromRow, Postgres, Transaction};
|
||||
@@ -716,13 +720,14 @@ async fn add_owner(
|
||||
.await?;
|
||||
|
||||
validate_owner(&owner)?;
|
||||
sqlx::query(&format!(
|
||||
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
|
||||
true) WHERE name = $2 AND workspace_id = $3 RETURNING extra_perms"
|
||||
))
|
||||
sqlx::query(
|
||||
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, array[$4]::text[], to_jsonb($1), \
|
||||
true) WHERE name = $2 AND workspace_id = $3 RETURNING extra_perms",
|
||||
)
|
||||
.bind(true)
|
||||
.bind(&name)
|
||||
.bind(&w_id)
|
||||
.bind(&owner)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
@@ -787,14 +792,15 @@ async fn remove_owner(
|
||||
}
|
||||
|
||||
if let Some(write) = write {
|
||||
let old_write = sqlx::query_scalar::<_, Option<bool>>(&format!(
|
||||
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
|
||||
true) FROM (SELECT (extra_perms->>'{owner}')::boolean as old_val FROM folder WHERE name = $2 AND workspace_id = $3) old \
|
||||
let old_write = sqlx::query_scalar::<_, Option<bool>>(
|
||||
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, array[$4]::text[], to_jsonb($1), \
|
||||
true) FROM (SELECT (extra_perms->>$4)::boolean as old_val FROM folder WHERE name = $2 AND workspace_id = $3) old \
|
||||
WHERE name = $2 AND workspace_id = $3 RETURNING old.old_val"
|
||||
))
|
||||
)
|
||||
.bind(write)
|
||||
.bind(&name)
|
||||
.bind(&w_id)
|
||||
.bind(&owner)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
@@ -656,6 +656,7 @@ async fn delete_group(
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
|
||||
@@ -31,6 +31,7 @@ reqwest.workspace = true
|
||||
tokio.workspace = true
|
||||
anyhow.workspace = true
|
||||
uuid.workspace = true
|
||||
futures.workspace = true
|
||||
rand.workspace = true
|
||||
rumqttc.workspace = true
|
||||
rdkafka.workspace = true
|
||||
|
||||
@@ -422,6 +422,7 @@ async fn test_delete_integration_full_cascade(db: Pool<Postgres>) -> anyhow::Res
|
||||
"ext-1",
|
||||
&trigger_config,
|
||||
json!({"triggerType": "drive"}),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -511,6 +512,7 @@ async fn test_cleanup_preserves_triggers(db: Pool<Postgres>) -> anyhow::Result<(
|
||||
"ext-1",
|
||||
&trigger_config,
|
||||
json!({"triggerType": "drive"}),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
//! Integration tests for sensitive log masking.
|
||||
//!
|
||||
//! A single comprehensive test that runs real bun scripts through real workers,
|
||||
//! covering all masking scenarios: secret variables, non-secret variables,
|
||||
//! multiple secrets, mid-string secrets, `$encrypted:` args, resources
|
||||
//! referencing secret variables, and cross-job isolation.
|
||||
//!
|
||||
//! Run with:
|
||||
//! cargo test -p windmill-api-integration-tests --test sensitive_log_masking -- --nocapture
|
||||
//!
|
||||
//! Requires: bun runtime, live database (migrations applied by sqlx::test).
|
||||
|
||||
use futures::StreamExt;
|
||||
use serde_json::json;
|
||||
use sqlx::{Pool, Postgres};
|
||||
use uuid::Uuid;
|
||||
use windmill_common::jobs::{JobPayload, RawCode};
|
||||
use windmill_common::scripts::ScriptLang;
|
||||
use windmill_common::worker::to_raw_value;
|
||||
use windmill_test_utils::*;
|
||||
|
||||
fn client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||
builder.header("Authorization", "Bearer SECRET_TOKEN")
|
||||
}
|
||||
|
||||
/// Helper: create a variable via the API.
|
||||
async fn create_variable(port: u16, path: &str, value: &str, is_secret: bool) {
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/variables");
|
||||
let resp = authed(client().post(format!("{base}/create")))
|
||||
.json(&json!({
|
||||
"path": path,
|
||||
"value": value,
|
||||
"is_secret": is_secret,
|
||||
"description": "test variable for log masking"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"failed to create variable {path}: {}",
|
||||
resp.text().await.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: create a resource via the API.
|
||||
async fn create_resource(port: u16, path: &str, value: serde_json::Value) {
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/resources");
|
||||
let resp = authed(client().post(format!("{base}/create")))
|
||||
.json(&json!({
|
||||
"path": path,
|
||||
"value": value,
|
||||
"resource_type": "object",
|
||||
"description": "test resource for log masking"
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"failed to create resource {path}: {}",
|
||||
resp.text().await.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: fetch job logs from the job_logs table.
|
||||
async fn get_job_logs(db: &Pool<Postgres>, job_id: Uuid) -> Option<String> {
|
||||
sqlx::query_scalar!(
|
||||
r#"SELECT logs as "logs!" FROM job_logs WHERE job_id = $1"#,
|
||||
job_id,
|
||||
)
|
||||
.fetch_optional(db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Helper: push a bun preview job and return its UUID.
|
||||
async fn push_bun_job(db: &Pool<Postgres>, code: String) -> Uuid {
|
||||
RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: code,
|
||||
path: None,
|
||||
language: ScriptLang::Bun,
|
||||
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(),
|
||||
modules: None,
|
||||
}))
|
||||
.push(db)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Helper: push a bun preview job with encrypted args.
|
||||
async fn push_bun_job_with_encrypted_arg(
|
||||
db: &Pool<Postgres>,
|
||||
code: String,
|
||||
arg_name: &str,
|
||||
plaintext_value: &str,
|
||||
) -> Uuid {
|
||||
// We need to know the job_id in advance to encrypt with the right key suffix.
|
||||
let job_id = Uuid::new_v4();
|
||||
|
||||
// Encrypt the value the same way the frontend does:
|
||||
// build_crypt_with_key_suffix(db, workspace, root_job_id)
|
||||
let mc = windmill_common::variables::build_crypt_with_key_suffix(
|
||||
db,
|
||||
"test-workspace",
|
||||
&job_id.to_string(),
|
||||
)
|
||||
.await
|
||||
.expect("build_crypt_with_key_suffix");
|
||||
|
||||
// Encrypt the JSON-serialized string value
|
||||
let json_str = serde_json::to_string(plaintext_value).unwrap();
|
||||
let encrypted = windmill_common::variables::encrypt(&mc, &json_str);
|
||||
let arg_value = format!("$encrypted:{encrypted}");
|
||||
|
||||
let mut args = std::collections::HashMap::new();
|
||||
args.insert(arg_name.to_string(), to_raw_value(&json!(arg_value)));
|
||||
|
||||
RunJob::from(JobPayload::Code(RawCode {
|
||||
hash: None,
|
||||
content: code,
|
||||
path: None,
|
||||
language: ScriptLang::Bun,
|
||||
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(),
|
||||
modules: None,
|
||||
}))
|
||||
.job_id(job_id)
|
||||
.arg(arg_name, json!(arg_value))
|
||||
.push(db)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Comprehensive test covering all sensitive log masking scenarios in a single
|
||||
/// test function to amortize server/worker startup cost.
|
||||
///
|
||||
/// Scenarios covered (each as a separate job inside the same worker):
|
||||
/// 1. Secret variable fetched and logged → masked
|
||||
/// 2. Non-secret variable fetched and logged → NOT masked (no false positives)
|
||||
/// 3. Two different secrets fetched and logged in the same job → both masked
|
||||
/// 4. Secret embedded mid-string (e.g. "token=SECRET&user=bob") → masked
|
||||
/// 5. Same secret logged 3 times → all occurrences masked
|
||||
/// 6. `$encrypted:` password arg logged → masked
|
||||
/// 7. Resource referencing a secret variable via `$var:` → secret masked when logged
|
||||
/// 8. Cross-job isolation: job A's secret does NOT leak into job B's logs
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_sensitive_log_masking(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
|
||||
// === Setup: create variables and resources ===
|
||||
let secret1 = "alpha_secret_value_9x7k2m";
|
||||
let secret2 = "beta_secret_token_4j8n3p";
|
||||
let plain_val = "plain_visible_value_12345";
|
||||
let encrypted_password = "encrypted_pass_w0rd_zq5r";
|
||||
let resource_secret = "resource_db_password_h7t2";
|
||||
|
||||
create_variable(port, "u/test-user/secret_alpha", secret1, true).await;
|
||||
create_variable(port, "u/test-user/secret_beta", secret2, true).await;
|
||||
create_variable(port, "u/test-user/plain_var", plain_val, false).await;
|
||||
// Secret variable that will be referenced by a resource via $var:
|
||||
create_variable(port, "u/test-user/res_secret_var", resource_secret, true).await;
|
||||
// Resource whose "password" field references the secret variable
|
||||
create_resource(
|
||||
port,
|
||||
"u/test-user/db_with_secret",
|
||||
json!({"host": "db.example.com", "password": "$var:u/test-user/res_secret_var"}),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut completed = listen_for_completed_jobs(&db).await;
|
||||
let db2 = db.clone();
|
||||
in_test_worker(
|
||||
db.clone(),
|
||||
async move {
|
||||
// ================================================================
|
||||
// Scenario 1: Secret variable fetched and console.logged → masked
|
||||
// ================================================================
|
||||
let job1 = push_bun_job(
|
||||
&db2,
|
||||
r#"import * as wmill from "windmill-client";
|
||||
export async function main() {
|
||||
const secret = await wmill.getVariable("u/test-user/secret_alpha");
|
||||
console.log("The secret value is: " + secret);
|
||||
return "ok";
|
||||
}"#
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
completed.next().await;
|
||||
let cjob1 = completed_job(job1, &db2).await;
|
||||
assert!(cjob1.success, "scenario 1 job failed");
|
||||
let logs1 = get_job_logs(&db2, job1).await.expect("scenario 1: no logs");
|
||||
|
||||
assert!(
|
||||
!logs1.contains(secret1),
|
||||
"scenario 1: secret value leaked in logs\nLogs:\n{logs1}"
|
||||
);
|
||||
assert!(
|
||||
logs1.contains("The secret value is: alp*****"),
|
||||
"scenario 1: expected masked output with first 3 chars\nLogs:\n{logs1}"
|
||||
);
|
||||
assert!(
|
||||
logs1.contains("[windmill] secret value was masked for security reasons, use string transformations to display full value"),
|
||||
"scenario 1: expected security notice\nLogs:\n{logs1}"
|
||||
);
|
||||
|
||||
// ================================================================
|
||||
// Scenario 2: Non-secret variable → NOT masked (no false positives)
|
||||
// ================================================================
|
||||
let job2 = push_bun_job(
|
||||
&db2,
|
||||
r#"import * as wmill from "windmill-client";
|
||||
export async function main() {
|
||||
const val = await wmill.getVariable("u/test-user/plain_var");
|
||||
console.log("The plain value is: " + val);
|
||||
return "ok";
|
||||
}"#
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
completed.next().await;
|
||||
let cjob2 = completed_job(job2, &db2).await;
|
||||
assert!(cjob2.success, "scenario 2 job failed");
|
||||
let logs2 = get_job_logs(&db2, job2).await.expect("scenario 2: no logs");
|
||||
|
||||
assert!(
|
||||
logs2.contains(plain_val),
|
||||
"scenario 2: plain value should appear unmasked\nLogs:\n{logs2}"
|
||||
);
|
||||
|
||||
// ================================================================
|
||||
// Scenario 3: Two different secrets fetched in the same job → both masked
|
||||
// ================================================================
|
||||
let job3 = push_bun_job(
|
||||
&db2,
|
||||
r#"import * as wmill from "windmill-client";
|
||||
export async function main() {
|
||||
const s1 = await wmill.getVariable("u/test-user/secret_alpha");
|
||||
const s2 = await wmill.getVariable("u/test-user/secret_beta");
|
||||
console.log("secret1=" + s1);
|
||||
console.log("secret2=" + s2);
|
||||
return "ok";
|
||||
}"#
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
completed.next().await;
|
||||
let cjob3 = completed_job(job3, &db2).await;
|
||||
assert!(cjob3.success, "scenario 3 job failed");
|
||||
let logs3 = get_job_logs(&db2, job3).await.expect("scenario 3: no logs");
|
||||
|
||||
assert!(
|
||||
!logs3.contains(secret1),
|
||||
"scenario 3: secret1 leaked\nLogs:\n{logs3}"
|
||||
);
|
||||
assert!(
|
||||
!logs3.contains(secret2),
|
||||
"scenario 3: secret2 leaked\nLogs:\n{logs3}"
|
||||
);
|
||||
assert!(
|
||||
logs3.contains("secret1=alp*****"),
|
||||
"scenario 3: secret1 not masked\nLogs:\n{logs3}"
|
||||
);
|
||||
assert!(
|
||||
logs3.contains("secret2=bet*****"),
|
||||
"scenario 3: secret2 not masked\nLogs:\n{logs3}"
|
||||
);
|
||||
|
||||
// ================================================================
|
||||
// Scenario 4: Secret embedded mid-string → masked in place
|
||||
// ================================================================
|
||||
let job4 = push_bun_job(
|
||||
&db2,
|
||||
r#"import * as wmill from "windmill-client";
|
||||
export async function main() {
|
||||
const secret = await wmill.getVariable("u/test-user/secret_alpha");
|
||||
console.log("token=" + secret + "&user=bob&format=json");
|
||||
return "ok";
|
||||
}"#
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
completed.next().await;
|
||||
let cjob4 = completed_job(job4, &db2).await;
|
||||
assert!(cjob4.success, "scenario 4 job failed");
|
||||
let logs4 = get_job_logs(&db2, job4).await.expect("scenario 4: no logs");
|
||||
|
||||
assert!(
|
||||
!logs4.contains(secret1),
|
||||
"scenario 4: secret leaked mid-string\nLogs:\n{logs4}"
|
||||
);
|
||||
assert!(
|
||||
logs4.contains("token=alp*****&user=bob&format=json"),
|
||||
"scenario 4: mid-string masking failed\nLogs:\n{logs4}"
|
||||
);
|
||||
|
||||
// ================================================================
|
||||
// Scenario 5: Same secret logged 3 times → all occurrences masked
|
||||
// ================================================================
|
||||
let job5 = push_bun_job(
|
||||
&db2,
|
||||
r#"import * as wmill from "windmill-client";
|
||||
export async function main() {
|
||||
const secret = await wmill.getVariable("u/test-user/secret_beta");
|
||||
console.log("First: " + secret);
|
||||
console.log("Second: " + secret);
|
||||
console.log("Third: " + secret);
|
||||
return "ok";
|
||||
}"#
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
completed.next().await;
|
||||
let cjob5 = completed_job(job5, &db2).await;
|
||||
assert!(cjob5.success, "scenario 5 job failed");
|
||||
let logs5 = get_job_logs(&db2, job5).await.expect("scenario 5: no logs");
|
||||
|
||||
assert!(
|
||||
!logs5.contains(secret2),
|
||||
"scenario 5: secret leaked\nLogs:\n{logs5}"
|
||||
);
|
||||
let mask_count = logs5.matches("bet*****").count();
|
||||
assert!(
|
||||
mask_count >= 3,
|
||||
"scenario 5: expected >= 3 masked occurrences, found {mask_count}\nLogs:\n{logs5}"
|
||||
);
|
||||
// Security notice should appear only once even though masking happened 3 times
|
||||
let notice_count = logs5.matches("[windmill] secret value was masked").count();
|
||||
assert_eq!(
|
||||
notice_count, 1,
|
||||
"scenario 5: security notice should appear exactly once, found {notice_count}\nLogs:\n{logs5}"
|
||||
);
|
||||
|
||||
// ================================================================
|
||||
// Scenario 6: $encrypted: password arg → masked when logged
|
||||
// ================================================================
|
||||
let job6 = push_bun_job_with_encrypted_arg(
|
||||
&db2,
|
||||
r#"export async function main(password: string) {
|
||||
console.log("password is: " + password);
|
||||
return "ok";
|
||||
}"#
|
||||
.into(),
|
||||
"password",
|
||||
encrypted_password,
|
||||
)
|
||||
.await;
|
||||
completed.next().await;
|
||||
let cjob6 = completed_job(job6, &db2).await;
|
||||
assert!(cjob6.success, "scenario 6 job failed");
|
||||
let logs6 = get_job_logs(&db2, job6).await.expect("scenario 6: no logs");
|
||||
|
||||
assert!(
|
||||
!logs6.contains(encrypted_password),
|
||||
"scenario 6: encrypted password leaked\nLogs:\n{logs6}"
|
||||
);
|
||||
assert!(
|
||||
logs6.contains("password is: enc*****"),
|
||||
"scenario 6: encrypted password not masked\nLogs:\n{logs6}"
|
||||
);
|
||||
|
||||
// ================================================================
|
||||
// Scenario 7: Resource with $var: referencing a secret → masked
|
||||
// ================================================================
|
||||
let job7 = push_bun_job(
|
||||
&db2,
|
||||
r#"import * as wmill from "windmill-client";
|
||||
export async function main() {
|
||||
const res = await wmill.getResource("u/test-user/db_with_secret");
|
||||
console.log("db password: " + res.password);
|
||||
console.log("db host: " + res.host);
|
||||
return "ok";
|
||||
}"#
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
completed.next().await;
|
||||
let cjob7 = completed_job(job7, &db2).await;
|
||||
assert!(cjob7.success, "scenario 7 job failed");
|
||||
let logs7 = get_job_logs(&db2, job7).await.expect("scenario 7: no logs");
|
||||
|
||||
assert!(
|
||||
!logs7.contains(resource_secret),
|
||||
"scenario 7: resource secret leaked\nLogs:\n{logs7}"
|
||||
);
|
||||
assert!(
|
||||
logs7.contains("db password: res*****"),
|
||||
"scenario 7: resource secret not masked\nLogs:\n{logs7}"
|
||||
);
|
||||
// Non-secret field should remain visible
|
||||
assert!(
|
||||
logs7.contains("db host: db.example.com"),
|
||||
"scenario 7: non-secret resource field should be visible\nLogs:\n{logs7}"
|
||||
);
|
||||
|
||||
// ================================================================
|
||||
// Scenario 8: Cross-job isolation — job A fetches secret_alpha,
|
||||
// then job B logs "alpha_secret_value_9x7k2m" as a
|
||||
// literal string (not fetched as a secret).
|
||||
// Job B should NOT mask it because the secret belongs
|
||||
// to job A which already completed.
|
||||
// ================================================================
|
||||
// Job A: fetch the secret (registers it) then completes
|
||||
let job_a = push_bun_job(
|
||||
&db2,
|
||||
r#"import * as wmill from "windmill-client";
|
||||
export async function main() {
|
||||
const s = await wmill.getVariable("u/test-user/secret_alpha");
|
||||
console.log("fetched secret");
|
||||
return "ok";
|
||||
}"#
|
||||
.into(),
|
||||
)
|
||||
.await;
|
||||
completed.next().await;
|
||||
let cjob_a = completed_job(job_a, &db2).await;
|
||||
assert!(cjob_a.success, "scenario 8 job A failed");
|
||||
|
||||
// Job B: logs the same string as a hardcoded literal (NOT fetched as secret)
|
||||
// Since job A already completed and unregistered, and job B never
|
||||
// fetched the secret, it should NOT be masked.
|
||||
let job_b_code = format!(
|
||||
r#"export async function main() {{
|
||||
console.log("literal value: {secret1}");
|
||||
return "ok";
|
||||
}}"#
|
||||
);
|
||||
let job_b = push_bun_job(&db2, job_b_code).await;
|
||||
completed.next().await;
|
||||
let cjob_b = completed_job(job_b, &db2).await;
|
||||
assert!(cjob_b.success, "scenario 8 job B failed");
|
||||
let logs_b = get_job_logs(&db2, job_b)
|
||||
.await
|
||||
.expect("scenario 8 job B: no logs");
|
||||
|
||||
assert!(
|
||||
logs_b.contains(secret1),
|
||||
"scenario 8: job B should show the literal string unmasked (it never fetched a secret)\nLogs:\n{logs_b}"
|
||||
);
|
||||
},
|
||||
port,
|
||||
)
|
||||
.await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -82,12 +82,10 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
// --- allowed_domain_auto_invite ---
|
||||
let resp = authed(client().get(format!(
|
||||
"{global_base}/allowed_domain_auto_invite"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().get(format!("{global_base}/allowed_domain_auto_invite")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
resp.json::<bool>().await?;
|
||||
|
||||
@@ -213,12 +211,10 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
resp.json::<Vec<String>>().await?;
|
||||
|
||||
// --- get_dependents (empty, no dependencies exist) ---
|
||||
let resp = authed(client().get(format!(
|
||||
"{base}/get_dependents/u/test-user/nonexistent"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().get(format!("{base}/get_dependents/u/test-user/nonexistent")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let dependents = resp.json::<Vec<serde_json::Value>>().await?;
|
||||
assert!(dependents.is_empty());
|
||||
@@ -425,13 +421,11 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
);
|
||||
|
||||
// --- edit_large_file_storage_config ---
|
||||
let resp = authed(client().post(format!(
|
||||
"{base}/edit_large_file_storage_config"
|
||||
)))
|
||||
.json(&json!({"large_file_storage": null}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().post(format!("{base}/edit_large_file_storage_config")))
|
||||
.json(&json!({"large_file_storage": null}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
@@ -532,9 +526,7 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.unwrap();
|
||||
let invites = resp.json::<Vec<serde_json::Value>>().await?;
|
||||
assert!(
|
||||
invites
|
||||
.iter()
|
||||
.any(|i| i["email"] == "invited@example.com"),
|
||||
invites.iter().any(|i| i["email"] == "invited@example.com"),
|
||||
"invite not found: {:?}",
|
||||
invites
|
||||
);
|
||||
@@ -549,12 +541,7 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
201,
|
||||
"delete_invite: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
assert_eq!(resp.status(), 201, "delete_invite: {}", resp.text().await?);
|
||||
|
||||
// ===== Critical alerts (EE-gated, returns 404 in OSS) =====
|
||||
|
||||
@@ -624,12 +611,7 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"create_fork: {}",
|
||||
resp.text().await?
|
||||
);
|
||||
assert_eq!(resp.status(), 200, "create_fork: {}", resp.text().await?);
|
||||
|
||||
// verify fork exists
|
||||
let resp = authed(client().post(format!("{global_base}/exists")))
|
||||
@@ -702,13 +684,122 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
|
||||
assert_eq!(resp.json::<bool>().await?, false);
|
||||
|
||||
// --- create_workspace_require_superadmin ---
|
||||
let resp = authed(client().get(format!(
|
||||
"{global_base}/create_workspace_require_superadmin"
|
||||
)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let resp = authed(client().get(format!("{global_base}/create_workspace_require_superadmin")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_get_copilot_settings_state_reports_instance_ai_fallback_flags(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
|
||||
|
||||
let instance_ai_config = json!({
|
||||
"providers": {
|
||||
"openai": {
|
||||
"resource_path": "u/test-user/openai_instance",
|
||||
"models": ["gpt-4o-mini"]
|
||||
}
|
||||
}
|
||||
});
|
||||
let workspace_ai_config = json!({
|
||||
"providers": {
|
||||
"anthropic": {
|
||||
"resource_path": "u/test-user/anthropic_workspace",
|
||||
"models": ["claude-3-5-haiku-latest"]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sqlx::query("UPDATE workspace_settings SET ai_config = NULL WHERE workspace_id = $1")
|
||||
.bind("test-workspace")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
|
||||
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
|
||||
)
|
||||
.bind("ai_config")
|
||||
.bind(instance_ai_config)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get_copilot_settings_state")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let settings = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(settings["has_instance_ai_config"], true);
|
||||
assert_eq!(settings["uses_instance_ai_config"], true);
|
||||
assert_eq!(
|
||||
settings["instance_ai_summary"]["providers"][0]["provider"],
|
||||
"openai"
|
||||
);
|
||||
assert_eq!(
|
||||
settings["instance_ai_summary"]["providers"][0]["models"][0],
|
||||
"gpt-4o-mini"
|
||||
);
|
||||
|
||||
sqlx::query("UPDATE workspace_settings SET ai_config = $1 WHERE workspace_id = $2")
|
||||
.bind(workspace_ai_config)
|
||||
.bind("test-workspace")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get_copilot_settings_state")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let settings = resp.json::<serde_json::Value>().await?;
|
||||
assert_eq!(settings["has_instance_ai_config"], true);
|
||||
assert_eq!(settings["uses_instance_ai_config"], false);
|
||||
assert_eq!(
|
||||
settings["instance_ai_summary"]["providers"][0]["provider"],
|
||||
"openai"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
||||
async fn test_get_copilot_info_ignores_empty_instance_ai_row(
|
||||
db: Pool<Postgres>,
|
||||
) -> anyhow::Result<()> {
|
||||
initialize_tracing().await;
|
||||
let server = ApiServer::start(db.clone()).await?;
|
||||
let port = server.addr.port();
|
||||
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
|
||||
|
||||
sqlx::query("UPDATE workspace_settings SET ai_config = NULL WHERE workspace_id = $1")
|
||||
.bind("test-workspace")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
|
||||
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
|
||||
)
|
||||
.bind("ai_config")
|
||||
.bind(json!({}))
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
let resp = authed(client().get(format!("{base}/get_copilot_info")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let settings = resp.json::<serde_json::Value>().await?;
|
||||
assert!(settings["providers"].is_null());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -50,11 +50,11 @@ pub fn filter_list_queue_query(
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
let p = v.replace("*", "%");
|
||||
if w.negated {
|
||||
format!("v2_job_queue.worker NOT LIKE '{p}'")
|
||||
format!("v2_job_queue.worker NOT LIKE {}", quote(&p))
|
||||
} else {
|
||||
format!("v2_job_queue.worker LIKE '{p}'")
|
||||
format!("v2_job_queue.worker LIKE {}", quote(&p))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -77,11 +77,11 @@ pub fn filter_list_queue_query(
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let e = v.replace("'", "''");
|
||||
let p = format!("{}%", v);
|
||||
if ps.negated {
|
||||
format!("runnable_path NOT LIKE '{e}%'")
|
||||
format!("runnable_path NOT LIKE {}", quote(&p))
|
||||
} else {
|
||||
format!("runnable_path LIKE '{e}%'")
|
||||
format!("runnable_path LIKE {}", quote(&p))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -123,11 +123,11 @@ pub fn filter_list_queue_query(
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
let p = v.replace("*", "%");
|
||||
if t.negated {
|
||||
format!("v2_job.tag NOT LIKE '{p}'")
|
||||
format!("v2_job.tag NOT LIKE {}", quote(&p))
|
||||
} else {
|
||||
format!("v2_job.tag LIKE '{p}'")
|
||||
format!("v2_job.tag LIKE {}", quote(&p))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -287,14 +287,14 @@ pub fn filter_list_completed_query(
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
let p = v.replace("*", "%");
|
||||
if label.negated {
|
||||
format!(
|
||||
"NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE '{p}')"
|
||||
"NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE {})", quote(&p)
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE '{p}')"
|
||||
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE {})", quote(&p)
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -308,14 +308,14 @@ pub fn filter_list_completed_query(
|
||||
let clauses: Vec<_> = label
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| format!("NOT (result->'wm_labels' ? '{}')", v.replace("'", "''")))
|
||||
.map(|v| format!("NOT (result->'wm_labels' ? {})", quote(v)))
|
||||
.collect();
|
||||
sqlb.and_where(format!("({})", clauses.join(" AND ")));
|
||||
} else {
|
||||
let clauses: Vec<_> = label
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| format!("result->'wm_labels' ? '{}'", v.replace("'", "''")))
|
||||
.map(|v| format!("result->'wm_labels' ? {}", quote(v)))
|
||||
.collect();
|
||||
sqlb.and_where("result ? 'wm_labels'");
|
||||
sqlb.and_where(format!("({})", clauses.join(" OR ")));
|
||||
@@ -329,11 +329,11 @@ pub fn filter_list_completed_query(
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
let p = v.replace("*", "%");
|
||||
if worker.negated {
|
||||
format!("v2_job_completed.worker NOT LIKE '{p}'")
|
||||
format!("v2_job_completed.worker NOT LIKE {}", quote(&p))
|
||||
} else {
|
||||
format!("v2_job_completed.worker LIKE '{p}'")
|
||||
format!("v2_job_completed.worker LIKE {}", quote(&p))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -366,11 +366,11 @@ pub fn filter_list_completed_query(
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let e = v.replace("'", "''");
|
||||
let p = format!("{}%", v);
|
||||
if ps.negated {
|
||||
format!("runnable_path NOT LIKE '{e}%'")
|
||||
format!("runnable_path NOT LIKE {}", quote(&p))
|
||||
} else {
|
||||
format!("runnable_path LIKE '{e}%'")
|
||||
format!("runnable_path LIKE {}", quote(&p))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -400,11 +400,11 @@ pub fn filter_list_completed_query(
|
||||
.values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
let p = v.replace("*", "%").replace("'", "''");
|
||||
let p = v.replace("*", "%");
|
||||
if t.negated {
|
||||
format!("v2_job.tag NOT LIKE '{p}'")
|
||||
format!("v2_job.tag NOT LIKE {}", quote(&p))
|
||||
} else {
|
||||
format!("v2_job.tag LIKE '{p}'")
|
||||
format!("v2_job.tag LIKE {}", quote(&p))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -449,11 +449,7 @@ pub fn filter_list_completed_query(
|
||||
}
|
||||
if let Some(dt) = &lq.created_or_started_after {
|
||||
let ts = dt.to_rfc3339();
|
||||
sqlb.and_where(format!(
|
||||
"(created_at >= '{}' OR started_at >= '{}')",
|
||||
ts.replace("'", "''"),
|
||||
ts.replace("'", "''")
|
||||
));
|
||||
sqlb.and_where("(created_at >= ? OR started_at >= ?)".bind(&ts).bind(&ts));
|
||||
}
|
||||
|
||||
if let Some(dt) = &lq.created_before {
|
||||
|
||||
@@ -963,6 +963,15 @@ async fn delete_schedule(
|
||||
)));
|
||||
}
|
||||
|
||||
// Capture row for trashbin before deleting
|
||||
let trash_data: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT jsonb_build_object('row', to_jsonb(t)) FROM schedule t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let del = sqlx::query_scalar!(
|
||||
"DELETE FROM schedule WHERE path = $1 AND workspace_id = $2 RETURNING 1",
|
||||
path,
|
||||
@@ -979,6 +988,18 @@ async fn delete_schedule(
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(data) = trash_data {
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"schedule",
|
||||
path,
|
||||
data,
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
|
||||
@@ -239,6 +239,7 @@ pub fn workspaced_service() -> Router {
|
||||
"/history_update/h/:hash/p/*path",
|
||||
post(update_script_history),
|
||||
)
|
||||
.route("/list_dedicated_with_deps", get(list_dedicated_with_deps))
|
||||
// Temporary raw script storage for CLI lock generation
|
||||
.route("/raw_temp/store", post(store_raw_script_temp))
|
||||
.route("/raw_temp/diff", post(diff_raw_scripts_with_deployed))
|
||||
@@ -2239,33 +2240,58 @@ async fn delete_script_by_path(
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
let script = if !draft_only {
|
||||
if !draft_only {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
sqlx::query_scalar!(
|
||||
"DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path",
|
||||
}
|
||||
|
||||
// Capture all script versions and drafts for trashbin before deleting
|
||||
let trash_scripts: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM script t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_drafts: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let script = sqlx::query_scalar!(
|
||||
"DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))?;
|
||||
|
||||
if !trash_scripts.is_empty() {
|
||||
let mut trash_data = serde_json::json!({"scripts": trash_scripts});
|
||||
if !trash_drafts.is_empty() {
|
||||
trash_data["drafts"] = serde_json::Value::Array(trash_drafts);
|
||||
}
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"script",
|
||||
path,
|
||||
w_id
|
||||
trash_data,
|
||||
&authed.username,
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))?
|
||||
} else {
|
||||
sqlx::query_scalar!(
|
||||
"DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))?
|
||||
};
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.execute(&db)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if !query.keep_captures.unwrap_or(false) {
|
||||
@@ -2274,7 +2300,7 @@ async fn delete_script_by_path(
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.execute(&db)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
@@ -2282,7 +2308,7 @@ async fn delete_script_by_path(
|
||||
path,
|
||||
w_id
|
||||
)
|
||||
.execute(&db)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
@@ -2369,6 +2395,30 @@ async fn delete_scripts_bulk(
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Capture scripts for trashbin per path before bulk delete
|
||||
for path in &request.paths {
|
||||
let trash_scripts: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM script t WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if !trash_scripts.is_empty() {
|
||||
let trash_data = serde_json::json!({"scripts": trash_scripts});
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"script",
|
||||
path,
|
||||
trash_data,
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
let mut deleted_paths = sqlx::query_scalar!(
|
||||
"DELETE FROM script WHERE workspace_id = $1 AND path = ANY($2) RETURNING path",
|
||||
w_id,
|
||||
@@ -2480,6 +2530,62 @@ async fn guard_script_from_debounce_data(ns: &NewScript) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DedicatedScriptDeps {
|
||||
path: String,
|
||||
language: ScriptLang,
|
||||
workspace_dep_names: Vec<String>,
|
||||
}
|
||||
|
||||
async fn list_dedicated_with_deps(
|
||||
authed: ApiAuthed,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<Vec<DedicatedScriptDeps>> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
let rows = sqlx::query!(
|
||||
"SELECT DISTINCT ON (path) path, language AS \"language: ScriptLang\", content FROM script
|
||||
WHERE workspace_id = $1
|
||||
AND archived = false
|
||||
AND dedicated_worker = true
|
||||
AND language = ANY($2::SCRIPT_LANG[])
|
||||
ORDER BY path, created_at DESC",
|
||||
&w_id,
|
||||
&[
|
||||
ScriptLang::Python3,
|
||||
ScriptLang::Bun,
|
||||
ScriptLang::Bunnative,
|
||||
ScriptLang::Deno,
|
||||
] as &[ScriptLang],
|
||||
)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
|
||||
let result = rows
|
||||
.into_iter()
|
||||
.map(|row| {
|
||||
let dep_names =
|
||||
windmill_common::scripts::extract_workspace_dependencies_annotated_refs(
|
||||
&row.language,
|
||||
&row.content,
|
||||
&row.path,
|
||||
)
|
||||
.map(|refs| refs.external)
|
||||
.unwrap_or_default();
|
||||
DedicatedScriptDeps {
|
||||
path: row.path,
|
||||
language: row.language,
|
||||
workspace_dep_names: dep_names,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Temporary Raw Script Storage for CLI Lock Generation
|
||||
// ============================================================================
|
||||
@@ -2508,11 +2614,9 @@ async fn store_raw_script_temp(
|
||||
.await?;
|
||||
|
||||
// Clean up old entries (1 week TTL)
|
||||
sqlx::query!(
|
||||
"DELETE FROM raw_script_temp WHERE created_at < NOW() - INTERVAL '1 week'"
|
||||
)
|
||||
.execute(&db)
|
||||
.await?;
|
||||
sqlx::query!("DELETE FROM raw_script_temp WHERE created_at < NOW() - INTERVAL '1 week'")
|
||||
.execute(&db)
|
||||
.await?;
|
||||
|
||||
Ok(Json(hash))
|
||||
}
|
||||
@@ -2560,7 +2664,7 @@ async fn diff_raw_scripts_with_deployed(
|
||||
FROM script s \
|
||||
WHERE s.path = local.path AND s.workspace_id = $3 AND s.archived = false \
|
||||
ORDER BY s.created_at DESC LIMIT 1 \
|
||||
) deployed ON deployed.deployed_hash = local.hash"
|
||||
) deployed ON deployed.deployed_hash = local.hash",
|
||||
)
|
||||
.bind(&paths)
|
||||
.bind(&hashes)
|
||||
@@ -2582,7 +2686,7 @@ async fn diff_raw_scripts_with_deployed(
|
||||
AND wd.language = $3::SCRIPT_LANG \
|
||||
AND wd.name IS NOT DISTINCT FROM $4 \
|
||||
AND encode(sha256(convert_to(wd.content, 'UTF8')), 'hex') = $5 \
|
||||
)"
|
||||
)",
|
||||
)
|
||||
.bind(&dep.path)
|
||||
.bind(&w_id)
|
||||
|
||||
@@ -16,6 +16,7 @@ parquet = ["windmill-common/parquet", "windmill-object-store/parquet"]
|
||||
license = ["dep:rsa"]
|
||||
|
||||
[dependencies]
|
||||
windmill-ai = { workspace = true, default-features = false }
|
||||
windmill-alerting.workspace = true
|
||||
windmill-api-auth.workspace = true
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
|
||||
@@ -37,15 +37,16 @@ use serde::{Deserialize, Serialize};
|
||||
use windmill_common::ee_oss::{send_critical_alert, CriticalAlertKind, CriticalErrorChannel};
|
||||
#[cfg(all(feature = "private", feature = "enterprise"))]
|
||||
use windmill_common::secret_backend::{SecretMigrationReport, VaultSettings};
|
||||
use windmill_ai::ai_cache::bump_instance_ai_config_revision;
|
||||
use windmill_common::{
|
||||
email_oss::send_email_plain_text,
|
||||
error::{self, JsonResult, Result},
|
||||
get_database_url,
|
||||
global_settings::{
|
||||
APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
|
||||
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
|
||||
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING,
|
||||
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
|
||||
WS_BASE_URL_SETTING,
|
||||
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
|
||||
HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, WS_BASE_URL_SETTING,
|
||||
},
|
||||
instance_config::{self, ApplyMode, InstanceConfig},
|
||||
server::Smtp,
|
||||
@@ -284,6 +285,7 @@ pub async fn set_global_setting_internal(
|
||||
key: String,
|
||||
value: serde_json::Value,
|
||||
) -> error::Result<()> {
|
||||
let should_bump_instance_ai_revision = key == AI_CONFIG_SETTING;
|
||||
let value = if key == "retention_period_secs" {
|
||||
instance_config::clamp_retention_period(value)
|
||||
} else {
|
||||
@@ -325,6 +327,10 @@ pub async fn set_global_setting_internal(
|
||||
}
|
||||
};
|
||||
|
||||
if should_bump_instance_ai_revision {
|
||||
bump_instance_ai_config_revision();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -418,6 +424,74 @@ async fn run_setting_pre_write_hook(
|
||||
}
|
||||
}
|
||||
}
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => {
|
||||
let serde_json::Value::Bool(workspaced_route) = value else {
|
||||
return Err(error::Error::BadRequest(format!(
|
||||
"{} setting expected to be boolean",
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING
|
||||
)));
|
||||
};
|
||||
|
||||
if !*workspaced_route {
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[allow(unused)]
|
||||
struct DuplicateRoute {
|
||||
route_path: String,
|
||||
workspace_id: String,
|
||||
http_method: String,
|
||||
}
|
||||
let duplicate_routes = sqlx::query_as!(
|
||||
DuplicateRoute,
|
||||
r#"
|
||||
SELECT
|
||||
route_path,
|
||||
workspace_id,
|
||||
http_method::TEXT AS "http_method!"
|
||||
FROM
|
||||
http_trigger
|
||||
WHERE
|
||||
workspaced_route IS FALSE
|
||||
AND route_path_key IN (
|
||||
SELECT
|
||||
route_path_key
|
||||
FROM
|
||||
http_trigger
|
||||
WHERE
|
||||
workspaced_route IS FALSE
|
||||
GROUP BY
|
||||
route_path_key, http_method
|
||||
HAVING COUNT(*) > 1
|
||||
)
|
||||
ORDER BY route_path_key
|
||||
"#
|
||||
)
|
||||
.fetch_all(db)
|
||||
.await?;
|
||||
|
||||
if !duplicate_routes.is_empty() {
|
||||
tracing::error!(
|
||||
"Cannot disable {} setting as duplicate http routes were found: {:?}",
|
||||
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
|
||||
&duplicate_routes
|
||||
);
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ErrorResponse {
|
||||
error: String,
|
||||
details: Vec<DuplicateRoute>,
|
||||
}
|
||||
|
||||
let error_response = ErrorResponse {
|
||||
error: "Duplicate HTTP route paths detected".to_string(),
|
||||
details: duplicate_routes,
|
||||
};
|
||||
|
||||
return Err(error::Error::JsonErr(
|
||||
serde_json::to_value(error_response).unwrap(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
@@ -471,6 +545,10 @@ async fn set_instance_config(
|
||||
let current_map = current.global_settings.to_settings_map();
|
||||
let settings_diff =
|
||||
instance_config::diff_global_settings(¤t_map, &desired_map, ApplyMode::Merge);
|
||||
let ai_config_changed = settings_diff
|
||||
.upserts
|
||||
.iter()
|
||||
.any(|(key, _)| key == AI_CONFIG_SETTING);
|
||||
|
||||
for (key, value) in &settings_diff.upserts {
|
||||
run_setting_pre_write_hook(&db, key, value).await?;
|
||||
@@ -479,6 +557,10 @@ async fn set_instance_config(
|
||||
instance_config::apply_settings_diff(&db, &settings_diff)
|
||||
.await
|
||||
.map_err(|e| error::Error::internal_err(e.to_string()))?;
|
||||
|
||||
if ai_config_changed {
|
||||
bump_instance_ai_config_revision();
|
||||
}
|
||||
}
|
||||
|
||||
if !desired.worker_configs.is_empty() {
|
||||
@@ -527,6 +609,7 @@ pub async fn get_global_setting(
|
||||
&& key != DISABLE_HUB_SETTING
|
||||
&& key != EMAIL_DOMAIN_SETTING
|
||||
&& key != APP_WORKSPACED_ROUTE_SETTING
|
||||
&& key != HTTP_ROUTE_WORKSPACED_ROUTE_SETTING
|
||||
&& key != WS_BASE_URL_SETTING
|
||||
{
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
@@ -1110,6 +1193,59 @@ struct CachedResourceType {
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct HubResourceTypeRaw {
|
||||
id: i64,
|
||||
name: String,
|
||||
schema: Option<String>,
|
||||
app: String,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType>> {
|
||||
let response = HTTP_CLIENT
|
||||
.get(format!(
|
||||
"{}/resource_types/list",
|
||||
windmill_common::DEFAULT_HUB_BASE_URL
|
||||
))
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| error::Error::InternalErr(format!("Failed to fetch from hub: {}", e)))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(error::Error::InternalErr(format!(
|
||||
"Hub returned status {}",
|
||||
response.status()
|
||||
)));
|
||||
}
|
||||
|
||||
let raw_types: Vec<HubResourceTypeRaw> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| error::Error::InternalErr(format!("Failed to parse hub response: {}", e)))?;
|
||||
|
||||
Ok(raw_types
|
||||
.into_iter()
|
||||
.filter_map(|rt| {
|
||||
let schema = match rt.schema {
|
||||
Some(s) => match serde_json::from_str(&s) {
|
||||
Ok(v) => Some(v),
|
||||
Err(_) => return None,
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
Some(CachedResourceType {
|
||||
id: rt.id,
|
||||
name: rt.name,
|
||||
schema,
|
||||
app: rt.app,
|
||||
description: rt.description,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn sync_cached_resource_types(
|
||||
Extension(db): Extension<DB>,
|
||||
authed: ApiAuthed,
|
||||
@@ -1119,16 +1255,12 @@ async fn sync_cached_resource_types(
|
||||
use windmill_common::worker::HUB_RT_CACHE_DIR;
|
||||
let cache_path = format!("{}/resource_types.json", *HUB_RT_CACHE_DIR);
|
||||
|
||||
let content = tokio::fs::read_to_string(&cache_path).await.map_err(|e| {
|
||||
error::Error::NotFound(format!(
|
||||
"No cached resource types found at {}: {}",
|
||||
cache_path, e
|
||||
))
|
||||
})?;
|
||||
|
||||
let cached_types: Vec<CachedResourceType> = serde_json::from_str(&content).map_err(|e| {
|
||||
error::Error::InternalErr(format!("Failed to parse cached resource types: {}", e))
|
||||
})?;
|
||||
let cached_types = match tokio::fs::read_to_string(&cache_path).await {
|
||||
Ok(content) => serde_json::from_str::<Vec<CachedResourceType>>(&content).map_err(|e| {
|
||||
error::Error::InternalErr(format!("Failed to parse cached resource types: {}", e))
|
||||
})?,
|
||||
Err(_) => fetch_resource_types_from_hub().await?,
|
||||
};
|
||||
|
||||
let mut synced_count = 0;
|
||||
|
||||
|
||||
@@ -34,3 +34,4 @@ time.workspace = true
|
||||
tokio.workspace = true
|
||||
tower-cookies.workspace = true
|
||||
tracing.workspace = true
|
||||
url.workspace = true
|
||||
|
||||
@@ -49,13 +49,13 @@ use windmill_common::users::truncate_token;
|
||||
use windmill_common::users::COOKIE_NAME;
|
||||
use windmill_common::utils::paginate;
|
||||
use windmill_common::worker::CLOUD_HOSTED;
|
||||
use windmill_common::BASE_URL;
|
||||
use windmill_common::{
|
||||
auth::{get_folders_for_user, get_groups_for_user},
|
||||
db::UserDB,
|
||||
error::{self, Error, JsonResult, Result},
|
||||
utils::{not_found_if_none, rd_string, require_admin, Pagination, StripPath},
|
||||
};
|
||||
use windmill_common::{BASE_URL, HUB_BASE_URL};
|
||||
use windmill_git_sync::handle_deployment_metadata;
|
||||
|
||||
const COOKIE_PATH: &str = "/";
|
||||
@@ -157,6 +157,7 @@ pub struct GlobalUserInfo {
|
||||
operator_only: Option<bool>,
|
||||
first_time_user: bool,
|
||||
role_source: String,
|
||||
disabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
@@ -213,6 +214,7 @@ pub struct EditUser {
|
||||
pub is_super_admin: Option<bool>,
|
||||
pub is_devops: Option<bool>,
|
||||
pub name: Option<String>,
|
||||
pub disabled: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -396,7 +398,7 @@ async fn list_users_as_super_admin(
|
||||
GlobalUserInfo,
|
||||
"WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),
|
||||
authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)
|
||||
SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source
|
||||
SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source, disabled
|
||||
FROM password
|
||||
WHERE email IN (SELECT email FROM active_users)
|
||||
ORDER BY super_admin DESC, devops DESC
|
||||
@@ -409,7 +411,7 @@ async fn list_users_as_super_admin(
|
||||
} else {
|
||||
sqlx::query_as!(
|
||||
GlobalUserInfo,
|
||||
"SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \
|
||||
"SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \
|
||||
$1 OFFSET $2",
|
||||
per_page as i32,
|
||||
offset as i32
|
||||
@@ -577,12 +579,44 @@ async fn logout(
|
||||
}
|
||||
tx.commit().await?;
|
||||
if let Some(rd) = rd {
|
||||
Ok((StatusCode::TEMPORARY_REDIRECT, [(LOCATION, rd)]).into_response())
|
||||
if is_valid_logout_redirect(&rd).await {
|
||||
Ok((StatusCode::TEMPORARY_REDIRECT, [(LOCATION, rd)]).into_response())
|
||||
} else {
|
||||
tracing::warn!("Blocked logout redirect to non-whitelisted URL: {}", rd);
|
||||
Ok((StatusCode::OK, "logged out successfully".to_string()).into_response())
|
||||
}
|
||||
} else {
|
||||
Ok((StatusCode::OK, "logged out successfully".to_string()).into_response())
|
||||
}
|
||||
}
|
||||
|
||||
async fn is_valid_logout_redirect(rd: &str) -> bool {
|
||||
// Allow relative paths (same-origin redirects)
|
||||
if rd.starts_with('/') && !rd.starts_with("//") {
|
||||
return true;
|
||||
}
|
||||
let parsed = match url::Url::parse(rd) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let host: &str = match parsed.host_str() {
|
||||
Some(h) => h,
|
||||
None => return false,
|
||||
};
|
||||
if host == "windmill.dev" || host.ends_with(".windmill.dev") {
|
||||
return true;
|
||||
}
|
||||
let hub_url = HUB_BASE_URL.read().await.clone();
|
||||
if let Ok(hub_parsed) = url::Url::parse(&hub_url) {
|
||||
if let Some(hub_host) = hub_parsed.host_str() {
|
||||
if host == hub_host {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn whoami(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
@@ -625,7 +659,7 @@ async fn global_whoami(
|
||||
) -> JsonResult<GlobalUserInfo> {
|
||||
let user = sqlx::query_as!(
|
||||
GlobalUserInfo,
|
||||
"SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password WHERE \
|
||||
"SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password WHERE \
|
||||
email = $1",
|
||||
email
|
||||
)
|
||||
@@ -648,6 +682,7 @@ async fn global_whoami(
|
||||
operator_only: None,
|
||||
first_time_user: false,
|
||||
role_source: "manual".to_string(),
|
||||
disabled: false,
|
||||
}))
|
||||
} else {
|
||||
Err(user.unwrap_err())
|
||||
@@ -1407,6 +1442,22 @@ async fn update_user(
|
||||
.await?;
|
||||
}
|
||||
|
||||
if let Some(d) = eu.disabled {
|
||||
sqlx::query_scalar!(
|
||||
"UPDATE password SET disabled = $1 WHERE email = $2",
|
||||
d,
|
||||
&email_to_update
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
if d {
|
||||
// Delete all tokens for immediate session revocation
|
||||
sqlx::query!("DELETE FROM token WHERE email = $1", &email_to_update)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
@@ -1429,6 +1480,9 @@ async fn delete_user(
|
||||
require_super_admin(&db, &authed.email).await?;
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
sqlx::query!("DELETE FROM token WHERE email = $1", &email_to_delete)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
@@ -1687,7 +1741,7 @@ async fn login(
|
||||
};
|
||||
let email_w_h: Option<(String, String, bool)> = sqlx::query_as(
|
||||
"SELECT email, password_hash, super_admin FROM password WHERE email = $1 AND login_type = \
|
||||
'password'",
|
||||
'password' AND disabled = false",
|
||||
)
|
||||
.bind(&email)
|
||||
.fetch_optional(&mut *tx)
|
||||
@@ -1776,7 +1830,7 @@ async fn refresh_token(
|
||||
}
|
||||
|
||||
let super_admin = sqlx::query_scalar!(
|
||||
"SELECT super_admin FROM password WHERE email = $1",
|
||||
"SELECT super_admin FROM password WHERE email = $1 AND disabled = false",
|
||||
&authed.email
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
|
||||
@@ -14,9 +14,11 @@ enterprise = ["windmill-common/enterprise"]
|
||||
private = ["windmill-common/private"]
|
||||
cloud = ["windmill-common/cloud"]
|
||||
no_auth = ["windmill-api-auth/no_auth"]
|
||||
parquet = ["windmill-object-store/parquet"]
|
||||
|
||||
[dependencies]
|
||||
windmill-common = { workspace = true, default-features = false }
|
||||
windmill-object-store = { workspace = true, optional = true }
|
||||
windmill-types.workspace = true
|
||||
windmill-api-auth.workspace = true
|
||||
windmill-api-users.workspace = true
|
||||
|
||||
@@ -35,7 +35,6 @@ use windmill_common::variables::{
|
||||
build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE,
|
||||
};
|
||||
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_common::workspaces::GitRepositorySettings;
|
||||
#[cfg(feature = "enterprise")]
|
||||
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
|
||||
@@ -82,6 +81,10 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/get_dependents/*imported_path", get(get_dependents))
|
||||
.route("/get_dependents_amounts", post(get_dependents_amounts))
|
||||
.route("/get_settings", get(get_settings))
|
||||
.route(
|
||||
"/get_copilot_settings_state",
|
||||
get(get_copilot_settings_state),
|
||||
)
|
||||
.route("/get_deploy_to", get(get_deploy_to))
|
||||
.route("/edit_slack_command", post(edit_slack_command))
|
||||
.route(
|
||||
@@ -111,6 +114,7 @@ pub fn workspaced_service() -> Router {
|
||||
.route("/list_datatables", get(list_datatables))
|
||||
.route("/list_datatable_schemas", get(list_datatable_schemas))
|
||||
.route("/edit_datatable_config", post(edit_datatable_config))
|
||||
.route("/git_sync_enabled", get(get_git_sync_enabled))
|
||||
.route("/edit_git_sync_config", post(edit_git_sync_config))
|
||||
.route("/edit_git_sync_repository", post(edit_git_sync_repository))
|
||||
.route(
|
||||
@@ -257,6 +261,35 @@ pub struct WorkspaceSettings {
|
||||
pub public_app_execution_limit_per_minute: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct CopilotSettingsState {
|
||||
pub has_instance_ai_config: bool,
|
||||
pub uses_instance_ai_config: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub instance_ai_summary: Option<InstanceAISummary>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct InstanceAIProviderSummary {
|
||||
pub provider: String,
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct InstanceAIModelSummary {
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
pub struct InstanceAISummary {
|
||||
pub providers: Vec<InstanceAIProviderSummary>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_model: Option<InstanceAIModelSummary>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub code_completion_model: Option<InstanceAIModelSummary>,
|
||||
}
|
||||
|
||||
/// #[derive(sqlx::Type, Serialize, Deserialize, Debug)]
|
||||
// #[sqlx(type_name = "WORKSPACE_KEY_KIND", rename_all = "lowercase")]
|
||||
// pub enum WorkspaceKeyKind {
|
||||
@@ -608,15 +641,106 @@ async fn get_settings(
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("getting settings: {e:#}")))?;
|
||||
|
||||
let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?;
|
||||
tx.commit().await?;
|
||||
|
||||
let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?;
|
||||
if !authed.is_admin {
|
||||
settings.slack_oauth_client_secret = None;
|
||||
}
|
||||
Ok(Json(settings))
|
||||
}
|
||||
|
||||
async fn get_copilot_settings_state(
|
||||
authed: ApiAuthed,
|
||||
Path(w_id): Path<String>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
) -> JsonResult<CopilotSettingsState> {
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let workspace_ai_config = sqlx::query_scalar!(
|
||||
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("getting workspace ai settings: {e:#}")))?;
|
||||
let workspace_ai_config = not_found_if_none(workspace_ai_config, "workspace settings", &w_id)?;
|
||||
let instance_ai_config: Option<serde_json::Value> =
|
||||
sqlx::query_scalar("SELECT value FROM global_settings WHERE name = 'ai_config'")
|
||||
.fetch_optional(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("getting instance ai settings: {e:#}")))?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(build_copilot_settings_state(
|
||||
has_ai_providers(workspace_ai_config.as_ref()),
|
||||
instance_ai_config.as_ref(),
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn has_ai_providers(config: Option<&serde_json::Value>) -> bool {
|
||||
config
|
||||
.and_then(|value| value.get("providers"))
|
||||
.and_then(|providers| providers.as_object())
|
||||
.map(|providers| !providers.is_empty())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn build_copilot_settings_state(
|
||||
has_workspace_ai_config: bool,
|
||||
instance_ai_config: Option<&serde_json::Value>,
|
||||
) -> CopilotSettingsState {
|
||||
let has_instance_ai_config = has_ai_providers(instance_ai_config);
|
||||
CopilotSettingsState {
|
||||
has_instance_ai_config,
|
||||
uses_instance_ai_config: !has_workspace_ai_config && has_instance_ai_config,
|
||||
instance_ai_summary: build_instance_ai_summary(instance_ai_config),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_instance_ai_summary(config: Option<&serde_json::Value>) -> Option<InstanceAISummary> {
|
||||
let config = config?;
|
||||
if !has_ai_providers(Some(config)) {
|
||||
return None;
|
||||
}
|
||||
let providers = config.get("providers")?.as_object()?;
|
||||
|
||||
let mut provider_summaries = providers
|
||||
.iter()
|
||||
.map(|(provider, provider_config)| InstanceAIProviderSummary {
|
||||
provider: provider.clone(),
|
||||
models: provider_config
|
||||
.get("models")
|
||||
.and_then(|models| models.as_array())
|
||||
.map(|models| {
|
||||
models
|
||||
.iter()
|
||||
.filter_map(|model| model.as_str().map(ToOwned::to_owned))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
provider_summaries.sort_by(|left, right| left.provider.cmp(&right.provider));
|
||||
|
||||
Some(InstanceAISummary {
|
||||
providers: provider_summaries,
|
||||
default_model: extract_instance_ai_model_summary(config, "default_model"),
|
||||
code_completion_model: extract_instance_ai_model_summary(config, "code_completion_model"),
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_instance_ai_model_summary(
|
||||
config: &serde_json::Value,
|
||||
key: &str,
|
||||
) -> Option<InstanceAIModelSummary> {
|
||||
let model_config = config.get(key)?.as_object()?;
|
||||
Some(InstanceAIModelSummary {
|
||||
provider: model_config.get("provider")?.as_str()?.to_owned(),
|
||||
model: model_config.get("model")?.as_str()?.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct DeployTo {
|
||||
deploy_to: Option<String>,
|
||||
@@ -1471,24 +1595,20 @@ async fn edit_datatable_config(
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct EditGitSyncConfig {
|
||||
#[cfg(feature = "enterprise")]
|
||||
pub git_sync_settings: Option<WorkspaceGitSyncSettings>,
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct EditGitSyncRepository {
|
||||
pub git_repo_resource_path: String,
|
||||
pub repository: GitRepositorySettings,
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
#[derive(Deserialize, Debug)]
|
||||
pub struct DeleteGitSyncRepositoryRequest {
|
||||
pub git_repo_resource_path: String,
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
fn validate_git_repo_resource_path(path: &str) -> Result<()> {
|
||||
// Resource paths should follow the pattern: $res:f/<folder>/<name> or $res:u/<username>/<name>
|
||||
if path.is_empty() {
|
||||
@@ -1537,7 +1657,6 @@ fn validate_git_repo_resource_path(path: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
fn cleanup_legacy_git_sync_settings_in_memory(
|
||||
git_sync_settings: &mut windmill_common::workspaces::WorkspaceGitSyncSettings,
|
||||
workspace_id: &str,
|
||||
@@ -1564,18 +1683,72 @@ fn cleanup_legacy_git_sync_settings_in_memory(
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
async fn edit_git_sync_config(
|
||||
_authed: ApiAuthed,
|
||||
Extension(_db): Extension<DB>,
|
||||
Path(_w_id): Path<String>,
|
||||
Json(_new_config): Json<EditGitSyncConfig>,
|
||||
) -> Result<String> {
|
||||
return Err(Error::BadRequest(
|
||||
"Git sync is only available on Windmill Enterprise Edition".to_string(),
|
||||
));
|
||||
const CE_GIT_SYNC_MAX_USERS: i64 = 2;
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
async fn check_git_sync_access(_db: &DB, _w_id: &str) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
async fn check_git_sync_access(db: &DB, w_id: &str) -> Result<()> {
|
||||
let user_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
|
||||
w_id
|
||||
)
|
||||
.fetch_one(db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
if user_count > CE_GIT_SYNC_MAX_USERS {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Git sync is available for workspaces with up to {} members. \
|
||||
Upgrade to Windmill Enterprise Edition for unlimited workspace members.",
|
||||
CE_GIT_SYNC_MAX_USERS
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
async fn get_git_sync_enabled(
|
||||
_authed: ApiAuthed,
|
||||
Extension(_db): Extension<DB>,
|
||||
Path(_w_id): Path<String>,
|
||||
) -> JsonResult<serde_json::Value> {
|
||||
Ok(Json(serde_json::json!({
|
||||
"enabled": true,
|
||||
"reason": "enterprise",
|
||||
"max_repos": null,
|
||||
"user_count": null,
|
||||
"max_users": null,
|
||||
})))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
async fn get_git_sync_enabled(
|
||||
_authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<serde_json::Value> {
|
||||
let user_count: i64 = sqlx::query_scalar!(
|
||||
"SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
let enabled = user_count <= CE_GIT_SYNC_MAX_USERS;
|
||||
Ok(Json(serde_json::json!({
|
||||
"enabled": enabled,
|
||||
"reason": if enabled { Some("free_tier") } else { None::<&str> },
|
||||
"max_repos": if enabled { Some(1) } else { None::<i32> },
|
||||
"user_count": user_count,
|
||||
"max_users": CE_GIT_SYNC_MAX_USERS,
|
||||
})))
|
||||
}
|
||||
|
||||
async fn edit_git_sync_config(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -1584,6 +1757,7 @@ async fn edit_git_sync_config(
|
||||
Json(new_config): Json<EditGitSyncConfig>,
|
||||
) -> Result<String> {
|
||||
require_admin(is_admin, &username)?;
|
||||
check_git_sync_access(&db, &w_id).await?;
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
@@ -1640,19 +1814,6 @@ async fn edit_git_sync_config(
|
||||
Ok(format!("Edit git sync config for workspace {}", &w_id))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
async fn edit_git_sync_repository(
|
||||
_authed: ApiAuthed,
|
||||
Extension(_db): Extension<DB>,
|
||||
Path(_w_id): Path<String>,
|
||||
Json(_new_config): Json<serde_json::Value>,
|
||||
) -> Result<String> {
|
||||
return Err(Error::BadRequest(
|
||||
"Git sync is only available on Windmill Enterprise Edition".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
async fn edit_git_sync_repository(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -1661,10 +1822,19 @@ async fn edit_git_sync_repository(
|
||||
Json(new_config): Json<EditGitSyncRepository>,
|
||||
) -> Result<String> {
|
||||
require_admin(is_admin, &username)?;
|
||||
check_git_sync_access(&db, &w_id).await?;
|
||||
|
||||
// Validate the resource path format
|
||||
validate_git_repo_resource_path(&new_config.git_repo_resource_path)?;
|
||||
|
||||
// Promotion mode: EE only
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
if new_config.repository.use_individual_branch.unwrap_or(false) {
|
||||
return Err(Error::BadRequest(
|
||||
"Promotion mode is an Enterprise Edition feature".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// First, get the current git sync settings
|
||||
@@ -1686,6 +1856,20 @@ async fn edit_git_sync_repository(
|
||||
WorkspaceGitSyncSettings::default()
|
||||
};
|
||||
|
||||
// Multi-repo: EE only
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
{
|
||||
let is_new = !git_sync_settings
|
||||
.repositories
|
||||
.iter()
|
||||
.any(|r| r.git_repo_resource_path == new_config.git_repo_resource_path);
|
||||
if is_new && !git_sync_settings.repositories.is_empty() {
|
||||
return Err(Error::BadRequest(
|
||||
"Multiple git sync repositories is an Enterprise Edition feature".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log before we move the repository
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
@@ -1769,19 +1953,6 @@ async fn edit_git_sync_repository(
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
async fn delete_git_sync_repository(
|
||||
_authed: ApiAuthed,
|
||||
Extension(_db): Extension<DB>,
|
||||
Path(_w_id): Path<String>,
|
||||
Json(_request): Json<serde_json::Value>,
|
||||
) -> Result<String> {
|
||||
return Err(Error::BadRequest(
|
||||
"Git sync is only available on Windmill Enterprise Edition".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(feature = "enterprise")]
|
||||
async fn delete_git_sync_repository(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
@@ -1791,7 +1962,7 @@ async fn delete_git_sync_repository(
|
||||
) -> Result<String> {
|
||||
require_admin(is_admin, &username)?;
|
||||
|
||||
// For deletion, only validate that path is not empty to allow cleanup of malformed entries
|
||||
// No check_git_sync_access here — admins should always be able to delete/clean up repos
|
||||
if request.git_repo_resource_path.is_empty() {
|
||||
return Err(Error::BadRequest(
|
||||
"Resource path cannot be empty".to_string(),
|
||||
@@ -2109,22 +2280,29 @@ async fn edit_default_app(
|
||||
#[derive(Serialize)]
|
||||
struct WorkspaceDefaultApp {
|
||||
pub default_app_path: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub default_app_raw: Option<bool>,
|
||||
}
|
||||
async fn get_default_app(
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> JsonResult<WorkspaceDefaultApp> {
|
||||
let mut tx = db.begin().await?;
|
||||
let default_app_path = sqlx::query_scalar!(
|
||||
"SELECT default_app FROM workspace_settings WHERE workspace_id = $1",
|
||||
let row = sqlx::query!(
|
||||
"SELECT ws.default_app AS default_app_path, av.raw_app AS \"default_app_raw: Option<bool>\"
|
||||
FROM workspace_settings ws
|
||||
LEFT JOIN app ON app.path = ws.default_app AND app.workspace_id = ws.workspace_id
|
||||
LEFT JOIN app_version av ON av.id = app.versions[array_upper(app.versions, 1)]
|
||||
WHERE ws.workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.fetch_one(&db)
|
||||
.await
|
||||
.map_err(|err| Error::internal_err(format!("getting default_app: {err}")))?;
|
||||
tx.commit().await?;
|
||||
|
||||
Ok(Json(WorkspaceDefaultApp { default_app_path }))
|
||||
Ok(Json(WorkspaceDefaultApp {
|
||||
default_app_path: row.default_app_path,
|
||||
default_app_raw: row.default_app_raw,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn edit_error_handler(
|
||||
@@ -3296,6 +3474,11 @@ async fn clone_apps(
|
||||
.fetch_all(&mut **tx)
|
||||
.await?;
|
||||
|
||||
let mut cloned_from_db: std::collections::HashSet<(i64, String)> = HashSet::new();
|
||||
for bundle in &bundles {
|
||||
cloned_from_db.insert((bundle.app_version_id, bundle.file_type.clone()));
|
||||
}
|
||||
|
||||
for bundle in bundles {
|
||||
if let Some(&new_version_id) = version_id_mapping.get(&bundle.app_version_id) {
|
||||
sqlx::query!(
|
||||
@@ -3310,6 +3493,66 @@ async fn clone_apps(
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Clone bundles from S3 for versions not found in DB
|
||||
#[cfg(all(feature = "enterprise", feature = "parquet"))]
|
||||
{
|
||||
let object_store = windmill_object_store::get_object_store().await;
|
||||
if let Some(os) = object_store {
|
||||
for (&old_version_id, &new_version_id) in &version_id_mapping {
|
||||
for file_type in &["js", "css"] {
|
||||
if cloned_from_db.contains(&(old_version_id, file_type.to_string())) {
|
||||
continue;
|
||||
}
|
||||
let src_path = format!(
|
||||
"/app_bundles/{}/{}.{}",
|
||||
source_workspace_id, old_version_id, file_type
|
||||
);
|
||||
let get_result = os
|
||||
.get(&windmill_object_store::object_store_reexports::Path::from(
|
||||
src_path,
|
||||
))
|
||||
.await;
|
||||
match get_result {
|
||||
Ok(result) => {
|
||||
let data = result.bytes().await.map_err(
|
||||
windmill_object_store::object_store_error_to_error,
|
||||
)?;
|
||||
let dst_path = format!(
|
||||
"/app_bundles/{}/{}.{}",
|
||||
target_workspace_id, new_version_id, file_type
|
||||
);
|
||||
os.put(
|
||||
&windmill_object_store::object_store_reexports::Path::from(
|
||||
dst_path.clone(),
|
||||
),
|
||||
data.into(),
|
||||
)
|
||||
.await
|
||||
.map_err(
|
||||
windmill_object_store::object_store_error_to_error,
|
||||
)?;
|
||||
tracing::info!(
|
||||
"Cloned app bundle from S3: {}.{} -> {}.{}",
|
||||
old_version_id,
|
||||
file_type,
|
||||
new_version_id,
|
||||
file_type
|
||||
);
|
||||
}
|
||||
Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { .. }) => {
|
||||
// No bundle in S3 for this version/type, skip
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(
|
||||
windmill_object_store::object_store_error_to_error(e),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update app versions arrays
|
||||
|
||||
@@ -18,7 +18,7 @@ agent_worker_server = ["dep:windmill-worker", "dep:windmill-api-agent-workers"]
|
||||
enterprise_saml = ["dep:samael", "dep:libxml"]
|
||||
benchmark = []
|
||||
embedding = ["windmill-api-embeddings/embedding"]
|
||||
parquet = ["dep:datafusion", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker?/parquet", "windmill-api-users/parquet", "windmill-api-settings/parquet", "dep:aws-sigv4", "dep:aws-sdk-config"]
|
||||
parquet = ["dep:datafusion", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker?/parquet", "windmill-api-users/parquet", "windmill-api-settings/parquet", "windmill-api-workspaces/parquet", "dep:aws-sigv4", "dep:aws-sdk-config"]
|
||||
prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker?/prometheus"]
|
||||
openidconnect = ["dep:openidconnect", "windmill-common/openidconnect", "windmill-store/openidconnect"]
|
||||
tantivy = ["dep:windmill-indexer"]
|
||||
@@ -39,12 +39,13 @@ sqs_trigger = ["dep:windmill-trigger-sqs", "windmill-store/sqs_trigger"]
|
||||
gcp_trigger = ["dep:windmill-trigger-gcp", "windmill-store/gcp_trigger"]
|
||||
cloud = ["windmill-common/cloud", "windmill-api-auth/cloud", "windmill-store/cloud", "windmill-api-workspaces/cloud"]
|
||||
mcp = ["dep:windmill-mcp", "windmill-mcp/server", "windmill-mcp/auth", "windmill-api-auth/mcp", "windmill-store/mcp"]
|
||||
bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "windmill-common/bedrock", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types"]
|
||||
bedrock = ["windmill-ai/bedrock", "dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "windmill-common/bedrock", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types"]
|
||||
python = ["windmill-dep-map/python", "dep:windmill-parser-py", "dep:windmill-parser-py-imports", "windmill-api-scripts/python", "windmill-api-configs/python", "windmill-api-agent-workers?/python", "windmill-trigger/python", "windmill-common/python"]
|
||||
no_auth = ["windmill-api-auth/no_auth", "windmill-store/no_auth", "windmill-api-users/no_auth"]
|
||||
quickjs = ["windmill-jseval/quickjs"]
|
||||
|
||||
[dependencies]
|
||||
windmill-ai = { workspace = true, default-features = false }
|
||||
windmill-mcp = { workspace = true, optional = true }
|
||||
windmill-api-auth.workspace = true
|
||||
windmill-api-scripts.workspace = true
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: "3.0.3"
|
||||
|
||||
info:
|
||||
version: 1.662.0
|
||||
version: 1.665.0
|
||||
title: Windmill API
|
||||
|
||||
contact:
|
||||
@@ -588,6 +588,8 @@ paths:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
disabled:
|
||||
type: boolean
|
||||
responses:
|
||||
"200":
|
||||
description: user updated
|
||||
@@ -3191,9 +3193,49 @@ paths:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
text/plain:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
type: object
|
||||
properties:
|
||||
effective_ai_config:
|
||||
$ref: "#/components/schemas/AIConfig"
|
||||
has_instance_ai_config:
|
||||
type: boolean
|
||||
uses_instance_ai_config:
|
||||
type: boolean
|
||||
instance_ai_summary:
|
||||
$ref: "#/components/schemas/InstanceAISummary"
|
||||
required:
|
||||
- effective_ai_config
|
||||
- has_instance_ai_config
|
||||
- uses_instance_ai_config
|
||||
|
||||
/w/{workspace}/workspaces/get_copilot_settings_state:
|
||||
get:
|
||||
summary: get copilot settings state
|
||||
operationId: getCopilotSettingsState
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
|
||||
responses:
|
||||
"200":
|
||||
description: status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
has_instance_ai_config:
|
||||
type: boolean
|
||||
uses_instance_ai_config:
|
||||
type: boolean
|
||||
instance_ai_summary:
|
||||
$ref: "#/components/schemas/InstanceAISummary"
|
||||
required:
|
||||
- has_instance_ai_config
|
||||
- uses_instance_ai_config
|
||||
|
||||
/w/{workspace}/workspaces/get_copilot_info:
|
||||
get:
|
||||
@@ -3390,6 +3432,37 @@ paths:
|
||||
application/json:
|
||||
schema: {}
|
||||
|
||||
/w/{workspace}/workspaces/git_sync_enabled:
|
||||
get:
|
||||
summary: Check if git sync is available for this workspace
|
||||
operationId: getGitSyncEnabled
|
||||
tags:
|
||||
- workspace
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: Git sync availability status
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
reason:
|
||||
type: string
|
||||
nullable: true
|
||||
max_repos:
|
||||
type: integer
|
||||
nullable: true
|
||||
user_count:
|
||||
type: integer
|
||||
nullable: true
|
||||
max_users:
|
||||
type: integer
|
||||
nullable: true
|
||||
|
||||
/w/{workspace}/workspaces/edit_git_sync_config:
|
||||
post:
|
||||
summary: edit workspace git sync settings
|
||||
@@ -3662,6 +3735,8 @@ paths:
|
||||
properties:
|
||||
default_app_path:
|
||||
type: string
|
||||
default_app_raw:
|
||||
type: boolean
|
||||
|
||||
/w/{workspace}/workspaces/usage:
|
||||
get:
|
||||
@@ -4759,6 +4834,11 @@ paths:
|
||||
mcp_server_url:
|
||||
type: string
|
||||
description: "MCP server URL for MCP OAuth token refresh"
|
||||
scopes:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "OAuth scopes to use for token refresh. Overrides instance-level scopes."
|
||||
required:
|
||||
- refresh_token
|
||||
- expires_in
|
||||
@@ -6867,6 +6947,60 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/scripts/list_dedicated_with_deps:
|
||||
get:
|
||||
summary: list dedicated worker scripts with workspace dependency annotations
|
||||
operationId: listDedicatedWithDeps
|
||||
tags:
|
||||
- script
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
responses:
|
||||
"200":
|
||||
description: list of dedicated scripts with their workspace dependency names
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
language:
|
||||
type: string
|
||||
enum:
|
||||
- python3
|
||||
- deno
|
||||
- go
|
||||
- bash
|
||||
- powershell
|
||||
- postgresql
|
||||
- mysql
|
||||
- bigquery
|
||||
- snowflake
|
||||
- mssql
|
||||
- graphql
|
||||
- nativets
|
||||
- bun
|
||||
- bunnative
|
||||
- php
|
||||
- rust
|
||||
- ansible
|
||||
- csharp
|
||||
- oracledb
|
||||
- duckdb
|
||||
- java
|
||||
- ruby
|
||||
workspace_dep_names:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required:
|
||||
- path
|
||||
- language
|
||||
- workspace_dep_names
|
||||
|
||||
/w/{workspace}/scripts/raw/p/{path}:
|
||||
get:
|
||||
summary: raw script by path
|
||||
@@ -10571,6 +10705,10 @@ paths:
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
- name: fast
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
|
||||
responses:
|
||||
"200":
|
||||
@@ -11048,6 +11186,129 @@ paths:
|
||||
"200":
|
||||
description: Interactive slack approval message sent successfully
|
||||
|
||||
/w/{workspace}/jobs_u/flow/resume_suspended/{job_id}:
|
||||
post:
|
||||
summary: resume or cancel a suspended flow/WAC job
|
||||
description: >
|
||||
Resume or cancel a suspended flow/WAC job. Uses approval rules to
|
||||
determine authorization. Either a valid approval_token or an
|
||||
authenticated session is required.
|
||||
operationId: resumeSuspended
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: job_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
payload:
|
||||
description: payload to send to the resumed job
|
||||
approval_token:
|
||||
type: string
|
||||
description: approval token for unauthenticated access
|
||||
approved:
|
||||
type: boolean
|
||||
description: whether to approve (true) or cancel (false) the job
|
||||
default: true
|
||||
responses:
|
||||
"201":
|
||||
description: job resumed
|
||||
content:
|
||||
text/plain:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/jobs_u/flow/approval_info/{job_id}:
|
||||
get:
|
||||
summary: get approval info for a suspended flow/WAC job
|
||||
description: >
|
||||
Get approval info for a suspended flow/WAC job. Returns form schema,
|
||||
approval rules, and whether the current user can approve. Either a
|
||||
valid token query parameter or an authenticated session is required.
|
||||
operationId: getApprovalInfo
|
||||
tags:
|
||||
- job
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/WorkspaceId"
|
||||
- name: job_id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- name: token
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: approval token for unauthenticated access
|
||||
responses:
|
||||
"200":
|
||||
description: approval info
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required:
|
||||
- flow_id
|
||||
- can_approve
|
||||
- user_auth_required
|
||||
- approvers
|
||||
properties:
|
||||
flow_id:
|
||||
type: string
|
||||
format: uuid
|
||||
form_schema:
|
||||
description: form schema for the approval step
|
||||
description:
|
||||
description: description of the approval step
|
||||
approval_conditions:
|
||||
type: object
|
||||
properties:
|
||||
user_auth_required:
|
||||
type: boolean
|
||||
user_groups_required:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
self_approval_disabled:
|
||||
type: boolean
|
||||
required:
|
||||
- user_auth_required
|
||||
- user_groups_required
|
||||
- self_approval_disabled
|
||||
can_approve:
|
||||
type: boolean
|
||||
description: whether the current user/token holder can approve
|
||||
user_auth_required:
|
||||
type: boolean
|
||||
description: whether user authentication is required to approve
|
||||
hide_cancel:
|
||||
type: boolean
|
||||
description: whether to hide the cancel button in the UI
|
||||
approvers:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required:
|
||||
- resume_id
|
||||
- approver
|
||||
properties:
|
||||
resume_id:
|
||||
type: integer
|
||||
approver:
|
||||
type: string
|
||||
|
||||
/w/{workspace}/jobs_u/resume/{id}/{resume_id}/{signature}:
|
||||
get:
|
||||
summary: resume a job for a suspended flow
|
||||
@@ -17264,6 +17525,27 @@ paths:
|
||||
description: count of log lines that matched the query per hostname
|
||||
type: object
|
||||
|
||||
/srch/index/storage/disk:
|
||||
get:
|
||||
summary: Get index disk storage sizes from the indexer.
|
||||
operationId: getIndexDiskStorageSizes
|
||||
tags:
|
||||
- indexSearch
|
||||
responses:
|
||||
"200":
|
||||
description: disk storage sizes for each index
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
job_index_disk_size_bytes:
|
||||
type: integer
|
||||
nullable: true
|
||||
log_index_disk_size_bytes:
|
||||
type: integer
|
||||
nullable: true
|
||||
|
||||
/indexer/delete/{idx_name}:
|
||||
delete:
|
||||
summary: Clear an index and restart the indexer.
|
||||
@@ -18792,6 +19074,33 @@ components:
|
||||
minimum: 1
|
||||
maximum: 2000000
|
||||
|
||||
InstanceAIProviderSummary:
|
||||
type: object
|
||||
properties:
|
||||
provider:
|
||||
$ref: "#/components/schemas/AIProvider"
|
||||
models:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required:
|
||||
- provider
|
||||
- models
|
||||
|
||||
InstanceAISummary:
|
||||
type: object
|
||||
properties:
|
||||
providers:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/InstanceAIProviderSummary"
|
||||
default_model:
|
||||
$ref: "#/components/schemas/AIProviderModel"
|
||||
code_completion_model:
|
||||
$ref: "#/components/schemas/AIProviderModel"
|
||||
required:
|
||||
- providers
|
||||
|
||||
Alert:
|
||||
type: object
|
||||
properties:
|
||||
@@ -23168,6 +23477,8 @@ components:
|
||||
role_source:
|
||||
type: string
|
||||
enum: ["manual", "instance_group"]
|
||||
disabled:
|
||||
type: boolean
|
||||
|
||||
required:
|
||||
- email
|
||||
@@ -23176,6 +23487,7 @@ components:
|
||||
- verified
|
||||
- first_time_user
|
||||
- role_source
|
||||
- disabled
|
||||
|
||||
Flow:
|
||||
allOf:
|
||||
@@ -24592,6 +24904,10 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Error message if the trigger is in an error state
|
||||
summary:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Short summary to be displayed when listed
|
||||
required:
|
||||
- external_id
|
||||
- workspace_id
|
||||
@@ -24626,6 +24942,10 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Error message if the trigger is in an error state
|
||||
summary:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Short summary to be displayed when listed
|
||||
external_data:
|
||||
type: object
|
||||
description: Configuration data from the external service
|
||||
@@ -24718,6 +25038,10 @@ components:
|
||||
type: object
|
||||
description: Service-specific configuration (e.g., event types, filters)
|
||||
additionalProperties: true
|
||||
summary:
|
||||
type: string
|
||||
nullable: true
|
||||
description: Short summary to be displayed when listed
|
||||
required:
|
||||
- script_path
|
||||
- is_flow
|
||||
|
||||
@@ -16,7 +16,8 @@ use serde_json::{json, value::RawValue};
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::ai_providers::{
|
||||
use windmill_ai::ai_cache::current_instance_ai_config_revision;
|
||||
use windmill_ai::ai_providers::{
|
||||
empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel,
|
||||
};
|
||||
use windmill_common::error::{to_anyhow, Error, Result};
|
||||
@@ -127,6 +128,10 @@ lazy_static::lazy_static! {
|
||||
};
|
||||
}
|
||||
|
||||
pub(crate) fn invalidate_ai_request_cache_for_workspace(workspace_id: &str) {
|
||||
AI_REQUEST_CACHE.retain(|(cached_workspace_id, _), _| cached_workspace_id != workspace_id);
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug)]
|
||||
struct AIOAuthResource {
|
||||
client_id: String,
|
||||
@@ -373,8 +378,7 @@ 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 == AIPlatform::GoogleVertexAi;
|
||||
let is_anthropic_vertex = is_anthropic && self.platform == AIPlatform::GoogleVertexAi;
|
||||
let is_anthropic_sdk = headers.get("X-Anthropic-SDK").is_some();
|
||||
let is_google_ai = matches!(provider, AIProvider::GoogleAI);
|
||||
|
||||
@@ -483,18 +487,27 @@ impl AIRequestConfig {
|
||||
pub struct ExpiringAIRequestConfig {
|
||||
config: AIRequestConfig,
|
||||
expires_at: std::time::Instant,
|
||||
instance_ai_config_revision: Option<u64>,
|
||||
}
|
||||
|
||||
impl ExpiringAIRequestConfig {
|
||||
fn new(config: AIRequestConfig) -> Self {
|
||||
Self { config, expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60) }
|
||||
fn new(config: AIRequestConfig, instance_ai_config_revision: Option<u64>) -> Self {
|
||||
Self {
|
||||
config,
|
||||
expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60),
|
||||
instance_ai_config_revision,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_expired(&self) -> bool {
|
||||
self.expires_at < std::time::Instant::now()
|
||||
|| self
|
||||
.instance_ai_config_revision
|
||||
.is_some_and(|revision| revision != current_instance_ai_config_revision())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, Default)]
|
||||
pub struct AIConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub providers: Option<HashMap<AIProvider, ProviderConfig>>,
|
||||
@@ -508,6 +521,14 @@ pub struct AIConfig {
|
||||
pub max_tokens_per_model: Option<HashMap<String, i32>>,
|
||||
}
|
||||
|
||||
impl AIConfig {
|
||||
pub fn has_providers(&self) -> bool {
|
||||
self.providers
|
||||
.as_ref()
|
||||
.is_some_and(|providers| !providers.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// Anthropic API version for Google Vertex AI
|
||||
const ANTHROPIC_VERSION_VERTEX: &str = "vertex-2023-10-16";
|
||||
|
||||
@@ -603,8 +624,8 @@ pub fn workspaced_service() -> Router {
|
||||
async fn check_bedrock_credentials(
|
||||
_authed: ApiAuthed,
|
||||
Path(_w_id): Path<String>,
|
||||
) -> Result<Json<windmill_common::ai_bedrock::BedrockCredentialsCheck>> {
|
||||
let response = windmill_common::ai_bedrock::check_env_credentials().await;
|
||||
) -> Result<Json<windmill_ai::ai_bedrock::BedrockCredentialsCheck>> {
|
||||
let response = windmill_ai::ai_bedrock::check_env_credentials().await;
|
||||
Ok(Json(response))
|
||||
}
|
||||
|
||||
@@ -762,47 +783,76 @@ async fn proxy(
|
||||
request_cache.config
|
||||
}
|
||||
_ => {
|
||||
let (resource_path, save_to_cache) = if let Some(resource_path) = forced_resource_path {
|
||||
// forced resource path
|
||||
(resource_path, false)
|
||||
} else {
|
||||
let ai_config = sqlx::query_scalar!(
|
||||
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
let (resource_path, save_to_cache, resource_workspace, instance_ai_config_revision) =
|
||||
if let Some(resource_path) = forced_resource_path {
|
||||
// forced resource path
|
||||
(resource_path, false, w_id.clone(), None)
|
||||
} else {
|
||||
let workspace_ai_config = sqlx::query_scalar!(
|
||||
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
|
||||
&w_id
|
||||
)
|
||||
.fetch_one(&db)
|
||||
.await?;
|
||||
|
||||
if ai_config.is_none() {
|
||||
return Err(Error::internal_err(
|
||||
"AI resource not configured".to_string(),
|
||||
));
|
||||
}
|
||||
let (ai_config_value, resource_workspace, instance_ai_config_revision) = {
|
||||
let ws_has_config = workspace_ai_config
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<AIConfig>(v.clone()).ok())
|
||||
.is_some_and(|config| config.has_providers());
|
||||
|
||||
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config.unwrap())
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
||||
if ws_has_config {
|
||||
(workspace_ai_config.unwrap(), w_id.clone(), None)
|
||||
} else {
|
||||
let instance_config = sqlx::query_scalar!(
|
||||
"SELECT value FROM global_settings WHERE name = 'ai_config'"
|
||||
)
|
||||
.fetch_optional(&db)
|
||||
.await?;
|
||||
|
||||
let provider_config = ai_config
|
||||
.providers
|
||||
.as_mut()
|
||||
.map(|providers| providers.remove(&provider))
|
||||
.flatten()
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(format!("Provider {:?} not configured", provider))
|
||||
})?;
|
||||
match instance_config {
|
||||
Some(config) => (
|
||||
config,
|
||||
"admins".to_string(),
|
||||
Some(current_instance_ai_config_revision()),
|
||||
),
|
||||
None => {
|
||||
return Err(Error::internal_err(
|
||||
"AI resource not configured".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if provider_config.resource_path.is_empty() {
|
||||
return Err(Error::BadRequest("Resource path is empty".to_string()));
|
||||
}
|
||||
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config_value)
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
||||
|
||||
(provider_config.resource_path, true)
|
||||
};
|
||||
let provider_config = ai_config
|
||||
.providers
|
||||
.as_mut()
|
||||
.and_then(|providers| providers.remove(&provider))
|
||||
.ok_or_else(|| {
|
||||
Error::BadRequest(format!("Provider {:?} not configured", provider))
|
||||
})?;
|
||||
|
||||
let resource= sqlx::query_scalar!(
|
||||
"SELECT value as \"value: sqlx::types::Json<Box<RawValue>>\" FROM resource WHERE path = $1 AND workspace_id = $2",
|
||||
&resource_path,
|
||||
&w_id
|
||||
if provider_config.resource_path.is_empty() {
|
||||
return Err(Error::BadRequest("Resource path is empty".to_string()));
|
||||
}
|
||||
|
||||
(
|
||||
provider_config.resource_path,
|
||||
true,
|
||||
resource_workspace,
|
||||
instance_ai_config_revision,
|
||||
)
|
||||
};
|
||||
|
||||
let resource = sqlx::query_scalar::<_, Option<sqlx::types::Json<Box<RawValue>>>>(
|
||||
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(&resource_path)
|
||||
.bind(&resource_workspace)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", resource_path)))?
|
||||
@@ -811,11 +861,15 @@ async fn proxy(
|
||||
let resource = serde_json::from_str::<AIResource>(resource.0.get())
|
||||
.map_err(|e| Error::BadRequest(e.to_string()))?;
|
||||
|
||||
let request_config = AIRequestConfig::new(&provider, &db, &w_id, resource).await?;
|
||||
let request_config =
|
||||
AIRequestConfig::new(&provider, &db, &resource_workspace, resource).await?;
|
||||
if save_to_cache {
|
||||
AI_REQUEST_CACHE.insert(
|
||||
(w_id.clone(), provider.clone()),
|
||||
ExpiringAIRequestConfig::new(request_config.clone()),
|
||||
ExpiringAIRequestConfig::new(
|
||||
request_config.clone(),
|
||||
instance_ai_config_revision,
|
||||
),
|
||||
);
|
||||
}
|
||||
request_config
|
||||
@@ -858,9 +912,7 @@ async fn proxy(
|
||||
"chat/completions" => {
|
||||
crate::google::handle_google_ai_chat(&body, api_key, base_url, is_vertex).await
|
||||
}
|
||||
"models" => {
|
||||
crate::google::handle_google_ai_models(api_key, base_url, is_vertex).await
|
||||
}
|
||||
"models" => crate::google::handle_google_ai_models(api_key, base_url, is_vertex).await,
|
||||
_ => Err(Error::BadRequest(format!(
|
||||
"Unsupported Google AI path: {}",
|
||||
ai_path
|
||||
@@ -893,7 +945,7 @@ async fn proxy(
|
||||
let region = request_config
|
||||
.region
|
||||
.as_deref()
|
||||
.unwrap_or(windmill_common::ai_providers::USE_ENV_REGION);
|
||||
.unwrap_or(windmill_ai::ai_providers::USE_ENV_REGION);
|
||||
|
||||
// Audit log before making the SDK request
|
||||
let mut tx = db.begin().await?;
|
||||
@@ -1005,3 +1057,76 @@ async fn proxy(
|
||||
};
|
||||
Ok((status_code, headers, body))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use windmill_ai::ai_cache::bump_instance_ai_config_revision;
|
||||
use windmill_ai::ai_providers::AIPlatform;
|
||||
|
||||
static TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
|
||||
|
||||
fn sample_request_config() -> AIRequestConfig {
|
||||
AIRequestConfig {
|
||||
base_url: "https://example.com".to_string(),
|
||||
api_key: None,
|
||||
access_token: None,
|
||||
organization_id: None,
|
||||
user: None,
|
||||
region: None,
|
||||
aws_access_key_id: None,
|
||||
aws_secret_access_key: None,
|
||||
aws_session_token: None,
|
||||
platform: AIPlatform::Standard,
|
||||
enable_1m_context: false,
|
||||
custom_headers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalidates_all_cached_providers_for_workspace() {
|
||||
let _guard = TEST_LOCK.lock().unwrap();
|
||||
AI_REQUEST_CACHE.clear();
|
||||
AI_REQUEST_CACHE.insert(
|
||||
("workspace-a".to_string(), AIProvider::OpenAI),
|
||||
ExpiringAIRequestConfig::new(sample_request_config(), None),
|
||||
);
|
||||
AI_REQUEST_CACHE.insert(
|
||||
("workspace-a".to_string(), AIProvider::Anthropic),
|
||||
ExpiringAIRequestConfig::new(sample_request_config(), None),
|
||||
);
|
||||
AI_REQUEST_CACHE.insert(
|
||||
("workspace-b".to_string(), AIProvider::OpenAI),
|
||||
ExpiringAIRequestConfig::new(sample_request_config(), None),
|
||||
);
|
||||
|
||||
invalidate_ai_request_cache_for_workspace("workspace-a");
|
||||
|
||||
assert!(AI_REQUEST_CACHE
|
||||
.get(&("workspace-a".to_string(), AIProvider::OpenAI))
|
||||
.is_none());
|
||||
assert!(AI_REQUEST_CACHE
|
||||
.get(&("workspace-a".to_string(), AIProvider::Anthropic))
|
||||
.is_none());
|
||||
assert!(AI_REQUEST_CACHE
|
||||
.get(&("workspace-b".to_string(), AIProvider::OpenAI))
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_backed_cache_entries_expire_when_revision_changes() {
|
||||
let _guard = TEST_LOCK.lock().unwrap();
|
||||
AI_REQUEST_CACHE.clear();
|
||||
|
||||
let cached = ExpiringAIRequestConfig::new(
|
||||
sample_request_config(),
|
||||
Some(current_instance_ai_config_revision()),
|
||||
);
|
||||
assert!(!cached.is_expired());
|
||||
|
||||
bump_instance_ai_config_revision();
|
||||
|
||||
assert!(cached.is_expired());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +259,7 @@ pub async fn get_approval_form_details(
|
||||
}
|
||||
};
|
||||
|
||||
let flow_value = &flow_data.flow;
|
||||
let flow_value = flow_data.value();
|
||||
let flow_step_id = flow_step_id.unwrap_or("");
|
||||
let module = flow_value.modules.iter().find(|m| m.id == flow_step_id);
|
||||
|
||||
|
||||
@@ -440,17 +440,29 @@ async fn get_raw_app_data(
|
||||
#[cfg(all(feature = "enterprise", feature = "parquet"))]
|
||||
if let Some(os) = object_store {
|
||||
let path = format!("/app_bundles/{}/{}.{}", w_id, id, file_type);
|
||||
let stream = os
|
||||
match os
|
||||
.get(&windmill_object_store::object_store_reexports::Path::from(
|
||||
path,
|
||||
))
|
||||
.await
|
||||
.map_err(windmill_object_store::object_store_error_to_error)?
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(windmill_object_store::object_store_error_to_error)?;
|
||||
tracing::info!("stream: {}", stream.len());
|
||||
body = Some(Body::from(stream));
|
||||
{
|
||||
Ok(result) => {
|
||||
let stream = result
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(windmill_object_store::object_store_error_to_error)?;
|
||||
tracing::info!("stream: {}", stream.len());
|
||||
body = Some(Body::from(stream));
|
||||
}
|
||||
Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound {
|
||||
..
|
||||
}) => {
|
||||
// S3 key not found, fall through to DB lookup below
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(windmill_object_store::object_store_error_to_error(e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if body.is_none() {
|
||||
@@ -1439,6 +1451,30 @@ async fn delete_app(
|
||||
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
// Capture all related data for trashbin before deleting (CASCADE will remove app_version, etc.)
|
||||
let trash_app: Option<serde_json::Value> =
|
||||
sqlx::query_scalar("SELECT to_jsonb(t) FROM app t WHERE path = $1 AND workspace_id = $2")
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_app_versions: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM app_version t WHERE app_id = (SELECT id FROM app WHERE path = $1 AND workspace_id = $2)",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let trash_drafts: Vec<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
|
||||
)
|
||||
.bind(path)
|
||||
.bind(&w_id)
|
||||
.fetch_all(&mut *tx)
|
||||
.await?;
|
||||
|
||||
sqlx::query!(
|
||||
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
|
||||
path,
|
||||
@@ -1455,6 +1491,25 @@ async fn delete_app(
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(app_data) = trash_app {
|
||||
let mut trash_data = serde_json::json!({"row": app_data});
|
||||
if !trash_app_versions.is_empty() {
|
||||
trash_data["app_versions"] = serde_json::Value::Array(trash_app_versions);
|
||||
}
|
||||
if !trash_drafts.is_empty() {
|
||||
trash_data["drafts"] = serde_json::Value::Array(trash_drafts);
|
||||
}
|
||||
windmill_common::trashbin::move_to_trash(
|
||||
&mut *tx,
|
||||
&w_id,
|
||||
"app",
|
||||
path,
|
||||
trash_data,
|
||||
&authed.username,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
|
||||
@@ -18,13 +18,13 @@
|
||||
|
||||
use axum::body::Bytes;
|
||||
use serde::Deserialize;
|
||||
use windmill_common::ai_bedrock::build_tool_config;
|
||||
use windmill_common::ai_bedrock::{
|
||||
use windmill_ai::ai_bedrock::build_tool_config;
|
||||
use windmill_ai::ai_bedrock::{
|
||||
bedrock_stream_event_is_block_stop, bedrock_stream_event_to_text,
|
||||
bedrock_stream_event_to_tool_delta, bedrock_stream_event_to_tool_start, format_bedrock_error,
|
||||
BedrockClient,
|
||||
};
|
||||
use windmill_common::ai_types::{
|
||||
use windmill_ai::ai_types::{
|
||||
OpenAIFunction, OpenAIMessage, OpenAIToolCall, ToolDef, ToolDefFunction,
|
||||
};
|
||||
use windmill_common::error::{Error, Result};
|
||||
@@ -182,7 +182,7 @@ async fn create_bedrock_control_client(
|
||||
region: &str,
|
||||
) -> Result<aws_sdk_bedrock::Client> {
|
||||
use aws_config::BehaviorVersion;
|
||||
use windmill_common::ai_bedrock::BearerTokenProvider;
|
||||
use windmill_ai::ai_bedrock::BearerTokenProvider;
|
||||
|
||||
let region_provider = aws_sdk_bedrock::config::Region::new(region.to_string());
|
||||
|
||||
@@ -366,10 +366,10 @@ pub async fn handle_bedrock_sdk_streaming(
|
||||
|
||||
// Convert messages using shared conversion
|
||||
let (bedrock_messages, system_prompts) =
|
||||
windmill_common::ai_bedrock::openai_messages_to_bedrock(&openai_req.messages)?;
|
||||
windmill_ai::ai_bedrock::openai_messages_to_bedrock(&openai_req.messages)?;
|
||||
|
||||
// Build inference configuration
|
||||
let inference_config = windmill_common::ai_bedrock::create_inference_config(
|
||||
let inference_config = windmill_ai::ai_bedrock::create_inference_config(
|
||||
openai_req.temperature,
|
||||
openai_req.max_tokens,
|
||||
);
|
||||
@@ -626,10 +626,10 @@ pub async fn handle_bedrock_sdk_non_streaming(
|
||||
|
||||
// Convert messages using shared conversion
|
||||
let (bedrock_messages, system_prompts) =
|
||||
windmill_common::ai_bedrock::openai_messages_to_bedrock(&openai_req.messages)?;
|
||||
windmill_ai::ai_bedrock::openai_messages_to_bedrock(&openai_req.messages)?;
|
||||
|
||||
// Build inference configuration
|
||||
let inference_config = windmill_common::ai_bedrock::create_inference_config(
|
||||
let inference_config = windmill_ai::ai_bedrock::create_inference_config(
|
||||
openai_req.temperature,
|
||||
openai_req.max_tokens,
|
||||
);
|
||||
|
||||
@@ -15,15 +15,15 @@ use eventsource_stream::Eventsource;
|
||||
use futures::StreamExt;
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use windmill_common::{
|
||||
use windmill_ai::{
|
||||
ai_google::{
|
||||
gemini_event_to_openai_sse_chunks, gemini_response_to_openai, openai_messages_to_gemini,
|
||||
parse_gemini_response, parse_gemini_sse_event, sanitize_schema_for_google,
|
||||
GeminiFunctionDeclaration, GeminiGenerationConfig, GeminiTextRequest, GeminiTool,
|
||||
},
|
||||
ai_types::OpenAIMessage,
|
||||
error::{Error, Result},
|
||||
};
|
||||
use windmill_common::error::{Error, Result};
|
||||
|
||||
use crate::ai::{inject_keepalives, HTTP_CLIENT, KEEPALIVE_INTERVAL_SECS};
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ use windmill_common::{
|
||||
cache,
|
||||
db::UserDB,
|
||||
error::{self, to_anyhow, Error},
|
||||
flow_status::{Approval, FlowStatus, FlowStatusModule},
|
||||
flow_status::{Approval, ApprovalConditions, FlowStatus, FlowStatusModule},
|
||||
flows::{add_virtual_items_if_necessary, resolve_maybe_value, FlowValue},
|
||||
jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode},
|
||||
oauth2::HmacSha256,
|
||||
@@ -401,6 +401,8 @@ pub fn workspace_unauthed_service() -> Router {
|
||||
post(cancel_persistent_script_api),
|
||||
)
|
||||
.route("/queue/force_cancel/:id", post(force_cancel))
|
||||
.route("/flow/resume_suspended/:job_id", post(resume_suspended))
|
||||
.route("/flow/approval_info/:job_id", get(get_approval_info))
|
||||
}
|
||||
|
||||
pub fn global_root_service() -> Router {
|
||||
@@ -1058,6 +1060,7 @@ impl<'a> GetQuery<'a> {
|
||||
Self { with_code: false, ..self }
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn without_flow(self) -> Self {
|
||||
Self { with_flow: false, ..self }
|
||||
}
|
||||
@@ -2027,10 +2030,7 @@ async fn count_completed_jobs_detail(
|
||||
if let Some(tags) = query.tags {
|
||||
sqlb.and_where_in(
|
||||
"v2_job.tag",
|
||||
&tags
|
||||
.split(",")
|
||||
.map(|t| format!("'{}'", t))
|
||||
.collect::<Vec<_>>(),
|
||||
&tags.split(",").map(|t| quote(t)).collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2184,7 +2184,7 @@ pub async fn resume_suspended_flow_as_owner(
|
||||
) -> error::Result<StatusCode> {
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
let (flow, job_id) = get_suspended_flow_info(flow_id, &mut tx).await?;
|
||||
let (flow, job_id, is_wac) = get_suspended_flow_info(flow_id, &mut tx).await?;
|
||||
|
||||
let flow_path = flow.script_path.as_deref().unwrap_or_else(|| "");
|
||||
require_owner_of_path(&authed, flow_path)?;
|
||||
@@ -2192,10 +2192,17 @@ pub async fn resume_suspended_flow_as_owner(
|
||||
|
||||
// Check approval conditions (self-approval, required groups, etc.)
|
||||
if let Some(ref flow_status_value) = flow.flow_status {
|
||||
if let Ok(flow_status) = serde_json::from_value::<FlowStatus>(flow_status_value.clone()) {
|
||||
let trigger_email = flow.email.as_deref().unwrap_or("");
|
||||
conditionally_require_authed_user(Some(authed.clone()), flow_status, trigger_email)?;
|
||||
}
|
||||
let trigger_email = flow.email.as_deref().unwrap_or("");
|
||||
let ac = serde_json::from_value::<FlowStatus>(flow_status_value.clone())
|
||||
.ok()
|
||||
.and_then(|fs| fs.approval_conditions)
|
||||
.or_else(|| {
|
||||
// WAC flows store approval_conditions directly in flow_status JSONB
|
||||
flow_status_value
|
||||
.get("approval_conditions")
|
||||
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok())
|
||||
});
|
||||
conditionally_require_authed_user(Some(authed.clone()), ac, trigger_email)?;
|
||||
}
|
||||
|
||||
let value = value.unwrap_or(serde_json::Value::Null);
|
||||
@@ -2211,12 +2218,426 @@ pub async fn resume_suspended_flow_as_owner(
|
||||
)
|
||||
.await?;
|
||||
|
||||
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
|
||||
if is_wac {
|
||||
// WAC: directly decrement suspend counter
|
||||
if flow.suspend > 0 {
|
||||
sqlx::query!(
|
||||
"UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1",
|
||||
flow.id,
|
||||
)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
} else {
|
||||
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
// --- New approval system endpoints ---
|
||||
|
||||
use windmill_common::variables::generate_approval_token;
|
||||
|
||||
/// Verify an approval token against the workspace key + job_id.
|
||||
async fn validate_approval_token(
|
||||
db: &DB,
|
||||
token: &str,
|
||||
job_id: Uuid,
|
||||
workspace_id: &str,
|
||||
) -> error::Result<()> {
|
||||
let expected = generate_approval_token(workspace_id, job_id, db).await?;
|
||||
if token != expected {
|
||||
return Err(Error::NotAuthorized("Invalid approval token".to_string()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ResumeSuspendedBody {
|
||||
payload: Option<serde_json::Value>,
|
||||
approval_token: Option<String>,
|
||||
approved: Option<bool>,
|
||||
}
|
||||
|
||||
async fn resume_suspended(
|
||||
OptAuthed(opt_authed): OptAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, job_id)): Path<(String, Uuid)>,
|
||||
Json(body): Json<ResumeSuspendedBody>,
|
||||
) -> error::Result<StatusCode> {
|
||||
let approved = body.approved.unwrap_or(true);
|
||||
let value = body.payload.unwrap_or(serde_json::Value::Null);
|
||||
|
||||
// Determine if we have a valid authed user or token
|
||||
let has_token = if let Some(ref token) = body.approval_token {
|
||||
validate_approval_token(&db, token, job_id, &w_id)
|
||||
.await
|
||||
.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if opt_authed.is_none() && !has_token {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Must be logged in or provide a valid approval token".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut tx = db.begin().await?;
|
||||
|
||||
// Resolve the suspended flow (works for both WAC and classic flows)
|
||||
let (flow, resume_job_id, is_wac) = get_suspended_flow_info(job_id, &mut tx).await?;
|
||||
|
||||
// Verify the job belongs to this workspace
|
||||
let job_workspace: Option<String> =
|
||||
sqlx::query_scalar("SELECT workspace_id FROM v2_job WHERE id = $1")
|
||||
.bind(&flow.id)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
if job_workspace.as_deref() != Some(w_id.as_str()) {
|
||||
return Err(Error::NotFound(
|
||||
"Job not found in this workspace".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Check approval conditions
|
||||
let approval_conditions = if is_wac {
|
||||
flow.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("approval_conditions"))
|
||||
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok())
|
||||
} else {
|
||||
flow.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
|
||||
.and_then(|fs| fs.approval_conditions)
|
||||
};
|
||||
|
||||
if let Some(ref ac) = approval_conditions {
|
||||
if ac.user_auth_required && opt_authed.is_none() {
|
||||
return Err(Error::NotAuthorized(
|
||||
"This approval requires a logged-in user. Please sign in.".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// If logged in, check authorization rules
|
||||
if let Some(ref authed) = opt_authed {
|
||||
let is_admin = authed.is_admin;
|
||||
let is_owner = flow
|
||||
.script_path
|
||||
.as_deref()
|
||||
.map(|p| require_owner_of_path(authed, p).is_ok())
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_admin && !is_owner {
|
||||
let trigger_email = flow.email.as_deref().unwrap_or("");
|
||||
conditionally_require_authed_user(
|
||||
Some(authed.clone()),
|
||||
approval_conditions.clone(),
|
||||
trigger_email,
|
||||
)?;
|
||||
}
|
||||
} else if !has_token {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Must be logged in or provide a valid approval token".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Generate a unique resume_id
|
||||
let resume_id: u32 = rand::random();
|
||||
|
||||
// Check for duplicate
|
||||
let exists: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM resume_job WHERE id = $1)")
|
||||
.bind(Uuid::from_u128(resume_job_id.as_u128() ^ resume_id as u128))
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if exists {
|
||||
return Err(Error::BadRequest("Resume request already sent".to_string()));
|
||||
}
|
||||
|
||||
let approver_value = opt_authed.as_ref().map(|a| a.username.clone());
|
||||
|
||||
insert_resume_job(
|
||||
resume_id,
|
||||
resume_job_id,
|
||||
&flow,
|
||||
value,
|
||||
approver_value.clone(),
|
||||
approved,
|
||||
&mut tx,
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !approved {
|
||||
sqlx::query("UPDATE v2_job_queue SET suspend = 0 WHERE id = $1")
|
||||
.bind(&flow.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
} else if is_wac {
|
||||
if flow.suspend > 0 {
|
||||
sqlx::query("UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1")
|
||||
.bind(&flow.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
} else {
|
||||
resume_immediately_if_relevant(flow, resume_job_id, &mut tx).await?;
|
||||
}
|
||||
|
||||
let approver = approver_value.unwrap_or_else(|| "anonymous".to_string());
|
||||
let audit_author = if let Some(ref authed) = opt_authed {
|
||||
AuditAuthor::from(authed)
|
||||
} else {
|
||||
AuditAuthor {
|
||||
email: approver.clone(),
|
||||
username: approver.clone(),
|
||||
username_override: None,
|
||||
token_prefix: None,
|
||||
}
|
||||
};
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&audit_author,
|
||||
"jobs.suspend_resume",
|
||||
ActionKind::Update,
|
||||
&w_id,
|
||||
Some(
|
||||
&serde_json::json!({
|
||||
"approved": approved,
|
||||
"job_id": job_id,
|
||||
"details": if approved {
|
||||
format!("Approved by {}", &approver)
|
||||
} else {
|
||||
format!("Cancelled by {}", &approver)
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(StatusCode::CREATED)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ApprovalInfoQuery {
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ApprovalInfo {
|
||||
flow_id: Uuid,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
form_schema: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
approval_conditions: Option<ApprovalConditions>,
|
||||
can_approve: bool,
|
||||
user_auth_required: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
hide_cancel: Option<bool>,
|
||||
approvers: Vec<Approval>,
|
||||
}
|
||||
|
||||
async fn get_approval_info(
|
||||
OptAuthed(opt_authed): OptAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, job_id)): Path<(String, Uuid)>,
|
||||
Query(query): Query<ApprovalInfoQuery>,
|
||||
) -> error::Result<Json<ApprovalInfo>> {
|
||||
// Validate access: either logged in or valid token
|
||||
let has_token = if let Some(ref token) = query.token {
|
||||
validate_approval_token(&db, token, job_id, &w_id)
|
||||
.await
|
||||
.is_ok()
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if opt_authed.is_none() && !has_token {
|
||||
return Err(Error::NotAuthorized(
|
||||
"Must be logged in or provide a valid approval token".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Fetch job info
|
||||
#[derive(sqlx::FromRow)]
|
||||
struct ApprovalJobRow {
|
||||
id: Uuid,
|
||||
script_path: Option<String>,
|
||||
email: String,
|
||||
flow_status: Option<serde_json::Value>,
|
||||
workflow_as_code_status: Option<serde_json::Value>,
|
||||
}
|
||||
let row = sqlx::query_as::<_, ApprovalJobRow>(
|
||||
"SELECT j.id, j.runnable_path as script_path, j.permissioned_as_email as email,
|
||||
s.flow_status, s.workflow_as_code_status
|
||||
FROM v2_job j
|
||||
LEFT JOIN v2_job_status s ON s.id = j.id
|
||||
WHERE j.id = $1 AND j.workspace_id = $2",
|
||||
)
|
||||
.bind(&job_id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?;
|
||||
|
||||
let is_wac = row.workflow_as_code_status.is_some();
|
||||
|
||||
// Extract approval info based on WAC vs classic flow
|
||||
let (form_schema, description, approval_conditions, hide_cancel) = if is_wac {
|
||||
let approval_meta = row
|
||||
.workflow_as_code_status
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("_approval"));
|
||||
let form = approval_meta.and_then(|m| m.get("form").cloned());
|
||||
let ac = row
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| v.get("approval_conditions"))
|
||||
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok());
|
||||
(form, None, ac, None)
|
||||
} else {
|
||||
let fs = row
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok());
|
||||
let ac = fs.as_ref().and_then(|s| s.approval_conditions.clone());
|
||||
|
||||
// For classic flows, form/description come from the flow definition and step result
|
||||
let approval_step = fs.as_ref().map(|s| (s.step as usize).saturating_sub(1));
|
||||
|
||||
// Fetch flow definition to get suspend settings (form schema, hide_cancel).
|
||||
// Try raw_flow on the job first, fall back to flow_version for deployed flows.
|
||||
let raw_flow: Option<FlowValue> = {
|
||||
let from_job: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT raw_flow FROM v2_job WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(&job_id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
if let Some(v) = from_job {
|
||||
serde_json::from_value(v).ok()
|
||||
} else {
|
||||
// Deployed flow: fetch from flow_version using runnable_id
|
||||
let from_version: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT fv.value FROM v2_job j JOIN flow_version fv ON fv.id = j.runnable_id \
|
||||
WHERE j.id = $1 AND j.workspace_id = $2",
|
||||
)
|
||||
.bind(&job_id)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
from_version.and_then(|v| serde_json::from_value(v).ok())
|
||||
}
|
||||
};
|
||||
|
||||
let suspend_module = raw_flow
|
||||
.as_ref()
|
||||
.and_then(|rf| approval_step.and_then(|s| rf.modules.get(s)));
|
||||
let suspend_settings = suspend_module.and_then(|m| m.suspend.as_ref());
|
||||
|
||||
let form = suspend_settings
|
||||
.and_then(|s| s.resume_form.as_ref())
|
||||
.map(|rf| serde_json::json!(rf));
|
||||
let hc = suspend_settings.map(|s| s.hide_cancel.unwrap_or(false));
|
||||
|
||||
// Fetch description and default_args from the step's completed job result
|
||||
let step_job_id = fs
|
||||
.as_ref()
|
||||
.and_then(|s| approval_step.and_then(|step| s.modules.get(step)))
|
||||
.and_then(|m| m.job());
|
||||
let (desc, _default_args) = if let Some(sjid) = step_job_id {
|
||||
let result: Option<serde_json::Value> = sqlx::query_scalar(
|
||||
"SELECT result FROM v2_job_completed WHERE id = $1 AND workspace_id = $2",
|
||||
)
|
||||
.bind(sjid)
|
||||
.bind(&w_id)
|
||||
.fetch_optional(&db)
|
||||
.await?
|
||||
.flatten();
|
||||
let desc = result.as_ref().and_then(|r| r.get("description").cloned());
|
||||
let da = result.as_ref().and_then(|r| r.get("default_args").cloned());
|
||||
(desc, da)
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
|
||||
(form, desc, ac, hc)
|
||||
};
|
||||
|
||||
let user_auth_required = approval_conditions
|
||||
.as_ref()
|
||||
.map(|ac| ac.user_auth_required)
|
||||
.unwrap_or(false);
|
||||
|
||||
// Determine if current user can approve
|
||||
let can_approve = if let Some(ref authed) = opt_authed {
|
||||
if authed.is_admin {
|
||||
true
|
||||
} else {
|
||||
let is_owner = row
|
||||
.script_path
|
||||
.as_deref()
|
||||
.map(|p| require_owner_of_path(authed, p).is_ok())
|
||||
.unwrap_or(false);
|
||||
if is_owner {
|
||||
true
|
||||
} else {
|
||||
let trigger_email = row.email.as_str();
|
||||
conditionally_require_authed_user(
|
||||
Some(authed.clone()),
|
||||
approval_conditions.clone(),
|
||||
trigger_email,
|
||||
)
|
||||
.is_ok()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Not logged in — can approve only if no auth required
|
||||
!user_auth_required
|
||||
};
|
||||
|
||||
// Get existing approvers
|
||||
let approvers: Vec<Approval> = sqlx::query_as::<_, (i32, Option<String>)>(
|
||||
"SELECT resume_id, approver FROM resume_job WHERE flow = $1",
|
||||
)
|
||||
.bind(&job_id)
|
||||
.fetch_all(&db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|(rid, approver)| Approval {
|
||||
resume_id: rid as u16,
|
||||
approver: approver.unwrap_or_else(|| "anonymous".to_string()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ApprovalInfo {
|
||||
flow_id: row.id,
|
||||
form_schema,
|
||||
description,
|
||||
approval_conditions,
|
||||
can_approve,
|
||||
user_auth_required,
|
||||
hide_cancel,
|
||||
approvers,
|
||||
}))
|
||||
}
|
||||
|
||||
// --- End new approval system endpoints ---
|
||||
|
||||
pub async fn resume_suspended_job(
|
||||
authed: Option<ApiAuthed>,
|
||||
opt_tokened: OptTokened,
|
||||
@@ -2258,26 +2679,8 @@ async fn resume_suspended_job_internal(
|
||||
// Get flow info - works for step-level, flow-level, and WAC approval
|
||||
let (flow_info, is_flow_level, is_wac) = get_flow_info_for_resume(job_id, &db).await?;
|
||||
|
||||
// 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
|
||||
// For WAC approvals, skip flow status checks (there is no flow)
|
||||
if !is_flow_level && !is_wac {
|
||||
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)?;
|
||||
}
|
||||
// HMAC secret = full capability. Skip approval_conditions checks.
|
||||
// Authorization rules are enforced by the new resume_suspended endpoint instead.
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
r#"
|
||||
@@ -2543,7 +2946,7 @@ async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowI
|
||||
async fn get_suspended_flow_info<'c>(
|
||||
job_id: Uuid,
|
||||
tx: &mut Transaction<'c, Postgres>,
|
||||
) -> error::Result<(FlowInfo, Uuid)> {
|
||||
) -> error::Result<(FlowInfo, Uuid, bool)> {
|
||||
let flow = sqlx::query_as!(
|
||||
FlowInfo,
|
||||
r#"
|
||||
@@ -2556,7 +2959,9 @@ async fn get_suspended_flow_info<'c>(
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?;
|
||||
let job_id = flow
|
||||
|
||||
// Try to extract step job_id from FlowStatus modules (classic flow path)
|
||||
let step_job_id = flow
|
||||
.flow_status
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
|
||||
@@ -2565,8 +2970,31 @@ async fn get_suspended_flow_info<'c>(
|
||||
_ => None,
|
||||
});
|
||||
|
||||
if let Some(job_id) = job_id {
|
||||
Ok((flow, job_id))
|
||||
if let Some(step_job_id) = step_job_id {
|
||||
// Classic flow
|
||||
Ok((flow, step_job_id, false))
|
||||
} else if flow.suspend > 0 {
|
||||
// WAC approval: no FlowStatus modules, but the job is suspended
|
||||
// The flow_status here comes from COALESCE(flow_status, workflow_as_code_status),
|
||||
// so for WAC it may contain approval_conditions from flow_status column
|
||||
// or the WAC checkpoint from workflow_as_code_status column.
|
||||
// We need the approval_conditions which are in flow_status column.
|
||||
// Re-fetch just flow_status (without COALESCE fallback) for the auth check.
|
||||
let flow_status_only: Option<serde_json::Value> =
|
||||
sqlx::query_scalar("SELECT flow_status FROM v2_job_status WHERE id = $1")
|
||||
.bind(&job_id)
|
||||
.fetch_optional(&mut **tx)
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
let flow = FlowInfo {
|
||||
id: flow.id,
|
||||
flow_status: flow_status_only,
|
||||
suspend: flow.suspend,
|
||||
script_path: flow.script_path,
|
||||
email: flow.email,
|
||||
};
|
||||
Ok((flow, job_id, true))
|
||||
} else {
|
||||
Err(anyhow::anyhow!("the flow is not in a suspended state anymore").into())
|
||||
}
|
||||
@@ -2643,7 +3071,11 @@ pub async fn get_suspended_job_flow(
|
||||
Job::CompletedJob(job) => &job.email,
|
||||
Job::QueuedJob(job) => &job.email,
|
||||
};
|
||||
conditionally_require_authed_user(authed.clone(), flow_status.clone(), trigger_email)?;
|
||||
conditionally_require_authed_user(
|
||||
authed.clone(),
|
||||
flow_status.approval_conditions.clone(),
|
||||
trigger_email,
|
||||
)?;
|
||||
|
||||
let approvers_from_status = match flow_module_status {
|
||||
FlowStatusModule::Success { approvers, .. } => approvers.to_owned(),
|
||||
@@ -2684,16 +3116,25 @@ pub async fn get_suspended_job_flow(
|
||||
|
||||
fn conditionally_require_authed_user(
|
||||
_authed: Option<ApiAuthed>,
|
||||
flow_status: FlowStatus,
|
||||
approval_conditions_opt: Option<ApprovalConditions>,
|
||||
_trigger_email: &str,
|
||||
) -> error::Result<()> {
|
||||
let approval_conditions_opt = flow_status.approval_conditions;
|
||||
|
||||
if approval_conditions_opt.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
let approval_conditions = approval_conditions_opt.unwrap();
|
||||
|
||||
// Check self-approval independently of user_auth_required
|
||||
if approval_conditions.self_approval_disabled {
|
||||
if let Some(ref authed) = _authed {
|
||||
if !authed.is_admin && authed.email.eq(_trigger_email) {
|
||||
return Err(Error::PermissionDenied(
|
||||
"Self-approval is disabled for this flow step".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if approval_conditions.user_auth_required {
|
||||
{
|
||||
#[cfg(not(feature = "enterprise"))]
|
||||
@@ -2711,13 +3152,6 @@ fn conditionally_require_authed_user(
|
||||
|
||||
let authed = _authed.unwrap();
|
||||
if !authed.is_admin {
|
||||
if approval_conditions.self_approval_disabled && authed.email.eq(_trigger_email)
|
||||
{
|
||||
return Err(Error::PermissionDenied(
|
||||
"Self-approval is disabled for this flow step".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
if !approval_conditions.user_groups_required.is_empty() {
|
||||
#[cfg(feature = "enterprise")]
|
||||
{
|
||||
@@ -2863,11 +3297,18 @@ pub async fn get_resume_urls_internal(
|
||||
.map(|x| format!("?approver={}", encode(x)))
|
||||
.unwrap_or_else(String::new);
|
||||
|
||||
// Generate approval token for the new approval page URL.
|
||||
// The token targets the parent flow/WAC job for proper resolution.
|
||||
let approval_target_id = get_flow_id_for_job(&db, job_id)
|
||||
.await
|
||||
.unwrap_or(target_job_id);
|
||||
let approval_token = generate_approval_token(&w_id, approval_target_id, &db).await?;
|
||||
|
||||
let base_url_str = BASE_URL.read().await.clone();
|
||||
let base_url = base_url_str.as_str();
|
||||
let res = ResumeUrls {
|
||||
approvalPage: format!(
|
||||
"{base_url}/approve/{w_id}/{target_job_id}/{resume_id}/{signature}{approver_query}"
|
||||
"{base_url}/approve/{w_id}/{approval_target_id}?token={approval_token}"
|
||||
),
|
||||
cancel: build_resume_url(
|
||||
"cancel",
|
||||
|
||||
@@ -45,8 +45,8 @@ use windmill_common::global_settings::EMAIL_DOMAIN_SETTING;
|
||||
use windmill_common::worker::HUB_CACHE_DIR;
|
||||
|
||||
use std::fs::DirBuilder;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use std::{net::SocketAddr, sync::Arc};
|
||||
use tokio::sync::RwLock;
|
||||
use tower::ServiceBuilder;
|
||||
use tower_cookies::CookieManagerLayer;
|
||||
@@ -165,6 +165,7 @@ pub mod teams_ee;
|
||||
mod teams_oss;
|
||||
mod token;
|
||||
mod tracing_init;
|
||||
mod trash;
|
||||
pub mod triggers;
|
||||
mod users;
|
||||
#[cfg(feature = "private")]
|
||||
@@ -325,7 +326,7 @@ pub async fn run_server(
|
||||
db: DB,
|
||||
job_index_reader: Option<IndexReader>,
|
||||
log_index_reader: Option<ServiceLogIndexReader>,
|
||||
addr: SocketAddr,
|
||||
listener: tokio::net::TcpListener,
|
||||
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
|
||||
port_tx: tokio::sync::oneshot::Sender<String>,
|
||||
server_mode: bool,
|
||||
@@ -411,6 +412,9 @@ pub async fn run_server(
|
||||
auth_cache: auth_cache.clone(),
|
||||
base_internal_url: _base_internal_url.clone(),
|
||||
});
|
||||
let addr = listener
|
||||
.local_addr()
|
||||
.unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], 0)));
|
||||
if let Err(err) = smtp_server.start_listener_thread(addr).await {
|
||||
tracing::error!("Error starting SMTP server: {err:#}");
|
||||
}
|
||||
@@ -451,9 +455,6 @@ pub async fn run_server(
|
||||
health::start_health_check_loop(db.clone(), killpill_rx.resubscribe());
|
||||
}
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.context("binding main windmill server")?;
|
||||
let port = listener.local_addr().map(|x| x.port()).unwrap_or(8000);
|
||||
let ip = listener
|
||||
.local_addr()
|
||||
@@ -597,6 +598,7 @@ pub async fn run_server(
|
||||
.nest("/resources", resources::workspaced_service())
|
||||
.nest("/schedules", windmill_api_schedule::workspaced_service())
|
||||
.nest("/scripts", scripts::workspaced_service())
|
||||
.nest("/trash", trash::workspaced_service())
|
||||
.nest(
|
||||
"/users",
|
||||
users::workspaced_service().layer(Extension(argon2.clone())),
|
||||
|
||||
511
backend/windmill-api/src/trash.rs
Normal file
511
backend/windmill-api/src/trash.rs
Normal file
@@ -0,0 +1,511 @@
|
||||
use axum::{
|
||||
extract::{Extension, Json, Path, Query},
|
||||
routing::{delete, get, post},
|
||||
Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use windmill_audit::{audit_oss::audit_log, ActionKind};
|
||||
use windmill_common::{
|
||||
db::UserDB,
|
||||
error::{Error, Result},
|
||||
trashbin::{self, TrashItem, TrashItemWithData},
|
||||
utils::require_admin,
|
||||
};
|
||||
|
||||
use crate::db::{ApiAuthed, DB};
|
||||
|
||||
pub fn workspaced_service() -> Router {
|
||||
Router::new()
|
||||
.route("/list", get(list_trash))
|
||||
.route("/get/:id", get(get_trash_item))
|
||||
.route("/restore/:id", post(restore_trash_item))
|
||||
.route("/delete/:id", delete(permanently_delete_item))
|
||||
.route("/empty", post(empty_trash))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ListTrashQuery {
|
||||
item_kind: Option<String>,
|
||||
page: Option<i64>,
|
||||
per_page: Option<i64>,
|
||||
}
|
||||
|
||||
async fn list_trash(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path(w_id): Path<String>,
|
||||
Query(query): Query<ListTrashQuery>,
|
||||
) -> Result<Json<Vec<TrashItem>>> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let items = trashbin::list_trash(
|
||||
&db,
|
||||
&w_id,
|
||||
query.item_kind.as_deref(),
|
||||
query.page,
|
||||
query.per_page,
|
||||
)
|
||||
.await?;
|
||||
Ok(Json(items))
|
||||
}
|
||||
|
||||
async fn get_trash_item(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Path((w_id, id)): Path<(String, i64)>,
|
||||
) -> Result<Json<TrashItemWithData>> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let item = trashbin::get_trash_item(&db, &w_id, id).await?;
|
||||
Ok(Json(item))
|
||||
}
|
||||
|
||||
async fn restore_trash_item(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, id)): Path<(String, i64)>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let item = trashbin::get_trash_item(&db, &w_id, id).await?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
|
||||
match item.item_kind.as_str() {
|
||||
"script" => restore_script(&mut tx, &item).await?,
|
||||
"flow" => restore_flow(&mut tx, &item).await?,
|
||||
"app" => restore_app(&mut tx, &item).await?,
|
||||
"schedule" => restore_schedule(&mut tx, &item).await?,
|
||||
"variable" => restore_variable(&mut tx, &item).await?,
|
||||
"resource" => restore_resource(&mut tx, &item).await?,
|
||||
kind if kind.ends_with("_trigger") => restore_trigger(&mut tx, &item).await?,
|
||||
_ => {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Unknown item kind: {}",
|
||||
item.item_kind
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
sqlx::query!("DELETE FROM trashbin WHERE id = $1", item.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"trash.restore",
|
||||
ActionKind::Create,
|
||||
&w_id,
|
||||
Some(&item.item_path),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(format!("{} '{}' restored", item.item_kind, item.item_path))
|
||||
}
|
||||
|
||||
async fn permanently_delete_item(
|
||||
authed: ApiAuthed,
|
||||
Extension(db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path((w_id, id)): Path<(String, i64)>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let item = trashbin::get_trash_item(&db, &w_id, id).await?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
trashbin::permanently_delete_item(&mut *tx, &w_id, id).await?;
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"trashbin.permanently_delete",
|
||||
ActionKind::Delete,
|
||||
&w_id,
|
||||
Some(&item.item_path),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok("permanently deleted".to_string())
|
||||
}
|
||||
|
||||
async fn empty_trash(
|
||||
authed: ApiAuthed,
|
||||
Extension(_db): Extension<DB>,
|
||||
Extension(user_db): Extension<UserDB>,
|
||||
Path(w_id): Path<String>,
|
||||
) -> Result<String> {
|
||||
require_admin(authed.is_admin, &authed.username)?;
|
||||
let mut tx = user_db.begin(&authed).await?;
|
||||
let count = trashbin::empty_trash(&mut *tx, &w_id).await?;
|
||||
audit_log(
|
||||
&mut *tx,
|
||||
&authed,
|
||||
"trashbin.empty",
|
||||
ActionKind::Delete,
|
||||
&w_id,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
tx.commit().await?;
|
||||
Ok(format!("{} items permanently deleted", count))
|
||||
}
|
||||
|
||||
// --- Restore functions per item kind ---
|
||||
|
||||
async fn restore_script(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
|
||||
let data = &item.item_data;
|
||||
|
||||
// Check for path conflict
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2)",
|
||||
&item.item_path,
|
||||
&item.workspace_id,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"A script already exists at path '{}'",
|
||||
item.item_path
|
||||
)));
|
||||
}
|
||||
|
||||
// Scripts are stored as an array (all versions for the path)
|
||||
let scripts = data
|
||||
.get("scripts")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| Error::internal_err("Invalid trash data for script"))?;
|
||||
|
||||
for script in scripts {
|
||||
sqlx::query("INSERT INTO script SELECT * FROM jsonb_populate_record(null::script, $1)")
|
||||
.bind(script)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("restoring script: {e:#}")))?;
|
||||
}
|
||||
|
||||
// Restore drafts if present
|
||||
if let Some(drafts) = data.get("drafts").and_then(|v| v.as_array()) {
|
||||
for draft in drafts {
|
||||
sqlx::query(
|
||||
"INSERT INTO draft SELECT * FROM jsonb_populate_record(null::draft, $1)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(draft)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_flow(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
|
||||
let data = &item.item_data;
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM flow WHERE path = $1 AND workspace_id = $2)",
|
||||
&item.item_path,
|
||||
&item.workspace_id,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"A flow already exists at path '{}'",
|
||||
item.item_path
|
||||
)));
|
||||
}
|
||||
|
||||
let row = data
|
||||
.get("row")
|
||||
.ok_or_else(|| Error::internal_err("Invalid trash data for flow"))?;
|
||||
|
||||
sqlx::query("INSERT INTO flow SELECT * FROM jsonb_populate_record(null::flow, $1)")
|
||||
.bind(row)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Restore flow_versions
|
||||
if let Some(versions) = data.get("flow_versions").and_then(|v| v.as_array()) {
|
||||
for version in versions {
|
||||
sqlx::query(
|
||||
"INSERT INTO flow_version SELECT * FROM jsonb_populate_record(null::flow_version, $1)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(version)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore flow_nodes
|
||||
if let Some(nodes) = data.get("flow_nodes").and_then(|v| v.as_array()) {
|
||||
for node in nodes {
|
||||
sqlx::query(
|
||||
"INSERT INTO flow_node SELECT * FROM jsonb_populate_record(null::flow_node, $1)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(node)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore drafts
|
||||
if let Some(drafts) = data.get("drafts").and_then(|v| v.as_array()) {
|
||||
for draft in drafts {
|
||||
sqlx::query(
|
||||
"INSERT INTO draft SELECT * FROM jsonb_populate_record(null::draft, $1)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(draft)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_app(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
|
||||
let data = &item.item_data;
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2)",
|
||||
&item.item_path,
|
||||
&item.workspace_id,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"An app already exists at path '{}'",
|
||||
item.item_path
|
||||
)));
|
||||
}
|
||||
|
||||
let row = data
|
||||
.get("row")
|
||||
.ok_or_else(|| Error::internal_err("Invalid trash data for app"))?;
|
||||
|
||||
// Insert app first (app_version has FK to app.id)
|
||||
sqlx::query("INSERT INTO app SELECT * FROM jsonb_populate_record(null::app, $1)")
|
||||
.bind(row)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("restoring app row: {e:#}")))?;
|
||||
|
||||
// Then restore app_versions
|
||||
if let Some(versions) = data.get("app_versions").and_then(|v| v.as_array()) {
|
||||
for version in versions {
|
||||
sqlx::query(
|
||||
"INSERT INTO app_version SELECT * FROM jsonb_populate_record(null::app_version, $1)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(version)
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| Error::internal_err(format!("restoring app_version: {e:#}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore drafts
|
||||
if let Some(drafts) = data.get("drafts").and_then(|v| v.as_array()) {
|
||||
for draft in drafts {
|
||||
sqlx::query(
|
||||
"INSERT INTO draft SELECT * FROM jsonb_populate_record(null::draft, $1)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(draft)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_schedule(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
|
||||
let data = &item.item_data;
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM schedule WHERE path = $1 AND workspace_id = $2)",
|
||||
&item.item_path,
|
||||
&item.workspace_id,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"A schedule already exists at path '{}'",
|
||||
item.item_path
|
||||
)));
|
||||
}
|
||||
|
||||
let row = data
|
||||
.get("row")
|
||||
.ok_or_else(|| Error::internal_err("Invalid trash data for schedule"))?;
|
||||
|
||||
sqlx::query("INSERT INTO schedule SELECT * FROM jsonb_populate_record(null::schedule, $1)")
|
||||
.bind(row)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_variable(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
|
||||
let data = &item.item_data;
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM variable WHERE path = $1 AND workspace_id = $2)",
|
||||
&item.item_path,
|
||||
&item.workspace_id,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"A variable already exists at path '{}'",
|
||||
item.item_path
|
||||
)));
|
||||
}
|
||||
|
||||
let row = data
|
||||
.get("row")
|
||||
.ok_or_else(|| Error::internal_err("Invalid trash data for variable"))?;
|
||||
|
||||
sqlx::query("INSERT INTO variable SELECT * FROM jsonb_populate_record(null::variable, $1)")
|
||||
.bind(row)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Restore linked resource if present
|
||||
if let Some(linked_resource) = data.get("linked_resource") {
|
||||
if !linked_resource.is_null() {
|
||||
sqlx::query(
|
||||
"INSERT INTO resource SELECT * FROM jsonb_populate_record(null::resource, $1)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(linked_resource)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_resource(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
|
||||
let data = &item.item_data;
|
||||
|
||||
let exists = sqlx::query_scalar!(
|
||||
"SELECT EXISTS(SELECT 1 FROM resource WHERE path = $1 AND workspace_id = $2)",
|
||||
&item.item_path,
|
||||
&item.workspace_id,
|
||||
)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?
|
||||
.unwrap_or(false);
|
||||
|
||||
if exists {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"A resource already exists at path '{}'",
|
||||
item.item_path
|
||||
)));
|
||||
}
|
||||
|
||||
let row = data
|
||||
.get("row")
|
||||
.ok_or_else(|| Error::internal_err("Invalid trash data for resource"))?;
|
||||
|
||||
sqlx::query("INSERT INTO resource SELECT * FROM jsonb_populate_record(null::resource, $1)")
|
||||
.bind(row)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Restore linked variables if present
|
||||
if let Some(linked_vars) = data.get("linked_variables").and_then(|v| v.as_array()) {
|
||||
for var in linked_vars {
|
||||
sqlx::query(
|
||||
"INSERT INTO variable SELECT * FROM jsonb_populate_record(null::variable, $1)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(var)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn restore_trigger(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
|
||||
let data = &item.item_data;
|
||||
|
||||
let table_name = data
|
||||
.get("table_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| Error::internal_err("Invalid trash data for trigger: missing table_name"))?;
|
||||
|
||||
// Validate table name to prevent SQL injection
|
||||
let valid_tables = [
|
||||
"http_trigger",
|
||||
"websocket_trigger",
|
||||
"kafka_trigger",
|
||||
"nats_trigger",
|
||||
"postgres_trigger",
|
||||
"mqtt_trigger",
|
||||
"sqs_trigger",
|
||||
"gcp_trigger",
|
||||
"email_trigger",
|
||||
];
|
||||
|
||||
if !valid_tables.contains(&table_name) {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"Invalid trigger table: {}",
|
||||
table_name
|
||||
)));
|
||||
}
|
||||
|
||||
let exists: bool = sqlx::query_scalar(&format!(
|
||||
"SELECT EXISTS(SELECT 1 FROM {} WHERE path = $1 AND workspace_id = $2)",
|
||||
table_name
|
||||
))
|
||||
.bind(&item.item_path)
|
||||
.bind(&item.workspace_id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if exists {
|
||||
return Err(Error::BadRequest(format!(
|
||||
"A trigger already exists at path '{}'",
|
||||
item.item_path
|
||||
)));
|
||||
}
|
||||
|
||||
let row = data
|
||||
.get("row")
|
||||
.ok_or_else(|| Error::internal_err("Invalid trash data for trigger"))?;
|
||||
|
||||
sqlx::query(&format!(
|
||||
"INSERT INTO {} SELECT * FROM jsonb_populate_record(null::{}, $1)",
|
||||
table_name, table_name
|
||||
))
|
||||
.bind(row)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user