Merge remote-tracking branch 'origin/main' into fix-runnable-test-draft-app

This commit is contained in:
Guilhem Lemouel
2026-09-28 23:53:53 -07:00
344 changed files with 16190 additions and 3360 deletions
+1 -1
View File
@@ -295,4 +295,4 @@ jobs:
# never reaches it. Pin the target dir (matching the cache step above) so
# its own tests run off this compile rather than a second bundled build.
(cd windmill-duckdb-ffi-internal && export CARGO_TARGET_DIR="$PWD/target" && ./build_dev.sh && cargo test --release -p windmill_duckdb_ffi_internal)
DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline,smtp --all -- --nocapture --test-threads=10
DENO_PATH=$(which deno) BUN_PATH=$(which bun) NODE_BIN_PATH=$(which node) GO_PATH=$(which go) UV_PATH=$(which uv) PHP_PATH=$(which php) COMPOSER_PATH=$(which composer) RUBY_PATH=$(which ruby) RUBY_BUNDLE_PATH=$(which bundle) RUBY_GEM_PATH=$(which gem) POWERSHELL_PATH=$(which pwsh) DOTNET_PATH=$(which dotnet) cargo test --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline,smtp,http_trigger --all -- --nocapture --test-threads=10
+7 -1
View File
@@ -454,12 +454,18 @@ jobs:
dockerhub-user: windmilllabs
dockerhub-password: ${{ secrets.DOCKER_PAT }}
# Filed under main, not under the release tag this job runs on. The scanned image is
# `-ee:main` and the Security tab only reads the default branch, so tag-only uploads
# left main's last Docker Scout result at 2025-01-29: the tool showed as failing and
# its alerts could never close. `sha` is the release commit, which is on main.
- name: Upload SARIF result
id: upload-sarif
if: ${{ github.event_name != 'pull_request_target' }}
uses: github/codeql-action/upload-sarif@v2
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: sarif.output.json
ref: refs/heads/main
sha: ${{ github.sha }}
# docker_scout_ee:
# runs-on: ubicloud
+18
View File
@@ -35,6 +35,24 @@ jobs:
with:
bun-version: latest
# Matches the Node major DockerfileExtra installs. The pin is load-bearing:
# glob arguments to `node --test` need Node >= 21.
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
- name: Run multiplayer unit tests
working-directory: multiplayer
run: |
npm ci --omit=dev
npm test
# Runs the real entrypoint-extra.sh with stub services, so the supervision
# behaviour is checked without building the image first.
- name: Run entrypoint supervision tests
run: bash docker/test_entrypoint_extra.sh
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.817.0"
".": "1.819.0"
}
+3
View File
@@ -30,6 +30,9 @@ Open-source platform for internal tools, workflows, API integrations, background
reaches the DB only through the API, so `Connection::Http` paths are never taken by a plain
`cargo run`; a normal build cannot start one at all.
- **Enterprise**: `docs/enterprise.md` — EE file conventions and PR workflow
- **Operator write rights**: `docs/operator-write-rights.md` — which `operator_settings` flags are
enforced rather than cosmetic, and why a right that is granted-unless-withdrawn needs `Option`
fields and a jsonb merge rather than a serde default
- **Auth surface**: `docs/auth-surface.md` — credential precedence, session/cache invalidation
scope, which token labels email their owner at expiry, how OAuth login matches `login_type`, and
that every superadmin route refuses `$WM_TOKEN`. Read before designing anything that creates
+99
View File
@@ -1,5 +1,104 @@
# Changelog
## [1.819.0](https://github.com/windmill-labs/windmill/compare/v1.818.0...v1.819.0) (2026-09-28)
### Features
* add hub sync button to the resource types tab ([#11375](https://github.com/windmill-labs/windmill/issues/11375)) ([d76a962](https://github.com/windmill-labs/windmill/commit/d76a962331784672934749e2237f83259cbbc633))
* add provenance claims to job OIDC tokens ([#11369](https://github.com/windmill-labs/windmill/issues/11369)) ([990a726](https://github.com/windmill-labs/windmill/commit/990a72640928336a4583e55376f37aa3c9eb17ea))
### Bug Fixes
* complete a canceled flow whose worker died between two steps ([#11366](https://github.com/windmill-labs/windmill/issues/11366)) ([c2d8997](https://github.com/windmill-labs/windmill/commit/c2d899754977ab95936b1084817da98d7e5d68f4))
* **frontend:** apply operator write locks from the session's operating workspace ([#11395](https://github.com/windmill-labs/windmill/issues/11395)) ([5e59cef](https://github.com/windmill-labs/windmill/commit/5e59cefd1fa579148a06bb687aa60d1de21826b5))
* gate batch rerun on job read access, scope started_at to workspace ([#11387](https://github.com/windmill-labs/windmill/issues/11387)) ([14a2619](https://github.com/windmill-labs/windmill/commit/14a2619ad24d98592821b12b2e6a54cbcfbc3d09))
* hold interpolated references and captures to the token path scopes ([#11391](https://github.com/windmill-labs/windmill/issues/11391)) ([cedd6dc](https://github.com/windmill-labs/windmill/commit/cedd6dc901918d3001229fa95a5fe86e369b7bc4))
* judge IPv4 embedded in IPv6 and pin the object storage test connect ([#11389](https://github.com/windmill-labs/windmill/issues/11389)) ([ec6ec1b](https://github.com/windmill-labs/windmill/commit/ec6ec1b06febd235e8a0d1ae8f5175fd929b79e7))
* keep smtp_clicktracking_off when syncing instance config ([#11372](https://github.com/windmill-labs/windmill/issues/11372)) ([60ef821](https://github.com/windmill-labs/windmill/commit/60ef82196e014d39b0f497a9b8a88048d6e9792b))
* keep test panel controls off the args form in debug mode ([#11382](https://github.com/windmill-labs/windmill/issues/11382)) ([22b5a1c](https://github.com/windmill-labs/windmill/commit/22b5a1cd62a483ab42ce7192b3b8dabe7738b99e))
* let custom workspace error handlers send email with the instance SMTP ([#11365](https://github.com/windmill-labs/windmill/issues/11365)) ([e2be584](https://github.com/windmill-labs/windmill/commit/e2be584ca51bfa7bbe927e7c81c5355817649e7b))
* list only the paths the caller can read in path autocomplete ([#11388](https://github.com/windmill-labs/windmill/issues/11388)) ([f4dcaf3](https://github.com/windmill-labs/windmill/commit/f4dcaf3e45a90951b6c2da71ac0d7af51eb65d89))
* never double-process a slow canceled flow in the zombie sweep ([#11368](https://github.com/windmill-labs/windmill/issues/11368)) ([d7a61de](https://github.com/windmill-labs/windmill/commit/d7a61de23f98a09c881121d9595ec3c11101d04f))
* only let a job's own token claim run lineage ([#11367](https://github.com/windmill-labs/windmill/issues/11367)) ([90f9e59](https://github.com/windmill-labs/windmill/commit/90f9e59321a16ce65ae25b49bda7e632beb313e8))
* only restart a flow on a version of its own path and workspace ([#11376](https://github.com/windmill-labs/windmill/issues/11376)) ([893e64f](https://github.com/windmill-labs/windmill/commit/893e64f63056ba42b0fe0bcf07c6078830d3669c))
* redesign run not found page and fix switching to the right workspace ([#11374](https://github.com/windmill-labs/windmill/issues/11374)) ([d9c7d71](https://github.com/windmill-labs/windmill/commit/d9c7d71f072b0562bb3b1642cfdc961a8948671b))
* run an AI agent tool on the worker its own tag selects ([#11370](https://github.com/windmill-labs/windmill/issues/11370)) ([649c43e](https://github.com/windmill-labs/windmill/commit/649c43e7c162ee47f4896330595647e73d4e8888))
* scope flow resume to its workspace and minting to the job's run ([#11392](https://github.com/windmill-labs/windmill/issues/11392)) ([163a4ff](https://github.com/windmill-labs/windmill/commit/163a4ffa4e60b1eb312476da617665a71bdae040))
* scope workspace dependencies create to the path workspace ([#11385](https://github.com/windmill-labs/windmill/issues/11385)) ([3eaf288](https://github.com/windmill-labs/windmill/commit/3eaf2888c002e7697b105fe56d8358e099809e8f))
* stop the schedule enabled toggle from showing unsaved changes ([#11390](https://github.com/windmill-labs/windmill/issues/11390)) ([3838cd6](https://github.com/windmill-labs/windmill/commit/3838cd6ee08c16556abdf62d0bbf4c73542a6d9c))
### Performance Improvements
* advance a flow step with one v2_job_status update ([#11357](https://github.com/windmill-labs/windmill/issues/11357)) ([65cba2d](https://github.com/windmill-labs/windmill/commit/65cba2dbb7bf5de88ee474f3849eba3b1bcd1232))
* complete a job in one statement on the common path ([#11355](https://github.com/windmill-labs/windmill/issues/11355)) ([bebd762](https://github.com/windmill-labs/windmill/commit/bebd762194b2db740d4989d8925e1efb5b348e08))
* shrink the module graph that gates first paint in dev ([#11373](https://github.com/windmill-labs/windmill/issues/11373)) ([47525b2](https://github.com/windmill-labs/windmill/commit/47525b211a37c2edcccda558d3f2b2300b5cc280))
## [1.818.0](https://github.com/windmill-labs/windmill/compare/v1.817.0...v1.818.0) (2026-09-25)
### Features
* add an instance-wide accent color setting with sidebar tint ([#11335](https://github.com/windmill-labs/windmill/issues/11335)) ([1fd729a](https://github.com/windmill-labs/windmill/commit/1fd729ac1aaec1e6a3af0d6db5fc1d6e1db6fa62))
* add an options field to the postgresql resource ([#11223](https://github.com/windmill-labs/windmill/issues/11223)) ([d3d5392](https://github.com/windmill-labs/windmill/commit/d3d539291716baf5807a724d60d338cd3c4fe521))
* **ai-chat:** show native script edit diffs ([#11278](https://github.com/windmill-labs/windmill/issues/11278)) ([1de54ee](https://github.com/windmill-labs/windmill/commit/1de54eeea4c4820e7291d9648a44ef3c86979c5a))
* alert on and optionally cancel jobs stuck on unserved tags ([#11354](https://github.com/windmill-labs/windmill/issues/11354)) ([da866c5](https://github.com/windmill-labs/windmill/commit/da866c5eff77f66e45b8e3941166c695536762f5))
* **bedrock:** add OIDC role assumption as a fourth auth mode ([#10936](https://github.com/windmill-labs/windmill/issues/10936)) ([e14da5c](https://github.com/windmill-labs/windmill/commit/e14da5c6bc8256bfe1d71ddecb886c3efc994ae4))
* chat with a saved agent from the agent editor ([#11292](https://github.com/windmill-labs/windmill/issues/11292)) ([d8aaa73](https://github.com/windmill-labs/windmill/commit/d8aaa73573c46a8a23f6c6850d38438ad395752c))
* **cli:** include extra paths in codebase digest for monorepo imports ([#11327](https://github.com/windmill-labs/windmill/issues/11327)) ([9b46b21](https://github.com/windmill-labs/windmill/commit/9b46b21f517a67867912092c619675d91a197313))
* collapse the fork members setting and show its state in a badge ([#11220](https://github.com/windmill-labs/windmill/issues/11220)) ([3bd89e9](https://github.com/windmill-labs/windmill/commit/3bd89e92d8f563b564a3d2e619749f377a7d6982))
* count enabled service accounts as operator seats ([#11297](https://github.com/windmill-labs/windmill/issues/11297)) ([318f899](https://github.com/windmill-labs/windmill/commit/318f89960e5594278d2aaad68f40db36b96918e7))
* **db-manager:** add a schema diagram view ([#11030](https://github.com/windmill-labs/windmill/issues/11030)) ([7dec3d9](https://github.com/windmill-labs/windmill/commit/7dec3d97717406d078499ea6509cdb00c89c3480))
* filter ai session tools to the user's workspace capabilities ([#10719](https://github.com/windmill-labs/windmill/issues/10719)) ([6cd70a8](https://github.com/windmill-labs/windmill/commit/6cd70a8e08cf4869cb52ef554bc89d5d13fadf5f))
* infer a script's schema when a deploy (e.g. MCP) sends none ([#11339](https://github.com/windmill-labs/windmill/issues/11339)) ([d18d704](https://github.com/windmill-labs/windmill/commit/d18d7043df6536e10b22ee9f3df77e926a0460b4))
* let a workspace withdraw operator schedule and trigger writes ([#11226](https://github.com/windmill-labs/windmill/issues/11226)) ([9a1c6e5](https://github.com/windmill-labs/windmill/commit/9a1c6e508127579dee4c1fae5c3257930e0b8aa1))
* mount session list pages in process instead of iframes ([#11289](https://github.com/windmill-labs/windmill/issues/11289)) ([ebb3048](https://github.com/windmill-labs/windmill/commit/ebb3048ca0b6cb9f3c9130ac0871581f3c2ed307))
* per-folder AI chat instructions via ai_instruction resources ([#11325](https://github.com/windmill-labs/windmill/issues/11325)) ([80903c9](https://github.com/windmill-labs/windmill/commit/80903c9a64125763e7514c09618e1def21a5bf97))
* refresh MCP tools when scripts and flows change ([#11337](https://github.com/windmill-labs/windmill/issues/11337)) ([0bac766](https://github.com/windmill-labs/windmill/commit/0bac766756cf9ebcb615fe20d5a69c09c823c725))
* render a tool's web search result as the session web search card ([#11313](https://github.com/windmill-labs/windmill/issues/11313)) ([fa0fc24](https://github.com/windmill-labs/windmill/commit/fa0fc242691ed3997c14e821b6030166223c0da0))
* rework the db manager: native grid, tabs, sql editor, joined columns ([#11340](https://github.com/windmill-labs/windmill/issues/11340)) ([4fd7d62](https://github.com/windmill-labs/windmill/commit/4fd7d62bd0cf4970a6b155c4a3afac5fb1b79d16))
* rework the git sync setup into a guided flow ([#11308](https://github.com/windmill-labs/windmill/issues/11308)) ([504d01a](https://github.com/windmill-labs/windmill/commit/504d01a5631e33b3a2604ef401ab8b73e4a55395))
* schedule hub scripts ([#11330](https://github.com/windmill-labs/windmill/issues/11330)) ([733c119](https://github.com/windmill-labs/windmill/commit/733c119fd9464a951e157a8d18ec9d474430e910))
* seed SCIM usernames from nickName ([#11346](https://github.com/windmill-labs/windmill/issues/11346)) ([9100ab9](https://github.com/windmill-labs/windmill/commit/9100ab954ab5b40cf6b9d7320e0fe435186f7eb4))
* show restart from failed step in run page top bar ([#11317](https://github.com/windmill-labs/windmill/issues/11317)) ([62d0088](https://github.com/windmill-labs/windmill/commit/62d0088de5463d44d00a8c9204e03d20649acd07))
* start a deferred queued job now without changing its id ([#11347](https://github.com/windmill-labs/windmill/issues/11347)) ([4b09558](https://github.com/windmill-labs/windmill/commit/4b09558e13fadca6fe604c231a5cdfb896d4d0f8))
* support aiagent steps in test_run_step ([#11321](https://github.com/windmill-labs/windmill/issues/11321)) ([1b74939](https://github.com/windmill-labs/windmill/commit/1b749399521cc5ebaa07fc5a55773113a6a3b903))
### Bug Fixes
* always save slack/teams/email handlers as scripts ([#11345](https://github.com/windmill-labs/windmill/issues/11345)) ([421fdab](https://github.com/windmill-labs/windmill/commit/421fdab3d89fb0241a4266393d318a5f9be6ead9))
* bill from a single service account telemetry count ([#11301](https://github.com/windmill-labs/windmill/issues/11301)) ([ceb1722](https://github.com/windmill-labs/windmill/commit/ceb17223dcb5f66532017036916e91a5323279fb))
* build ag grid link cells as DOM nodes with a vetted href ([#11316](https://github.com/windmill-labs/windmill/issues/11316)) ([5b03c17](https://github.com/windmill-labs/windmill/commit/5b03c1732e905673480d0960b95577d47ec919b3))
* carry labels when deploying variables, resources and folders ([#11222](https://github.com/windmill-labs/windmill/issues/11222)) ([94e4fb1](https://github.com/windmill-labs/windmill/commit/94e4fb1c842057e92f3775eac0826ac7e396d725))
* centre the toggle knob inside its track ([#11314](https://github.com/windmill-labs/windmill/issues/11314)) ([d02ff9a](https://github.com/windmill-labs/windmill/commit/d02ff9ac24d6d720c478e8424e73eebae9ec98fe))
* **cli:** stub the API client over its real exports in tests ([#11363](https://github.com/windmill-labs/windmill/issues/11363)) ([30bb62c](https://github.com/windmill-labs/windmill/commit/30bb62cd2516466936fc791f2ff89dd61c743dba))
* correct the tool controls and name field of a nested AI agent ([#11221](https://github.com/windmill-labs/windmill/issues/11221)) ([1d119e6](https://github.com/windmill-labs/windmill/commit/1d119e6b25fd5c72776dfb49917387de4be065c1))
* **frontend:** keep the session when the persisted workspace is stale ([#11344](https://github.com/windmill-labs/windmill/issues/11344)) ([2e5f6d0](https://github.com/windmill-labs/windmill/commit/2e5f6d0e76301d19d2017239f5ad4fe25d67dc57))
* hide answer actions until the chat turn ends ([#11323](https://github.com/windmill-labs/windmill/issues/11323)) ([98d3897](https://github.com/windmill-labs/windmill/commit/98d3897b8c926cc6d47a2417b6943a5d5612b3aa))
* keep a watched job's code when its completion update drops it ([#11290](https://github.com/windmill-labs/windmill/issues/11290)) ([592ef73](https://github.com/windmill-labs/windmill/commit/592ef736104ff63dd36978a69353a2585636da87))
* label chat job links with the end of the job id ([#11324](https://github.com/windmill-labs/windmill/issues/11324)) ([2f2882a](https://github.com/windmill-labs/windmill/commit/2f2882adff8371e33c0fb72cbf0c61298bbcb2ca))
* limit compare page guidance to fork deploys ([#11300](https://github.com/windmill-labs/windmill/issues/11300)) ([c232ea4](https://github.com/windmill-labs/windmill/commit/c232ea43b4bfe1ed295dd2f71cc6cefa47772bed))
* memoize the resolved authed so one request resolves identity once ([#11299](https://github.com/windmill-labs/windmill/issues/11299)) ([42655ff](https://github.com/windmill-labs/windmill/commit/42655ff5ef3e4eae8ff90c623683bb321ae7d06b))
* **multiplayer:** a malformed frame from one client no longer exits the server ([#11353](https://github.com/windmill-labs/windmill/issues/11353)) ([8caf414](https://github.com/windmill-labs/windmill/commit/8caf41430123318a73b76e4c202da07aecaa3c1e))
* **multiplayer:** don't drop client messages during cold-start token verification ([#11343](https://github.com/windmill-labs/windmill/issues/11343)) ([e8c3f95](https://github.com/windmill-labs/windmill/commit/e8c3f9514e5f712a3d84e301771de748f1cc1fda))
* pass the schedule's custom tag when using run now ([#11322](https://github.com/windmill-labs/windmill/issues/11322)) ([8525206](https://github.com/windmill-labs/windmill/commit/852520636160408cd16119184ffe9c7be92682c8))
* relock importers on their own tag, not the bare dependency tag ([#11359](https://github.com/windmill-labs/windmill/issues/11359)) ([a1abb36](https://github.com/windmill-labs/windmill/commit/a1abb36d9f5408b5fa3b659b769bc198604804ce))
* space chat rows evenly after thinking and answers ([#11315](https://github.com/windmill-labs/windmill/issues/11315)) ([0e53d53](https://github.com/windmill-labs/windmill/commit/0e53d53b07ee1d4611c0f5fb605a290f596de980))
* stop the chat repeating run results the card already shows ([#11293](https://github.com/windmill-labs/windmill/issues/11293)) ([ea0b947](https://github.com/windmill-labs/windmill/commit/ea0b947644d4094304ecea26406fa6aac15dcfac))
* trust the system CA store for SMTP TLS ([#11328](https://github.com/windmill-labs/windmill/issues/11328)) ([9375c93](https://github.com/windmill-labs/windmill/commit/9375c93fd8d55bf22e3f290f44650affdf1868c2))
* validate app and trigger paths, refuse traversal in workspace export ([#11311](https://github.com/windmill-labs/windmill/issues/11311)) ([7593597](https://github.com/windmill-labs/windmill/commit/7593597617110fbd276f3f32ca2be7064547a080))
### Performance Improvements
* skip job-start pings and checkpoint read for short non-WAC jobs ([#11356](https://github.com/windmill-labs/windmill/issues/11356)) ([53a5cfd](https://github.com/windmill-labs/windmill/commit/53a5cfd17ad4d6f378450f7e502539c5ad8c445d))
* skip parent status write when a parallel loop iteration starts ([#11348](https://github.com/windmill-labs/windmill/issues/11348)) ([2456282](https://github.com/windmill-labs/windmill/commit/245628210f7d75aedb9e0a289d1dee63f1513b5b))
* skip the flow_env ancestor walk for sub-flows with nothing to inherit ([#11349](https://github.com/windmill-labs/windmill/issues/11349)) ([bc4f872](https://github.com/windmill-labs/windmill/commit/bc4f872f1093a59b9b8fbc3e11a6f00b42474f0c))
* stop polling http trigger routes on workers and every minute ([#11351](https://github.com/windmill-labs/windmill/issues/11351)) ([d931032](https://github.com/windmill-labs/windmill/commit/d9310327063b8b51ab3fe8d27cbdab99f5552967))
## [1.817.0](https://github.com/windmill-labs/windmill/compare/v1.816.0...v1.817.0) (2026-09-22)
@@ -1,31 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job_completed AS cj\n ( workspace_id\n , id\n , started_at\n , duration_ms\n , result\n , result_columns\n , canceled_by\n , canceled_reason\n , flow_status\n , workflow_as_code_status\n , memory_peak\n , status\n , worker\n )\n SELECT q.workspace_id, q.id, started_at, COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(started_at, now()))))*1000), $3::text::jsonb, $10, $5, $6,\n flow_status, workflow_as_code_status,\n $8, CASE WHEN $4::BOOL THEN 'canceled'::job_status\n WHEN $7::BOOL THEN 'skipped'::job_status\n WHEN $2::BOOL THEN 'success'::job_status\n ELSE 'failure'::job_status END AS status,\n q.worker\n FROM v2_job_queue q LEFT JOIN v2_job_status USING (id) WHERE q.id = $1\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb RETURNING duration_ms AS \"duration_ms!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "duration_ms!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid",
"Bool",
"Text",
"Bool",
"Varchar",
"Text",
"Bool",
"Int4",
"Int8",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "042ff3003bf82d11a78a1074a63379c19880e4d66dbf4dd5391f053b6dfa6b01"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_status\n SET flow_status = flow_status - 'retry'\n WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "06db0e720dd59a7c52c0a98ea7b316237eb1a547678858c1a1e45985035b3468"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, started_at FROM v2_job_queue WHERE id = ANY($1)",
"query": "SELECT id, started_at FROM v2_job_queue WHERE id = ANY($1) AND workspace_id = $2",
"describe": {
"columns": [
{
@@ -16,7 +16,8 @@
],
"parameters": {
"Left": [
"UuidArray"
"UuidArray",
"Text"
]
},
"nullable": [
@@ -24,5 +25,5 @@
true
]
},
"hash": "8167af4650c99dc66de483d72a675a7b57946b430ad0839685797a10ac1adfd8"
"hash": "0a13981484dc657a3d314d8b791250b053f4aa3dd728268cffff7c0b43fafef7"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET running = false,\n started_at = CASE WHEN canceled_by IS NULL THEN NULL ELSE started_at END\n WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "0ad65482d8282f778f4123b4133f9f230fffd4ea1f403dd37b28d4a105c7ef58"
}
@@ -0,0 +1,40 @@
{
"db_name": "PostgreSQL",
"query": "WITH served AS (\n SELECT unnest(custom_tags) AS tag FROM worker_ping\n WHERE ping_at > now() - $1::bigint * interval '1 second'\n UNION SELECT regexp_replace(worker, '-[^-]*$', '') FROM worker_ping\n WHERE ping_at > now() - $1::bigint * interval '1 second'\n UNION SELECT p.key\n FROM worker_ping w JOIN config c ON c.name = 'worker__' || w.worker_group,\n jsonb_each(c.config->'priority_tags') p\n WHERE w.ping_at > now() - $1::bigint * interval '1 second'\n AND jsonb_typeof(c.config->'priority_tags') = 'object'\n AND jsonb_typeof(p.value) = 'number' AND p.value::text::numeric > 0\n ),\n pending AS MATERIALIZED (\n SELECT q.id, q.workspace_id, q.tag, q.scheduled_for FROM v2_job_queue q\n WHERE q.running = false\n AND q.canceled_by IS NULL\n AND q.scheduled_for <= now() - $1::bigint * interval '1 second'\n AND NOT EXISTS (SELECT 1 FROM served s WHERE s.tag = q.tag)\n )\n SELECT p.workspace_id AS \"workspace_id!\", p.tag AS \"tag!\", count(*) AS \"count!\",\n min(p.scheduled_for) AS \"oldest!\"\n FROM pending p JOIN v2_job j ON j.id = p.id\n WHERE (j.parent_job IS NULL\n OR NOT EXISTS (SELECT 1 FROM v2_job_queue pq WHERE pq.id = j.parent_job))\n GROUP BY p.workspace_id, p.tag\n ORDER BY min(p.scheduled_for)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "tag!",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "count!",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "oldest!",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
false,
null,
null
]
},
"hash": "0da220d34f1dda6409c58a77767270986cf666543cad9204c286d5142a72962a"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH job_info AS (\n SELECT id, kind::text AS kind, parent_job\n FROM v2_job\n WHERE id = $1\n )\n SELECT\n q.id AS \"id!\",\n s.flow_status,\n q.suspend AS \"suspend!\",\n j.runnable_path AS script_path,\n j.permissioned_as_email AS email,\n (ji.kind IN ('flow', 'flowpreview', 'singlestepflow')) AS \"is_flow_level!\",\n (ji.kind NOT IN ('flow', 'flowpreview', 'singlestepflow') AND q.id = ji.id) AS \"is_wac!\"\n FROM job_info ji\n JOIN v2_job_queue q ON q.id = CASE\n WHEN ji.kind IN ('flow', 'flowpreview', 'singlestepflow') THEN ji.id\n ELSE COALESCE(ji.parent_job, ji.id)\n END\n JOIN v2_job j ON j.id = q.id\n LEFT JOIN v2_job_status s ON s.id = q.id\n FOR UPDATE OF q\n ",
"query": "\n WITH job_info AS (\n SELECT id, kind::text AS kind, parent_job\n FROM v2_job\n WHERE id = $1 AND workspace_id = $2\n )\n SELECT\n q.id AS \"id!\",\n s.flow_status,\n q.suspend AS \"suspend!\",\n j.runnable_path AS script_path,\n j.permissioned_as_email AS email,\n (ji.kind IN ('flow', 'flowpreview', 'singlestepflow')) AS \"is_flow_level!\",\n (ji.kind NOT IN ('flow', 'flowpreview', 'singlestepflow') AND q.id = ji.id) AS \"is_wac!\"\n FROM job_info ji\n JOIN v2_job_queue q ON q.id = CASE\n WHEN ji.kind IN ('flow', 'flowpreview', 'singlestepflow') THEN ji.id\n ELSE COALESCE(ji.parent_job, ji.id)\n END\n JOIN v2_job j ON j.id = q.id\n LEFT JOIN v2_job_status s ON s.id = q.id\n FOR UPDATE OF q\n ",
"describe": {
"columns": [
{
@@ -41,7 +41,8 @@
],
"parameters": {
"Left": [
"Uuid"
"Uuid",
"Text"
]
},
"nullable": [
@@ -54,5 +55,5 @@
null
]
},
"hash": "3e68b73a1c2fdcb2ef538de1addbb096921e26fed398d5c0966b60503ca97fdb"
"hash": "11b5d006acf040b2461b9539863b1e511c23e1a5f9be97da066a6808a49f2092"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue q SET scheduled_for = now()\n FROM (SELECT id, scheduled_for FROM v2_job_queue WHERE id = $1 AND workspace_id = $2 FOR UPDATE) prev\n WHERE q.id = prev.id AND NOT q.running AND q.suspend = 0 AND q.canceled_by IS NULL\n AND q.scheduled_for > now()\n RETURNING prev.scheduled_for",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "scheduled_for",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false
]
},
"hash": "1740f8bd6f9e084a470a775151e751e1317e7398dfaf1dfde5c57169cc146c9d"
}
@@ -0,0 +1,83 @@
{
"db_name": "PostgreSQL",
"query": "WITH RECURSIVE lineage AS (\n SELECT id, parent_job, kind, runnable_path, runnable_id, trigger_kind, trigger,\n 0 AS depth\n FROM v2_job WHERE id = $1 AND workspace_id = $2\n UNION ALL\n SELECT p.id, p.parent_job, p.kind, p.runnable_path, p.runnable_id, p.trigger_kind,\n p.trigger, l.depth + 1\n FROM v2_job p JOIN lineage l ON p.id = l.parent_job\n WHERE p.workspace_id = $2 AND l.depth < 100\n )\n SELECT id AS \"id!\", parent_job, kind AS \"kind!: JobKind\", runnable_path,\n trigger_kind::text AS trigger_kind,\n -- A restart takes its flow version from the request, so a trusted flow path\n -- can carry another flow's code: the version must belong to that path.\n CASE kind\n WHEN 'flow' THEN EXISTS (SELECT 1 FROM flow_version fv\n WHERE fv.id = runnable_id AND fv.path = runnable_path AND fv.workspace_id = $2)\n WHEN 'script' THEN EXISTS (SELECT 1 FROM script s\n WHERE s.hash = runnable_id AND s.path = runnable_path AND s.workspace_id = $2)\n -- An app editor preview also runs an app script, at an app path it does not\n -- have to own; only deployed-app runs are stamped with their app.\n WHEN 'appscript' THEN COALESCE(trigger_kind = 'app'\n AND starts_with(runnable_path, trigger || '/'), false)\n ELSE true\n END AS \"origin_verified!\"\n FROM lineage ORDER BY depth",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id!",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "parent_job",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "kind!: JobKind",
"type_info": {
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlestepflow",
"flowscript",
"flownode",
"appscript",
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow"
]
}
}
}
},
{
"ordinal": 3,
"name": "runnable_path",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "trigger_kind",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "origin_verified!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
null,
null,
null,
null,
null,
null
]
},
"hash": "201c4d014dd71da5a38ba832f836ca71b0fe6dbee8645449e97517aeb4ef9655"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (SELECT 1 FROM worker_ping\n WHERE ping_at > now() - $1::bigint * interval '1 second') AS \"exists!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "253ea6abfee1824f8b2863a6a7eb7dd8c42952216db9ebad6903d92f06e29b8f"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM v2_job WHERE id = ANY($1) AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"UuidArray",
"Text"
]
},
"nullable": [
false
]
},
"hash": "2ab8038052a8fe8ff0e7648909a2a50fb1d5072f7177e86bbf0ca942aac0223b"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO v2_job_completed\n (workspace_id, id, started_at, duration_ms, result, memory_peak, status, worker)\n SELECT q.workspace_id, q.id, q.started_at,\n COALESCE((EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000, 0)::bigint,\n $2::jsonb, r.memory_peak, 'failure'::job_status, q.worker\n FROM v2_job_queue q\n LEFT JOIN v2_job_runtime r ON r.id = q.id\n WHERE q.id = $1\n ON CONFLICT (id) DO UPDATE SET status = 'failure', result = $2::jsonb\n RETURNING duration_ms AS \"duration_ms!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "duration_ms!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid",
"Jsonb"
]
},
"nullable": [
false
]
},
"hash": "2abc2a5830130b2b4b32983407abeea41923ba4122fa666c6e8d8b06dcb5a71f"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT parent_job, root_job, flow_innermost_root_job FROM v2_job\n WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "parent_job",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "root_job",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "flow_innermost_root_job",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
true,
true,
true
]
},
"hash": "2b40bde4c0f78a689ec7e0f5b2600311ac84b3f0a0229729b770bcf3cc55f03c"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id FROM v2_job_queue WHERE id = $1 AND running = false FOR UPDATE",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "2b8eef255c6c76d098c4e4f6b0dd1a74cdad79632ac6e4a0ce7715616b630083"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT path FROM capture WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8",
"Text"
]
},
"nullable": [
false
]
},
"hash": "2da4b7cf1549494945c9761885dfd18ae25e0ee4810f1c9638e4400ab0c914a7"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue q SET canceled_by = 'monitor', canceled_reason = $3\n WHERE q.id = $1 AND q.running = false AND q.canceled_by IS NULL\n AND NOT EXISTS (SELECT 1 FROM worker_ping w\n LEFT JOIN config c ON c.name = 'worker__' || w.worker_group\n WHERE w.ping_at > now() - $2::bigint * interval '1 second'\n AND (q.tag = ANY(w.custom_tags)\n OR q.tag = regexp_replace(w.worker, '-[^-]*$', '')\n OR (jsonb_typeof(c.config->'priority_tags'->q.tag) = 'number'\n AND (c.config->'priority_tags'->>q.tag)::numeric > 0)))\n RETURNING q.id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Int8",
"Text"
]
},
"nullable": [
false
]
},
"hash": "33cc2b020be2868a731ef0e09d0d4dc9792557621269e11ae76009d0c25f98f3"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (SELECT 1 FROM v2_job WHERE id = $1 AND workspace_id = $2)",
"query": "SELECT EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1 AND workspace_id = $2)",
"describe": {
"columns": [
{
@@ -19,5 +19,5 @@
null
]
},
"hash": "f3bae157b92cbc1b3741593a620c9203012a7d3754f56af665e2f34a969eabd7"
"hash": "372db51a00e5cf37f00af4b7962b30d71c74fffbba3f5ac6197c985fd73e5c11"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_status\n SET flow_status = JSONB_SET(flow_status, ARRAY['modules', $1::TEXT], $2)\n WHERE id = $3",
"query": "UPDATE v2_job_status\n SET flow_leaf_jobs = JSONB_SET(coalesce(flow_leaf_jobs, '{}'::jsonb), ARRAY[$1::TEXT], $2)\n WHERE id = $3",
"describe": {
"columns": [],
"parameters": {
@@ -12,5 +12,5 @@
},
"nullable": []
},
"hash": "0aaec91ab06753e46c595d82469924a98f28b0dead245df7248a9ccb8a5f20c3"
"hash": "3c74bcf357bb87e2ee960473d40949b5bacac241549614bd39e4b055db7fc1ea"
}
@@ -1,14 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_queue SET running = false, started_at = null\n WHERE id = $1 AND canceled_by IS NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "3e55d027327bd3c76810fbe22d3ccb1bbbf83c8cff69d8f5907d1417a2522e69"
}
@@ -1,27 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH step_index AS (\n SELECT idx::text AS idx\n FROM v2_job_status,\n jsonb_array_elements(flow_status->'modules') WITH ORDINALITY arr(elem, idx)\n WHERE id = $1\n AND elem->>'id' = $5\n LIMIT 1\n ), completed AS (\n INSERT INTO v2_job_completed\n (workspace_id, id, started_at, duration_ms, result,\n flow_status, workflow_as_code_status, status, worker)\n SELECT\n q.workspace_id, q.id, q.started_at,\n (EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000,\n $3::text::jsonb,\n CASE WHEN si.idx IS NOT NULL\n THEN jsonb_set(\n s.flow_status,\n ARRAY['modules', (si.idx::int - 1)::text],\n $6::jsonb\n )\n ELSE s.flow_status\n END,\n s.workflow_as_code_status,\n 'skipped'::job_status,\n q.worker\n FROM v2_job_queue q\n LEFT JOIN v2_job_status s ON s.id = q.id\n LEFT JOIN step_index si ON true\n WHERE q.id = $1\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = EXCLUDED.result\n RETURNING 1 AS x\n ), _deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n ), _logged AS (\n INSERT INTO job_logs (logs, job_id, workspace_id)\n VALUES ($4, $1, $2)\n ON CONFLICT (job_id) DO UPDATE SET logs = concat(job_logs.logs, EXCLUDED.logs)\n )\n SELECT x FROM completed\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "x",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Text",
"Text",
"Text",
"Jsonb"
]
},
"nullable": [
null
]
},
"hash": "4461fe84370e7f07b4423ea5e71b913dd6ee9c655f9ec7087cc0fec9b9c3099a"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings\n SET operator_settings = COALESCE(operator_settings, '{}'::jsonb) || $1\n WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Text"
]
},
"nullable": []
},
"hash": "4515bdeded3b40abbdcc554c9562cce3225d2b66dc9fe23b1fa59cfaa120cd30"
}
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE((operator_settings->>'manage_schedules')::boolean, true) AS \"schedules!\",\n COALESCE((operator_settings->>'manage_triggers')::boolean, true) AS \"triggers!\"\n FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "schedules!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "triggers!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null,
null
]
},
"hash": "457575e0858db5747964d4b2a69d3506774e50f871963da9697c7035def2aedd"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_status\n SET flow_status = JSONB_SET(flow_status, ARRAY['step'], $1)\n WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Uuid"
]
},
"nullable": []
},
"hash": "4622d28e2fa09bc60b9d0c79397efe0ca030638ded82c2ffd2155cacdf36ec11"
}
@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "WITH pending AS MATERIALIZED (\n SELECT q.id, q.scheduled_for FROM v2_job_queue q\n WHERE q.workspace_id = $1 AND q.tag = $2\n AND q.running = false\n AND q.canceled_by IS NULL\n AND q.scheduled_for <= now() - $3::bigint * interval '1 second'\n )\n SELECT p.id FROM pending p JOIN v2_job j ON j.id = p.id\n WHERE (j.parent_job IS NULL\n OR NOT EXISTS (SELECT 1 FROM v2_job_queue pq WHERE pq.id = j.parent_job))\n ORDER BY p.scheduled_for\n LIMIT $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "499dc116f9916af49322461285f698e7fd3d3185206ae48f72445ce78299d9b1"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n j.id AS \"id!\", j.workspace_id AS \"workspace_id!\", j.parent_job, j.flow_step_id IS NOT NULL AS \"is_flow_step?\",\n COALESCE(s.flow_status, s.workflow_as_code_status)::text AS \"flow_status: Box<str>\", r.ping AS last_ping, j.same_worker AS \"same_worker?\",\n q.worker AS \"worker?\",\n wp.ping_at AS \"worker_last_ping?\",\n wp.memory_usage AS \"worker_memory_usage?\",\n wp.wm_memory_usage AS \"worker_wm_memory_usage?\",\n wp.memory AS \"worker_memory_total?\",\n wp.worker_group AS \"worker_group?\",\n wp.wm_version AS \"worker_version?\",\n wp.current_job_id AS \"worker_current_job_id?\",\n wp.worker_instance AS \"worker_instance?\"\n FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)\n LEFT JOIN worker_ping wp ON wp.worker = q.worker\n WHERE q.running = true AND q.suspend = 0 AND q.suspend_until IS null AND q.scheduled_for <= now()\n AND (j.kind = 'flow' OR j.kind = 'flowpreview' OR j.kind = 'flownode' OR j.kind = 'singlestepflow')\n AND r.ping IS NOT NULL AND r.ping < NOW() - ($1 || ' seconds')::interval\n AND q.canceled_by IS NULL\n\n ",
"query": "\n SELECT\n j.id AS \"id!\", j.workspace_id AS \"workspace_id!\", j.parent_job, j.flow_step_id IS NOT NULL AS \"is_flow_step?\",\n COALESCE(s.flow_status, s.workflow_as_code_status)::text AS \"flow_status: Box<str>\", r.ping AS last_ping, j.same_worker AS \"same_worker?\",\n q.worker AS \"worker?\",\n wp.ping_at AS \"worker_last_ping?\",\n wp.memory_usage AS \"worker_memory_usage?\",\n wp.wm_memory_usage AS \"worker_wm_memory_usage?\",\n wp.memory AS \"worker_memory_total?\",\n wp.worker_group AS \"worker_group?\",\n wp.wm_version AS \"worker_version?\",\n wp.current_job_id AS \"worker_current_job_id?\",\n wp.worker_instance AS \"worker_instance?\",\n q.canceled_by AS \"canceled_by?\", q.canceled_reason AS \"canceled_reason?\"\n FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)\n LEFT JOIN worker_ping wp ON wp.worker = q.worker\n WHERE q.running = true AND q.suspend = 0 AND q.suspend_until IS null AND q.scheduled_for <= now()\n AND (j.kind = 'flow' OR j.kind = 'flowpreview' OR j.kind = 'flownode' OR j.kind = 'singlestepflow')\n AND r.ping IS NOT NULL AND r.ping < NOW() - ($1 || ' seconds')::interval\n AND (q.canceled_by IS NULL OR r.ping < NOW() - ($1 || ' seconds')::interval * 10)\n\n ",
"describe": {
"columns": [
{
@@ -82,6 +82,16 @@
"ordinal": 15,
"name": "worker_instance?",
"type_info": "Varchar"
},
{
"ordinal": 16,
"name": "canceled_by?",
"type_info": "Varchar"
},
{
"ordinal": 17,
"name": "canceled_reason?",
"type_info": "Text"
}
],
"parameters": {
@@ -105,8 +115,10 @@
false,
false,
true,
false
false,
true,
true
]
},
"hash": "1bf87fe9667f860c6089bffb803291f4ff5c979be956369fa4406d789cca92fc"
"hash": "4f0b93160853a64c618872abbe934e4bcee02d8a9101cc7c1518ca73459c1fca"
}
@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "SELECT c.id IS NOT NULL AS \"completed!\",\n c.result AS \"result: sqlx::types::Json<Box<RawValue>>\",\n c.status = 'success' AS \"success\",\n EXISTS(SELECT 1 FROM v2_job_queue WHERE id = $1) AS \"queued!\"\n FROM (SELECT 1) one\n LEFT JOIN v2_job_completed c ON c.id = $1 AND c.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "completed!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "result: sqlx::types::Json<Box<RawValue>>",
"type_info": "Jsonb"
},
{
"ordinal": 2,
"name": "success",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "queued!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
null,
true,
null,
null
]
},
"hash": "52f9582423689f098a337f88341239a136ee5132bc97f4b06cc1ee5beaa621e5"
}
@@ -0,0 +1,95 @@
{
"db_name": "PostgreSQL",
"query": "WITH norm AS (\n SELECT\n j.id, j.workspace_id, j.runnable_path, j.runnable_id, j.kind, j.args, j.created_by,\n -- Project effective kind for dispatch: pass script/flow through;\n -- for singlestepflow, read the wrapped runnable's type from\n -- raw_flow.modules[id='a'].value.type (always 'script' or 'flow').\n CASE\n WHEN j.kind IN ('script', 'flow') THEN j.kind::text\n WHEN j.kind = 'singlestepflow' THEN\n COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' = 'a'\n LIMIT 1),\n 'script'\n )\n END AS norm_kind,\n -- Pinned script hash for script-wrapped singlestepflow lives in\n -- raw_flow.modules[id='a'].value.hash. Flow-wrapped doesn't pin a\n -- version, so this is NULL there (Flow rerun pushes by path).\n (CASE WHEN j.kind = 'singlestepflow' THEN\n (SELECT ('x' || lpad(m->'value'->>'hash', 16, '0'))::bit(64)::bigint\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' = 'a'\n AND m->'value'->>'hash' IS NOT NULL\n LIMIT 1)\n END) AS ssf_hash\n FROM v2_job j\n WHERE j.id = ANY($1)\n AND j.workspace_id = $2\n AND j.kind IN ('script', 'flow', 'singlestepflow')\n )\n SELECT\n n.id,\n n.norm_kind::JOB_KIND AS \"kind!: _\",\n COALESCE(s.path, f.path, n.runnable_path) AS \"script_path!\",\n -- script_hash is unused on the Flow rerun path (path-based push), so\n -- 0 is a safe placeholder when no version is pinned.\n COALESCE(s.hash, f.id, n.ssf_hash, 0::bigint) AS \"script_hash!: _\",\n COALESCE(jc.started_at, jq.scheduled_for, make_date(1970, 1, 1)) AS \"scheduled_for!: _\",\n n.args AS input,\n n.created_by,\n -- Pinned schema for script/flow; latest-by-path fallback for\n -- singlestepflow so input_transforms still resolve at rerun time.\n COALESCE(\n s.schema,\n f.schema,\n (CASE WHEN n.kind = 'singlestepflow' AND n.norm_kind = 'script' THEN\n (SELECT s2.schema FROM script s2\n WHERE s2.workspace_id = $2 AND s2.path = n.runnable_path\n ORDER BY s2.created_at DESC LIMIT 1)\n END),\n (CASE WHEN n.kind = 'singlestepflow' AND n.norm_kind = 'flow' THEN\n (SELECT fv.schema FROM flow\n LEFT JOIN flow_version fv ON fv.id = flow.versions[array_upper(flow.versions, 1)]\n WHERE flow.workspace_id = $2 AND flow.path = n.runnable_path)\n END)\n ) AS \"schema: _\"\n FROM norm n\n LEFT JOIN script s ON s.hash = n.runnable_id AND n.kind = 'script'\n LEFT JOIN flow_version f ON f.id = n.runnable_id AND f.path = n.runnable_path AND n.kind = 'flow'\n LEFT JOIN v2_job_completed jc ON jc.id = n.id\n LEFT JOIN v2_job_queue jq ON jq.id = n.id\n WHERE n.norm_kind IS NOT NULL\n AND COALESCE(s.path, f.path, n.runnable_path) IS NOT NULL\n AND (\n n.kind = 'singlestepflow'\n OR (COALESCE(s.hash, f.id) IS NOT NULL AND COALESCE(s.path, f.path) IS NOT NULL)\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "kind!: _",
"type_info": {
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlestepflow",
"flowscript",
"flownode",
"appscript",
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow"
]
}
}
}
},
{
"ordinal": 2,
"name": "script_path!",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "script_hash!: _",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "scheduled_for!: _",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "input",
"type_info": "Jsonb"
},
{
"ordinal": 6,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "schema: _",
"type_info": "Json"
}
],
"parameters": {
"Left": [
"UuidArray",
"Text"
]
},
"nullable": [
false,
null,
null,
null,
null,
true,
false,
null
]
},
"hash": "535882853f7fd4365134c5caf1ca987970808b8b73a65e2bf0b3b209eae5b63a"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE(c.duration_ms, COALESCE((EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000, 0)::bigint) AS \"duration_ms!\"\n FROM v2_job_queue q LEFT JOIN v2_job_completed c ON c.id = q.id WHERE q.id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "duration_ms!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "538f12535bd1b93a3a9baac91dc0d4ee0f3c949a00f62d38686e75d22e9d7884"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE workspace_settings SET operator_settings = $1 WHERE workspace_id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Text"
]
},
"nullable": []
},
"hash": "597b89889c3820fe7986b834c0e5a0652d6a72024a0c17f5271add38168d1ab3"
}
@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_status\n SET flow_status = flow_status - 'retry'\n WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": []
},
"hash": "5ffba9baef525e1e46e9a258e39614fe8c00a264667ce48fb1f0b45662cef43d"
}
@@ -0,0 +1,35 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, canceled_reason AS \"reason!\" FROM v2_job_queue\n WHERE running = false AND canceled_by = 'monitor' AND canceled_reason LIKE $1 || '%'\n LIMIT $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "reason!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "623c3d92f4398ba4f88d1b4178134498bfa578b1fd459e1f730a2e06688aa142"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COALESCE(c.duration_ms, COALESCE($2::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(q.started_at, now()))))*1000)::bigint) AS \"duration_ms!\"\n FROM v2_job_queue q LEFT JOIN v2_job_completed c ON c.id = q.id WHERE q.id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "duration_ms!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid",
"Int8"
]
},
"nullable": [
null
]
},
"hash": "67bde191639648d1f225a046eac57f4055900fdf0b1f65979deb1c1c64092a8e"
}
@@ -1,89 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "WITH norm AS (\n SELECT\n j.id, j.workspace_id, j.runnable_path, j.runnable_id, j.kind, j.args,\n -- Project effective kind for dispatch: pass script/flow through;\n -- for singlestepflow, read the wrapped runnable's type from\n -- raw_flow.modules[id='a'].value.type (always 'script' or 'flow').\n CASE\n WHEN j.kind IN ('script', 'flow') THEN j.kind::text\n WHEN j.kind = 'singlestepflow' THEN\n COALESCE(\n (SELECT m->'value'->>'type'\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' = 'a'\n LIMIT 1),\n 'script'\n )\n END AS norm_kind,\n -- Pinned script hash for script-wrapped singlestepflow lives in\n -- raw_flow.modules[id='a'].value.hash. Flow-wrapped doesn't pin a\n -- version, so this is NULL there (Flow rerun pushes by path).\n (CASE WHEN j.kind = 'singlestepflow' THEN\n (SELECT ('x' || lpad(m->'value'->>'hash', 16, '0'))::bit(64)::bigint\n FROM jsonb_array_elements(j.raw_flow->'modules') m\n WHERE m->>'id' = 'a'\n AND m->'value'->>'hash' IS NOT NULL\n LIMIT 1)\n END) AS ssf_hash\n FROM v2_job j\n WHERE j.id = ANY($1)\n AND j.workspace_id = $2\n AND j.kind IN ('script', 'flow', 'singlestepflow')\n )\n SELECT\n n.id,\n n.norm_kind::JOB_KIND AS \"kind!: _\",\n COALESCE(s.path, f.path, n.runnable_path) AS \"script_path!\",\n -- script_hash is unused on the Flow rerun path (path-based push), so\n -- 0 is a safe placeholder when no version is pinned.\n COALESCE(s.hash, f.id, n.ssf_hash, 0::bigint) AS \"script_hash!: _\",\n COALESCE(jc.started_at, jq.scheduled_for, make_date(1970, 1, 1)) AS \"scheduled_for!: _\",\n n.args AS input,\n -- Pinned schema for script/flow; latest-by-path fallback for\n -- singlestepflow so input_transforms still resolve at rerun time.\n COALESCE(\n s.schema,\n f.schema,\n (CASE WHEN n.kind = 'singlestepflow' AND n.norm_kind = 'script' THEN\n (SELECT s2.schema FROM script s2\n WHERE s2.workspace_id = $2 AND s2.path = n.runnable_path\n ORDER BY s2.created_at DESC LIMIT 1)\n END),\n (CASE WHEN n.kind = 'singlestepflow' AND n.norm_kind = 'flow' THEN\n (SELECT fv.schema FROM flow\n LEFT JOIN flow_version fv ON fv.id = flow.versions[array_upper(flow.versions, 1)]\n WHERE flow.workspace_id = $2 AND flow.path = n.runnable_path)\n END)\n ) AS \"schema: _\"\n FROM norm n\n LEFT JOIN script s ON s.hash = n.runnable_id AND n.kind = 'script'\n LEFT JOIN flow_version f ON f.id = n.runnable_id AND f.path = n.runnable_path AND n.kind = 'flow'\n LEFT JOIN v2_job_completed jc ON jc.id = n.id\n LEFT JOIN v2_job_queue jq ON jq.id = n.id\n WHERE n.norm_kind IS NOT NULL\n AND COALESCE(s.path, f.path, n.runnable_path) IS NOT NULL\n AND (\n n.kind = 'singlestepflow'\n OR (COALESCE(s.hash, f.id) IS NOT NULL AND COALESCE(s.path, f.path) IS NOT NULL)\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "kind!: _",
"type_info": {
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlestepflow",
"flowscript",
"flownode",
"appscript",
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow"
]
}
}
}
},
{
"ordinal": 2,
"name": "script_path!",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "script_hash!: _",
"type_info": "Int8"
},
{
"ordinal": 4,
"name": "scheduled_for!: _",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "input",
"type_info": "Jsonb"
},
{
"ordinal": 6,
"name": "schema: _",
"type_info": "Json"
}
],
"parameters": {
"Left": [
"UuidArray",
"Text"
]
},
"nullable": [
false,
null,
null,
null,
null,
true,
null
]
},
"hash": "6cda14bc33e3144b78b2147ac1153c516b5962b5648ae07c247fbd57b1b16ada"
}
@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_runtime r SET\n ping = now()\n FROM v2_job_queue q\n WHERE r.id = $1 AND q.id = r.id\n AND q.workspace_id = $2\n AND canceled_by IS NULL",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": []
},
"hash": "7d07a717533bfcaf581f6655bc387095542490fbb4aae30ec7fa75c2dae98ec8"
}
@@ -0,0 +1,31 @@
{
"db_name": "PostgreSQL",
"query": "WITH deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason\n ), completed AS (\n INSERT INTO v2_job_completed AS cj\n ( workspace_id\n , id\n , started_at\n , duration_ms\n , result\n , result_columns\n , canceled_by\n , canceled_reason\n , flow_status\n , workflow_as_code_status\n , memory_peak\n , status\n , worker\n )\n SELECT d.workspace_id, d.id, d.started_at,\n COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(d.started_at, now()))))*1000),\n $3::text::jsonb, $10,\n CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,\n CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,\n s.flow_status, s.workflow_as_code_status, $8,\n CASE WHEN $4::BOOL OR d.canceled_by IS NOT NULL THEN 'canceled'::job_status\n WHEN $7::BOOL THEN 'skipped'::job_status\n WHEN $2::BOOL THEN 'success'::job_status\n ELSE 'failure'::job_status END,\n d.worker\n FROM deleted d LEFT JOIN v2_job_status s ON s.id = d.id\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb,\n canceled_by = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,\n canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END\n RETURNING duration_ms\n )\n SELECT duration_ms AS \"duration_ms!\" FROM completed",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "duration_ms!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid",
"Bool",
"Text",
"Bool",
"Varchar",
"Text",
"Bool",
"Int4",
"Int8",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "9212902d4eb2834ab3b429968a7a85c53c49e4ef0f42c146324d5c71fbd1e21d"
}
@@ -0,0 +1,33 @@
{
"db_name": "PostgreSQL",
"query": "WITH deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1\n RETURNING id, workspace_id, started_at, worker, canceled_by, canceled_reason\n ), completed AS (\n INSERT INTO v2_job_completed AS cj\n ( workspace_id\n , id\n , started_at\n , duration_ms\n , result\n , result_columns\n , canceled_by\n , canceled_reason\n , flow_status\n , workflow_as_code_status\n , memory_peak\n , status\n , worker\n )\n SELECT d.workspace_id, d.id, d.started_at,\n COALESCE($9::bigint, (EXTRACT('epoch' FROM (now())) - EXTRACT('epoch' FROM (COALESCE(d.started_at, now()))))*1000),\n $3::text::jsonb, $10,\n CASE WHEN $4::BOOL THEN $5 ELSE d.canceled_by END,\n CASE WHEN $4::BOOL THEN $6 WHEN d.canceled_by IS NOT NULL THEN d.canceled_reason END,\n s.flow_status, s.workflow_as_code_status, $8,\n CASE WHEN $4::BOOL OR d.canceled_by IS NOT NULL THEN 'canceled'::job_status\n WHEN $7::BOOL THEN 'skipped'::job_status\n WHEN $2::BOOL THEN 'success'::job_status\n ELSE 'failure'::job_status END,\n d.worker\n FROM deleted d LEFT JOIN v2_job_status s ON s.id = d.id\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = $3::text::jsonb,\n canceled_by = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_by ELSE cj.canceled_by END,\n canceled_reason = CASE WHEN NOT $4::BOOL AND EXCLUDED.canceled_by IS NOT NULL\n THEN EXCLUDED.canceled_reason ELSE cj.canceled_reason END\n RETURNING duration_ms\n ), parent_ping AS (\n UPDATE v2_job_runtime r SET ping = now()\n FROM v2_job_queue q\n WHERE r.id = $11 AND q.id = r.id AND q.workspace_id = $12\n AND EXISTS (SELECT 1 FROM completed)\n )\n SELECT duration_ms AS \"duration_ms!\" FROM completed",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "duration_ms!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Uuid",
"Bool",
"Text",
"Bool",
"Varchar",
"Text",
"Bool",
"Int4",
"Int8",
"TextArray",
"Uuid",
"Text"
]
},
"nullable": [
false
]
},
"hash": "975158ae72ec12f3f933fba1d9623bf1c925feebe746299cd1f0eda3f8b949d5"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_completed SET status = 'canceled'::job_status, canceled_by = $2, canceled_reason = $3 WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Text"
]
},
"nullable": []
},
"hash": "9a2345e35c9f44578aa7dde994fdde0be9e0d192e0aee224bff78d8bd2ccbd8b"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO zombie_job_counter (job_id, counter) VALUES ($1, 1)\n ON CONFLICT (job_id) DO UPDATE SET counter = zombie_job_counter.counter + 1\n RETURNING counter",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "counter",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false
]
},
"hash": "9d8e3b978d746432d336900282602f0ce092d50783d8b3ea17b7e6121f207b92"
}
@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_status\n SET flow_status = JSONB_SET(flow_status, ARRAY['step'], $1)\n WHERE id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Jsonb",
"Uuid"
]
},
"nullable": []
},
"hash": "9fb1a1e4e39e0e8a0934fcda7b3e6e6180a48ab792d305d04146e81b06b0d69b"
}
@@ -0,0 +1,325 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_status SET\n flow_status = JSONB_SET(\n JSONB_SET(v2_job_status.flow_status, $2::TEXT[], $3),\n $4::TEXT[], $5\n ) - $6::TEXT[],\n flow_leaf_jobs = CASE\n WHEN $7::TEXT IS NULL\n OR COALESCE(v2_job.flow_innermost_root_job, v2_job_status.id) <> v2_job_status.id\n THEN v2_job_status.flow_leaf_jobs\n ELSE JSONB_SET(COALESCE(v2_job_status.flow_leaf_jobs, '{}'::JSONB), ARRAY[$7::TEXT], $8) END\n FROM v2_job_queue INNER JOIN v2_job ON v2_job.id = v2_job_queue.id\n WHERE v2_job_status.id = $1 AND v2_job_queue.id = $1\n RETURNING\n v2_job_queue.workspace_id,\n v2_job_queue.id,\n v2_job.args as \"args: sqlx::types::Json<HashMap<String, Box<RawValue>>>\",\n v2_job.parent_job,\n v2_job.created_by,\n v2_job_queue.started_at,\n v2_job_queue.runnable_settings_handle,\n v2_job_queue.scheduled_for,\n v2_job.runnable_path,\n v2_job.kind as \"kind: JobKind\",\n v2_job.runnable_id as \"runnable_id: ScriptHash\",\n v2_job_queue.canceled_reason,\n v2_job_queue.canceled_by,\n v2_job.permissioned_as,\n v2_job.permissioned_as_email,\n v2_job_status.flow_status as \"flow_status: sqlx::types::Json<Box<RawValue>>\",\n v2_job.tag,\n v2_job.script_lang as \"script_lang: ScriptLang\",\n v2_job.same_worker,\n v2_job.pre_run_error,\n v2_job.concurrent_limit,\n v2_job.concurrency_time_window_s,\n v2_job.flow_innermost_root_job,\n v2_job.root_job,\n v2_job.timeout,\n v2_job.flow_step_id,\n v2_job.cache_ttl,\n v2_job_queue.cache_ignore_s3_path,\n v2_job_queue.priority,\n v2_job.preprocessed,\n v2_job.script_entrypoint_override,\n v2_job.trigger,\n v2_job.trigger_kind as \"trigger_kind: TriggerKindLabel\",\n v2_job.visible_to_owner,\n NULL as permissioned_as_end_user_email",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "args: sqlx::types::Json<HashMap<String, Box<RawValue>>>",
"type_info": "Jsonb"
},
{
"ordinal": 3,
"name": "parent_job",
"type_info": "Uuid"
},
{
"ordinal": 4,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "started_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "runnable_settings_handle",
"type_info": "Int8"
},
{
"ordinal": 7,
"name": "scheduled_for",
"type_info": "Timestamptz"
},
{
"ordinal": 8,
"name": "runnable_path",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "kind: JobKind",
"type_info": {
"Custom": {
"name": "job_kind",
"kind": {
"Enum": [
"script",
"preview",
"flow",
"dependencies",
"flowpreview",
"script_hub",
"identity",
"flowdependencies",
"http",
"graphql",
"postgresql",
"noop",
"appdependencies",
"deploymentcallback",
"singlestepflow",
"flowscript",
"flownode",
"appscript",
"aiagent",
"unassigned_script",
"unassigned_flow",
"unassigned_singlestepflow"
]
}
}
}
},
{
"ordinal": 10,
"name": "runnable_id: ScriptHash",
"type_info": "Int8"
},
{
"ordinal": 11,
"name": "canceled_reason",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "canceled_by",
"type_info": "Varchar"
},
{
"ordinal": 13,
"name": "permissioned_as",
"type_info": "Varchar"
},
{
"ordinal": 14,
"name": "permissioned_as_email",
"type_info": "Varchar"
},
{
"ordinal": 15,
"name": "flow_status: sqlx::types::Json<Box<RawValue>>",
"type_info": "Jsonb"
},
{
"ordinal": 16,
"name": "tag",
"type_info": "Varchar"
},
{
"ordinal": 17,
"name": "script_lang: ScriptLang",
"type_info": {
"Custom": {
"name": "script_lang",
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash",
"postgresql",
"nativets",
"bun",
"mysql",
"bigquery",
"snowflake",
"graphql",
"powershell",
"mssql",
"php",
"bunnative",
"rust",
"ansible",
"csharp",
"oracledb",
"nu",
"java",
"duckdb",
"ruby",
"rlang",
"dbt"
]
}
}
}
},
{
"ordinal": 18,
"name": "same_worker",
"type_info": "Bool"
},
{
"ordinal": 19,
"name": "pre_run_error",
"type_info": "Text"
},
{
"ordinal": 20,
"name": "concurrent_limit",
"type_info": "Int4"
},
{
"ordinal": 21,
"name": "concurrency_time_window_s",
"type_info": "Int4"
},
{
"ordinal": 22,
"name": "flow_innermost_root_job",
"type_info": "Uuid"
},
{
"ordinal": 23,
"name": "root_job",
"type_info": "Uuid"
},
{
"ordinal": 24,
"name": "timeout",
"type_info": "Int4"
},
{
"ordinal": 25,
"name": "flow_step_id",
"type_info": "Varchar"
},
{
"ordinal": 26,
"name": "cache_ttl",
"type_info": "Int4"
},
{
"ordinal": 27,
"name": "cache_ignore_s3_path",
"type_info": "Bool"
},
{
"ordinal": 28,
"name": "priority",
"type_info": "Int2"
},
{
"ordinal": 29,
"name": "preprocessed",
"type_info": "Bool"
},
{
"ordinal": 30,
"name": "script_entrypoint_override",
"type_info": "Varchar"
},
{
"ordinal": 31,
"name": "trigger",
"type_info": "Varchar"
},
{
"ordinal": 32,
"name": "trigger_kind: TriggerKindLabel",
"type_info": {
"Custom": {
"name": "job_trigger_kind",
"kind": {
"Enum": [
"webhook",
"http",
"websocket",
"kafka",
"email",
"nats",
"schedule",
"app",
"ui",
"postgres",
"sqs",
"gcp",
"mqtt",
"nextcloud",
"google",
"ci_test",
"github",
"azure",
"asset",
"freshness",
"amqp"
]
}
}
}
},
{
"ordinal": 33,
"name": "visible_to_owner",
"type_info": "Bool"
},
{
"ordinal": 34,
"name": "permissioned_as_end_user_email",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid",
"TextArray",
"Jsonb",
"TextArray",
"Jsonb",
"TextArray",
"Text",
"Jsonb"
]
},
"nullable": [
false,
false,
true,
true,
false,
true,
true,
false,
true,
false,
true,
true,
true,
false,
false,
true,
false,
true,
false,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
true,
false,
null
]
},
"hash": "ab05c1359039ae99a2aa6156eb2a208814ce37fcde72e8ef61904a4f41fb4579"
}
@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_status\n SET flow_leaf_jobs = JSONB_SET(coalesce(flow_leaf_jobs, '{}'::jsonb), ARRAY[$1::TEXT], $2)\n WHERE COALESCE((SELECT flow_innermost_root_job FROM v2_job WHERE id = $3), $3) = id",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Jsonb",
"Uuid"
]
},
"nullable": []
},
"hash": "aed8bd751c3e988f422216e74acfb77dc03469355d2a0da0b2d6b4aeeea37d3e"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT q.id FROM v2_job_queue q JOIN v2_job_runtime r ON r.id = q.id\n WHERE q.id = $1 AND q.running AND r.ping = $2\n FOR UPDATE OF q SKIP LOCKED",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Timestamptz"
]
},
"nullable": [
false
]
},
"hash": "bf2a8883c3a9d57ac56b347dccd26eaa4d92e36965e7f069a0107647a2bfff8b"
}
@@ -0,0 +1,27 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH step_index AS (\n SELECT idx::text AS idx\n FROM v2_job_status,\n jsonb_array_elements(flow_status->'modules') WITH ORDINALITY arr(elem, idx)\n WHERE id = $1\n AND elem->>'id' = $5\n LIMIT 1\n ), target AS (\n -- The queue row is locked before the completed row's key, as every completion does.\n SELECT id FROM v2_job_queue WHERE id = $1 FOR UPDATE\n ), completed AS (\n INSERT INTO v2_job_completed\n (workspace_id, id, started_at, duration_ms, result,\n flow_status, workflow_as_code_status, status, worker)\n SELECT\n q.workspace_id, q.id, q.started_at,\n (EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000,\n $3::text::jsonb,\n CASE WHEN si.idx IS NOT NULL\n THEN jsonb_set(\n s.flow_status,\n ARRAY['modules', (si.idx::int - 1)::text],\n $6::jsonb\n )\n ELSE s.flow_status\n END,\n s.workflow_as_code_status,\n 'skipped'::job_status,\n q.worker\n FROM v2_job_queue q\n LEFT JOIN v2_job_status s ON s.id = q.id\n LEFT JOIN step_index si ON true\n WHERE q.id IN (SELECT id FROM target)\n ON CONFLICT (id) DO UPDATE SET status = EXCLUDED.status, result = EXCLUDED.result\n RETURNING 1 AS x\n ), _deleted AS (\n DELETE FROM v2_job_queue WHERE id IN (SELECT id FROM target)\n ), _logged AS (\n INSERT INTO job_logs (logs, job_id, workspace_id)\n VALUES ($4, $1, $2)\n ON CONFLICT (job_id) DO UPDATE SET logs = concat(job_logs.logs, EXCLUDED.logs)\n )\n SELECT x FROM completed\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "x",
"type_info": "Int4"
}
],
"parameters": {
"Left": [
"Uuid",
"Varchar",
"Text",
"Text",
"Text",
"Jsonb"
]
},
"nullable": [
null
]
},
"hash": "c4254f50fc6f74aae9e23b461a6c69c181eba0210cc068b64e67b0a518fe1cba"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS (SELECT 1 FROM alerts\n WHERE alert_type = 'critical_error' AND resource = $1\n AND created_at > now() - $2::bigint * interval '1 second') AS \"exists!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": [
null
]
},
"hash": "c538f81b9622356edecea467f72d4dd24d13b3ddb380634e6ed4a3af56090b78"
}
@@ -0,0 +1,47 @@
{
"db_name": "PostgreSQL",
"query": "SELECT q.scheduled_for, s.path, s.schedule, s.cron_version, s.timezone\n FROM v2_job j\n JOIN v2_job_queue q USING (id)\n JOIN schedule s ON s.workspace_id = j.workspace_id AND s.path = j.trigger\n WHERE j.id = $1 AND j.workspace_id = $2 AND j.trigger_kind = 'schedule'\n AND j.parent_job IS NULL AND q.scheduled_for > now()",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "scheduled_for",
"type_info": "Timestamptz"
},
{
"ordinal": 1,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "schedule",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "cron_version",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "timezone",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false,
false,
false,
true,
false
]
},
"hash": "c703da3c3e133536dda9721644561af624ab6de67a5c82967f4df8117fe50bc7"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_status SET\n flow_status = jsonb_set(\n jsonb_set(flow_status, ARRAY['modules', $3::INTEGER::TEXT, 'job'], to_jsonb($1::UUID::TEXT)),\n ARRAY['modules', $3::INTEGER::TEXT, 'type'],\n to_jsonb('InProgress'::text)\n )\n WHERE id = $2",
"query": "UPDATE v2_job_status SET\n flow_status = jsonb_set(\n jsonb_set(flow_status, ARRAY['modules', $3::INTEGER::TEXT, 'job'], to_jsonb($1::UUID::TEXT)),\n ARRAY['modules', $3::INTEGER::TEXT, 'type'],\n to_jsonb('InProgress'::text)\n )\n WHERE id = $2\n AND NOT COALESCE(\n flow_status->'modules'->$3::INTEGER @> '{\"type\": \"InProgress\", \"parallel\": true}',\n false\n )",
"describe": {
"columns": [],
"parameters": {
@@ -12,5 +12,5 @@
},
"nullable": []
},
"hash": "5c141db5cd6806cc27696a38b7f56a190dbea3ff59e2961658dd1677365e0fdf"
"hash": "cbb59234f5a2d2e866aa6ceea8981bd3618b90615f7afab360dfbd6fb567b45b"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM flow_version WHERE id = $1 AND workspace_id = $2 AND path = $3) AS \"exists!\"",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Int8",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "cd8a933c10eefc76ad5be2316c83e413174d3ae4d639d50fc4a3344c02af3f95"
}
@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "WITH deleted AS (\n DELETE FROM v2_job_queue WHERE id = $1 RETURNING id, workspace_id, started_at, worker\n )\n INSERT INTO v2_job_completed\n (workspace_id, id, started_at, duration_ms, result, memory_peak, status, worker)\n SELECT d.workspace_id, d.id, d.started_at, $3, $2::jsonb, r.memory_peak,\n 'failure'::job_status, d.worker\n FROM deleted d\n LEFT JOIN v2_job_runtime r ON r.id = d.id\n ON CONFLICT (id) DO UPDATE SET status = 'failure', result = $2::jsonb\n RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Jsonb",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "d9b7e45213aebced9cebe8ce1278d38136bc73c3517d0e0fbad369666504930c"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM v2_job_queue WHERE id = $1 RETURNING canceled_by, canceled_reason",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "canceled_by",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "canceled_reason",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true,
true
]
},
"hash": "ed2eb32d445164e7ab5fb735e01f86505bc499320e08d1aad15a9a82c3823c0b"
}
+234 -227
View File
File diff suppressed because it is too large Load Diff
+4 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.817.0"
version = "1.819.0"
authors.workspace = true
edition.workspace = true
@@ -88,7 +88,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.817.0"
version = "1.819.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -511,6 +511,8 @@ quick-xml = { version = "^0.37", features = ["serialize"] }
url = { version = "^2" , features = ["serde"]}
async-oauth2 = "0.5.1"
reqwest = { version = "^0.13", features = ["json", "stream", "gzip", "multipart", "query", "form"] }
# The reqwest object_store builds its HTTP clients with, for a custom `HttpConnector`.
reqwest_object_store = { package = "reqwest", version = "0.12", default-features = false, features = ["rustls-tls-native-roots"] }
eventsource-stream = "0.2.3"
time = "^0"
serde_urlencoded = "^0"
+1 -1
View File
@@ -1 +1 @@
bf64ca98472a57cbfd150f8876877d2d4d9c217e
421cf2a8b4f98b421e93c0fc7c1c378314a66e50
@@ -128,11 +128,16 @@ def extract_separate_schemas(parameters: List[Dict[str, Any]], request_body: Opt
r for r in body_schema['required'] if r in include_fields
]
# Apply opaque_fields: simplify listed properties to {"type": "object"}
# Apply opaque_fields: simplify listed properties to {"type": "object"},
# keeping the description, which is all a caller learns about the field
if body_schema and opaque_fields and 'properties' in body_schema:
for field in opaque_fields:
if field in body_schema['properties']:
body_schema['properties'][field] = {"type": "object"}
opaque = {"type": "object"}
description = body_schema['properties'][field].get('description')
if description:
opaque['description'] = description
body_schema['properties'][field] = opaque
# If we have required fields specified and a body schema, update the required array
if body_schema and required_fields:
@@ -0,0 +1,2 @@
DROP TRIGGER IF EXISTS operator_settings_change_trigger ON workspace_settings;
DROP FUNCTION IF EXISTS notify_operator_settings_change();
@@ -0,0 +1,18 @@
-- The write rights in `operator_settings` are authorization decisions read through a per-process
-- cache. Without this, withdrawing one on a single API replica leaves every other replica
-- authorizing writes until its own entry expires.
CREATE OR REPLACE FUNCTION notify_operator_settings_change()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO notify_event (channel, payload)
VALUES ('notify_operator_settings_change', NEW.workspace_id);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
DROP TRIGGER IF EXISTS operator_settings_change_trigger ON workspace_settings;
CREATE TRIGGER operator_settings_change_trigger
AFTER UPDATE OF operator_settings ON workspace_settings
FOR EACH ROW
WHEN (OLD.operator_settings IS DISTINCT FROM NEW.operator_settings)
EXECUTE FUNCTION notify_operator_settings_change();
+81 -10
View File
@@ -16,11 +16,13 @@ use windmill_parser::{
use swc_common::{sync::Lrc, FileName, SourceMap, SourceMapper, Span, Spanned};
use swc_ecma_ast::{
ArrayLit, AssignPat, BigInt, BindingIdent, Bool, Decl, ExportDecl, Expr, Ident, IdentName, Lit,
MemberExpr, MemberProp, ModuleDecl, ModuleItem, Number, ObjectLit, ObjectPat, Param, Pat, Stmt,
Str, TsArrayType, TsEntityName, TsInterfaceDecl, TsKeywordType, TsKeywordTypeKind, TsLit,
TsLitType, TsOptionalType, TsParenthesizedType, TsPropertySignature, TsType, TsTypeAliasDecl,
TsTypeAnn, TsTypeElement, TsTypeLit, TsTypeRef, TsUnionOrIntersectionType, TsUnionType,
ArrayLit, AssignPat, BigInt, BindingIdent, Bool, Decl, ExportDecl, Expr, ExprOrSpread, Ident,
IdentName, KeyValueProp, Lit, MemberExpr, MemberProp, ModuleDecl, ModuleItem, Number,
ObjectLit, ObjectPat, Param, Pat, Prop, PropName, PropOrSpread, Stmt, Str, TsArrayType,
TsEntityName, TsInterfaceDecl, TsKeywordType, TsKeywordTypeKind, TsLit, TsLitType,
TsOptionalType, TsParenthesizedType, TsPropertySignature, TsType, TsTypeAliasDecl, TsTypeAnn,
TsTypeElement, TsTypeLit, TsTypeRef, TsUnionOrIntersectionType, TsUnionType, UnaryExpr,
UnaryOp,
};
use swc_ecma_parser::{lexer::Lexer, EsSyntax, Parser, StringInput, Syntax, TsSyntax};
@@ -578,6 +580,15 @@ fn parse_param(
}
};
let literal = match &*right {
Expr::Object(_) | Expr::Array(_) if !skip_dflt => literal_json(&right),
_ => None,
};
// Types an untyped object default that is not a plain literal (`{ a: x }`).
let literal_shape = match &*right {
Expr::Object(_) => Some(Typ::Object(ObjectType::new(None, None))),
_ => None,
};
let dflt = if skip_dflt {
None
} else {
@@ -593,14 +604,19 @@ fn parse_param(
Expr::Lit(Lit::Num(Number { value, .. })) => Some(serde_json::json!(value)),
Expr::Lit(Lit::BigInt(BigInt { value, .. })) => Some(serde_json::json!(value)),
Expr::Lit(Lit::Bool(Bool { value, .. })) => Some(Value::Bool(value)),
Expr::Object(ObjectLit { span, .. }) => eval_span(span, cm),
Expr::Array(ArrayLit { span, .. }) => eval_span(span, cm),
Expr::Object(ObjectLit { span, .. }) | Expr::Array(ArrayLit { span, .. }) => {
literal.or_else(|| eval_span(span, cm))
}
_ => None,
}
};
if typ == Typ::Unknown && dflt.is_some() {
typ = json_to_typ(dflt.as_ref().unwrap(), false);
if typ == Typ::Unknown {
match &dflt {
Some(d) => typ = json_to_typ(d, false),
None if !skip_dflt => typ = literal_shape.unwrap_or(Typ::Unknown),
None => {}
}
}
Ok(Arg {
otyp,
@@ -636,6 +652,59 @@ fn parse_param(
r
}
/// A default written as a plain literal, read off the AST: `['a']` and `{ a: 1 }` are
/// not JSON, and outside wasm nothing can evaluate them (see `eval_sync`).
fn literal_json(expr: &Expr) -> Option<Value> {
let num = |v: f64| {
if v.fract() == 0.0 && v.abs() < 9007199254740992.0 {
Some(serde_json::json!(v as i64))
} else {
serde_json::Number::from_f64(v).map(Value::Number)
}
};
match expr {
Expr::Lit(Lit::Str(s)) => Some(Value::String(s.value.to_string())),
Expr::Lit(Lit::Num(n)) => num(n.value),
Expr::Lit(Lit::Bool(b)) => Some(Value::Bool(b.value)),
Expr::Lit(Lit::Null(_)) => Some(Value::Null),
Expr::Unary(UnaryExpr { op: UnaryOp::Minus, arg, .. }) => match &**arg {
Expr::Lit(Lit::Num(n)) => num(-n.value),
_ => None,
},
Expr::Paren(p) => literal_json(&p.expr),
Expr::Array(a) => a
.elems
.iter()
.map(|e| match e {
Some(ExprOrSpread { spread: None, expr }) => literal_json(expr),
_ => None,
})
.collect::<Option<Vec<_>>>()
.map(Value::Array),
Expr::Object(o) => o
.props
.iter()
.map(|p| match p {
PropOrSpread::Prop(p) => match &**p {
Prop::KeyValue(KeyValueProp { key, value }) => {
let key = match key {
PropName::Ident(i) => i.sym.to_string(),
PropName::Str(s) => s.value.to_string(),
PropName::Num(n) => n.value.to_string(),
_ => return None,
};
Some((key, literal_json(value)?))
}
_ => None,
},
_ => None,
})
.collect::<Option<serde_json::Map<_, _>>>()
.map(Value::Object),
_ => None,
}
}
fn eval_span(span: Span, cm: &Lrc<SourceMap>) -> Option<Value> {
let expr = cm
.span_to_snippet(span)
@@ -1361,5 +1430,7 @@ pub fn eval_sync(code: &str) -> Result<serde_json::Value, String> {
#[cfg(not(target_arch = "wasm32"))]
pub fn eval_sync(_code: &str) -> Result<serde_json::Value, String> {
panic!("eval_sync is only available in wasm32")
// The server parses signatures with defaults too: a default that is not JSON
// (`{ a: 1 }`, `['x']`) is then left out rather than failing the parse.
Err("evaluating a JavaScript default needs the wasm build".to_string())
}
@@ -7,6 +7,38 @@ mod tests {
remove_pinned_import_specifiers,
};
/// The server parses defaults natively, where a non-JSON literal cannot be evaluated
/// but must still type and default the argument as the editor's wasm parse does.
#[test]
fn non_json_literal_defaults_parse_natively() {
let code =
"export function main(opts = { enabled: true, 'n': -1 }, tags = ['a'], o = { a: x }) {}";
let sig = parse_deno_signature(code, false, false, None).unwrap();
let defaults: Vec<_> = sig
.args
.iter()
.map(|a| (a.typ.clone(), a.default.clone()))
.collect();
assert_eq!(
defaults,
vec![
(
Typ::Object(ObjectType::new(
None,
Some(vec![
ObjectProperty::new("enabled".into(), Box::new(Typ::Bool)),
ObjectProperty::new("n".into(), Box::new(Typ::Int)),
])
)),
Some(json!({"enabled": true, "n": -1}))
),
(Typ::List(Box::new(Typ::Str(None))), Some(json!(["a"]))),
// Not a plain literal: typed by its shape, with no default value.
(Typ::Object(ObjectType::new(None, None)), None),
]
);
}
#[test]
fn test_imports_basic() {
let code = r#"
+24 -24
View File
@@ -6216,7 +6216,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6302,7 +6302,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6314,7 +6314,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"convert_case",
"serde",
@@ -6323,7 +6323,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6335,7 +6335,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6347,7 +6347,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6359,7 +6359,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6371,7 +6371,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6383,7 +6383,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6394,7 +6394,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6405,7 +6405,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6417,7 +6417,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6428,7 +6428,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6450,7 +6450,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6462,7 +6462,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6476,7 +6476,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6493,7 +6493,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6506,7 +6506,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"serde",
@@ -6518,7 +6518,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6536,7 +6536,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6552,7 +6552,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6568,7 +6568,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6600,7 +6600,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6614,7 +6614,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.817.0"
version = "1.819.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.817.0"
version = "1.819.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
@@ -0,0 +1,402 @@
use serde_json::{json, Map, Value};
use crate::{MainArgSignature, ObjectProperty, Typ};
/// The run-form schema of a script's `main`, derived the way the editor derives it
/// (`inferArgs` in the frontend, `argSigToJsonSchemaType` in `windmill-utils-internal`),
/// so a script deployed without a schema gets the one the editor would have sent.
/// `previous` is the schema of the version being superseded: as in the editor, it is
/// updated in place, so what the user set on it (argument order, descriptions, ...)
/// survives wherever the code still agrees with it.
pub fn main_arg_schema(sig: &MainArgSignature, previous: Option<&Value>) -> Value {
let mut schema = match previous {
Some(Value::Object(p)) => p.clone(),
_ => json!({
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
})
.as_object()
.cloned()
.unwrap_or_default(),
};
let mut old_props = match schema.remove("properties") {
Some(Value::Object(p)) => p,
_ => Map::new(),
};
let mut properties = Map::new();
let mut required = vec![];
for arg in &sig.args {
let mut prop = match old_props.remove(&arg.name) {
Some(Value::Object(p)) => p,
_ => fresh_property(),
};
if !matches!(arg.typ, Typ::OneOf(_)) {
prop.remove("oneOf");
}
apply_typ(&arg.typ, &mut prop);
// `T | T[]` marks an argument debouncing accumulates into.
if let Some(otyp) = arg
.otyp
.as_deref()
.filter(|o| o.contains('[') && o.contains('|'))
{
prop.insert("originalType".into(), json!(otyp));
}
match &arg.default {
Some(d) => prop.insert("default".into(), d.clone()),
None => prop.remove("default"),
};
if !arg.has_default {
required.push(json!(arg.name));
}
properties.insert(arg.name.clone(), Value::Object(prop));
}
schema.insert("properties".into(), Value::Object(properties));
schema.insert("required".into(), Value::Array(required));
if sig.has_cmd_binding == Some(true) {
schema.insert("x-windmill-ps-cmd-binding".into(), json!(true));
schema.insert(
"x-windmill-ps-supports-should-process".into(),
json!(sig.supports_should_process.unwrap_or(false)),
);
} else {
schema.remove("x-windmill-ps-cmd-binding");
schema.remove("x-windmill-ps-supports-should-process");
}
Value::Object(schema)
}
/// Keys a user sets on a property in the schema editor, kept whatever the code says.
const USER_FIELDS: &[&str] = &[
"description",
"pattern",
"min",
"max",
"currency",
"currencyLocale",
"multiselect",
"customErrorMessage",
"required",
"showExpr",
"password",
"order",
"dateFormat",
"title",
"placeholder",
];
/// Item keys a list the parser cannot see into keeps from the previous schema.
const ITEMS_USER_FIELDS: &[&str] = &[
"properties",
"required",
"additionalProperties",
"enum",
"resourceType",
"contentEncoding",
"description",
];
fn fresh_property() -> Map<String, Value> {
let mut p = Map::new();
p.insert("description".into(), json!(""));
p.insert("type".into(), json!(""));
p
}
fn object_properties(
props: &[ObjectProperty],
old: Option<&Map<String, Value>>,
) -> Map<String, Value> {
props
.iter()
.map(|p| {
let mut prop = old
.and_then(|o| o.get(&p.key))
.and_then(Value::as_object)
.cloned()
.unwrap_or_else(fresh_property);
apply_typ(&p.typ, &mut prop);
(p.key.clone(), Value::Object(prop))
})
.collect()
}
fn kept_items(old: &Map<String, Value>) -> Value {
let old_items = old.get("items").and_then(Value::as_object);
let mut items = Map::new();
items.insert(
"type".into(),
old_items
.and_then(|i| i.get("type"))
.filter(|t| t.as_str().is_some_and(|t| !t.is_empty()))
.cloned()
.unwrap_or(json!("object")),
);
if let Some(old_items) = old_items {
for f in ITEMS_USER_FIELDS {
if let Some(v) = old_items.get(*f) {
items.insert(f.to_string(), v.clone());
}
}
}
Value::Object(items)
}
/// Port of `argSigToJsonSchemaType`: rewrites `old` in place to carry `typ`.
fn apply_typ(typ: &Typ, old: &mut Map<String, Value>) {
let mut new = Map::new();
// Keys the editor sets to `undefined`, which drops them from the stored JSON.
let mut unset: Vec<&str> = vec![];
let mut set = |k: &str, v: Value| {
new.insert(k.to_string(), v);
};
match typ {
Typ::Int => set("type", json!("integer")),
Typ::Float => set("type", json!("number")),
Typ::Bool => set("type", json!("boolean")),
Typ::Email => {
set("type", json!("string"));
set("format", json!("email"));
}
Typ::Sql => {
set("type", json!("string"));
set("format", json!("sql"));
}
Typ::Bytes => {
set("type", json!("string"));
set("contentEncoding", json!("base64"));
set("originalType", json!("bytes"));
}
Typ::Datetime => {
set("type", json!("string"));
set("format", json!("date-time"));
}
Typ::Date => {
set("type", json!("string"));
set("format", json!("date"));
}
Typ::OneOf(variants) => {
set("type", json!("object"));
let old_variants = old.get("oneOf").and_then(Value::as_array);
let one_of: Vec<Value> = variants
.iter()
.map(|v| {
let old_variant = old_variants
.and_then(|o| o.iter().find(|o| o.get("title") == Some(&json!(v.label))))
.and_then(Value::as_object);
let old_props = old_variant
.and_then(|o| o.get("properties"))
.and_then(Value::as_object);
let mut variant = json!({
"type": "object",
"title": v.label,
"properties": object_properties(&v.properties, old_props),
});
if let Some(order) = old_variant.and_then(|o| o.get("order")) {
variant["order"] = order.clone();
}
variant
})
.collect();
set("oneOf", Value::Array(one_of));
}
Typ::Object(o) => {
set("type", json!("object"));
if let Some(name) = &o.name {
set("format", json!(format!("resource-{name}")));
}
if let Some(props) = &o.props {
let old_props = old.get("properties").and_then(Value::as_object);
set(
"properties",
Value::Object(object_properties(props, old_props)),
);
}
}
Typ::Str(variants) => {
set("type", json!("string"));
match variants {
Some(v) => {
set("originalType", json!("enum"));
set("enum", json!(v));
}
None => {
set("originalType", json!("string"));
match old.get("enum") {
Some(e) if old.get("originalType") == Some(&json!("string")) => {
set("enum", e.clone())
}
_ => unset.push("enum"),
}
}
}
}
Typ::Resource(name) => {
set("type", json!("object"));
set("format", json!(format!("resource-{name}")));
}
Typ::DynSelect(name) => {
set("type", json!("object"));
set("format", json!(format!("dynselect-{name}")));
}
Typ::DynMultiselect(name) => {
set("type", json!("object"));
set("format", json!(format!("dynmultiselect-{name}")));
}
Typ::List(inner) => {
set("type", json!("array"));
let (items, original_type) = match inner.as_ref() {
Typ::Int | Typ::Float => (json!({"type": "number"}), "number[]"),
Typ::Bytes => (
json!({"type": "string", "contentEncoding": "base64"}),
"bytes[]",
),
Typ::Str(Some(v)) => (json!({"type": "string", "enum": v}), "enum[]"),
Typ::Str(None) => {
let mut items = json!({"type": "string"});
if let Some(e) = old.get("items").and_then(|i| i.get("enum")) {
items["enum"] = e.clone();
}
(items, "string[]")
}
Typ::Resource(r) => (json!({"type": "resource", "resourceType": r}), "resource[]"),
Typ::Object(o) => {
if let Some(name) = &o.name {
set("format", json!(format!("resource-{name}")));
}
let items = match o.props.as_deref().filter(|p| !p.is_empty()) {
Some(props) => json!({
"type": "object",
"properties": object_properties(props, None),
}),
None => kept_items(old),
};
(items, "record[]")
}
_ => (kept_items(old), "object[]"),
};
set("items", items);
set("originalType", json!(original_type));
}
// The parser cannot tell: whatever type the user gave the argument stands.
Typ::Unknown => set("type", old.get("type").cloned().unwrap_or(json!("object"))),
}
for f in USER_FIELDS {
if let Some(v) = old.get(*f) {
new.insert(f.to_string(), v.clone());
}
}
let old_type = old.get("type").cloned();
let new_type = new.get("type").cloned();
let old_format = old
.get("format")
.and_then(Value::as_str)
.map(str::to_string);
let new_format = new
.get("format")
.and_then(Value::as_str)
.map(str::to_string);
let is_date = |f: &Option<String>| matches!(f.as_deref(), Some("date" | "date-time"));
let mut keep_old_format = false;
// A changed type takes every key `new` sets, so none of the adjustments below
// apply: running them would carry the old type's date format or items over.
if old_type != new_type {
} else if new_format.as_deref() == Some("date-time") && old_format.as_deref() == Some("date") {
new.insert("format".into(), json!("date"));
} else if is_date(&new_format) {
keep_old_format = true;
} else if old.get("items").and_then(|i| i.get("type"))
!= new.get("items").and_then(|i| i.get("type"))
{
old.remove("items");
} else if old_type == Some(json!("string")) && old_format.is_some() {
keep_old_format = true;
}
if keep_old_format {
match old.get("format") {
Some(f) => {
new.insert("format".into(), f.clone());
}
None => {
new.remove("format");
unset.push("format");
}
}
}
if (old_type != new_type || new_type != Some(json!("string"))) && !new.contains_key("format") {
old.remove("format");
}
for k in unset {
if !new.contains_key(k) {
old.remove(k);
}
}
old.extend(new);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Arg, ObjectType};
fn arg(name: &str, typ: Typ, default: Option<Value>) -> Arg {
Arg {
name: name.to_string(),
has_default: default.is_some(),
default,
typ,
..Default::default()
}
}
#[test]
fn keeps_what_the_user_set_on_the_previous_version() {
let sig = MainArgSignature {
args: vec![
arg("n", Typ::Int, None),
arg("db", Typ::Resource("postgresql".into()), None),
arg(
"tags",
Typ::List(Box::new(Typ::Str(None))),
Some(json!(["a"])),
),
arg(
"opts",
Typ::Object(ObjectType::new(None, None)),
Some(json!({})),
),
arg("any", Typ::Unknown, None),
],
..Default::default()
};
let previous = json!({
"type": "object",
"order": ["db", "n"],
"properties": {
"n": {"type": "integer", "description": "how many", "min": 1, "nullable": true},
"tags": {"type": "array", "items": {"type": "string", "enum": ["a", "b"]}},
"gone": {"type": "string"},
},
});
let schema = main_arg_schema(&sig, Some(&previous));
assert_eq!(schema["order"], json!(["db", "n"]));
assert_eq!(schema["required"], json!(["n", "db", "any"]));
assert_eq!(
schema["properties"]["n"],
json!({"type": "integer", "description": "how many", "min": 1, "nullable": true})
);
assert!(schema["properties"].get("gone").is_none());
assert_eq!(schema["properties"]["db"]["format"], "resource-postgresql");
assert_eq!(
schema["properties"]["tags"]["items"],
json!({"type": "string", "enum": ["a", "b"]})
);
assert_eq!(schema["properties"]["tags"]["default"], json!(["a"]));
assert_eq!(schema["properties"]["opts"]["type"], "object");
// Untyped in the code and new: any JSON value, as the editor stores it.
assert_eq!(schema["properties"]["any"]["type"], "");
}
}
@@ -15,6 +15,7 @@ use serde_json::Value;
pub mod asset_parser;
pub mod duckdb_builtins;
pub mod duckdb_macros;
pub mod json_schema;
pub mod sql_materialize;
/// S3 output format for SQL queries (moved here to avoid pulling sqlx into WASM via windmill-types)
+13 -5
View File
@@ -171,6 +171,7 @@ mod db_connect;
pub mod ee;
mod ee_oss;
mod monitor;
mod stranded_jobs;
// Windows service support - EE feature
#[cfg(all(windows, feature = "enterprise", feature = "private"))]
@@ -1835,6 +1836,13 @@ async fn process_notify_event(
);
windmill_common::variables::CUSTOM_ENVS_CACHE.remove(payload);
}
"notify_operator_settings_change" => {
tracing::info!(
"Operator settings change detected, invalidating operator rights cache: {}",
payload
);
windmill_common::workspaces::invalidate_operator_rights_cache(payload);
}
"notify_asset_producer_change" => {
tracing::debug!(
"Asset producer change for workspace {}, invalidating producer-writes cache",
@@ -1944,13 +1952,13 @@ async fn process_notify_event(
#[cfg(feature = "http_trigger")]
"notify_http_trigger_change" => {
tracing::info!("HTTP trigger change detected: {}", payload);
match windmill_api::triggers::http::refresh_routers(db, true).await {
Ok(_) => {
match windmill_api::triggers::http::refresh_loaded_routers(db, true).await {
Ok(true) => {
tracing::info!("Refreshed HTTP routers (trigger change)");
}
Ok(false) => {}
Err(err) => {
tracing::error!("Error refreshing HTTP routers (trigger change): {err:#}");
windmill_api::triggers::http::invalidate_routers();
return false;
}
};
@@ -2207,8 +2215,8 @@ async fn process_notify_event(
tracing::error!(error = %e, "Could not reload http route workspaced route setting");
}
#[cfg(feature = "http_trigger")]
match windmill_api::triggers::http::refresh_routers(db, false).await {
Ok((true, _)) => {
match windmill_api::triggers::http::refresh_loaded_routers(db, false).await {
Ok(true) => {
tracing::info!(
"Refreshed HTTP routers (http workspaced route setting change)"
);
+322 -53
View File
@@ -122,9 +122,9 @@ use windmill_common::{
#[cfg(feature = "parquet")]
use windmill_object_store::reload_object_store_setting;
use windmill_queue::{
cancel_job, get_queued_job_v2,
add_completed_job_error, cancel_job, canceled_result, get_queued_job_v2,
schedule::{find_unarmed_schedules, rearm_schedule, RearmOutcome},
SameWorkerPayload,
CanceledBy, MiniCompletedJob, SameWorkerPayload,
};
use windmill_store::resources::MAX_RESOURCE_VERSIONS;
use windmill_worker::{
@@ -4368,6 +4368,15 @@ pub async fn monitor_db(
}
};
// run every 30 iterations (~5min at the default LISTEN_NEW_EVENTS_INTERVAL_SEC).
let stranded_jobs_f = async {
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(30) {
if let Some(db) = conn.as_sql() {
crate::stranded_jobs::check_stranded_jobs(&db).await;
}
}
};
// Poll git-sync repositories for new commits and pull them into the
// workspace (repo → Windmill auto-pull). Runs every 2 iterations.
let git_auto_pull_f = async {
@@ -4471,6 +4480,7 @@ pub async fn monitor_db(
ai_session_retention_f,
pipeline_freshness_watchdog_f,
reconcile_unarmed_schedules_f,
stranded_jobs_f,
);
}
@@ -6200,8 +6210,8 @@ async fn handle_zombie_jobs(db: &Pool<Postgres>, base_internal_url: &str, node_n
}
/// Force-complete a zombie job that handle_job_error failed to complete.
/// This is a minimal fallback: it inserts a failed completed job and deletes
/// from the queue in a single transaction, without schedule pushing or
/// This is a minimal fallback: it moves the job from the queue to a failed
/// completed job in a single transaction, without schedule pushing or
/// error handler logic. The one thing it keeps is the WAC parent notification,
/// deliberately inside the transaction: if that fails, the whole completion
/// rolls back and the job waits for the next sweep, which is cheaper than a
@@ -6238,52 +6248,67 @@ async fn force_complete_zombie_job(
let mut tx = db.begin().await?;
// Locks in the order of every other completion: a WAC parent's rows, then this job's queue
// row, then its completed row (see `record_child_completion`). A worker completing the same
// job concurrently would otherwise deadlock against this.
let duration_ms = sqlx::query_scalar!(
"INSERT INTO v2_job_completed
(workspace_id, id, started_at, duration_ms, result, memory_peak, status, worker)
SELECT q.workspace_id, q.id, q.started_at,
COALESCE((EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000, 0)::bigint,
$2::jsonb, r.memory_peak, 'failure'::job_status, q.worker
FROM v2_job_queue q
LEFT JOIN v2_job_runtime r ON r.id = q.id
WHERE q.id = $1
ON CONFLICT (id) DO UPDATE SET status = 'failure', result = $2::jsonb
RETURNING duration_ms AS \"duration_ms!\"",
"SELECT COALESCE(c.duration_ms, COALESCE((EXTRACT('epoch' FROM now()) - EXTRACT('epoch' FROM COALESCE(q.started_at, now()))) * 1000, 0)::bigint) AS \"duration_ms!\"
FROM v2_job_queue q LEFT JOIN v2_job_completed c ON c.id = q.id WHERE q.id = $1",
job_id,
error_value,
)
.fetch_optional(&mut *tx)
.await?;
let Some(duration_ms) = duration_ms else {
return Ok(());
};
// A WAC parent parked on this job must learn of the failure here too, or it
// waits out its whole suspend window and runs the task again.
let mut wac_parent_ready = false;
if let Some(duration_ms) = duration_ms {
let parent = sqlx::query!(
"SELECT parent_job, flow_step_id FROM v2_job WHERE id = $1",
job_id
let parent = sqlx::query!(
"SELECT parent_job, flow_step_id FROM v2_job WHERE id = $1",
job_id
)
.fetch_optional(&mut *tx)
.await?;
if let Some(parent_job) = parent
.filter(|j| j.flow_step_id.is_none())
.and_then(|j| j.parent_job)
{
wac_parent_ready = windmill_common::wac::record_child_completion(
&mut tx,
&parent_job,
job_id,
false,
duration_ms,
&error_value.to_string(),
)
.fetch_optional(&mut *tx)
.await?;
if let Some(parent_job) = parent
.filter(|j| j.flow_step_id.is_none())
.and_then(|j| j.parent_job)
{
wac_parent_ready = windmill_common::wac::record_child_completion(
&mut tx,
&parent_job,
job_id,
false,
duration_ms,
&error_value.to_string(),
)
.await?;
}
}
sqlx::query!("DELETE FROM v2_job_queue WHERE id = $1", job_id)
.execute(&mut *tx)
.await?;
let completed = sqlx::query_scalar!(
"WITH deleted AS (
DELETE FROM v2_job_queue WHERE id = $1 RETURNING id, workspace_id, started_at, worker
)
INSERT INTO v2_job_completed
(workspace_id, id, started_at, duration_ms, result, memory_peak, status, worker)
SELECT d.workspace_id, d.id, d.started_at, $3, $2::jsonb, r.memory_peak,
'failure'::job_status, d.worker
FROM deleted d
LEFT JOIN v2_job_runtime r ON r.id = d.id
ON CONFLICT (id) DO UPDATE SET status = 'failure', result = $2::jsonb
RETURNING id",
job_id,
error_value,
duration_ms,
)
.fetch_optional(&mut *tx)
.await?;
// Completed by someone else while this waited on the WAC parent: roll back, so the parent
// keeps what the winning completion recorded.
if completed.is_none() {
return Ok(());
}
tx.commit().await?;
@@ -6476,6 +6501,9 @@ async fn find_zombie_flow_culprit_worker(
}
async fn handle_zombie_flows(db: &DB) -> error::Result<()> {
// A canceled flow gets ten times the transition timeout: requeuing it while its worker is
// still in a slow transition lets a second worker complete it under that transition, and
// which worker runs a transition is not recorded anywhere to tell a slow one from a dead one.
// flow_status is cast ::text on purpose: decoding the jsonb column directly as Box<str>
// yields its binary form (leading version byte) and fails serde_json parsing at column 1.
let flows = sqlx::query!(
@@ -6491,13 +6519,14 @@ async fn handle_zombie_flows(db: &DB) -> error::Result<()> {
wp.worker_group AS "worker_group?",
wp.wm_version AS "worker_version?",
wp.current_job_id AS "worker_current_job_id?",
wp.worker_instance AS "worker_instance?"
wp.worker_instance AS "worker_instance?",
q.canceled_by AS "canceled_by?", q.canceled_reason AS "canceled_reason?"
FROM v2_job_queue q JOIN v2_job j USING (id) LEFT JOIN v2_job_runtime r USING (id) LEFT JOIN v2_job_status s USING (id)
LEFT JOIN worker_ping wp ON wp.worker = q.worker
WHERE q.running = true AND q.suspend = 0 AND q.suspend_until IS null AND q.scheduled_for <= now()
AND (j.kind = 'flow' OR j.kind = 'flowpreview' OR j.kind = 'flownode' OR j.kind = 'singlestepflow')
AND r.ping IS NOT NULL AND r.ping < NOW() - ($1 || ' seconds')::interval
AND q.canceled_by IS NULL
AND (q.canceled_by IS NULL OR r.ping < NOW() - ($1 || ' seconds')::interval * 10)
"#,
FLOW_ZOMBIE_TRANSITION_TIMEOUT.as_str()
@@ -6510,21 +6539,99 @@ async fn handle_zombie_flows(db: &DB) -> error::Result<()> {
.flow_status
.as_deref()
.and_then(|x| serde_json::from_str::<FlowStatus>(x).ok());
if !flow.same_worker.unwrap_or(false)
&& status.as_ref().is_some_and(|s| s.is_not_yet_started())
// A worker that pulls a canceled flow completes it as canceled, and hands that to its
// parent like any step's cancel, so a canceled flow whose transition was lost goes back
// to the queue exactly like a flow that never started.
if flow.canceled_by.is_some()
|| (!flow.same_worker.unwrap_or(false)
&& status.as_ref().is_some_and(|s| s.is_not_yet_started()))
{
let error_message = format!(
"Zombie flow detected: {} in workspace {}. It hasn't started yet, restarting it.",
flow.id, flow.workspace_id
);
tracing::error!(error_message);
if !CRITICAL_ALERT_MUTE_ZOMBIE_JOB_RESTART.load(Ordering::Relaxed) {
report_critical_error(error_message, db.clone(), Some(&flow.workspace_id), None)
.await;
}
// if the flow hasn't started and is a zombie, we can simply restart it
let mut tx = db.begin().await?;
// Claims the flow unless another sweep holds it or it visibly moved on since it was
// read. This does not tell a slow transition from a lost one: the ping is written when
// the step completes, at the start of the transition, and nothing is locked until its
// end, so the grace in the query above is what keeps a live transition safe.
let claimed = sqlx::query_scalar!(
"SELECT q.id FROM v2_job_queue q JOIN v2_job_runtime r ON r.id = q.id
WHERE q.id = $1 AND q.running AND r.ping = $2
FOR UPDATE OF q SKIP LOCKED",
flow.id,
flow.last_ping,
)
.fetch_optional(&mut *tx)
.await?
.is_some();
if !claimed {
continue;
}
let error_message = if let Some(canceler) = flow.canceled_by.as_deref() {
// Bounds the requeues of a canceled flow whose completion keeps failing: past the
// limit it is completed on its own instead, on every sweep until that succeeds.
// The count is taken under the row claim, so the alert goes out once.
let attempt = sqlx::query_scalar!(
"INSERT INTO zombie_job_counter (job_id, counter) VALUES ($1, 1)
ON CONFLICT (job_id) DO UPDATE SET counter = zombie_job_counter.counter + 1
RETURNING counter",
flow.id
)
.fetch_one(&mut *tx)
.await?;
if attempt > RESTART_LIMIT {
tx.commit().await?;
if attempt == RESTART_LIMIT + 1 {
let error_message = format!(
"Zombie flow detected: {} in workspace {}. It was canceled by {canceler} but could not be completed after {RESTART_LIMIT} attempts, completing it as canceled without handing it to its parent.",
flow.id, flow.workspace_id
);
tracing::error!(error_message);
report_critical_error(
error_message,
db.clone(),
Some(&flow.workspace_id),
None,
)
.await;
}
let completed = match get_queued_job_v2(db, &flow.id).await {
Ok(Some(job)) => add_completed_job_error(
db,
&MiniCompletedJob::from(job),
0,
Some(CanceledBy {
username: Some(canceler.to_string()),
reason: flow.canceled_reason.clone(),
}),
canceled_result(flow.canceled_reason.as_deref(), Some(canceler)),
"monitor",
false,
None,
)
.await
.map(|_| ()),
Ok(None) => Ok(()),
Err(e) => Err(e),
};
if let Err(e) = completed {
tracing::error!(
"could not complete canceled zombie flow {}, retrying on the next sweep: {e:#}",
flow.id
);
}
continue;
}
format!(
"Zombie flow detected: {} in workspace {}. It was canceled by {canceler} but its worker stopped between two steps, queuing it again to complete the cancel ({attempt}/{RESTART_LIMIT}).",
flow.id, flow.workspace_id
)
} else {
format!(
"Zombie flow detected: {} in workspace {}. It hasn't started yet, restarting it.",
flow.id, flow.workspace_id
)
};
let concurrency_key =
sqlx::query_scalar!("SELECT key FROM concurrency_key WHERE job_id = $1", flow.id)
.fetch_optional(&mut *tx)
@@ -6544,15 +6651,26 @@ async fn handle_zombie_flows(db: &DB) -> error::Result<()> {
}
}
// A canceled flow keeps its start: the pull only sets a missing one, and the canceled
// run's duration is measured from it.
sqlx::query!(
"UPDATE v2_job_queue SET running = false, started_at = null
WHERE id = $1 AND canceled_by IS NULL",
"UPDATE v2_job_queue SET running = false,
started_at = CASE WHEN canceled_by IS NULL THEN NULL ELSE started_at END
WHERE id = $1",
flow.id
)
.execute(&mut *tx)
.await?;
tx.commit().await?;
tracing::error!(error_message);
if flow.canceled_by.is_some()
|| !CRITICAL_ALERT_MUTE_ZOMBIE_JOB_RESTART.load(Ordering::Relaxed)
{
report_critical_error(error_message, db.clone(), Some(&flow.workspace_id), None)
.await;
}
} else {
let id = flow.id.clone();
let last_ping = flow.last_ping.clone();
@@ -7772,3 +7890,154 @@ mod log_file_listing_tests {
assert_eq!(files[0].0.to_string(), "2026-08-29 06:46:00");
}
}
#[cfg(test)]
mod canceled_zombie_flow_tests {
use super::{handle_zombie_flows, DB, RESTART_LIMIT};
use serde_json::json;
use uuid::Uuid;
/// A running flow at `step` (0: not started yet), last pinged `ping_age` ago (`None`: mid-step, no ping).
async fn insert_flow(
db: &DB,
parent: Option<Uuid>,
step: i32,
ping_age: Option<&str>,
canceled_by: Option<&str>,
) -> anyhow::Result<Uuid> {
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email,
kind, tag, parent_job, flow_step_id)
VALUES ($1, 'admins', 'admin', 'u/admin', 'admin@windmill.dev', 'flowpreview', 'flow',
$2, CASE WHEN $2 IS NOT NULL THEN 'sf' END)",
)
.bind(id)
.bind(parent)
.execute(db)
.await?;
sqlx::query(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, started_at, tag,
canceled_by, canceled_reason)
VALUES ($1, 'admins', now(), true, now() - interval '1 hour', 'flow', $2, $2)",
)
.bind(id)
.bind(canceled_by)
.execute(db)
.await?;
sqlx::query("INSERT INTO v2_job_runtime (id, ping) VALUES ($1, now() - $2::interval)")
.bind(id)
.bind(ping_age)
.execute(db)
.await?;
sqlx::query("INSERT INTO v2_job_status (id, flow_status) VALUES ($1, $2)")
.bind(id)
.bind(json!({"step": step, "modules": [if step == 0 {
json!({"type": "WaitingForPriorSteps", "id": "a"})
} else {
json!({"type": "InProgress", "id": "a", "job": Uuid::nil()})
}],
"failure_module": {"type": "WaitingForPriorSteps", "id": "failure"}}))
.execute(db)
.await?;
Ok(id)
}
/// `(running, canceled_by, still started an hour ago)`
async fn queue_row(db: &DB, id: Uuid) -> anyhow::Result<(bool, Option<String>, Option<bool>)> {
Ok(sqlx::query_as(
"SELECT running, canceled_by, started_at < now() - interval '30 minutes'
FROM v2_job_queue WHERE id = $1",
)
.bind(id)
.fetch_one(db)
.await?)
}
/// A subflow canceled on its own whose worker died between two steps goes back to the queue
/// with its cancel and its start, for a worker to complete it and hand it to its parent. The
/// parent is left alone: the cancel must not reach flows the user never canceled.
#[sqlx::test(migrations = "./migrations")]
async fn requeues_a_stranded_canceled_flow_and_nothing_else(db: DB) -> anyhow::Result<()> {
let root = insert_flow(&db, None, 0, None, None).await?;
let child = insert_flow(&db, Some(root), 1, Some("1 hour"), Some("admin")).await?;
handle_zombie_flows(&db).await?;
assert_eq!(
queue_row(&db, child).await?,
(false, Some("admin".to_string()), Some(true))
);
assert_eq!(queue_row(&db, root).await?, (true, None, Some(true)));
Ok(())
}
/// A canceled flow is left to a transition that may just be slow for ten times the timeout
/// a flow that never started gets.
#[sqlx::test(migrations = "./migrations")]
async fn gives_a_canceled_flow_a_longer_grace(db: DB) -> anyhow::Result<()> {
let slow = insert_flow(&db, None, 1, Some("2 minutes"), Some("admin")).await?;
let stuck = insert_flow(&db, None, 1, Some("1 hour"), Some("admin")).await?;
let not_started = insert_flow(&db, None, 0, Some("2 minutes"), None).await?;
handle_zombie_flows(&db).await?;
assert!(
queue_row(&db, slow).await?.0,
"a recent canceled flow is left running"
);
assert!(
!queue_row(&db, stuck).await?.0,
"past the grace, it is requeued"
);
assert!(
!queue_row(&db, not_started).await?.0,
"a flow that never started is not held back"
);
Ok(())
}
/// A canceled flow that keeps coming back after its requeues is completed as canceled on its
/// own instead of being requeued (and alerted on) forever, and that completion is retried by
/// the next sweep if a previous one did not land. Its parent is not advanced, since the
/// transition that would do it is what kept failing.
#[sqlx::test(migrations = "./migrations")]
async fn completes_a_canceled_flow_requeued_too_often(db: DB) -> anyhow::Result<()> {
let root = insert_flow(&db, None, 0, None, None).await?;
let child = insert_flow(&db, Some(root), 1, Some("1 hour"), Some("admin")).await?;
let retried = insert_flow(&db, None, 1, Some("1 hour"), Some("admin")).await?;
for (id, counter) in [(child, RESTART_LIMIT), (retried, RESTART_LIMIT + 1)] {
sqlx::query("INSERT INTO zombie_job_counter (job_id, counter) VALUES ($1, $2)")
.bind(id)
.bind(counter)
.execute(&db)
.await?;
}
handle_zombie_flows(&db).await?;
for id in [child, retried] {
let completed: Option<(String, Option<String>)> = sqlx::query_as(
"SELECT status::text, canceled_by FROM v2_job_completed WHERE id = $1",
)
.bind(id)
.fetch_optional(&db)
.await?;
assert_eq!(
completed,
Some(("canceled".to_string(), Some("admin".to_string())))
);
}
// the parent is not advanced, but the completion pings it: the sweep then reaps it as a
// flow hanging between two steps instead of leaving it waiting
assert_eq!(queue_row(&db, root).await?, (true, None, Some(true)));
let parent_pinged: bool = sqlx::query_scalar(
"SELECT ping > now() - interval '1 minute' FROM v2_job_runtime WHERE id = $1",
)
.bind(root)
.fetch_one(&db)
.await?;
assert!(parent_pinged);
Ok(())
}
}
+664
View File
@@ -0,0 +1,664 @@
//! Pending jobs stuck on a tag no worker serves.
//!
//! Such a job waits forever, and every idle pull of every worker group walks past it in the
//! shared `queue_sort_v2` index. This pass alerts superadmins about them and, when
//! `cancel_stranded_jobs_after_days` is set, cancels them.
use chrono::{DateTime, Utc};
use sqlx::{Pool, Postgres};
use uuid::Uuid;
use windmill_common::{
error,
global_settings::{
load_value_from_global_settings, CANCEL_STRANDED_JOBS_AFTER_DAYS_SETTING,
CRITICAL_ALERT_MUTE_STRANDED_JOBS_SETTING,
},
utils::report_critical_error,
};
use windmill_queue::cancel_job;
/// How long a tag must go without a worker serving it before its pending jobs are reported.
/// Long enough that a group autoscaled to zero, or only started at night, is not reported.
const ALERT_WINDOW_SECS: i64 = 24 * 3600;
/// At most one alert per this interval, however many tags are stranded.
const ALERT_COOLDOWN_SECS: i64 = 24 * 3600;
/// Tags listed in one alert; the rest are counted.
const MAX_LISTED_GROUPS: usize = 20;
/// Upper bound on cancellations per pass; a larger backlog drains over the next passes.
const MAX_CANCELS_PER_PASS: i64 = 500;
const MAX_CANCEL_AFTER_DAYS: i64 = 3650;
/// Next to the other monitor pass locks (737_483_920..=737_483_923).
const STRANDED_JOBS_LOCK_ID: i64 = 737_483_924;
const ALERT_RESOURCE: &str = "stranded_jobs";
/// Every cancel reason starts with it, which is how `finish_lost_cancels` recognises them.
const CANCEL_REASON_PREFIX: &str = "no worker has served tag '";
pub async fn check_stranded_jobs(db: &Pool<Postgres>) {
// Transaction-scoped so a monitor timeout dropping this future cannot leave the lock held
// on a pooled connection (see `reconcile_unarmed_schedules`).
let mut lock_tx = match db.begin().await {
Ok(tx) => tx,
Err(e) => {
tracing::error!("stranded jobs: failed to begin lock tx: {e:#}");
return;
}
};
match sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_xact_lock($1)")
.bind(STRANDED_JOBS_LOCK_ID)
.fetch_one(&mut *lock_tx)
.await
{
Ok(true) => {}
Ok(false) => return,
Err(e) => {
tracing::error!("stranded jobs: advisory lock failed: {e:#}");
return;
}
}
let cancel_after_days = match cancel_after_days(db).await {
Ok(d) => d,
Err(e) => {
tracing::error!(
"stranded jobs: could not read {CANCEL_STRANDED_JOBS_AFTER_DAYS_SETTING}: {e:#}"
);
None
}
};
if let Err(e) = run_pass(db, cancel_after_days).await {
tracing::error!("stranded jobs: {e:#}");
}
let _ = lock_tx.rollback().await;
}
async fn cancel_after_days(db: &Pool<Postgres>) -> error::Result<Option<i64>> {
Ok(
load_value_from_global_settings(db, CANCEL_STRANDED_JOBS_AFTER_DAYS_SETTING)
.await?
.and_then(|v| v.as_i64())
.filter(|d| *d > 0)
.map(|d| d.min(MAX_CANCEL_AFTER_DAYS)),
)
}
async fn run_pass(db: &Pool<Postgres>, cancel_after_days: Option<i64>) -> error::Result<()> {
finish_lost_cancels(db).await?;
// With no worker pinging at all over a window, every tag looks unserved: that is a fleet
// outage, which would otherwise raise one alert per tag or cancel the whole queue. Each
// window gets its own check: a fleet quiet for a day may still show which tags went
// unserved over a longer cancel window.
let groups = if any_worker_pinged(db, ALERT_WINDOW_SECS).await? {
find_stranded(db, ALERT_WINDOW_SECS).await?
} else {
tracing::warn!("stranded jobs: no worker pinged in the last 24 hours, not alerting");
vec![]
};
report_stranded_jobs(db, &groups, cancel_after_days).await?;
if let Some(days) = cancel_after_days {
let window_secs = days * 24 * 3600;
let groups = if window_secs == ALERT_WINDOW_SECS {
groups
} else if any_worker_pinged(db, window_secs).await? {
find_stranded(db, window_secs).await?
} else {
tracing::warn!(
"stranded jobs: no worker pinged in the last {days} days, not cancelling"
);
vec![]
};
cancel_stranded_jobs(db, &groups, days).await?;
}
Ok(())
}
async fn any_worker_pinged(db: &Pool<Postgres>, window_secs: i64) -> error::Result<bool> {
Ok(sqlx::query_scalar!(
r#"SELECT EXISTS (SELECT 1 FROM worker_ping
WHERE ping_at > now() - $1::bigint * interval '1 second') AS "exists!""#,
window_secs,
)
.fetch_one(db)
.await?)
}
/// Stranded jobs of one tag in one workspace.
struct StrandedGroup {
workspace_id: String,
tag: String,
count: i64,
oldest: DateTime<Utc>,
}
/// Pending jobs due for at least `window_secs` whose tag no worker that pinged within the same
/// window serves, grouped by workspace and tag. A job whose parent is still queued is covered
/// by that parent; one whose parent is gone (a native script retry points at the completed
/// original) stands on its own.
///
/// A worker pulls its `custom_tags`, which hold resolved tags (dedicated-worker tag included),
/// the positive-priority `priority_tags` of its group config (which may be absent from
/// `custom_tags`), and its own name prefix. Jobs carry their resolved tag (per-workspace suffix, `$workspace`)
/// and the pull matches it exactly, so exact comparison is the pull's own criterion. Agent
/// workers ping `worker_ping` through the API, so they count too. `worker_ping` keeps a worker's
/// current tags only: a tag removed from a group's config stops counting at the next ping.
async fn find_stranded(db: &Pool<Postgres>, window_secs: i64) -> error::Result<Vec<StrandedGroup>> {
// MATERIALIZED keeps the queue driving the plan: joined freely, the planner may instead walk
// every root job of `v2_job`, which holds the whole job history.
Ok(sqlx::query_as!(
StrandedGroup,
r#"WITH served AS (
SELECT unnest(custom_tags) AS tag FROM worker_ping
WHERE ping_at > now() - $1::bigint * interval '1 second'
UNION SELECT regexp_replace(worker, '-[^-]*$', '') FROM worker_ping
WHERE ping_at > now() - $1::bigint * interval '1 second'
UNION SELECT p.key
FROM worker_ping w JOIN config c ON c.name = 'worker__' || w.worker_group,
jsonb_each(c.config->'priority_tags') p
WHERE w.ping_at > now() - $1::bigint * interval '1 second'
AND jsonb_typeof(c.config->'priority_tags') = 'object'
AND jsonb_typeof(p.value) = 'number' AND p.value::text::numeric > 0
),
pending AS MATERIALIZED (
SELECT q.id, q.workspace_id, q.tag, q.scheduled_for FROM v2_job_queue q
WHERE q.running = false
AND q.canceled_by IS NULL
AND q.scheduled_for <= now() - $1::bigint * interval '1 second'
AND NOT EXISTS (SELECT 1 FROM served s WHERE s.tag = q.tag)
)
SELECT p.workspace_id AS "workspace_id!", p.tag AS "tag!", count(*) AS "count!",
min(p.scheduled_for) AS "oldest!"
FROM pending p JOIN v2_job j ON j.id = p.id
WHERE (j.parent_job IS NULL
OR NOT EXISTS (SELECT 1 FROM v2_job_queue pq WHERE pq.id = j.parent_job))
GROUP BY p.workspace_id, p.tag
ORDER BY min(p.scheduled_for)"#,
window_secs,
)
.fetch_all(db)
.await?)
}
async fn report_stranded_jobs(
db: &Pool<Postgres>,
groups: &[StrandedGroup],
cancel_after_days: Option<i64>,
) -> error::Result<()> {
if groups.is_empty() {
return Ok(());
}
let muted = load_value_from_global_settings(db, CRITICAL_ALERT_MUTE_STRANDED_JOBS_SETTING)
.await?
.and_then(|v| v.as_bool())
.unwrap_or(false);
if muted {
return Ok(());
}
// Read from `alerts` rather than kept in memory so the limit holds across servers.
let alerted_recently = sqlx::query_scalar!(
r#"SELECT EXISTS (SELECT 1 FROM alerts
WHERE alert_type = 'critical_error' AND resource = $1
AND created_at > now() - $2::bigint * interval '1 second') AS "exists!""#,
ALERT_RESOURCE,
ALERT_COOLDOWN_SECS,
)
.fetch_one(db)
.await?;
if alerted_recently {
return Ok(());
}
let now = Utc::now();
let total: i64 = groups.iter().map(|g| g.count).sum();
let mut message = format!(
"{} waiting on tags that no worker has served in the last {}:",
plural(total, "pending job"),
fmt_duration(ALERT_WINDOW_SECS),
);
for g in groups.iter().take(MAX_LISTED_GROUPS) {
message.push_str(&format!(
"\n- workspace {}, tag '{}': {}, oldest waiting {}",
g.workspace_id,
g.tag,
plural(g.count, "job"),
fmt_duration((now - g.oldest).num_seconds()),
));
}
if groups.len() > MAX_LISTED_GROUPS {
message.push_str(&format!(
"\n- and {} more",
plural((groups.len() - MAX_LISTED_GROUPS) as i64, "tag")
));
}
match cancel_after_days {
Some(days) => message.push_str(&format!(
"\nJobs whose tag no worker serves for {} are canceled automatically.",
plural(days, "day")
)),
None => message.push_str(
"\nAdd these tags to a worker group or cancel the jobs. To cancel such jobs automatically, set 'Cancel jobs on unserved tags after (days)' in Instance settings > Jobs.",
),
}
message.push_str(
"\nThis alert is sent at most once a day. To turn it off, enable 'Mute stranded job alerts' in Instance settings > Alerts.",
);
tracing::warn!("stranded jobs: {message}");
report_critical_error(message, db.clone(), None, Some(ALERT_RESOURCE)).await;
Ok(())
}
async fn cancel_stranded_jobs(
db: &Pool<Postgres>,
groups: &[StrandedGroup],
days: i64,
) -> error::Result<()> {
let window_secs = days * 24 * 3600;
let mut budget = MAX_CANCELS_PER_PASS;
for group in groups {
if budget <= 0 {
break;
}
let ids = sqlx::query_scalar!(
r#"WITH pending AS MATERIALIZED (
SELECT q.id, q.scheduled_for FROM v2_job_queue q
WHERE q.workspace_id = $1 AND q.tag = $2
AND q.running = false
AND q.canceled_by IS NULL
AND q.scheduled_for <= now() - $3::bigint * interval '1 second'
)
SELECT p.id FROM pending p JOIN v2_job j ON j.id = p.id
WHERE (j.parent_job IS NULL
OR NOT EXISTS (SELECT 1 FROM v2_job_queue pq WHERE pq.id = j.parent_job))
ORDER BY p.scheduled_for
LIMIT $4"#,
group.workspace_id,
group.tag,
window_secs,
budget,
)
.fetch_all(db)
.await?;
budget -= ids.len() as i64;
tracing::warn!(
workspace_id = group.workspace_id,
tag = group.tag,
"stranded jobs: cancelling {} of {} pending job(s) on a tag no worker served in the last {days} day(s)",
ids.len(),
group.count,
);
let reason = format!(
"{CANCEL_REASON_PREFIX}{}' for {}",
group.tag,
plural(days, "day")
);
for id in ids {
if let Err(e) =
cancel_one(db, id, &group.workspace_id, reason.clone(), window_secs).await
{
tracing::error!("stranded jobs: could not cancel job {id}: {e:#}");
}
}
}
Ok(())
}
async fn cancel_one(
db: &Pool<Postgres>,
id: Uuid,
workspace_id: &str,
reason: String,
window_secs: i64,
) -> error::Result<()> {
let mut tx = db.begin().await?;
// A worker for the tag may have come back since the job was selected. The row lock keeps the
// pull (which skips locked rows) off it, and `canceled_by` is set under that lock because
// `cancel_job` completes a pending job asynchronously: a worker pulling it in between
// completes it as canceled instead of running it. That completion is lost if the server
// stops first; `finish_lost_cancels` picks those rows up.
let still_stranded = sqlx::query_scalar!(
r#"UPDATE v2_job_queue q SET canceled_by = 'monitor', canceled_reason = $3
WHERE q.id = $1 AND q.running = false AND q.canceled_by IS NULL
AND NOT EXISTS (SELECT 1 FROM worker_ping w
LEFT JOIN config c ON c.name = 'worker__' || w.worker_group
WHERE w.ping_at > now() - $2::bigint * interval '1 second'
AND (q.tag = ANY(w.custom_tags)
OR q.tag = regexp_replace(w.worker, '-[^-]*$', '')
OR (jsonb_typeof(c.config->'priority_tags'->q.tag) = 'number'
AND (c.config->'priority_tags'->>q.tag)::numeric > 0)))
RETURNING q.id"#,
id,
window_secs,
reason,
)
.fetch_optional(&mut *tx)
.await?
.is_some();
if !still_stranded {
return Ok(());
}
complete_canceled(tx, db, id, workspace_id, reason).await
}
/// Forced: the non-forced path leaves a job that has a parent to its worker, and no worker
/// serves this one. A job whose parent is still queued never gets here.
async fn complete_canceled(
tx: sqlx::Transaction<'_, Postgres>,
db: &Pool<Postgres>,
id: Uuid,
workspace_id: &str,
reason: String,
) -> error::Result<()> {
let (tx, _) = cancel_job(
"monitor",
Some(reason),
id,
workspace_id,
tx,
db,
true,
false,
)
.await?;
tx.commit().await?;
Ok(())
}
/// Completes jobs an earlier pass marked canceled whose asynchronous completion was lost (the
/// server stopped first). Runs whatever the cancel setting now says: the decision was made.
async fn finish_lost_cancels(db: &Pool<Postgres>) -> error::Result<()> {
let rows = sqlx::query!(
r#"SELECT id, workspace_id, canceled_reason AS "reason!" FROM v2_job_queue
WHERE running = false AND canceled_by = 'monitor' AND canceled_reason LIKE $1 || '%'
LIMIT $2"#,
CANCEL_REASON_PREFIX,
MAX_CANCELS_PER_PASS,
)
.fetch_all(db)
.await?;
for row in rows {
let mut tx = db.begin().await?;
let still_marked = sqlx::query_scalar!(
"SELECT id FROM v2_job_queue WHERE id = $1 AND running = false FOR UPDATE",
row.id
)
.fetch_optional(&mut *tx)
.await?
.is_some();
if still_marked {
tracing::warn!("stranded jobs: completing canceled job {}", row.id);
if let Err(e) = complete_canceled(tx, db, row.id, &row.workspace_id, row.reason).await {
tracing::error!("stranded jobs: could not complete job {}: {e:#}", row.id);
}
}
}
Ok(())
}
fn fmt_duration(secs: i64) -> String {
let hours = secs / 3600;
if hours >= 48 {
plural(hours / 24, "day")
} else if hours >= 1 {
plural(hours, "hour")
} else {
plural(secs / 60, "minute")
}
}
fn plural(n: i64, unit: &str) -> String {
if n == 1 {
format!("1 {unit}")
} else {
format!("{n} {unit}s")
}
}
#[cfg(test)]
mod tests {
use super::*;
async fn plant(db: &Pool<Postgres>, tag: &str, age: &str, parent: Option<Uuid>) -> Uuid {
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO v2_job (id, workspace_id, tag, parent_job) VALUES ($1, 'admins', $2, $3)",
)
.bind(id)
.bind(tag)
.bind(parent)
.execute(db)
.await
.unwrap();
sqlx::query(
"INSERT INTO v2_job_queue (id, workspace_id, tag, scheduled_for)
VALUES ($1, 'admins', $2, now() - $3::interval)",
)
.bind(id)
.bind(tag)
.bind(age)
.execute(db)
.await
.unwrap();
id
}
async fn ping(db: &Pool<Postgres>, worker: &str, group: &str, age: &str, tags: &[&str]) {
sqlx::query(
"INSERT INTO worker_ping (worker, worker_instance, worker_group, ping_at, custom_tags)
VALUES ($1, 'test', $2, now() - $3::interval, $4)",
)
.bind(worker)
.bind(group)
.bind(age)
.bind(tags)
.execute(db)
.await
.unwrap();
}
async fn set_canceled_by(db: &Pool<Postgres>, id: Uuid, by: Option<&str>) {
sqlx::query("UPDATE v2_job_queue SET canceled_by = $2 WHERE id = $1")
.bind(id)
.bind(by)
.execute(db)
.await
.unwrap();
}
async fn canceled_by(db: &Pool<Postgres>, id: Uuid) -> Option<String> {
sqlx::query_scalar("SELECT canceled_by FROM v2_job_queue WHERE id = $1")
.bind(id)
.fetch_one(db)
.await
.unwrap()
}
#[sqlx::test(migrations = "./migrations")]
async fn find_stranded_selects_unserved_root_jobs(db: Pool<Postgres>) {
ping(&db, "wk-live-a1b2", "live", "1 minute", &["live"]).await;
// A group scaled to zero within the window still serves its tags.
ping(&db, "wk-night-c3d4", "night", "10 hours", &["night"]).await;
ping(&db, "wk-gone-e5f6", "gone", "2 days", &["gone"]).await;
// A positive-priority tag is pulled even when absent from the worker's tags.
ping(&db, "wk-prio-g7h8", "prio", "1 minute", &["other"]).await;
sqlx::query(
r#"INSERT INTO config (name, config) VALUES ('worker__prio', '{"priority_tags": {"prio": 2, "zero": 0}}')"#,
)
.execute(&db)
.await
.unwrap();
let nobody = plant(&db, "nobody", "3 days", None).await;
plant(&db, "gone", "3 days", None).await;
let live = plant(&db, "live", "3 days", None).await;
plant(&db, "night", "3 days", None).await;
plant(&db, "prio", "3 days", None).await;
// Priority 0 is only pulled when also in the worker's tags.
plant(&db, "zero", "3 days", None).await;
// A worker also pulls its own name prefix.
plant(&db, "wk-live", "3 days", None).await;
plant(&db, "nobody", "1 hour", None).await;
plant(&db, "nobody", "-1 day", None).await;
plant(&db, "nobody", "3 days", Some(live)).await;
// A native retry points at the completed original, which has left the queue.
let original = Uuid::new_v4();
sqlx::query("INSERT INTO v2_job (id, workspace_id, tag) VALUES ($1, 'admins', 'retry')")
.bind(original)
.execute(&db)
.await
.unwrap();
plant(&db, "retry", "3 days", Some(original)).await;
let marked = plant(&db, "nobody", "3 days", None).await;
set_canceled_by(&db, marked, Some("monitor")).await;
let by_user = plant(&db, "nobody", "3 days", None).await;
set_canceled_by(&db, by_user, Some("alice")).await;
let running = plant(&db, "nobody", "3 days", None).await;
sqlx::query("UPDATE v2_job_queue SET running = true WHERE id = $1")
.bind(running)
.execute(&db)
.await
.unwrap();
let mut found: Vec<(String, i64)> = find_stranded(&db, ALERT_WINDOW_SECS)
.await
.unwrap()
.into_iter()
.map(|g| (g.tag, g.count))
.collect();
found.sort();
assert_eq!(
found,
vec![
("gone".to_string(), 1),
("nobody".to_string(), 1),
("retry".to_string(), 1),
("zero".to_string(), 1)
]
);
// Picked up by a worker after selection: must not be interrupted.
cancel_one(
&db,
running,
"admins",
"test".to_string(),
ALERT_WINDOW_SECS,
)
.await
.unwrap();
assert_eq!(canceled_by(&db, running).await, None);
// Marked before its asynchronous completion, so a worker pulling it meanwhile skips it.
cancel_one(&db, nobody, "admins", "test".to_string(), ALERT_WINDOW_SECS)
.await
.unwrap();
assert_eq!(cancel_state(&db, nobody).await, Some("monitor".to_string()));
}
/// `canceled_by` of a job, whether still queued or already completed.
async fn cancel_state(db: &Pool<Postgres>, id: Uuid) -> Option<String> {
// Queue first: the completion moves the row to `v2_job_completed` in one transaction.
let queued: Option<Option<String>> =
sqlx::query_scalar("SELECT canceled_by FROM v2_job_queue WHERE id = $1")
.bind(id)
.fetch_optional(db)
.await
.unwrap();
match queued {
Some(by) => by,
None => sqlx::query_scalar("SELECT canceled_by FROM v2_job_completed WHERE id = $1")
.bind(id)
.fetch_optional(db)
.await
.unwrap()
.flatten(),
}
}
#[sqlx::test(migrations = "./migrations")]
async fn lost_cancels_are_completed(db: Pool<Postgres>) {
let job = plant(&db, "nobody", "3 days", None).await;
sqlx::query(
"UPDATE v2_job_queue SET canceled_by = 'monitor', canceled_reason = $2 WHERE id = $1",
)
.bind(job)
.bind(format!("{CANCEL_REASON_PREFIX}nobody' for 1 day"))
.execute(&db)
.await
.unwrap();
finish_lost_cancels(&db).await.unwrap();
for _ in 0..50 {
let done: bool = sqlx::query_scalar(
"SELECT EXISTS (SELECT 1 FROM v2_job_completed WHERE id = $1 AND status = 'canceled')",
)
.bind(job)
.fetch_one(&db)
.await
.unwrap();
if done {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
panic!("job was not completed as canceled");
}
#[sqlx::test(migrations = "./migrations")]
async fn fleet_outage_neither_alerts_nor_cancels(db: Pool<Postgres>) {
ping(&db, "wk-all-a1b2", "all", "2 days", &["gone"]).await;
let job = plant(&db, "nobody", "3 days", None).await;
run_pass(&db, Some(1)).await.unwrap();
let alerts: i64 = sqlx::query_scalar("SELECT count(*) FROM alerts")
.fetch_one(&db)
.await
.unwrap();
assert_eq!(alerts, 0);
assert_eq!(canceled_by(&db, job).await, None);
}
#[sqlx::test(migrations = "./migrations")]
async fn alerts_at_most_daily_and_can_be_muted(db: Pool<Postgres>) {
let groups = vec![StrandedGroup {
workspace_id: "admins".to_string(),
tag: "nobody".to_string(),
count: 3,
oldest: Utc::now() - chrono::Duration::days(3),
}];
let alerts = || async {
sqlx::query_scalar::<_, i64>("SELECT count(*) FROM alerts")
.fetch_one(&db)
.await
.unwrap()
};
sqlx::query(
"INSERT INTO global_settings (name, value) VALUES ('critical_alert_mute_stranded_jobs', 'true')",
)
.execute(&db)
.await
.unwrap();
report_stranded_jobs(&db, &groups, None).await.unwrap();
assert_eq!(alerts().await, 0);
sqlx::query("DELETE FROM global_settings WHERE name = 'critical_alert_mute_stranded_jobs'")
.execute(&db)
.await
.unwrap();
report_stranded_jobs(&db, &groups, None).await.unwrap();
report_stranded_jobs(&db, &groups, None).await.unwrap();
assert_eq!(alerts().await, 1);
}
}
+12 -15
View File
@@ -577,24 +577,13 @@ async fn guests_mode_needs_a_scopable_path(db: Pool<Postgres>) -> anyhow::Result
.send()
.await?;
assert_eq!(resp.status(), 400, "renamed to a `,` path while in Guests");
let resp = authed(
client().post(format!("{ws}/apps/update/{APP_PATH}")),
ADMIN_TOKEN,
)
.json(&json!({ "path": "u/test-user/My App" }))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"a space is literal: {}",
resp.text().await?
);
// Set on update: an app that already sits on such a path cannot be switched.
// Set on update: an app that already sits on such a path cannot be switched. Path
// validation now refuses these paths on every write, so plant the kind of app saved
// before it did.
let resp = authed(client().post(format!("{ws}/apps/create")), ADMIN_TOKEN)
.json(&json!({
"path": "u/test-user/x:y",
"path": "u/test-user/legacy",
"summary": "App",
"value": {},
"policy": { "execution_mode": "publisher", "triggerables_v2": {} }
@@ -602,6 +591,12 @@ async fn guests_mode_needs_a_scopable_path(db: Pool<Postgres>) -> anyhow::Result
.send()
.await?;
assert_eq!(resp.status(), 201, "{}", resp.text().await?);
sqlx::query(
"UPDATE app SET path = 'u/test-user/x:y'
WHERE workspace_id = 'test-workspace' AND path = 'u/test-user/legacy'",
)
.execute(&db)
.await?;
let resp = authed(
client().post(format!("{ws}/apps/update/u/test-user/x:y")),
ADMIN_TOKEN,
@@ -610,6 +605,8 @@ async fn guests_mode_needs_a_scopable_path(db: Pool<Postgres>) -> anyhow::Result
.send()
.await?;
assert_eq!(resp.status(), 400, "switched to Guests on a `:` path");
let body = resp.text().await?;
assert!(body.contains("cannot be set to Guests"), "{body}");
Ok(())
}
+62
View File
@@ -238,3 +238,65 @@ async fn test_run_mode_rejects_traversal_component(db: Pool<Postgres>) -> anyhow
Ok(())
}
/// A deployed app's inline script (`app_script` id) runs as an `appscript` job whose
/// path job identity reads (OIDC `sub`), so it is derived server-side there too.
#[sqlx::test(fixtures("base"))]
async fn test_app_script_run_derives_path(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws = format!("http://localhost:{port}/api/w/test-workspace");
let app_path = "u/test-user/appscript_path";
let pin = rawscript_pin(CONTENT);
let resp = authed(client().post(format!("{ws}/apps/create")), "SECRET_TOKEN")
.json(&json!({
"path": app_path,
"summary": "",
"value": {},
"policy": {
"execution_mode": "viewer",
"triggerables_v2": { format!("comp:{pin}"): { "static_inputs": {}, "one_of_inputs": {} } }
}
}))
.send()
.await?;
assert_eq!(resp.status(), 201, "create app: {}", resp.text().await?);
let sha = pin.trim_start_matches("rawscript/").to_string();
let script_id: i64 = sqlx::query_scalar(
"INSERT INTO app_script (app, hash, code, code_sha256)
SELECT id, $2, $3, $2 FROM app WHERE path = $1 RETURNING id",
)
.bind(app_path)
.bind(&sha)
.bind(CONTENT)
.fetch_one(&db)
.await?;
let resp = authed(
client().post(format!("{ws}/apps_u/execute_component/{app_path}")),
"SECRET_TOKEN_2",
)
.json(&json!({
"args": {},
"component": "comp",
"id": script_id,
"raw_code": { "language": "python3", "content": "", "path": CALLER_PATH }
}))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status, 200, "deployed app script run must be accepted: {body}");
let uuid = uuid::Uuid::parse_str(body.trim())?;
let (_, _, _, _, runnable_path) = job_fields(&db, uuid).await?;
assert_eq!(
runnable_path.as_deref(),
Some(format!("{app_path}/comp").as_str()),
"an app_script run must not take the caller's path"
);
Ok(())
}
+43
View File
@@ -615,3 +615,46 @@ async fn list_groups_ssf_flow_has_schema_via_path(db: Pool<Postgres>) -> anyhow:
);
Ok(())
}
/// A member who may run the script but cannot see another user's run of it must not
/// be able to rerun that run: the rerun would hand them a job carrying its args.
#[sqlx::test(fixtures("base", "batch_rerun"))]
async fn batch_rerun_denies_invisible_source_job(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let member = windmill_api_client::create_client(
&format!("http://localhost:{}", server.addr.port()),
"SECRET_TOKEN_2".to_string(),
);
// `false` is a read (run) grant, not a denial: test-user-2 can run the script.
sqlx::query("UPDATE script SET extra_perms = '{\"u/test-user-2\": false}' WHERE hash = $1")
.bind(SCRIPT_HASH)
.execute(&db)
.await?;
let original = push_completed(&db, script_payload(), vec![("name", json!("secret"))]).await?;
let results = batch_rerun(
&member,
json!({
"job_ids": [original],
"script_options_by_path": {},
"flow_options_by_path": {},
}),
)
.await?;
assert_eq!(results.len(), 1, "the denied job must be reported");
assert!(
results[0]
.as_ref()
.is_err_and(|e| e.starts_with("Permission denied")),
"rerun must be denied by the read gate: {:?}",
results[0]
);
let pushed: i64 =
sqlx::query_scalar("SELECT count(*) FROM v2_job WHERE created_by = 'test-user-2'")
.fetch_one(&db)
.await?;
assert_eq!(pushed, 0);
Ok(())
}
+53
View File
@@ -539,4 +539,57 @@ def main():
.await;
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn relock_of_importer_runs_on_its_tag(db: Pool<Postgres>) -> anyhow::Result<()> {
for (path, hash, tag) in [
("f/tags/tagged", 7_000_001_i64, Some("mytag")),
("f/tags/plain", 7_000_002_i64, None),
] {
sqlx::query(
"INSERT INTO script(workspace_id, created_by, content, schema, summary, description, path, hash, language, tag)
VALUES ('test-workspace', 'test-user', 'def main(): pass', '{}', '', '', $1, $2, 'python3', $3)",
)
.bind(path)
.bind(hash)
.bind(tag)
.execute(&db)
.await?;
}
let importers = ["f/tags/tagged", "f/tags/plain"]
.map(
|p| windmill_dep_map::scoped_dependency_map::DependencyDependent {
importer_path: p.to_string(),
importer_kind: "script".to_string(),
importer_node_ids: None,
},
)
.to_vec();
windmill_dep_map::trigger_dependents::trigger_dependents_to_recompute_dependencies(
"test-workspace",
importers,
None,
None,
"test@windmill.dev",
"test-user",
"u/test-user",
&db,
vec![],
)
.await?;
let tags: Vec<(String, String)> = sqlx::query_as(
"SELECT runnable_path, tag FROM v2_job WHERE kind = 'dependencies' ORDER BY runnable_path",
)
.fetch_all(&db)
.await?;
assert_eq!(
tags,
vec![
("f/tags/plain".to_string(), "python3".to_string()),
("f/tags/tagged".to_string(), "mytag".to_string()),
]
);
Ok(())
}
}
+32
View File
@@ -0,0 +1,32 @@
-- Add-on fixture for job_provenance.rs (combine with `base.sql`). A deployed script
-- f/t/tool run as a step under three parents: the deployed flow f/t/agent, a flow
-- preview claiming that path, and f/t/agent running the version of another flow.
INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock)
VALUES ('test-workspace', 1111111111, 'f/t/tool', 'echo tool', 'bash', 'script', 'test-user', '{}', '', '', '');
INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, schema, extra_perms, versions)
VALUES
('test-workspace', 'f/t/agent', '', '', '{"modules":[]}', 'test-user', NOW(), '{}', '{}', ARRAY[2222222222::bigint]),
('test-workspace', 'u/test-user/evil', '', '', '{"modules":[]}', 'test-user', NOW(), '{}', '{}', ARRAY[3333333333::bigint]);
INSERT INTO flow_version (id, workspace_id, path, value, schema, created_by, created_at)
VALUES
(2222222222, 'test-workspace', 'f/t/agent', '{"modules":[]}', '{}', 'test-user', NOW()),
(3333333333, 'test-workspace', 'u/test-user/evil', '{"modules":[]}', '{}', 'test-user', NOW());
INSERT INTO v2_job (id, workspace_id, kind, runnable_path, runnable_id, parent_job, created_by, permissioned_as, permissioned_as_email)
VALUES
('3bb0c0de-0000-4000-8000-000000000001', 'test-workspace', 'flow', 'f/t/agent', 2222222222, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'),
('3bb0c0de-0000-4000-8000-000000000002', 'test-workspace', 'script', 'f/t/tool', 1111111111, '3bb0c0de-0000-4000-8000-000000000001', 'test-user', 'u/test-user', 'test@windmill.dev'),
('3bb0c0de-0000-4000-8000-000000000003', 'test-workspace', 'flowpreview', 'f/t/agent', NULL, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'),
('3bb0c0de-0000-4000-8000-000000000004', 'test-workspace', 'script', 'f/t/tool', 1111111111, '3bb0c0de-0000-4000-8000-000000000003', 'test-user', 'u/test-user', 'test@windmill.dev'),
('3bb0c0de-0000-4000-8000-000000000005', 'test-workspace', 'flow', 'f/t/agent', 3333333333, NULL, 'test-user', 'u/test-user', 'test@windmill.dev'),
('3bb0c0de-0000-4000-8000-000000000006', 'test-workspace', 'script', 'f/t/tool', 1111111111, '3bb0c0de-0000-4000-8000-000000000005', 'test-user', 'u/test-user', 'test@windmill.dev');
-- An app script run of f/t/app stamped by a deployed-app run, and one that is not
-- (an app editor preview).
INSERT INTO v2_job (id, workspace_id, kind, runnable_path, trigger_kind, trigger, created_by, permissioned_as, permissioned_as_email)
VALUES
('3bb0c0de-0000-4000-8000-000000000007', 'test-workspace', 'appscript', 'f/t/app/comp', 'app', 'f/t/app', 'test-user', 'u/test-user', 'test@windmill.dev'),
('3bb0c0de-0000-4000-8000-000000000008', 'test-workspace', 'appscript', 'f/t/app/comp', NULL, NULL, 'test-user', 'u/test-user', 'test@windmill.dev');
+125
View File
@@ -2877,6 +2877,131 @@ async fn test_flow_env_imported_flow_with_nested_branch(db: Pool<Postgres>) -> a
Ok(())
}
// A branch/loop sub-flow of a deployed flow is marked at push when its parent has no flow_env,
// own or inherited, so its worker skips `fetch_root_flow_env`. With an env two scopes up (root
// → loop → imported flow without env → parallel loop → leaf) nothing may be marked and the
// root's value must reach the leaf; with no env anywhere every loop iteration is marked.
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base"))]
async fn test_flow_env_marks_sub_flows_only_without_ancestor_env(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// Deployed, so their loops run as `FlowNode` sub-flows.
let deploy = |path: &'static str, version: i64, value: serde_json::Value| {
let db = db.clone();
async move {
sqlx::query(
"INSERT INTO flow(workspace_id, summary, description, path, versions, schema, value, edited_by) VALUES ('test-workspace', '', '', $1, ARRAY[$2]::bigint[], '{}'::jsonb, $3, 'system')",
)
.bind(path)
.bind(version)
.bind(&value)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO flow_version(id, workspace_id, path, schema, value, created_by) VALUES ($1, 'test-workspace', $2, '{}'::jsonb, $3, 'system')",
)
.bind(version)
.bind(path)
.bind(&value)
.execute(&db)
.await?;
RunJob::from(JobPayload::FlowDependencies {
path: path.to_string(),
dedicated_worker: None,
version,
debouncing_settings: Default::default(),
})
.run_until_complete(&db, false, port)
.await;
anyhow::Ok(())
}
};
deploy(
"f/system/imported_loop_no_env",
9991004,
json!({ "modules": [{ "id": "inner", "value": {
"type": "forloopflow",
"iterator": { "type": "javascript", "expr": "[1, 2]" },
"skip_failures": false,
"parallel": true,
"modules": [{ "id": "leaf", "value": {
"type": "rawscript",
"language": "deno",
"content": "export function main(key: any) { return key ?? null; }",
"input_transforms": { "key": { "type": "javascript", "expr": "flow_env.KEY" } }
}}, { "id": "tail", "value": {
// A single simple step is inlined rather than stored as a flow node.
"type": "rawscript",
"language": "deno",
"content": "export function main(prev: any) { return prev; }",
"input_transforms": { "prev": { "type": "javascript", "expr": "results.leaf" } }
}}]
}}]}),
)
.await?;
let root = |flow_env: serde_json::Value| {
json!({ "flow_env": flow_env, "modules": [{ "id": "outer", "value": {
"type": "forloopflow",
"iterator": { "type": "javascript", "expr": "[1]" },
"skip_failures": false,
"parallel": false,
"modules": [{ "id": "import", "value": {
"type": "flow",
"path": "f/system/imported_loop_no_env",
"input_transforms": {}
}}]
}}]})
};
deploy(
"f/system/root_with_env",
9991005,
root(json!({ "KEY": "from-root" })),
)
.await?;
deploy("f/system/root_no_env", 9991006, root(json!(null))).await?;
let run = |path: &'static str, version: i64| {
let db = db.clone();
async move {
let completed = RunJob::from(JobPayload::Flow {
path: path.to_string(),
dedicated_worker: None,
apply_preprocessor: false,
version,
labels: None,
})
.run_until_complete(&db, false, port)
.await;
// The mark of every loop iteration pushed under the run.
let marks = sqlx::query_as::<_, (String, Option<bool>)>(
"SELECT j.kind::text, (c.flow_status->>'no_inherited_flow_env')::bool
FROM v2_job j JOIN v2_job_completed c ON c.id = j.id
WHERE COALESCE(j.root_job, j.parent_job) = $1
AND j.flow_step_id IN ('outer', 'inner')",
)
.bind(completed.id)
.fetch_all(&db)
.await?;
anyhow::Ok((completed.json_result().unwrap(), marks))
}
};
let (result, marks) = run("f/system/root_with_env", 9991005).await?;
assert_eq!(result, json!([["from-root", "from-root"]]));
assert_eq!(marks, vec![("flownode".to_string(), None); 3]);
let (result, marks) = run("f/system/root_no_env", 9991006).await?;
assert_eq!(result, json!([[null, null]]));
assert_eq!(marks, vec![("flownode".to_string(), Some(true)); 3]);
Ok(())
}
// stop_after_if predicate sees flow_env. Regression for the eval at line 614
// of `update_flow_status_after_job_completion_internal` which used to pass
// `None` for flow_env unconditionally.
+108
View File
@@ -0,0 +1,108 @@
//! A flow step's completion is the progress that keeps its flow from being reaped as a zombie: it
//! must refresh the parent's ping, canceled or not, since a canceled flow still needs its next
//! transition to complete.
use serde_json::json;
use sqlx::{types::Json, Pool, Postgres};
use uuid::Uuid;
use windmill_queue::{add_completed_job, get_mini_completed_job};
const W_ID: &str = "test-workspace";
async fn insert_running(
db: &Pool<Postgres>,
kind: &str,
parent: Option<Uuid>,
step_id: Option<&str>,
) -> anyhow::Result<Uuid> {
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO v2_job (id, workspace_id, created_by, created_at, permissioned_as, \
permissioned_as_email, kind, runnable_path, script_lang, tag, visible_to_owner, \
parent_job, flow_step_id) \
VALUES ($1, $2, 'test-user', now(), 'u/test-user', 'test@windmill.dev', \
$3::job_kind, 'u/test-user/f', 'bash', 'bash', true, $4, $5)",
)
.bind(id)
.bind(W_ID)
.bind(kind)
.bind(parent)
.bind(step_id)
.execute(db)
.await?;
sqlx::query(
"INSERT INTO v2_job_queue (id, workspace_id, scheduled_for, running, started_at, tag) \
VALUES ($1, $2, now(), true, now(), 'bash')",
)
.bind(id)
.bind(W_ID)
.execute(db)
.await?;
sqlx::query("INSERT INTO v2_job_runtime (id, ping) VALUES ($1, now() - interval '1 hour')")
.bind(id)
.execute(db)
.await?;
Ok(id)
}
async fn complete_step_and_read_parent_ping_age(
db: &Pool<Postgres>,
canceled: bool,
) -> anyhow::Result<f64> {
let parent = insert_running(db, "flow", None, None).await?;
let step = insert_running(db, "script", Some(parent), Some("a")).await?;
if canceled {
// a cancel of the flow marks the flow and each of its steps
sqlx::query("UPDATE v2_job_queue SET canceled_by = 'test-user' WHERE id = ANY($1)")
.bind(vec![parent, step])
.execute(db)
.await?;
}
let job = get_mini_completed_job(&step, W_ID, db).await?.unwrap();
add_completed_job(
db,
&job,
true,
false,
Json(&json!("done")),
None,
0,
None,
false,
None,
false,
)
.await?;
let (status, queued): (String, bool) = sqlx::query_as(
"SELECT status::text, EXISTS (SELECT 1 FROM v2_job_queue WHERE id = $1) \
FROM v2_job_completed WHERE id = $1",
)
.bind(step)
.fetch_one(db)
.await?;
assert_eq!(status, if canceled { "canceled" } else { "success" });
assert!(!queued, "the step left the queue");
Ok(sqlx::query_scalar(
"SELECT EXTRACT(EPOCH FROM now() - ping)::float8 FROM v2_job_runtime WHERE id = $1",
)
.bind(parent)
.fetch_one(db)
.await?)
}
#[sqlx::test(fixtures("base"))]
async fn a_step_completion_refreshes_its_running_flow_ping(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
let age = complete_step_and_read_parent_ping_age(&db, false).await?;
assert!(age < 60.0, "the parent's ping was refreshed: {age}s old");
let age = complete_step_and_read_parent_ping_age(&db, true).await?;
assert!(
age < 60.0,
"a canceled parent's ping was refreshed: {age}s old"
);
Ok(())
}
+31
View File
@@ -1608,3 +1608,34 @@ async fn declarative_sync_rejects_an_unusable_default_allowed_origins(db: Pool<P
.await
.expect("a valid origin list must sync");
}
/// The accent color is interpolated into a stylesheet every user loads, so the operator
/// path must refuse anything but `#rrggbb` just like the settings API does.
#[sqlx::test(fixtures("base"))]
async fn declarative_sync_rejects_a_non_hex_accent_color(db: Pool<Postgres>) {
clear_settings_and_configs(&db).await;
let mut desired = BTreeMap::new();
desired.insert(
"accent_color".to_string(),
serde_json::json!("#000;}body{display:none"),
);
let err = windmill_common::instance_config::sync_global_settings_declarative(
&db,
&BTreeMap::new(),
&desired,
)
.await
.expect_err("a non-hex accent color must fail the sync");
assert!(err.to_string().contains("accent_color"), "got: {err}");
assert!(get_global_setting(&db, "accent_color").await.is_none());
desired.insert("accent_color".to_string(), serde_json::json!("#1f9d55"));
windmill_common::instance_config::sync_global_settings_declarative(
&db,
&BTreeMap::new(),
&desired,
)
.await
.expect("a hex accent color must sync");
}
+1
View File
@@ -239,6 +239,7 @@ mod job_payload {
let result = RunJob::from(JobPayload::FlowNode {
id,
path: "f/system/hello_with_nodes_flow/forloop-0".into(),
no_inherited_flow_env: false,
})
.arg("iter", json!({ "value": "tests", "index": 0 }))
.run_until_complete(&db, false, port)
+44
View File
@@ -0,0 +1,44 @@
//! `deployed` gates what job OIDC tokens let external trust policies accept, so a job
//! whose code or path the caller chose must never come out deployed.
use sqlx::{Pool, Postgres};
use uuid::Uuid;
use windmill_common::job_provenance::job_provenance;
async fn deployed(db: &Pool<Postgres>, job: &str) -> bool {
job_provenance(db, &Uuid::parse_str(job).unwrap(), "test-workspace")
.await
.unwrap()
.unwrap()
.deployed
}
#[sqlx::test(fixtures("base", "job_provenance"))]
async fn step_of_deployed_flow_is_deployed(db: Pool<Postgres>) {
let p = job_provenance(
&db,
&Uuid::parse_str("3bb0c0de-0000-4000-8000-000000000002").unwrap(),
"test-workspace",
)
.await
.unwrap()
.unwrap();
assert!(p.deployed);
assert_eq!(p.root_path.as_deref(), Some("f/t/agent"));
}
#[sqlx::test(fixtures("base", "job_provenance"))]
async fn step_under_flow_preview_is_not_deployed(db: Pool<Postgres>) {
assert!(!deployed(&db, "3bb0c0de-0000-4000-8000-000000000004").await);
}
#[sqlx::test(fixtures("base", "job_provenance"))]
async fn flow_running_another_flows_version_is_not_deployed(db: Pool<Postgres>) {
assert!(!deployed(&db, "3bb0c0de-0000-4000-8000-000000000006").await);
}
#[sqlx::test(fixtures("base", "job_provenance"))]
async fn app_script_is_deployed_only_from_a_deployed_app_run(db: Pool<Postgres>) {
assert!(deployed(&db, "3bb0c0de-0000-4000-8000-000000000007").await);
assert!(!deployed(&db, "3bb0c0de-0000-4000-8000-000000000008").await);
}
+91 -4
View File
@@ -513,13 +513,17 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
let (status, secret) = get(
&authed_base,
&format!("job_signature/{STEP_JOB}/0"),
Some("SECRET_TOKEN_2"),
Some("SECRET_TOKEN"),
)
.await;
assert!(status.is_success(), "owner must mint a resume secret: {secret}");
assert!(
status.is_success(),
"an admin must mint a resume secret: {secret}"
);
let secret = secret.trim().trim_matches('"').to_string();
let approval_result =
format!("completed/get_result/{STEP_JOB}?suspended_job={STEP_JOB}&resume_id=0&secret={secret}");
let approval_result = format!(
"completed/get_result/{STEP_JOB}?suspended_job={STEP_JOB}&resume_id=0&secret={secret}"
);
let (status, body) = get(&base, &approval_result, None).await;
assert!(
status.is_success(),
@@ -991,6 +995,89 @@ async fn test_single_job_read_authorization(db: Pool<Postgres>) -> anyhow::Resul
!status.is_success(),
"a public token must not let an anonymous caller cancel the run (got {status}): {body}"
);
// ---- RUN_NOW is gated like cancel, and only moves a job still waiting for a
// future start. ----
let run_now = format!("queue/run_now/{RUNNING_JOB}");
let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_3")).await;
assert_eq!(
status,
reqwest::StatusCode::FORBIDDEN,
"viewer must not start another user's job early (got {status}): {body}"
);
let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await;
assert_eq!(
status,
reqwest::StatusCode::BAD_REQUEST,
"a running job has no future start to move (got {status}): {body}"
);
sqlx::query(
"UPDATE v2_job_queue SET running = false, scheduled_for = now() + interval '1 hour'
WHERE id = $1::uuid",
)
.bind(RUNNING_JOB)
.execute(&db)
.await?;
let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await;
assert!(
status.is_success(),
"owner must start their deferred job now (got {status}): {body}"
);
let due: bool =
sqlx::query_scalar("SELECT scheduled_for <= now() FROM v2_job_queue WHERE id = $1::uuid")
.bind(RUNNING_JOB)
.fetch_one(&db)
.await?;
assert!(due, "run_now must make the job due immediately");
// A schedule tick that is not due yet is refused: the schedule would queue that same
// tick again on completion and run twice.
sqlx::query(
"INSERT INTO schedule (workspace_id, path, edited_by, schedule, script_path, permissioned_as)
VALUES ('test-workspace', 'u/test-user-2/daily', 'test-user-2', '0 0 3 * * *',
'u/test-user-2/running_secret', 'u/test-user-2')",
)
.execute(&db)
.await?;
sqlx::query(
"UPDATE v2_job SET trigger_kind = 'schedule', trigger = 'u/test-user-2/daily'
WHERE id = $1::uuid",
)
.bind(RUNNING_JOB)
.execute(&db)
.await?;
sqlx::query(
"UPDATE v2_job_queue SET scheduled_for =
(date_trunc('day', now() AT TIME ZONE 'UTC') + interval '1 day 3 hours') AT TIME ZONE 'UTC'
WHERE id = $1::uuid",
)
.bind(RUNNING_JOB)
.execute(&db)
.await?;
let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await;
assert_eq!(
status,
reqwest::StatusCode::BAD_REQUEST,
"an upcoming schedule tick must not be started early (got {status}): {body}"
);
// A tick a concurrency limit pushed past its time is off the cron occurrences and
// may still be started early.
sqlx::query(
"UPDATE v2_job_queue SET scheduled_for = now() + interval '1 hour 37 seconds 123 milliseconds'
WHERE id = $1::uuid",
)
.bind(RUNNING_JOB)
.execute(&db)
.await?;
let (status, body) = post(&authed_base, &run_now, Some("SECRET_TOKEN_2")).await;
assert!(
status.is_success(),
"a deferred schedule tick must still start now (got {status}): {body}"
);
sqlx::query("UPDATE v2_job SET trigger_kind = NULL, trigger = NULL WHERE id = $1::uuid")
.bind(RUNNING_JOB)
.execute(&db)
.await?;
// The owner still cancels their own job (no over-blocking). Keep this last: it
// takes RUNNING_JOB out of the queue.
let (status, body) = post(
+62
View File
@@ -0,0 +1,62 @@
//! `GET /path_autocomplete/list_paths` returns only the paths the caller can read,
//! and its cache never serves one caller's list to another.
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
async fn list_paths(port: u16, token: &str) -> anyhow::Result<Vec<String>> {
let resp = reqwest::Client::new()
.get(format!(
"http://localhost:{port}/api/w/test-workspace/path_autocomplete/list_paths"
))
.header("Authorization", format!("Bearer {token}"))
.send()
.await?;
assert_eq!(resp.status(), 200, "list_paths: {}", resp.text().await?);
let body: serde_json::Value = resp.json().await?;
Ok(serde_json::from_value(body["paths"].clone())?)
}
#[sqlx::test(fixtures("base"))]
async fn test_list_paths_is_scoped_to_caller(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query(
"INSERT INTO folder (workspace_id, name, display_name, owners, extra_perms)
VALUES ('test-workspace', 'secret', 'secret', '{}', '{}')",
)
.execute(&db)
.await?;
for path in [
"f/secret/hidden",
"u/test-user/private",
"u/test-user-2/mine",
] {
sqlx::query(
"INSERT INTO variable (workspace_id, path, value, is_secret, description)
VALUES ('test-workspace', $1, 'x', false, '')",
)
.bind(path)
.execute(&db)
.await?;
}
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// The admin's call fills the cache first, so a cache shared across callers
// would hand the admin's list to the non-admin.
assert_eq!(
list_paths(port, "SECRET_TOKEN").await?,
[
"f/secret/hidden",
"u/test-user-2/mine",
"u/test-user/private"
]
);
assert_eq!(
list_paths(port, "SECRET_TOKEN_2").await?,
["u/test-user-2/mine"]
);
Ok(())
}
+135
View File
@@ -0,0 +1,135 @@
//! A restart's `flow_version` is caller-supplied while the restarted job keeps the original
//! job's path, so only versions of that same flow may be restarted on.
use sqlx::{Pool, Postgres};
use windmill_common::cache;
use windmill_common::error::Error;
use windmill_common::flow_status::FlowStatus;
use windmill_common::jobs::JobPayload;
use windmill_queue::PushIsolationLevel;
const WS: &str = "test-workspace";
async fn push_restart(
db: &Pool<Postgres>,
completed_job_id: uuid::Uuid,
flow_version: i64,
) -> Result<uuid::Uuid, Error> {
let args = std::collections::HashMap::new();
let (uuid, tx) = windmill_queue::push(
db,
PushIsolationLevel::IsolatedRoot(db.clone()),
WS,
JobPayload::RestartedFlow {
completed_job_id,
step_id: "a".into(),
branch_or_iteration_n: None,
flow_version: Some(flow_version),
branch_chosen: None,
nested: None,
},
windmill_queue::PushArgs::from(&args),
"test-user",
"test@windmill.dev",
"u/test-user".to_string(),
None,
None,
None,
None,
None,
None,
None,
None,
false,
false,
None,
true,
None,
None,
None,
None,
None,
false,
None,
None,
None,
)
.await?;
tx.commit().await?;
Ok(uuid)
}
#[sqlx::test(fixtures("base", "hello"))]
async fn test_restart_rejects_flow_version_of_another_path(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
// A completed run of f/system/hello_flow (version 1443253234253453).
let version = 1443253234253453;
let flow = cache::flow::fetch_version(&db, version).await?;
let completed_job_id = uuid::Uuid::new_v4();
sqlx::query(
"INSERT INTO v2_job (id, workspace_id, kind, runnable_path, runnable_id, created_by,
permissioned_as, permissioned_as_email, tag)
VALUES ($1, $2, 'flow', 'f/system/hello_flow', $3, 'test-user', 'u/test-user',
'test@windmill.dev', 'flow')",
)
.bind(completed_job_id)
.bind(WS)
.bind(version)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO v2_job_completed (id, workspace_id, duration_ms, status, flow_status)
VALUES ($1, $2, 0, 'success', $3)",
)
.bind(completed_job_id)
.bind(WS)
.bind(serde_json::to_value(FlowStatus::new(flow.value()))?)
.execute(&db)
.await?;
// f/system/hello_with_nodes_flow's version, in the same workspace.
let err = push_restart(&db, completed_job_id, 1443253234253454)
.await
.expect_err("a version of another flow must be rejected");
assert!(matches!(err, Error::BadRequest(_)), "{err:?}");
// A version at the same path in another workspace.
sqlx::query(
"INSERT INTO workspace (id, name, owner) VALUES ('other-workspace', 'other-workspace', 'test-user')",
)
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO flow (workspace_id, path, summary, description, value, edited_by)
SELECT 'other-workspace', path, summary, description, value, edited_by FROM flow
WHERE workspace_id = $1 AND path = 'f/system/hello_flow'",
)
.bind(WS)
.execute(&db)
.await?;
let other_ws_version: i64 = sqlx::query_scalar(
"INSERT INTO flow_version (workspace_id, path, schema, value, created_by)
SELECT 'other-workspace', path, schema, value, created_by FROM flow_version WHERE id = $1
RETURNING id",
)
.bind(version)
.fetch_one(&db)
.await?;
let err = push_restart(&db, completed_job_id, other_ws_version)
.await
.expect_err("a version from another workspace must be rejected");
assert!(matches!(err, Error::BadRequest(_)), "{err:?}");
// Another version of the restarted flow itself stays allowed.
let same_path_version: i64 = sqlx::query_scalar(
"INSERT INTO flow_version (workspace_id, path, schema, value, created_by)
SELECT workspace_id, path, schema, value, created_by FROM flow_version WHERE id = $1
RETURNING id",
)
.bind(version)
.fetch_one(&db)
.await?;
push_restart(&db, completed_job_id, same_path_version).await?;
Ok(())
}
+136
View File
@@ -0,0 +1,136 @@
//! `parent_job` / `root_job` on the run endpoints set the lineage a job is identified by
//! (`WM_FLOW_JOB_ID`, `WM_ROOT_FLOW_JOB_ID`, the OIDC token's flow path). Only a job's own
//! `WM_TOKEN` may claim that job or its ancestors, and a workspace admin any job of the
//! workspace; a lineage anyone else names is dropped from the pushed job.
use reqwest::StatusCode;
use sqlx::{Pool, Postgres};
use uuid::Uuid;
use windmill_test_utils::*;
const ADMIN_JOB: &str = "a0000000-0000-0000-0000-000000000001";
const ROOT_JOB: &str = "a0000000-0000-0000-0000-000000000002";
const USER_JOB: &str = "a0000000-0000-0000-0000-000000000003";
async fn insert_job(
db: &Pool<Postgres>,
id: &str,
user: &str,
email: &str,
root: Option<&str>,
) -> anyhow::Result<()> {
let root = root.map(Uuid::parse_str).transpose()?;
sqlx::query(
"INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, permissioned_as_email,
kind, script_lang, runnable_path, tag, parent_job, root_job, flow_innermost_root_job)
VALUES ($1, 'test-workspace', $2, 'u/' || $2, $3, 'script', 'deno', 'u/x/y', 'deno',
$4, $4, $4)",
)
.bind(Uuid::parse_str(id)?)
.bind(user)
.bind(email)
.bind(root)
.execute(db)
.await?;
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_run_lineage_must_be_the_callers_own(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query(
"INSERT INTO script (workspace_id, created_by, content, schema, summary, description,
path, hash, language, lock, kind)
VALUES ('test-workspace', 'test-user-3', 'export function main() {}', '{}', '', '',
'u/test-user-3/probe', 424242, 'deno', '', 'script')",
)
.execute(&db)
.await?;
insert_job(&db, ADMIN_JOB, "test-user", "test@windmill.dev", None).await?;
insert_job(&db, ROOT_JOB, "test-user-3", "test3@windmill.dev", None).await?;
insert_job(
&db,
USER_JOB,
"test-user-3",
"test3@windmill.dev",
Some(ROOT_JOB),
)
.await?;
let server = ApiServer::start(db.clone()).await?;
set_jwt_secret().await;
let job_token = windmill_common::auth::create_token_for_owner(
&db,
"test-workspace",
"u/test-user-3",
"ephemeral-script",
300,
"test3@windmill.dev",
&Uuid::parse_str(USER_JOB)?,
None,
None,
)
.await?;
let client = reqwest::Client::new();
let cases = [
// a user's own token claims neither someone else's job nor even its own
(
"SECRET_TOKEN_3",
Some(ADMIN_JOB),
Some(ADMIN_JOB),
None,
None,
),
("SECRET_TOKEN_3", Some(USER_JOB), None, None, None),
// what the SDKs send from inside a job: that job and its root flow
(
&job_token,
Some(USER_JOB),
Some(ROOT_JOB),
Some(USER_JOB),
Some(ROOT_JOB),
),
(
&job_token,
Some(USER_JOB),
Some(ADMIN_JOB),
Some(USER_JOB),
None,
),
("SECRET_TOKEN", Some(USER_JOB), None, Some(USER_JOB), None),
];
for (token, parent, root, kept_parent, kept_root) in cases {
let query = [("parent_job", parent), ("root_job", root)]
.into_iter()
.filter_map(|(k, v)| v.map(|v| format!("{k}={v}")))
.collect::<Vec<_>>()
.join("&");
let resp = client
.post(format!(
"http://localhost:{}/api/w/test-workspace/jobs/run/p/u/test-user-3/probe?{query}",
server.addr.port()
))
.bearer_auth(token)
.json(&serde_json::json!({}))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(status, StatusCode::CREATED, "{query}: {body}");
let (stored_parent, stored_root): (Option<Uuid>, Option<Uuid>) =
sqlx::query_as("SELECT parent_job, flow_innermost_root_job FROM v2_job WHERE id = $1")
.bind(Uuid::parse_str(&body)?)
.fetch_one(&db)
.await?;
let expected = |id: Option<&str>| id.map(Uuid::parse_str).transpose();
assert_eq!(
stored_parent,
expected(kept_parent)?,
"parent_job for {query}"
);
assert_eq!(stored_root, expected(kept_root)?, "root_job for {query}");
}
Ok(())
}
+115
View File
@@ -0,0 +1,115 @@
use std::collections::HashMap;
use serde_json::{json, Value};
use sqlx::{Pool, Postgres};
use windmill_api_client::types::{NewScript, ScriptLang};
use windmill_test_utils::init_client;
const PATH: &str = "u/test-user/inferred_schema";
fn bun_ns(content: &str, parent_hash: Option<String>) -> NewScript {
NewScript {
draft_only: None,
content: content.into(),
language: ScriptLang::Bun,
lock: None,
parent_hash,
path: PATH.into(),
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
dedicated_worker: None,
description: "".to_string(),
envs: vec![],
is_template: None,
kind: None,
summary: "".to_string(),
tag: None,
// What this client sends for "no schema".
schema: HashMap::new(),
ws_error_handler_muted: Some(false),
priority: None,
delete_after_secs: None,
timeout: None,
restart_unless_cancelled: None,
deployment_message: None,
concurrency_key: None,
visible_to_runner_only: None,
auto_kind: None,
codebase: None,
has_preprocessor: None,
on_behalf_of_email: None,
assets: vec![],
modules: None,
}
}
async fn head(db: &Pool<Postgres>) -> anyhow::Result<(i64, Value)> {
Ok(sqlx::query_as(
"SELECT hash, schema::jsonb FROM script \
WHERE workspace_id = 'test-workspace' AND path = $1 AND archived = false",
)
.bind(PATH)
.fetch_one(db)
.await?)
}
/// A deploy without a schema (MCP, a bare API call) gets the one the editor would
/// have sent, and a new version keeps what the user wrote on the previous one's.
#[sqlx::test(fixtures("base"))]
async fn schemaless_deploy_infers_schema_keeping_annotations(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
let (client, _port, _s) = init_client(db.clone()).await;
client
.create_script(
"test-workspace",
&bun_ns(
"export function main(n: number, name = \"x\") { return n }",
None,
),
)
.await?;
let (hash, schema) = head(&db).await?;
assert_eq!(schema["required"], json!(["n"]));
assert_eq!(schema["properties"]["n"]["type"], "number");
assert_eq!(schema["properties"]["name"]["default"], "x");
sqlx::query(
"UPDATE script SET schema = jsonb_set(schema::jsonb, '{properties,n,description}', \
'\"how many\"')::json WHERE hash = $1",
)
.bind(hash)
.execute(&db)
.await?;
client
.create_script(
"test-workspace",
&bun_ns(
"export function main(n: number, flag: boolean) { return n }",
Some(format!("{hash:016x}")),
),
)
.await?;
let (hash, schema) = head(&db).await?;
assert_eq!(schema["required"], json!(["n", "flag"]));
assert_eq!(schema["properties"]["n"]["description"], "how many");
assert_eq!(schema["properties"]["flag"]["type"], "boolean");
assert!(schema["properties"].get("name").is_none());
// Code that does not parse keeps the schema rather than losing it for good.
client
.create_script(
"test-workspace",
&bun_ns(
"export function main(n: number {",
Some(format!("{hash:016x}")),
),
)
.await?;
assert_eq!(head(&db).await?.1, schema);
Ok(())
}
+103 -5
View File
@@ -201,7 +201,10 @@ async fn wac_approval_urls_mint_guards(db: Pool<Postgres>) -> anyhow::Result<()>
let client = reqwest::Client::new();
let mint = |ws: &str, job: &str, key: &str| {
let url = format!("{base}/w/{ws}/jobs/wac_approval_urls/{job}/{key}");
client.get(url).header("Authorization", "Bearer SECRET_TOKEN").send()
client
.get(url)
.header("Authorization", "Bearer SECRET_TOKEN")
.send()
};
assert_eq!(
@@ -227,8 +230,15 @@ async fn wac_approval_urls_mint_guards(db: Pool<Postgres>) -> anyhow::Result<()>
let resp = mint("test-workspace", WAC_JOB, APPROVAL_COLLISION_B).await?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
assert_eq!(status, reqwest::StatusCode::BAD_REQUEST, "colliding key: {body}");
assert!(body.contains(APPROVAL_COLLISION_A), "error must name the other key: {body}");
assert_eq!(
status,
reqwest::StatusCode::BAD_REQUEST,
"colliding key: {body}"
);
assert!(
body.contains(APPROVAL_COLLISION_A),
"error must name the other key: {body}"
);
Ok(())
}
@@ -236,7 +246,9 @@ async fn wac_approval_urls_mint_guards(db: Pool<Postgres>) -> anyhow::Result<()>
/// Colliding keys share one resume_job row and one capability, so the mint that
/// records them must let exactly one through however the requests interleave.
#[sqlx::test(fixtures("base", "wac_approval_urls"))]
async fn wac_concurrent_colliding_mints_admit_exactly_one(db: Pool<Postgres>) -> anyhow::Result<()> {
async fn wac_concurrent_colliding_mints_admit_exactly_one(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!("http://localhost:{}/api", server.addr.port());
@@ -262,6 +274,92 @@ async fn wac_concurrent_colliding_mints_admit_exactly_one(db: Pool<Postgres>) ->
other => anyhow::bail!("unexpected status {other}"),
}
}
assert_eq!((ok, rejected), (1, 1), "exactly one colliding key may be minted");
assert_eq!(
(ok, rejected),
(1, 1),
"exactly one colliding key may be minted"
);
Ok(())
}
/// A resume signature skips the step's approval_conditions, so only the suspended job's own
/// run (its `WM_TOKEN`) or a workspace admin may mint one, and a signature made with another
/// workspace's key must not reach a job outside that workspace.
#[sqlx::test(fixtures("base", "wac_approval_urls"))]
async fn resume_signatures_are_bound_to_the_run_and_its_workspace(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
use hmac::Mac;
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let base = format!("http://localhost:{}/api", server.addr.port());
let client = reqwest::Client::new();
set_jwt_secret().await;
let job_token = windmill_common::auth::create_token_for_owner(
&db,
"test-workspace",
"u/test-user-2",
"ephemeral-script",
300,
"test2@windmill.dev",
&uuid::Uuid::parse_str(WAC_JOB)?,
None,
None,
)
.await?;
for route in ["job_signature", "resume_urls"] {
for (token, allowed) in [
("SECRET_TOKEN_2", false),
(&job_token, true),
("SECRET_TOKEN", true),
] {
let status = client
.get(format!("{base}/w/test-workspace/jobs/{route}/{WAC_JOB}/7"))
.bearer_auth(token)
.send()
.await?
.status();
assert_eq!(
status.is_success(),
allowed,
"{route} with a {} token: {status}",
if token == job_token { "job" } else { token }
);
}
}
let mut mac = hmac::Hmac::<sha2::Sha256>::new_from_slice(b"test-key-2")?;
mac.update(uuid::Uuid::parse_str(WAC_JOB)?.as_bytes());
mac.update(&5u32.to_be_bytes());
let foreign = hex::encode(mac.finalize().into_bytes());
for op in ["resume", "cancel"] {
let status = client
.post(format!(
"{base}/w/test-workspace-2/jobs_u/{op}/{WAC_JOB}/5/{foreign}"
))
.json(&serde_json::json!({}))
.send()
.await?
.status();
assert_eq!(
status,
reqwest::StatusCode::NOT_FOUND,
"{op} from test-workspace-2"
);
}
let (suspend, resumes): (i32, i64) = sqlx::query_as(
"SELECT suspend, (SELECT count(*) FROM resume_job WHERE job = $1::uuid)
FROM v2_job_queue WHERE id = $1::uuid",
)
.bind(WAC_JOB)
.fetch_one(&db)
.await?;
assert_eq!(
(suspend, resumes),
(1, 0),
"the job must still be suspended"
);
Ok(())
}
+1 -1
View File
@@ -6,7 +6,7 @@ edition.workspace = true
[features]
default = []
bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config"]
bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config", "windmill-common/aws_auth"]
mcp = ["dep:windmill-mcp"]
[lib]
+258
View File
@@ -2,6 +2,7 @@
//!
//! This module provides:
//! - BedrockClient: SDK wrapper with bearer token and IAM credentials auth
//! - OIDC role assumption: exchange a Windmill OIDC token for temporary IAM keys
//! - Message/tool conversion: OpenAI format <-> Bedrock Converse API format
//! - Stream event parsing: Extract text/tool deltas from Bedrock stream events
//!
@@ -291,6 +292,187 @@ impl BedrockClient {
}
}
// ============================================================================
// OIDC Role Assumption
// ============================================================================
pub use windmill_common::auth::aws::AWS_OIDC_AUDIENCE;
/// Bedrock credential precedence: a bearer API key wins, then a complete pair of
/// explicit IAM keys, then OIDC role assumption, then the ambient environment.
/// Returns the role to assume only when both higher-priority modes are unset, so
/// a resource can carry a role ARN without it ever overriding keys set on it.
pub fn bedrock_oidc_role_to_assume<'a>(
api_key: Option<&str>,
aws_access_key_id: Option<&str>,
aws_secret_access_key: Option<&str>,
oidc_role_arn: Option<&'a str>,
) -> Option<&'a str> {
let has_bearer_token = api_key.is_some_and(|key| !key.is_empty());
let has_iam_keys = aws_access_key_id.is_some_and(|key| !key.is_empty())
&& aws_secret_access_key.is_some_and(|key| !key.is_empty());
if has_bearer_token || has_iam_keys {
return None;
}
oidc_role_arn.filter(|arn| !arn.is_empty())
}
/// Re-assume the role this long before AWS expires the credentials, so a request
/// that starts on a reused set cannot finish holding dead ones.
pub const ASSUMED_ROLE_REFRESH_MARGIN: std::time::Duration = std::time::Duration::from_secs(120);
/// Temporary IAM credentials minted by STS `AssumeRoleWithWebIdentity`.
#[derive(Clone, Debug)]
pub struct AssumedRoleCredentials {
pub access_key_id: String,
pub secret_access_key: String,
pub session_token: String,
pub expires_at: std::time::SystemTime,
}
impl AssumedRoleCredentials {
/// Whether these have enough life left to sign another request.
pub fn is_fresh(&self) -> bool {
self.expires_at > std::time::SystemTime::now() + ASSUMED_ROLE_REFRESH_MARGIN
}
}
/// Assume the resource's OIDC role for a job, reusing `cached` while it is still
/// fresh, and hand back the temporary keys to sign Bedrock requests with.
///
/// The agent loop calls this once per iteration, so without the reuse a
/// tool-heavy run would mint an OIDC token and call STS on every model turn.
/// Returns `None` when a higher-priority credential is set, so the caller can
/// call it unconditionally.
pub async fn refresh_bedrock_oidc_credentials<'a>(
credentials: &crate::credentials::ProviderCredentials,
cached: &'a mut Option<AssumedRoleCredentials>,
client: &windmill_common::client::AuthedClient,
job_id: &uuid::Uuid,
) -> Result<Option<&'a AssumedRoleCredentials>, Error> {
let Some(role_arn) = bedrock_oidc_role_to_assume(
credentials.api_key.as_deref(),
credentials.aws_access_key_id.as_deref(),
credentials.aws_secret_access_key.as_deref(),
credentials.oidc_role_arn.as_deref(),
) else {
return Ok(None);
};
// Ahead of the reuse check, so a request never signs against the empty region
// just because an earlier one had already assumed the role.
let region = bedrock_oidc_region(credentials.region.as_deref())?;
if !cached
.as_ref()
.is_some_and(AssumedRoleCredentials::is_fresh)
{
// A worker holds no OIDC signing key, so it asks the API to mint the
// token for this job; the session name carries the job into CloudTrail.
let id_token = client
.get_id_token(AWS_OIDC_AUDIENCE)
.await
.map_err(|e| Error::internal_err(format!("Failed to get OIDC token: {}", e)))?;
// The whole UUID: "windmill-ai-job-" plus 32 hex is 48 characters, well
// inside AWS's limit, and a truncated one would make two runs
// indistinguishable in CloudTrail.
let session_name = aws_role_session_name("windmill-ai-job", &job_id.simple().to_string());
*cached =
Some(assume_role_with_oidc_token(role_arn, region, id_token, &session_name).await?);
}
Ok(cached.as_ref())
}
/// Distinguishing digest appended to a session name that had to be truncated.
const SESSION_NAME_DIGEST_LEN: usize = 8;
/// Build a role session name AWS accepts: it constrains them to
/// `[\w+=,.@-]{2,64}`, and rejects the whole request otherwise. Disallowed
/// characters are replaced rather than dropped.
///
/// The name is the only thing carrying the caller into the assumed-role ARN and
/// CloudTrail, so an identity too long to fit keeps a prefix plus a digest of the
/// whole thing: two identities sharing a prefix would otherwise be
/// indistinguishable in exactly the audit trail this feature exists to produce.
pub fn aws_role_session_name(prefix: &str, identity: &str) -> String {
let sanitize = |s: &str| -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '_' | '+' | '=' | ',' | '.' | '@' | '-')
{
c
} else {
'-'
}
})
.collect()
};
let name = sanitize(&format!("{}-{}", prefix, identity));
if name.len() <= 64 {
return name;
}
let digest = &windmill_common::utils::calculate_hash(identity)[..SESSION_NAME_DIGEST_LEN];
let kept = 64 - SESSION_NAME_DIGEST_LEN - 1;
format!("{}-{}", &name[..kept], digest)
}
/// The region an OIDC role assumption runs in.
///
/// The assumption resolves to explicit IAM keys, and the SDK client built from
/// those has no ambient-region fallback the way [`BedrockClient::from_env`] does,
/// so a resource that assumes a role has to name its region. Callers check this
/// before minting the OIDC token, so a misconfigured resource never issues an
/// identity token it is only going to throw away.
pub fn bedrock_oidc_region(region: Option<&str>) -> Result<&str, Error> {
region.filter(|r| !r.is_empty()).ok_or_else(|| {
Error::BadRequest(
"AWS Bedrock resources that assume a role through OIDC must set a region".to_string(),
)
})
}
/// Exchange a Windmill OIDC token for temporary IAM credentials on `role_arn`.
pub async fn assume_role_with_oidc_token(
role_arn: &str,
region: &str,
id_token: String,
session_name: &str,
) -> Result<AssumedRoleCredentials, Error> {
use windmill_common::auth::aws::{
get_assume_role_with_web_identity_fluent_builder, GetAuthenticationOutput, OidcAuth,
};
let oidc_auth = OidcAuth { region: Some(region.to_string()), role_arn: role_arn.to_string() };
let output =
get_assume_role_with_web_identity_fluent_builder(&oidc_auth, id_token, Some(session_name))
.await?
.send()
.await
.map_err(|e| {
Error::internal_err(format!(
"Failed to assume AWS role {} with Windmill's OIDC token: {}",
role_arn,
format_bedrock_error(&e)
))
})?;
let credentials = output.get_credentials()?;
Ok(AssumedRoleCredentials {
access_key_id: credentials.access_key_id.clone(),
secret_access_key: credentials.secret_access_key.clone(),
session_token: credentials.session_token.clone(),
expires_at: std::time::UNIX_EPOCH
+ std::time::Duration::from_secs(credentials.expiration.secs().max(0) as u64),
})
}
// ============================================================================
// Error Formatting
// ============================================================================
@@ -1000,6 +1182,82 @@ mod tests {
use aws_sdk_bedrockruntime::types::ReasoningContentBlock;
use serde_json::value::RawValue;
#[test]
fn oidc_role_yields_only_to_bearer_and_complete_iam_keys() {
let arn = Some("arn:aws:iam::1:role/bedrock");
assert_eq!(
bedrock_oidc_role_to_assume(None, None, None, arn),
arn,
"nothing else set: assume the role rather than fall through to the environment"
);
assert_eq!(
bedrock_oidc_role_to_assume(Some("key"), None, None, arn),
None
);
assert_eq!(
bedrock_oidc_role_to_assume(None, Some("id"), Some("secret"), arn),
None
);
assert_eq!(
bedrock_oidc_role_to_assume(None, Some("id"), None, arn),
arn,
"half a key pair is not usable IAM credentials, so the role still wins"
);
// Cleared resource fields arrive as empty strings, not as absent ones.
assert_eq!(
bedrock_oidc_role_to_assume(Some(""), Some(""), Some(""), arn),
arn
);
assert_eq!(
bedrock_oidc_role_to_assume(None, None, None, Some("")),
None
);
}
#[test]
fn credentials_stop_being_fresh_before_aws_expires_them() {
let at = |d: std::time::Duration| AssumedRoleCredentials {
access_key_id: String::new(),
secret_access_key: String::new(),
session_token: String::new(),
expires_at: std::time::SystemTime::now() + d,
};
// Reused only while there is still margin left to finish a request.
assert!(at(ASSUMED_ROLE_REFRESH_MARGIN * 2).is_fresh());
assert!(!at(ASSUMED_ROLE_REFRESH_MARGIN / 2).is_fresh());
}
#[test]
fn role_session_name_stays_within_what_aws_accepts() {
// A whole job UUID fits, so CloudTrail names the exact run.
assert_eq!(
aws_role_session_name("windmill-ai-job", "0a1b2c3d4e5f60718293a4b5c6d7e8f9"),
"windmill-ai-job-0a1b2c3d4e5f60718293a4b5c6d7e8f9"
);
// Usernames and emails routinely carry characters AWS rejects.
assert_eq!(
aws_role_session_name("windmill-ai-copilot", "ada/lovelace (admin)"),
"windmill-ai-copilot-ada-lovelace--admin-"
);
// Identities too long to fit keep a digest, so a shared prefix does not
// collapse two callers into one CloudTrail identity.
let a = aws_role_session_name("windmill-ai-copilot", &format!("{}a", "x".repeat(60)));
let b = aws_role_session_name("windmill-ai-copilot", &format!("{}b", "x".repeat(60)));
assert_ne!(a, b);
for name in [&a, &b] {
assert_eq!(name.len(), 64);
assert!(name.chars().all(|c| c.is_ascii_alphanumeric()
|| matches!(c, '_' | '+' | '=' | ',' | '.' | '@' | '-')));
}
// Sanitising before truncating keeps every char one byte, so the cap is
// never applied mid-character.
let unicode = aws_role_session_name("windmill-ai-copilot", &"é".repeat(100));
assert_eq!(unicode.len(), 64);
}
fn text_message(role: &str, content: &str) -> OpenAIMessage {
OpenAIMessage {
role: role.to_string(),
+13
View File
@@ -19,6 +19,19 @@ pub struct ProviderCredentials {
pub aws_access_key_id: Option<String>,
pub aws_secret_access_key: Option<String>,
pub aws_session_token: Option<String>,
/// AWS IAM role to assume through Windmill's OIDC provider. Only consulted
/// when no bearer key and no explicit IAM keys are set — see
/// [`crate::ai_bedrock::bedrock_oidc_role_to_assume`]. The API proxy
/// exchanges it for temporary keys before it builds the request; the worker
/// exchanges it at the Bedrock call, because only the API can sign an OIDC
/// token.
///
/// The two paths therefore present different tokens to AWS: the proxy a
/// workspace claim with `sub = "<email>::<workspace>"`, the worker the job
/// claim `/oidc/token` issues, `sub = "<email>::<path>::<flow_path>::<workspace>"`.
/// A role trust policy that conditions on `sub` has to admit both, or only
/// one of the copilot and the AI agent step will be able to assume the role.
pub oidc_role_arn: Option<String>,
pub platform: AIPlatform,
pub custom_headers: HashMap<String, String>,
}
@@ -858,6 +858,7 @@ mod tests {
aws_access_key_id: None,
aws_secret_access_key: None,
aws_session_token: None,
oidc_role_arn: None,
platform,
custom_headers: HashMap::new(),
}
@@ -780,6 +780,7 @@ mod tests {
aws_access_key_id: None,
aws_secret_access_key: None,
aws_session_token: None,
oidc_role_arn: None,
platform,
custom_headers: HashMap::new(),
}
+1
View File
@@ -184,6 +184,7 @@ mod parity_tests {
aws_access_key_id: None,
aws_secret_access_key: None,
aws_session_token: None,
oidc_role_arn: None,
platform: kase.platform.clone(),
custom_headers: HashMap::new(),
}
+1
View File
@@ -263,6 +263,7 @@ mod tests {
aws_access_key_id: None,
aws_secret_access_key: None,
aws_session_token: None,
oidc_role_arn: None,
platform: AIPlatform::Standard,
custom_headers: HashMap::new(),
}
+7
View File
@@ -245,6 +245,12 @@ pub struct ProviderResource {
deserialize_with = "empty_string_as_none"
)]
pub aws_session_token: Option<String>,
#[serde(
alias = "oidcRoleArn",
default,
deserialize_with = "empty_string_as_none"
)]
pub oidc_role_arn: Option<String>,
/// Platform (standard or google_vertex_ai)
#[serde(default)]
pub platform: AIPlatform,
@@ -311,6 +317,7 @@ impl ProviderWithResource {
aws_access_key_id: self.resource.aws_access_key_id.clone(),
aws_secret_access_key: self.resource.aws_secret_access_key.clone(),
aws_session_token: self.resource.aws_session_token.clone(),
oidc_role_arn: self.resource.oidc_role_arn.clone(),
platform: self.resource.platform.clone(),
custom_headers: self.resource.headers.clone(),
})
+41 -1
View File
@@ -12,8 +12,12 @@ pub mod ee;
pub mod ee_oss;
pub mod scopes;
use axum::extract::{FromRequestParts, OptionalFromRequestParts};
use axum::extract::{FromRequestParts, OptionalFromRequestParts, Path, Request, State};
use axum::middleware::Next;
use axum::response::Response;
use axum::Extension;
use http::request::Parts;
use std::collections::HashMap;
use windmill_audit::audit_oss::AuditAuthorable;
use windmill_common::{
@@ -26,6 +30,7 @@ use windmill_common::{
jobs::JobTriggerKind,
triggers::TriggerMetadata,
users::username_to_permissioned_as,
workspaces::{check_operator_can_manage, ManageKind},
DB,
};
@@ -940,6 +945,41 @@ pub async fn maybe_refresh_folders(
}
}
// ------------ Router-level gate for withdrawable operator rights ------------
/// Refuses every write (anything but GET/HEAD/OPTIONS) under the router it layers, unless the
/// workspace still grants operators `kind`. It sits on the router rather than in each handler so
/// that a route added later is covered without its author doing anything; read
/// `docs/operator-write-rights.md` before putting a new route under one of these routers.
pub async fn gate_operator_writes(
State(kind): State<ManageKind>,
Extension(db): Extension<DB>,
Path(params): Path<HashMap<String, String>>,
request: Request,
next: Next,
) -> Result<Response> {
if matches!(
*request.method(),
http::Method::GET | http::Method::HEAD | http::Method::OPTIONS
) {
return Ok(next.run(request).await);
}
// Resolved here rather than taken as an extractor so that reads, which are the traffic under
// these routers, do not pay for it.
let (mut parts, body) = request.into_parts();
let authed = <ApiAuthed as FromRequestParts<()>>::from_request_parts(&mut parts, &()).await?;
// Absent only if this is layered outside `/w/{workspace_id}`, which is a wiring mistake:
// refuse rather than let the write through unchecked.
let w_id = params.get("workspace_id").ok_or_else(|| {
Error::internal_err("operator write gate layered outside a workspaced router".to_string())
})?;
check_operator_can_manage(&db, w_id, authed.is_operator, kind).await?;
Ok(next.run(Request::from_parts(parts, body)).await)
}
// ------------ FromRequestParts impls (direct call to auth module) ------------
impl<S> FromRequestParts<S> for ApiAuthed
@@ -15,6 +15,7 @@ use windmill_api_auth::require_owner_of_path;
use windmill_audit::audit_oss::audit_log;
use windmill_audit::ActionKind;
use windmill_common::scripts::ScriptHash;
use windmill_common::workspaces::{check_operator_can_manage, ManageKind};
use windmill_common::DB;
use windmill_git_sync::{handle_deployment_metadata, DeployedObject};
@@ -44,10 +45,18 @@ fn audit_action_prefix_for_acl_kind(kind: &str) -> Option<&'static str> {
"raw_app" => Some("raw_apps"),
"resource" => Some("resources"),
"variable" => Some("variables"),
"schedule" => Some("schedules"),
_ => manage_kind_for_acl_kind(kind).map(|k| k.audit_prefix()),
}
}
/// The kinds whose writes a workspace can withdraw from operators. `/acls` serves every kind, so it
/// cannot sit under the routers that layer the gate — see `docs/operator-write-rights.md`.
fn manage_kind_for_acl_kind(kind: &str) -> Option<ManageKind> {
match kind {
"schedule" => Some(ManageKind::Schedules),
"http_trigger" | "websocket_trigger" | "kafka_trigger" | "nats_trigger"
| "postgres_trigger" | "mqtt_trigger" | "amqp_trigger" | "gcp_trigger"
| "azure_trigger" | "sqs_trigger" | "email_trigger" => Some("triggers"),
| "azure_trigger" | "sqs_trigger" | "email_trigger" => Some(ManageKind::Triggers),
_ => None,
}
}
@@ -107,6 +116,10 @@ async fn add_granular_acl(
return Err(Error::BadRequest("Invalid kind".to_string()));
}
if let Some(manage_kind) = manage_kind_for_acl_kind(kind) {
check_operator_can_manage(&db, &w_id, authed.is_operator, manage_kind).await?;
}
let identifier = if kind == "group_" || kind == "folder" || kind == "volume" {
"name"
} else {
@@ -354,6 +367,10 @@ async fn remove_granular_acl(
return Err(Error::BadRequest("Invalid kind".to_string()));
}
if let Some(manage_kind) = manage_kind_for_acl_kind(kind) {
check_operator_can_manage(&db, &w_id, authed.is_operator, manage_kind).await?;
}
if !authed.is_admin {
if kind == "folder" {
crate::folders::require_is_owner(&authed, path)?;
@@ -16,6 +16,7 @@ deno_core = ["windmill-test-utils/deno_core"]
mcp = ["windmill-test-utils/mcp", "dep:rmcp"]
run_inline = ["dep:windmill-worker", "windmill-test-utils/run_inline", "windmill-test-utils/duckdb"]
postgres_trigger = ["windmill-test-utils/postgres_trigger"]
http_trigger = ["windmill-test-utils/http_trigger"]
[dependencies]
windmill-test-utils.workspace = true
@@ -47,3 +48,4 @@ aws-sdk-sqs = { workspace = true, optional = true }
base64 = { workspace = true, optional = true }
axum.workspace = true
rmcp = { workspace = true, optional = true }
serial_test = "3"
@@ -0,0 +1,215 @@
use serde_json::json;
use serial_test::serial;
use sqlx::{Pool, Postgres};
use windmill_common::workspaces::invalidate_operator_rights_cache;
use windmill_test_utils::*;
/// Every test here must be `#[serial]`. The rights cache is process-global and keyed by workspace
/// id alone, while `sqlx::test` gives each test its own database under this same id — so two
/// running at once can answer each other's reads from one cached entry, and a withdrawal in one
/// database reads as granted in the other.
const WS: &str = "test-workspace";
fn operator_client() -> reqwest::Client {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str("Bearer OPERATOR_TOKEN_1").unwrap(),
);
reqwest::ClientBuilder::new()
.default_headers(headers)
.build()
.unwrap()
}
fn new_schedule(path: &str) -> serde_json::Value {
json!({
"path": path,
"schedule": "0 0 * * * *",
"timezone": "UTC",
"script_path": "u/operator/some_script",
"is_flow": false,
"enabled": false,
})
}
/// Saves `body` as the workspace's operator settings and drops the local cache entry, which a test
/// flipping the setting out of band has to do itself — the notify trigger only reaches other
/// processes.
async fn set_settings(api: &str, body: serde_json::Value) -> anyhow::Result<u16> {
let resp = reqwest::Client::new()
.post(format!("{api}/workspaces/operator_settings"))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&body)
.send()
.await?;
let status = resp.status().as_u16();
invalidate_operator_rights_cache(WS);
Ok(status)
}
/// Granted unless withdrawn, and a payload that omits the key leaves the stored value alone.
#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))]
#[serial]
async fn test_operator_manage_rights(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let api = format!("http://localhost:{port}/api/w/{WS}");
let c = operator_client();
// Never configured: the right is held.
let resp = c
.post(format!("{api}/schedules/create"))
.json(&new_schedule("u/operator/sched_default"))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
// Both polarities: a 403 alone would equally be the route's own ownership check refusing.
let share = async || -> anyhow::Result<(u16, String)> {
let resp = c
.post(format!("{api}/acls/add/schedule/u/operator/sched_default"))
.json(&json!({"owner": "u/alice", "write": true}))
.send()
.await?;
Ok((resp.status().as_u16(), resp.text().await?))
};
let (status, body) = share().await?;
assert_eq!(status, 200, "{body}");
// An admin withdraws it.
assert_eq!(
set_settings(&api, json!({"manage_schedules": false})).await?,
200
);
// 403 is a contract, not an incidental status - see `check_operator_can_manage`.
let resp = c
.post(format!("{api}/schedules/create"))
.json(&new_schedule("u/operator/sched_withdrawn"))
.send()
.await?;
assert_eq!(resp.status(), 403, "{}", resp.text().await?);
let (status, body) = share().await?;
assert_eq!(status, 403, "{body}");
// The gate refuses on `is_operator` alone, so one over-broad condition costs every admin their
// schedule and trigger writes. The admin call above proves nothing: its route is not gated.
let resp = reqwest::Client::new()
.post(format!("{api}/schedules/create"))
.header("Authorization", "Bearer SECRET_TOKEN")
.json(&new_schedule("u/admin/sched_admin"))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
// A payload omitting the key must not restore it.
assert_eq!(set_settings(&api, json!({"runs": true})).await?, 200);
let resp = c
.post(format!("{api}/schedules/create"))
.json(&new_schedule("u/operator/sched_still_withdrawn"))
.send()
.await?;
assert_eq!(resp.status(), 403, "{}", resp.text().await?);
// Nor withdraw a stored grant.
assert_eq!(
set_settings(&api, json!({"manage_schedules": true})).await?,
200
);
assert_eq!(set_settings(&api, json!({"runs": true})).await?, 200);
let resp = c
.post(format!("{api}/schedules/create"))
.json(&new_schedule("u/operator/sched_restored"))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
Ok(())
}
/// The trigger gate is a layer over the whole trigger router rather than a check inside each
/// handler, because a trigger kind may register routes of its own beside the shared CRUD ones.
/// Bulk HTTP creation is exactly that: it inserts directly and never enters the shared create
/// handler, so a per-handler check missed it. Asserting both polarities is what proves the route
/// is reached and gated rather than merely absent - a route that did not exist would answer 404,
/// not 403.
#[cfg(feature = "http_trigger")]
#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))]
#[serial]
async fn test_manage_triggers_covers_a_route_outside_the_shared_handler(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let api = format!("http://localhost:{port}/api/w/{WS}");
let c = operator_client();
let bulk = json!([{
"path": "u/operator/bulk",
"script_path": "u/operator/some_script",
"is_flow": false,
"route_path": "bulk_route",
"request_type": "async",
"authentication_method": "none",
"http_method": "post",
"is_static_website": false,
// Non-admins may only create routes under the workspace prefix.
"workspaced_route": true,
}]);
let create_many = async || -> anyhow::Result<(u16, String)> {
let resp = c
.post(format!("{api}/http_triggers/create_many"))
.json(&bulk)
.send()
.await?;
Ok((resp.status().as_u16(), resp.text().await?))
};
// Capture is a separate router with its own layer, so a reshuffle of the nesting can drop it.
let set_capture = async || -> anyhow::Result<(u16, String)> {
let resp = c
.post(format!("{api}/capture/set_config"))
.json(&json!({
"trigger_kind": "webhook",
"path": "u/operator/some_script",
"is_flow": false,
}))
.send()
.await?;
Ok((resp.status().as_u16(), resp.text().await?))
};
// Held by default: the route is reached and does its own work.
let (status, body) = create_many().await?;
assert_eq!(status, 201, "{body}");
let (status, body) = set_capture().await?;
assert_eq!(status, 200, "{body}");
assert_eq!(
set_settings(&api, json!({"manage_triggers": false})).await?,
200
);
let (status, body) = create_many().await?;
assert_eq!(status, 403, "{body}");
let (status, body) = set_capture().await?;
assert_eq!(status, 403, "{body}");
// Moving captures re-points existing configs between runnables, so it is gated too; the
// builders skip it while the right is withdrawn.
let resp = c
.post(format!("{api}/capture/move/script/u/operator/draft_path"))
.json(&json!({"new_path": "u/operator/some_script"}))
.send()
.await?;
assert_eq!(resp.status(), 403, "{}", resp.text().await?);
Ok(())
}
@@ -566,6 +566,113 @@ async fn test_resource_value_cache_is_identity_scoped(db: Pool<Postgres>) -> any
Ok(())
}
/// A token scoped to one resource must not read, through the `$var:`/`$res:` references it
/// writes into that resource, what its scopes would refuse to read directly.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_interpolated_references_need_the_token_scopes(
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");
let resp = authed(client().post(format!("{base}/variables/create")))
.json(&json!({"path": "u/test-user/secret", "value": "CANARY", "is_secret": true, "description": ""}))
.send()
.await?;
assert_eq!(resp.status(), 201);
let resp = authed(client().post(format!("{base}/resources/create")))
.json(&json!({"path": "u/test-user/other", "value": {"pw": "OTHER"}, "resource_type": "object", "description": ""}))
.send()
.await?;
assert_eq!(resp.status(), 201);
let mint = |scopes: serde_json::Value| async move {
let resp =
authed(client().post(format!("http://localhost:{port}/api/users/tokens/create")))
.json(
&json!({"label": "scoped", "scopes": scopes, "workspace_id": "test-workspace"}),
)
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
resp.text().await.unwrap()
};
let probe_only = mint(json!(["resources:write:u/test-user/probe"])).await;
let with_var = mint(json!([
"resources:read:u/test-user/probe",
"variables:read:u/test-user/secret"
]))
.await;
let read_probe = |value: serde_json::Value, token: String| {
let base = base.clone();
let probe_only = probe_only.clone();
async move {
let resp = client()
.post(format!("{base}/resources/create?update_if_exists=true"))
.bearer_auth(&probe_only)
.json(&json!({"path": "u/test-user/probe", "value": value, "resource_type": "object", "description": ""}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201);
let resp = client()
.get(format!(
"{base}/resources/get_value_interpolated/u/test-user/probe"
))
.bearer_auth(&token)
.send()
.await
.unwrap();
(resp.status().as_u16(), resp.text().await.unwrap())
}
};
for value in [
json!({"v": "$var:u/test-user/secret"}),
json!({"v": "$jsonvar:u/test-user/secret"}),
json!({"v": ["$res:u/test-user/other"]}),
] {
let (status, body) = read_probe(value.clone(), probe_only.clone()).await;
assert_eq!(status, 403, "{value}: {body}");
assert!(
!body.contains("CANARY") && !body.contains("OTHER"),
"{body}"
);
}
let (status, body) =
read_probe(json!({"v": "$var:u/test-user/secret"}), with_var.clone()).await;
assert_eq!((status, body.as_str()), (200, r#"{"v":"CANARY"}"#));
// The resource's own linked secrets (at its path, or `<path>_<field>`) are part of it.
for (path, value) in [
("u/test-user/probe", "LINKED"),
("u/test-user/probe_key", "KEY"),
] {
let resp = authed(client().post(format!("{base}/variables/create")))
.json(&json!({"path": path, "value": value, "is_secret": true, "description": ""}))
.send()
.await?;
assert_eq!(resp.status(), 201);
}
let (status, body) = read_probe(
json!({"pw": "$var:u/test-user/probe", "key": "$var:u/test-user/probe_key"}),
probe_only.clone(),
)
.await;
assert_eq!(status, 200, "{body}");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&body)?,
json!({"pw": "LINKED", "key": "KEY"})
);
Ok(())
}
/// A resource whose value contains a `$WM_*` contextual variable (e.g. `$WM_TOKEN`) is
/// job-dependent and must NEVER be cached — even when first read WITHOUT a `job_id`, where the
/// placeholder is left unresolved (caching that would serve a stale placeholder to a later job
@@ -0,0 +1,57 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
#[allow(unused_imports)]
use windmill_test_utils::*;
/// An admin of one workspace must not be able to write dependency files into another
/// by naming it in the request body.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_create_rejects_body_workspace_other_than_path(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
// test-user-2 is a plain user of test-workspace and an admin of its own workspace.
sqlx::query("INSERT INTO workspace (id, name, owner) VALUES ('own-workspace', 'own-workspace', 'test-user-2')")
.execute(&db)
.await?;
sqlx::query("INSERT INTO usr (workspace_id, email, username, is_admin, role) VALUES ('own-workspace', 'test2@windmill.dev', 'test-user-2', true, 'Admin')")
.execute(&db)
.await?;
let (_client, port, _server) = init_client(db.clone()).await;
let resp = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/own-workspace/workspace_dependencies/create"
))
.header("Authorization", "Bearer SECRET_TOKEN_2")
.json(&json!({
"workspace_id": "test-workspace",
"language": "python3",
"content": "requests==2.28.0"
}))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_eq!(
status, 400,
"expected the mismatched body to be rejected, got {body}"
);
assert!(
body.contains("does not match"),
"expected the workspace mismatch rejection, got {body}"
);
let written: i64 = sqlx::query_scalar("SELECT count(*) FROM workspace_dependencies")
.fetch_one(&db)
.await?;
assert_eq!(
written, 0,
"no dependency file may be written in either workspace"
);
Ok(())
}
@@ -1218,3 +1218,49 @@ async fn test_edit_auto_invite_preserves_instance_groups(db: Pool<Postgres>) ->
Ok(())
}
/// Admin and operator are one role stored as two flags: granting one must clear the other, and a
/// request asking for both is refused, on update and on add.
#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))]
async fn test_admin_and_operator_are_exclusive(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let api = format!("http://localhost:{port}/api/w/test-workspace");
let flags = async || -> anyhow::Result<(bool, bool)> {
Ok(sqlx::query_as(
"SELECT is_admin, operator FROM usr WHERE workspace_id = 'test-workspace' AND username = 'operator'",
)
.fetch_one(&db)
.await?)
};
let resp = authed(client().post(format!("{api}/users/update/operator")))
.json(&json!({"is_admin": true}))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
assert_eq!(flags().await?, (true, false));
let resp = authed(client().post(format!("{api}/users/update/operator")))
.json(&json!({"operator": true}))
.send()
.await?;
assert_eq!(resp.status(), 200, "{}", resp.text().await?);
assert_eq!(flags().await?, (false, true));
let resp = authed(client().post(format!("{api}/users/update/operator")))
.json(&json!({"is_admin": true, "operator": true}))
.send()
.await?;
assert_eq!(resp.status(), 400, "{}", resp.text().await?);
assert_eq!(flags().await?, (false, true));
let resp = authed(client().post(format!("{api}/workspaces/add_user")))
.json(&json!({"email": "both@windmill.dev", "is_admin": true, "operator": true}))
.send()
.await?;
assert_eq!(resp.status(), 400, "{}", resp.text().await?);
Ok(())
}

Some files were not shown because too many files have changed in this diff Show More