mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 08:03:16 +00:00
Merge remote-tracking branch 'origin/main' into fix-runnable-test-draft-app
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
{
|
||||
".": "1.817.0"
|
||||
".": "1.819.0"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
-31
@@ -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"
|
||||
}
|
||||
-14
@@ -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"
|
||||
}
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+40
@@ -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"
|
||||
}
|
||||
+4
-3
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+83
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
-23
@@ -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"
|
||||
}
|
||||
+35
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+24
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
-14
@@ -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"
|
||||
}
|
||||
-27
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+28
@@ -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"
|
||||
}
|
||||
-15
@@ -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"
|
||||
}
|
||||
+25
@@ -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"
|
||||
}
|
||||
+15
-3
@@ -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"
|
||||
}
|
||||
+41
@@ -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"
|
||||
}
|
||||
+95
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
-15
@@ -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"
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
+35
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
-89
@@ -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"
|
||||
}
|
||||
-15
@@ -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"
|
||||
}
|
||||
+31
@@ -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"
|
||||
}
|
||||
+33
@@ -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"
|
||||
}
|
||||
-16
@@ -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"
|
||||
}
|
||||
+22
@@ -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"
|
||||
}
|
||||
+15
@@ -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"
|
||||
}
|
||||
+325
@@ -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"
|
||||
}
|
||||
-16
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+27
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+47
@@ -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"
|
||||
}
|
||||
+2
-2
@@ -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"
|
||||
}
|
||||
+24
@@ -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"
|
||||
}
|
||||
+24
@@ -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"
|
||||
}
|
||||
-28
@@ -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"
|
||||
}
|
||||
Generated
+234
-227
File diff suppressed because it is too large
Load Diff
+4
-2
@@ -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 @@
|
||||
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();
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
@@ -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');
|
||||
@@ -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.
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user