Compare commits

...

53 Commits

Author SHA1 Message Date
Ruben Fiszel
9a0c108360 fix: break stale companion script cycle in dependency_map
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 18:02:52 +00:00
Ruben Fiszel
bc7007bb42 fix: include importer_kind in dependency debounce key to prevent cross-kind collisions (#8567)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:22:35 +00:00
Ruben Fiszel
99b0ebd677 use fallback_service instead of nest_service for MCP router (#8566)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-27 16:14:47 +00:00
centdix
5fd2c1a129 chore(cli): separate unit tests from integration tests and fix test cleanup (#8562)
* fix(cli): separate unit tests from integration tests and fix test cleanup

- Rename 14 non-backend test files to *_unit.test.ts convention
- Add UNIT_ONLY env var guard in setup.ts to skip cargo build/backend startup
- Add test:unit and test:integration scripts to package.json
- Use setsid on Linux for process group management so stop() kills both
  cargo and the windmill child process
- Fix exit handler to kill process group instead of just the direct child
- Add cleanupStaleTestResources() to drop orphaned windmill_test_* databases
  and kill orphaned backend processes on startup
- Rewrite TESTING.md with current bun-based instructions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): fix process group approach - kill by db name instead of setsid

The setsid approach didn't work because setsid forks, making the PID
we get from Bun.spawn ephemeral. Instead, kill orphaned windmill child
processes by matching our unique database name in /proc/pid/environ.

Also add afterAll hook in setup.ts so full async cleanup (process kill
+ database drop) runs when all tests complete normally, not just on
SIGINT/SIGTERM.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): address PR review feedback

- Remove duplicate cleanupStaleTestResources() call in getTestBackend()
  (already called in setup.ts)
- Add regex guard on database names before SQL interpolation
- Extract shared killWindmillProcessesByEnvMatch() helper to deduplicate
  process-killing logic
- Remove redundant test:integration script (test already runs everything)
- Flip setup.ts to if/else pattern for readability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 16:13:33 +00:00
centdix
70f3ee5ed4 fix: use admin db pool in get_copilot_settings_state (#8564)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 15:21:42 +00:00
Ruben Fiszel
8df1d8ec17 test nits 2026-03-27 12:28:54 +00:00
Ruben Fiszel
2f32675801 feat: DB-coordinated graceful restart staggering for settings changes (#8555)
* feat: add DB-coordinated graceful restart staggering for settings changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve original instance names in restart coordination record

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove randomness, add drain delay for in-flight requests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: spawn restart in background, deduplicate entries, clarify stale filter

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:59:17 +00:00
Ruben Fiszel
ab868e9ebc perf: enable bun bundle caching for WAC v2 scripts (#8556)
WAC v2 scripts previously disabled bundle caching, forcing every execution
to resolve windmill-client from node_modules at runtime (~74ms overhead per
bun launch). This makes both the prebundle and execution paths WAC-aware by
including WorkflowCtx/StepSuspend/setWorkflowCtx re-exports in the bundle,
so the wrapper can import them from the cached bundle instead of node_modules.

Benchmarked improvement: wac_inline_2 12→38 wf/s (3.2x), wac_seq_2 6→17 wf/s
(2.8x) with no regression on plain bun scripts or flows.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:58:24 +00:00
centdix
ad19ac9b37 feat: support multiple folder selection in MCP scope selector (#8557)
* feat: support multiple folder selection in MCP scope selector

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add per-folder caching for multi-folder runnables loading

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address PR review — workspace prop, length check, empty folder state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: cache folder names per workspace and reload on workspace change

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:57:47 +00:00
Ruben Fiszel
0fb115304a fix: preserve notes on nodes inside collapsed groups (#8552)
* fix: preserve notes on nodes inside collapsed groups

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: hide notes for nodes inside collapsed groups instead of repositioning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:55:47 +00:00
Ruben Fiszel
79cc4a92d8 fix: emit 0 for OTEL queue metrics when tag queue is empty (#8559)
Previously, windmill.queue.count and windmill.queue.running_count OTEL
metrics would report no data instead of 0 when a tag's queue emptied.
This was because the SQL query uses GROUP BY tag, so empty tags are
absent from results. The Prometheus path already handled this by tracking
previously-seen tags and emitting 0, but the OTEL path was missing this
logic.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:55:09 +00:00
Ruben Fiszel
943fe9c6cc fix: handle inline script deletion in sync push + flow new nonDottedPaths (#8553)
* fix: handle inline script file deletions in app/flow folders during sync push

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add regression test for app inline script deletion during sync push

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: flow new respects nonDottedPaths setting

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add flow new nonDottedPaths test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: separate stat from pushObj in delete handler to avoid masking errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:54:45 +00:00
Ruben Fiszel
e15bfbf91e fix: sanitize flow step summaries for filesystem-safe names (#8554)
* fix: sanitize flow step summaries for filesystem-safe names

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: bump windmill-utils-internal to 1.3.6

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: handle Windows reserved device names in flow step sanitization

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: collapse consecutive underscores in sanitized flow step names

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: bump windmill-utils-internal to 1.3.7

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* bump

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-27 11:38:20 +00:00
centdix
d06b42613f feat(cli): generate commented wmill.yaml and add config reference command (#8546)
* feat: generate commented wmill.yaml template and add config reference command

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add missing options to config reference (promotion, skipBranchValidation, commonSpecificItems)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: generate YAML template from CONFIG_REFERENCE instead of handwritten string

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve YAML comments when binding workspace profile during init

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: simplify to `wmill config` and reorder table columns

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: generate JSON Schema for wmill.yaml editor autocomplete and validation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove redundant templateValue fields and make specificItemsSchema data-driven

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: use native JSON Schema types in CONFIG_REFERENCE, strip non-schema keys for generation

Eliminates typeToJsonSchema, specificItemsSchema, codebaseItemSchema,
branchConfigSchema, and the complex generateJsonSchema body. Each
CONFIG_REFERENCE entry is now a JSON Schema property with extra metadata.
Schema generation just iterates and strips non-schema keys.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove typeLabel and displayType — use schema types directly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove hidden entries, auto-expand nested schemas in reference table

Sub-fields (codebases[], gitBranches.<branch>.*) are now derived from
the parent's inline schema instead of being maintained as duplicate
hidden entries. Removes 29 entries and the hidden field entirely.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use console.log for JSON output and quote YAML-special branch names

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate system prompts to include new config command

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: review feedback + add tests for template, schema, and config reference

- Use console.log for --json output (no ANSI escape codes)
- Quote branch names with YAML-special characters
- Add 28 tests covering template generation, JSON Schema validation,
  config reference formatting, and CONFIG_REFERENCE integrity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add generate-schema script and commit wmill.schema.json to repo

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove schema.json generation from wmill init

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: eliminate read-back cycle, harden yamlKey, fix triple negation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 11:35:28 +00:00
Ruben Fiszel
0389d9601c chore: upgrade axum 0.7 to 0.8 (#8539)
* chore: upgrade axum 0.7 to 0.8 and related dependencies

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add route reachability tests for ~80 previously untested endpoints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: switch feature-gated trigger handlers from axum::async_trait to async_trait crate

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update new trash routes to axum 0.8 path syntax

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to latest EE commit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: upgrade route tests to assert 2xx responses with proper data setup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: restore npm_proxy and ai_routes tests using local echo servers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: gate workspace fork test behind enterprise feature flag

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add ~40 more endpoint tests (jobs authed, health, favorites, ACLs, reachability)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review findings from axum 0.8 upgrade

- Use cookie value_trimmed() instead of value() for cookie 0.18 compat
- Update comments still referencing old :workspace_id syntax

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 61ae055ea31481f1899953e9d5f65566b8c707b1

This commit updates the EE repository reference after PR #486 was merged in windmill-ee-private.

Previous ee-repo-ref: 0059d175a6fdddf52998b183bf91059b224704ac

New ee-repo-ref: 61ae055ea31481f1899953e9d5f65566b8c707b1

Automated by sync-ee-ref workflow.

* test: add test for new get_imports endpoint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove unused import in raw_apps test

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-27 09:55:04 +00:00
Ruben Fiszel
9e235937ce add WAC v2 benchmarks and improve benchmark infrastructure (#8550)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 08:53:46 +00:00
Ruben Fiszel
e2cc6e4709 nit sqlx 2026-03-26 20:58:23 +00:00
Tristan TR
c0aafee9a9 feat: improve-replay-ui (#8250)
* Improve UI of script record

* Improve UI for scripts

* Remove Result & Logs loading container while flow not finised

* Improve Graph view

* Add click on a step mention

* Fix spacing when empty

* Fix step duration disappearing in recorded flows

* Modernize timeline tab

* Improve Script recording result UI

* feat: externalize recording player controls for fake-window embedding

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: reorder FlowViewer tab sync effects for clarity

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: eliminate tab sync effects in FlowViewer, use selectedTab directly

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove unnecessary untrack in FlowViewer tab init

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip tab auto-selection when selectedTab is controlled externally

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: export recording types from package

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: non-null assertion for recording.flow in FlowGraphViewer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: replace banned $bindable(default_value) pattern and simplify tab sync

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use svelte 5 onclick syntax on replay page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: skip db clock endpoint during replay mode

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove line numbers from script recording code display

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: hugocasa <hugo@casademont.ch>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 18:52:15 +00:00
Ruben Fiszel
264fa33917 chore(main): release 1.666.0 (#8543)
* chore(main): release 1.666.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-03-26 18:46:25 +00:00
wendrul
d760ea5eaf fix: add relative imports to the dependency list in deploymentUI (#8548)
* prepare sqlx

* Add relative imports to getDependencies of deployUI

* nit

* fix: correct get_imports doc comment, add tracing, use Set for dedup

- Fix copy-pasted doc comment on get_imports (said "get dependents")
- Add tracing::debug to get_imports handler to match get_dependents
- Use Set for O(1) duplicate detection in deploy dependency traversal

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 18:28:18 +00:00
Ruben Fiszel
8866bd44cf nit backend tests 2026-03-26 18:20:46 +00:00
Ruben Fiszel
71549c3db0 fix: resolve parent_hash race condition in sync push with auto_parent (#8545)
* fix: resolve parent_hash race condition in sync push with auto_parent

During concurrent sync push operations (parallel CLI groups or separate
CI pipelines), multiple requests could read the same remote script hash
and both try to create a new version with the same parent_hash, causing
"the lineage must be linear" errors.

Adds an opt-in `auto_parent` field to the create_script API. When set,
the backend resolves the parent_hash to the current head script at that
path within the transaction, atomically. This eliminates the client-side
race window where the parent could change between read and write.

The CLI now sends `auto_parent: true` when updating existing scripts,
so sync push is resilient to concurrent deployments.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add missing auto_parent field in clone_script NewScript initializer

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add advisory lock to serialize concurrent auto_parent script creates

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* sqlx

* fix: add sqlx anchor for CE-only user count query

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 18:14:10 +00:00
Diego Imbert
1fa4d919b3 fix: upload_s3_file not working in VS Code extension (#8547) 2026-03-26 17:40:51 +00:00
centdix
1a73012e07 fix: filter null entries in FileUpload initialValue to prevent s3 access error (#8544)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-26 14:59:45 +01:00
centdix
e44504c6e9 feat: add PDF input support to AI agent (#8525)
* feat: add PDF input support to AI agent with user_attachments field

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add integration tests for PDF input and backward compat

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add ContentPart::File variant for PDF support across all providers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: address review feedback on PDF support

- Extract parse_data_url_bytes and mime_to_document_format helpers in Bedrock
- Add is_document_mime helper in ai_types for centralized MIME routing
- Extract s3_object_to_content_part helper to deduplicate image_handler/openai
- Rename AnthropicImageSource to AnthropicBase64Source
- Derive Bedrock DocumentFormat from MIME type instead of hardcoding Pdf

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: merge user message and attachments into single message for Bedrock

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 13:55:10 +00:00
Ruben Fiszel
d7f4b950ce fix: pass pre-bound TcpListener to run_server to fix Windows CI test race (#8542)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:42:02 +00:00
Ruben Fiszel
f6208af673 chore(main): release 1.665.0 (#8509)
* chore(main): release 1.665.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-03-26 11:49:16 +00:00
Ruben Fiszel
55ad0ff5c4 fix: use resource-level scope overrides during OAuth2 token refresh (#8540)
* fix: use resource-level scope overrides during OAuth2 token refresh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 6db424512b0d02f86489e85f0026581b7637d6e6

This commit updates the EE repository reference after PR #484 was merged in windmill-ee-private.

Previous ee-repo-ref: c9277992608537155a9505a089aca91403d91159

New ee-repo-ref: 6db424512b0d02f86489e85f0026581b7637d6e6

Automated by sync-ee-ref workflow.

* fix: restore non-enterprise sqlx cache entries deleted by update_sqlx.sh

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update sqlx cache for latest EE changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: rename migration to avoid timestamp collision with trashbin

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: collapse duplicate match arms and simplify effective_scopes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-26 11:43:26 +00:00
Ruben Fiszel
0885d8c986 feat: mask sensitive values in job logs (#8520)
* feat: mask sensitive values (secrets, password args) in job logs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: replace artificial unit tests with real integration tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: consolidate into single comprehensive masking test covering 8 scenarios

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: show first 3 chars of masked secrets and add security notice

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update masking notice to say "display full value"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: handle poisoned locks, deduplicate notice, mask non-string encrypted args

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf: snapshot-based masking, one lock per batch instead of per line

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf: use Aho-Corasick for O(m) single-pass matching regardless of secret count

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: track notice in snapshot (no global lock), document snapshot race trade-off

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:06:51 +00:00
Ruben Fiszel
69ce946241 feat: add trashbin system for soft-deleting items (#8519) 2026-03-26 09:51:34 +00:00
Ruben Fiszel
cc67fd9e46 refactor: move fs-backed cache under WINDMILL_DIR (#8537)
* refactor: move fs-backed cache under WINDMILL_DIR

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add WINDMILL_CACHE_PREFIX env var for per-session cache isolation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: auto-use WEBMUX_BRANCH as cache prefix for session isolation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:58:50 +00:00
Ruben Fiszel
6620f5513c update cachix/install-nix-action from v20 to v31 to fix hash mismatch (#8538)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:56:47 +00:00
Ruben Fiszel
82f2a3902f include notes/groups in flow_version_lite for run page (#8536)
* feat: show groups and notes in flow status viewer

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: include notes/groups in flow_version_lite for run page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:22:57 +00:00
Ruben Fiszel
167084a0eb feat: show groups and notes in flow status viewer (#8535)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:18:01 +00:00
Alexander Petric
935fb44c84 fix: GitHub Enterprise Server support for self-managed GitHub Apps (#8507)
* fix: GitHub Enterprise Server (GHE) support for self-managed GitHub Apps

- Fix GHE installation URL: use /github-apps/ path instead of /apps/ for non-github.com hosts
- Fix double decodeURIComponent on OAuth state param (URLSearchParams already decodes)
- Add client_id to self-managed GitHub App validation
- Bump hub scripts to GHE-compatible versions (sync, test, init, clone)
- Bump LATEST_GIT_SYNC_SCRIPT_PATH to hub/28176
- Rename "GitHub Enterprise App" → "GitHub App" in UI labels (it works for both)
- Formatting fixes in GhesAppSettings.svelte and gh_success page

EE ref: windmill-labs/windmill-ee-private@09c9ed1

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Update SQLx metadata

* fix: handle GHE Cloud (*.ghe.com) app installation URL path

GHE Cloud uses /apps/ like github.com, not /github-apps/ like self-hosted GHES.
Docs: https://docs.github.com/en/enterprise-cloud@latest/apps/using-github-apps/installing-a-github-app-from-a-third-party

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: handle GHE Cloud (*.ghe.com) installation URL and update ee-repo-ref

GHE Cloud uses /apps/ like github.com, not /github-apps/ like self-hosted GHES.
Docs: https://docs.github.com/en/enterprise-cloud@latest/apps/using-github-apps/installing-a-github-app-from-a-third-party

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: update hubPaths to deprecate 28176 and use 28180 as latest sync script

Aligns with main's LATEST_GIT_SYNC_SCRIPT_PATH bump in PR #8532.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: update ee-repo-ref to 6bb0ff0 (includes GHE fixes)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-26 06:26:57 +00:00
Ruben Fiszel
cb8b264dee add signed request authentication to multiplayer websocket (#8534)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 22:23:47 +00:00
hugocasa
9b3e558d84 feat: add instance setting to enforce workspace prefix for HTTP routes (#8528)
* feat: add instance-level setting to enforce workspace prefix for HTTP routes

Add `http_route_workspaced_route` instance setting that forces all HTTP routes
to use workspace prefix (`/api/r/{workspace_id}/{route}`), mirroring the existing
`app_workspaced_route` setting for apps.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: bump http trigger version on setting change to invalidate route cache

The route cache is version-based, not TTL-based. Without bumping the
version sequence when the instance setting changes, cached routes would
continue serving with the old prefix behavior until a route is
created/updated/deleted or the server restarts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: immediately refresh HTTP routers on setting change

The route cache polls every 60 seconds, but bumping the version sequence
only makes the next poll pick up changes. Explicitly call refresh_routers
after the setting reload so routes are rebuilt immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:54:36 +00:00
Ruben Fiszel
36a81004dc buffer stdin lines in deno dedicated worker wrapper to prevent chunk splitting (#8533)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:51:51 +00:00
hugocasa
b7475c7309 fix: consider wmill.yaml environments alias in git sync (#8532) 2026-03-25 21:33:39 +00:00
Ruben Fiszel
5501b7a729 replace host docker socket with dind sidecar for isolation (#8531)
* feat: replace host docker socket with dind sidecar for isolation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: comment out dind sidecar by default to avoid wasting resources

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: enable dind by default, comment out insecure host socket mount

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 21:33:17 +00:00
Ruben Fiszel
2e2dd511f7 sqlx nits 2026-03-25 21:32:00 +00:00
Ruben Fiszel
1ff14e3f45 sqlx nits 2026-03-25 21:12:24 +00:00
Ruben Fiszel
9e8d4af458 sqlx nits 2026-03-25 21:12:09 +00:00
Ruben Fiszel
ead1ea73af sqlx 2026-03-25 17:51:37 +00:00
hugocasa
0bd756839c feat: SCIM user deprovisioning (active:false) + instance-level user disable (#8484)
* [ee] feat: handle active:false in SCIM user PATCH/PUT for deprovisioning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref for SCIM active:false deprovision fix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* nit sqlx

* [ee] feat: add password.disabled column for SCIM user deactivation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [ee] feat: enforce password.disabled in auth checks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [ee] refactor: use scim_deactivated_user table instead of password.disabled

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [ee] fix: apply SCIM filters to deactivated users, add name column

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: add down migration for scim_deactivated_user

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: rename migration to avoid timestamp conflict, update sqlx cache

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [ee] refactor: use password.disabled for SCIM deactivation, block login for disabled users

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* [ee] feat: show disabled toggle in superadmin user list, add disabled field to API

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add confirmation modal when disabling instance user

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: improve disable user confirmation text

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: revert toggle state when disable confirmation is cancelled

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: properly revert toggle on disable cancel using reset key

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: move disable/enable to dropdown menu, add disabled badge on email

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: rename 'Show active users only' to 'Recently active only' to avoid confusion with disabled state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove accidentally committed gen files

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use .catch() for enable user error handling in dropdown action

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: delete tokens on user removal, improve confirmation modal texts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update sqlx cache for non-enterprise code paths

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: restore sqlx cache files deleted by incorrect prepare run

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add missing sqlx cache for non-enterprise git sync query

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to a1274aa11a83f608eacc32c0d449ca3527d98c15

This commit updates the EE repository reference after PR #473 was merged in windmill-ee-private.

Previous ee-repo-ref: 30f8c53b101b9e25107e793cdc038b0e07061739

New ee-repo-ref: a1274aa11a83f608eacc32c0d449ca3527d98c15

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-25 17:10:20 +00:00
Ruben Fiszel
7f48704cfd add missing grants on app_bundles for windmill_user and windmill_admin (#8527)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 15:50:45 +00:00
hugocasa
c28314f424 feat: runner groups for shared-process multi-script dedicated workers (#8434)
* feat: add runner groups for shared-process multi-script dedicated workers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: unify dedicated worker and runner group wrappers into single multi-script wrapper

Replace per-language single-script wrappers with the unified load/exec/exec_preprocess/end
protocol. Each start_worker() now writes scripts to scripts/<safe_name>/ and uses
generate_multi_script_wrapper(). handle_dedicated_process() sends load: on start and
exec: per job instead of raw JSON args.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: merge runner groups into dedicated workers with inline arg metadata

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to match EE branch

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: gate EE-only functions behind cfg(feature = "private") to fix OSS dead_code errors

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: auto-detect runner groups from workspace dependency annotations

- New endpoint GET /scripts/list_dedicated_with_deps: returns dedicated
  scripts with parsed workspace dependency names from content annotations
- Frontend: show dep badges in DedicatedWorkersSelector with links to
  workspace settings, warn when referenced dep doesn't exist, group
  scripts sharing deps into "Shared runner" sections
- Remove manual "Runner groups" tab and RunnerGroupSelector component
- Remove runner_groups from WorkerConfigOpt/WorkerConfig (auto-detected)
- Fix Node.js single dedicated workers: transpile main.ts -> main.js via
  Bun.build so the multi-script wrapper's dynamic import() works under Node
- Add package.json with type:module in scripts dir to silence Node warning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: unify dedicated worker wrappers with baked-in codegen and routing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add e2e tests for multi-script dedicated worker routing (bun, deno, python)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: remove dead generate_dedicated_worker_wrapper function

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: add dependency installation to runner groups + make dep functions pub(crate)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: prevent bun loader from intercepting absolute paths within cwd

When a plugin's onResolve returns an absolute path, Bun re-invokes
the resolver with that path. The loader was then routing it through
the remote URL resolver, breaking runner group script imports.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use _wm_ prefix for runner group scripts to avoid bun loader interception

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: extract DENO_UNSTABLE_ARGS constant to avoid repeating flags

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate system prompts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: gate private-only exports behind cfg(feature = "private") for OSS build

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: move format strings before handle_dedicated_process to fix lifetime

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate sqlx offline cache

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix sqlx

* fix: skip empty lines in deno e2e tests (double newline from console.log + '\n')

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use dict() instead of {{}} in python wrapper to avoid set literal

{{{{}}}} in format!() produces {{}} which Python interprets as an
empty set, not a dict. Use dict() which is unambiguous.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: remove deno from runner groups and associated tests

Deno resolves dependencies at runtime via URLs/import maps, so there's
no shared node_modules/pip install to benefit from runner groups.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: revert deno wrapper to inline old-style with exec: protocol

Since deno doesn't support runner groups, the unified multi-script
wrapper is unnecessary. Reverted to the old inline wrapper from main
but adapted to use the exec:<path>:<args> protocol.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: extract deno wrapper into reusable function and add e2e tests

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use codebase presence (not nodejs annotation) to determine wrapper import extension

On main, codebase scripts import ./main.js (pre-bundled JS).
The wrapper_ext was incorrectly based on annotation.nodejs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: improve dedicated workers UI - combine lists, better badges, tooltips

- Merge shared runners section with selected tags into one unified list
- Move language tag to right side of selector for alignment
- Change dep badge color from dark-gray to indigo
- Add tooltip on yellow warning badge explaining missing workspace dep

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: group shared runners visually in dedicated workers list

- Runner groups shown with a header (Shared runner · language · dep badge)
- Scripts in the same group nested under the header
- Standalone scripts/flows shown after groups
- Used Svelte snippet for reusable tag row rendering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: improve visual separation between shared runner groups and standalone items

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: give standalone runners same header style as shared runners

- Each standalone script/flow gets its own header row with bg-surface-secondary
- Header shows "Dedicated runner" / "Flow runner" label, dep link, language badge
- Shared runner header: swapped language and dep badge positions
- Dep shown as inline link instead of badge in headers for cleaner look

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: inline standalone runner path in header, language badge on right edge, no max height

- Standalone items: path shown directly in header row (no sub-row)
- Language badge placed after flex-1 spacer (right-aligned)
- Removed max-h-64 overflow constraint from the list

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* feat: consistent badges across runner list - dep+language on right, depBadge snippet

- Shared runner scripts: show (workspace) and language badge on right
- Standalone items: dep badges and language badge on right (after flex-1)
- Shared runner header: dep badge and language badge on right
- Extract depBadge snippet to deduplicate dep badge rendering
- Picker selector also uses depBadge snippet

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: show language badge on standalone items, hide from shared runner sub-items

- Fetch script language from API when not available from workspace deps
- Hide dep+language badges from tagRow when script is inside a runner group
  (already shown in the group header)
- Standalone items now always show language badge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: differentiate badge colors - gray for language, indigo for workspace deps

Matches codebase convention: gray for metadata (like script hashes),
indigo for linkable features/entities.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use transparent (bordered) badge for language - visible on all backgrounds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use gray badge for language everywhere

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: revert skills.ts and AI files, add _wm_ exclusion to Windows loader

- Revert cli/src/guidance/skills.ts to main (not our change)
- Revert AI provider formatting changes (not our change)
- Add _wm_ prefix exclusion to loader.bun.windows.js filterResolve

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update ee-repo-ref and regenerate system prompts after merge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* perf: use DISTINCT ON in list_dedicated_with_deps to dedup at DB level

Avoids fetching all script versions and deduplicating in Rust.
Addresses PR review feedback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use sqlx query! macro for list_dedicated_with_deps and regenerate cache

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: dedicated worker review fixes and test coverage

- Fix Python relative imports in dedicated workers (write loader.py, add
  import loader to wrapper when needed)
- Move Python colon parsing inside try/except to prevent crashes on
  malformed stdin
- Add indexOf guard in Bun/Deno wrappers for malformed protocol messages
- Add stderr logging for unrecognized stdin commands in all wrappers
- Remove asyncio handling from Python wrapper (consistent with normal path)
- Add exec_preprocess protocol tests for Bun, Deno, and Python
- Add argument transformation tests (dates, bytes, kwargs, sentinel)
- Add relative import detection test for Python wrapper
- Add PreprocessedArgs variant to DedicatedWorkerResult test helper

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove symlink from git and gate has_relative_imports behind private feature

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: update ee-repo-ref for dedicated_worker_ee.rs changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add mixed exec+preprocess test to use ProtocolCmd::Exec variant

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: remove hanging deno missing-preprocessor test

The Deno wrapper only generates the exec_preprocess handler when the
script has a preprocessor function. Without one, the message is
unrecognized and the test hangs reading stdout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 182943e5ad9bf2a905ccdf07d4e346437fb329a9

This commit updates the EE repository reference after PR #466 was merged in windmill-ee-private.

Previous ee-repo-ref: 995f701fe3754be6260fc6b679e5de8fc636e68a

New ee-repo-ref: 182943e5ad9bf2a905ccdf07d4e346437fb329a9

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-25 15:13:04 +00:00
Ruben Fiszel
4c8edd5e94 fix: restrict logout redirect to whitelisted domains (#8524)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-25 14:51:13 +00:00
centdix
8a32322c18 fix: auto-generate datatable SDK reference for app mode system prompt (#8522)
The app mode AI chat system prompt had hand-written datatable API docs
that were missing methods (fetchOneScalar, execute, query). This adds
datatable-specific extraction to generate.py so the prompt stays in
sync with the actual TypeScript and Python client APIs.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:38:29 +00:00
Ruben Fiszel
0317668089 fix: require admin for workspace encryption key export (#8523)
Move the require_admin check from blocking the entire tarball export
to only guarding the include_key=true path. Non-admins can still
export tarballs for workspace sync/git, but only admins can export
the raw workspace encryption key.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 14:33:20 +00:00
Ruben Fiszel
34cf0a0324 show sync resource types button when resource type is missing (#8514)
* feat: show sync resource types button when resource type is missing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: show prominent error message when resource type is not found

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use sync_cached_resource_types endpoint instead of hub_sync script

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: fallback to fetching resource types from hub when cache file missing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:51:34 +00:00
Samuel Wilk
0904d7fffe Add 'fast' query parameter to API definition (#8521) 2026-03-25 13:51:18 +00:00
centdix
520706b640 chore: use workingdir in webmux panes (#8516)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-25 13:01:03 +01:00
306 changed files with 13017 additions and 2418 deletions

View File

@@ -290,6 +290,49 @@ jobs:
path: |
*.json
benchmark_wac:
runs-on: ubicloud-standard-8
services:
postgres:
image: postgres
env:
POSTGRES_DB: windmill
POSTGRES_PASSWORD: changeme
POSTGRES_INITDB_ARGS: "-c shared_buffers=2GB -c work_mem=32MB -c effective_cache_size=4GB"
options: >-
--health-cmd pg_isready --health-interval 10s --health-timeout 5s
--health-retries 5
--shm-size=2g
windmill:
image: ghcr.io/windmill-labs/windmill-ee:main
env:
DATABASE_URL: postgres://postgres:changeme@postgres:5432/windmill
LICENSE_KEY: ${{ secrets.WM_LICENSE_KEY_CI }}
WORKER_GROUP: main
WORKER_TAGS: deno,bun,go,python3,bash,dependency,flow,nativets
options: >-
--pull always --health-interval 10s --health-timeout 5s
--health-retries 5 --health-cmd "curl
http://localhost:8000/api/version"
ports:
- 8000:8000
steps:
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- name: benchmark
timeout-minutes: 30
run: deno run -A -r
https://raw.githubusercontent.com/windmill-labs/windmill/${GITHUB_REF##ref/head/}/benchmarks/benchmark_suite.ts
-c
https://raw.githubusercontent.com/windmill-labs/windmill/${GITHUB_REF##ref/head/}/benchmarks/suite_wac.json
- name: Save benchmark results
uses: actions/upload-artifact@v4
with:
name: benchmark_wac
path: |
*.json
benchmark_graphs:
runs-on: ubicloud
needs:
@@ -297,6 +340,7 @@ jobs:
- benchmark_dedicated
- benchmark_4workers
- benchmark_8workers
- benchmark_wac
steps:
- uses: denoland/setup-deno@v2
with:

View File

@@ -18,10 +18,7 @@ jobs:
runs-on: ubicloud-standard-8
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v20
with:
extra_nix_config: |
experimental-features = nix-command flakes
- uses: cachix/install-nix-action@v31
- name: Check rust client builds
run: cd rust-client && nix develop ../ --command ./dev.nu --check
timeout-minutes: 16

View File

@@ -10,10 +10,7 @@ jobs:
runs-on: ubicloud-standard-8
steps:
- uses: actions/checkout@v4
- uses: cachix/install-nix-action@v20
with:
extra_nix_config: |
experimental-features = nix-command flakes
- uses: cachix/install-nix-action@v31
- run: cd rust-client && nix develop ../ --command ./dev.nu --check --publish
env:
CRATES_IO_TOKEN: ${{ secrets.CRATES_IO_TOKEN }}

View File

@@ -55,11 +55,13 @@ profiles:
- id: backend
kind: command
split: right
command: ROOT="$(git rev-parse --show-toplevel)"; cd "$ROOT/backend" && cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}"
workingDir: backend
command: PORT=${BACKEND_PORT:-8000} cargo watch -x "run ${CARGO_FEATURES:+--features $CARGO_FEATURES}"
- id: frontend
kind: command
split: bottom
command: ROOT="$(git rev-parse --show-toplevel)"; cd "$ROOT/frontend" && npm run generate-backend-client && npm run dev -- --host 0.0.0.0
workingDir: frontend
command: npm run generate-backend-client && REMOTE=${REMOTE:-http://localhost:${BACKEND_PORT:-8000}} npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0
frontendOnly:
runtime: host
@@ -82,7 +84,8 @@ profiles:
- id: frontend
kind: command
split: right
command: ROOT="$(git rev-parse --show-toplevel)"; cd "$ROOT/frontend" && npm run generate-backend-client && npm run dev -- --host 0.0.0.0
workingDir: frontend
command: npm run generate-backend-client && npm run dev -- --port ${FRONTEND_PORT:-3000} --host 0.0.0.0
agentOnly:
runtime: host

View File

@@ -1,5 +1,47 @@
# Changelog
## [1.666.0](https://github.com/windmill-labs/windmill/compare/v1.665.0...v1.666.0) (2026-03-26)
### Features
* add PDF input support to AI agent ([#8525](https://github.com/windmill-labs/windmill/issues/8525)) ([e44504c](https://github.com/windmill-labs/windmill/commit/e44504c6e93e7a4ee94ced03ab626b79a4fd0754))
### Bug Fixes
* add relative imports to the dependency list in deploymentUI ([#8548](https://github.com/windmill-labs/windmill/issues/8548)) ([d760ea5](https://github.com/windmill-labs/windmill/commit/d760ea5eaf4dc33007f1fd3e5e07b86925a0aa11))
* filter null entries in FileUpload initialValue to prevent s3 access error ([#8544](https://github.com/windmill-labs/windmill/issues/8544)) ([1a73012](https://github.com/windmill-labs/windmill/commit/1a73012e0737a6ebea8307013dc0f79982269d91))
* pass pre-bound TcpListener to run_server to fix Windows CI test race ([#8542](https://github.com/windmill-labs/windmill/issues/8542)) ([d7f4b95](https://github.com/windmill-labs/windmill/commit/d7f4b950ce6e966ed1b410e03d48fe96bc036e73))
* resolve parent_hash race condition in sync push with auto_parent ([#8545](https://github.com/windmill-labs/windmill/issues/8545)) ([71549c3](https://github.com/windmill-labs/windmill/commit/71549c3db053bcc209c7065ac8cd42f1e8047cc3))
* upload_s3_file not working in VS Code extension ([#8547](https://github.com/windmill-labs/windmill/issues/8547)) ([1fa4d91](https://github.com/windmill-labs/windmill/commit/1fa4d919b30ac9eff2d1789fba2695450ba115e7))
## [1.665.0](https://github.com/windmill-labs/windmill/compare/v1.664.0...v1.665.0) (2026-03-26)
### Features
* add instance setting to enforce workspace prefix for HTTP routes ([#8528](https://github.com/windmill-labs/windmill/issues/8528)) ([9b3e558](https://github.com/windmill-labs/windmill/commit/9b3e558d84f15052e9c32695a467f8ef7e4ad1f5))
* add trashbin system for soft-deleting items ([#8519](https://github.com/windmill-labs/windmill/issues/8519)) ([69ce946](https://github.com/windmill-labs/windmill/commit/69ce946241d98ea90bc7135d44ca0c87f928be88))
* mask sensitive values in job logs ([#8520](https://github.com/windmill-labs/windmill/issues/8520)) ([0885d8c](https://github.com/windmill-labs/windmill/commit/0885d8c986f13ac210e4db3ad38febe9be391ba4))
* move basic git sync from EE to CE with runtime user count gating ([#8493](https://github.com/windmill-labs/windmill/issues/8493)) ([79d2bd5](https://github.com/windmill-labs/windmill/commit/79d2bd51a00654162754046308d7670242120df6))
* runner groups for shared-process multi-script dedicated workers ([#8434](https://github.com/windmill-labs/windmill/issues/8434)) ([c28314f](https://github.com/windmill-labs/windmill/commit/c28314f424ea0e04b86565ce88e6c91e0df1a0cf))
* SCIM user deprovisioning (active:false) + instance-level user disable ([#8484](https://github.com/windmill-labs/windmill/issues/8484)) ([0bd7568](https://github.com/windmill-labs/windmill/commit/0bd756839c0261f255111d62088bdaaecb838085))
* show groups and notes in flow status viewer ([#8535](https://github.com/windmill-labs/windmill/issues/8535)) ([167084a](https://github.com/windmill-labs/windmill/commit/167084a0ebe73384fa0d31f0b24017a47686a072))
### Bug Fixes
* auto-generate datatable SDK reference for app mode system prompt ([#8522](https://github.com/windmill-labs/windmill/issues/8522)) ([8a32322](https://github.com/windmill-labs/windmill/commit/8a32322c187ccc60ec7eafb61a9678f267a82282))
* consider wmill.yaml environments alias in git sync ([#8532](https://github.com/windmill-labs/windmill/issues/8532)) ([b7475c7](https://github.com/windmill-labs/windmill/commit/b7475c73094a28f520f798f6cb1a0c6b4807ccb7))
* GitHub Enterprise Server support for self-managed GitHub Apps ([#8507](https://github.com/windmill-labs/windmill/issues/8507)) ([935fb44](https://github.com/windmill-labs/windmill/commit/935fb44c848b8bf9430b5600dd3c3bedb2f89efd))
* raw apps bundle not found during deployment error ([#8515](https://github.com/windmill-labs/windmill/issues/8515)) ([34e3115](https://github.com/windmill-labs/windmill/commit/34e3115bcbd19a8e0b6f483435586a2ab43d0a8e))
* require admin for workspace encryption key export ([#8523](https://github.com/windmill-labs/windmill/issues/8523)) ([0317668](https://github.com/windmill-labs/windmill/commit/031766808945aefc926f0836d011c0b2a5d2243d))
* restrict logout redirect to whitelisted domains ([#8524](https://github.com/windmill-labs/windmill/issues/8524)) ([4c8edd5](https://github.com/windmill-labs/windmill/commit/4c8edd5e944d77ed2d41c2b87171c1115c0fdcdc))
* serve index disk storage sizes from /srch/ endpoint ([#8511](https://github.com/windmill-labs/windmill/issues/8511)) ([e3620e0](https://github.com/windmill-labs/windmill/commit/e3620e074e1bdb46b2b8d732f35a91d300589663))
* use /apps_raw/get/ redirect URL for raw apps set as workspace default ([#8508](https://github.com/windmill-labs/windmill/issues/8508)) ([85c52e2](https://github.com/windmill-labs/windmill/commit/85c52e2cded10606cc895d0d3b717e13c69bc9b3))
* use resource-level scope overrides during OAuth2 token refresh ([#8540](https://github.com/windmill-labs/windmill/issues/8540)) ([55ad0ff](https://github.com/windmill-labs/windmill/commit/55ad0ff5c499c33b766f47c6f32ba5d3eeb14763))
## [1.664.0](https://github.com/windmill-labs/windmill/compare/v1.663.0...v1.664.0) (2026-03-24)

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM trashbin WHERE id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": []
},
"hash": "08522e494e34f4ecae21460262bf0ed3c5a197dd744c87cb760aaf47001febbd"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT $1 OFFSET $2",
"query": "SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT $1 OFFSET $2",
"describe": {
"columns": [
{
@@ -57,6 +57,11 @@
"ordinal": 10,
"name": "role_source",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "disabled",
"type_info": "Bool"
}
],
"parameters": {
@@ -76,8 +81,9 @@
true,
null,
false,
false,
false
]
},
"hash": "05027983ffdb11824190543754d0be922e1463d2046753cf80377369a90013ab"
"hash": "115a9cb44d0a41952c08dc36e0331410d32a8d672cfa4929e9e3763c51daa1bc"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM token WHERE email = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "192ddae8c3c82a8f099a4944483024d9826a328bf0416c22daf06fff5ced08f6"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM trashbin WHERE workspace_id = $1 AND id = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": []
},
"hash": "1d995dd5a094631ae96c16d68026fdeb22714af38162e87c02b052a5b8ec2645"
}

View File

@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, disabled FROM password WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "disabled",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "23b9c862d050b00aaa332527b62ef901cd3c417b9f3af03f35009213143bd443"
}

View File

@@ -0,0 +1,12 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM trashbin WHERE expires_at <= now()",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "3453c0b7dd3c4d2c9bc639f379901741955502c9345e82a9b7fbbf3d3c7ab517"
}

View File

@@ -0,0 +1,65 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, item_kind, item_path, item_data, deleted_by, deleted_at, expires_at\n FROM trashbin\n WHERE workspace_id = $1 AND id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "item_kind",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "item_path",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "item_data",
"type_info": "Jsonb"
},
{
"ordinal": 5,
"name": "deleted_by",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "deleted_at",
"type_info": "Timestamptz"
},
{
"ordinal": 7,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "446404eda9b9632c9a1384af6bf2f88594825dbaa647290a58bd63df61b531a7"
}

View File

@@ -0,0 +1,61 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, item_kind, item_path, deleted_by, deleted_at, expires_at\n FROM trashbin\n WHERE workspace_id = $1 AND item_kind = $2\n ORDER BY deleted_at DESC\n LIMIT $3 OFFSET $4",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "item_kind",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "item_path",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "deleted_by",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "deleted_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "51c3274a8092d80503a6b97ef3896cc3ba1957042a48ac5f9629ada25b3e78ef"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT DISTINCT imported_path as \"imported_path!\"\n FROM dependency_map\n WHERE workspace_id = $1\n AND importer_path = $2\n AND imported_path NOT LIKE 'dependencies/%'\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "imported_path!",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "52d765c87cb8da0ca71fb53156820e383a998a54c95355bb85fe7e762a0d9765"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url FROM account WHERE workspace_id = $1 AND id = $2",
"query": "SELECT client, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, scopes FROM account WHERE workspace_id = $1 AND id = $2",
"describe": {
"columns": [
{
@@ -32,6 +32,11 @@
"ordinal": 5,
"name": "cc_token_url",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "scopes",
"type_info": "TextArray"
}
],
"parameters": {
@@ -46,8 +51,9 @@
false,
true,
true,
true,
true
]
},
"hash": "cc269052ffc1e613d7edc31f0f7bb84f6e6301ad1afb028813105a121a69fa7e"
"hash": "63c48fde8c0c0fff9abffc3be27e9948556b636b70b818cc31c2d50921a27366"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT pg_advisory_xact_lock(hashtext($1 || '/' || $2))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "pg_advisory_xact_lock",
"type_info": "Void"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "6d070f476538aa6fcd6227fe5312561a7d098f2af5287e1e6c339e15080378be"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url) VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, $5, $6, $7, $8, $9) RETURNING id",
"query": "INSERT INTO account (workspace_id, client, expires_at, refresh_token, grant_type, cc_client_id, cc_client_secret, cc_token_url, mcp_server_url, scopes) VALUES ($1, $2, now() + ($3 || ' seconds')::interval, $4, $5, $6, $7, $8, $9, $10) RETURNING id",
"describe": {
"columns": [
{
@@ -19,12 +19,13 @@
"Varchar",
"Varchar",
"Varchar",
"Text"
"Text",
"TextArray"
]
},
"nullable": [
false
]
},
"hash": "b1bd088c2e1aca3104bede7d0953369b6b17ad3ad62692ae6f2303be890e6391"
"hash": "870e1c3f0dc1aaa07ac74a2e37721ce352ad4fb67d36c19dce09d841e36f85dd"
}

View File

@@ -0,0 +1,32 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n route_path,\n workspace_id,\n http_method::TEXT AS \"http_method!\"\n FROM\n http_trigger\n WHERE\n workspaced_route IS FALSE\n AND route_path_key IN (\n SELECT\n route_path_key\n FROM\n http_trigger\n WHERE\n workspaced_route IS FALSE\n GROUP BY\n route_path_key, http_method\n HAVING COUNT(*) > 1\n )\n ORDER BY route_path_key\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "route_path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "http_method!",
"type_info": "Text"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false,
null
]
},
"hash": "87ee10d8ba5ba281781f23e5390190fb90df980a19c900452f9b1a19c3620e30"
}

View File

@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO trashbin (workspace_id, item_kind, item_path, item_data, deleted_by)\n VALUES ($1, $2, $3, $4, $5) RETURNING id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Text",
"Jsonb",
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "8b25c4252da77cd2fe1b3916b518251dbb3c6d4c095efa015823f0324ab27d7f"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE password SET disabled = $1 WHERE email = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Bool",
"Text"
]
},
"nullable": []
},
"hash": "8bd266705fc8272f3d8941922ad7d18161eb6f5ec1ba9f1b55feffe8b6518c67"
}

View File

@@ -0,0 +1,60 @@
{
"db_name": "PostgreSQL",
"query": "SELECT id, workspace_id, item_kind, item_path, deleted_by, deleted_at, expires_at\n FROM trashbin\n WHERE workspace_id = $1\n ORDER BY deleted_at DESC\n LIMIT $2 OFFSET $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "item_kind",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "item_path",
"type_info": "Text"
},
{
"ordinal": 4,
"name": "deleted_by",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "deleted_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "expires_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false
]
},
"hash": "92fb6afe3b7041b2954340094c08e702fc1577d3fa4ff1ff2f1e089971ff5e32"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT args as \"args: sqlx::types::Json<Box<RawValue>>\"\n FROM v2_job\n WHERE id = $1",
"query": "SELECT args as \"args: sqlx::types::Json<Box<RawValue>>\"\n FROM v2_job\n WHERE id = $1",
"describe": {
"columns": [
{
@@ -18,5 +18,5 @@
true
]
},
"hash": "d1dcc7fc8a1e1bc4dad263ec5163a94fca9dd95cc3b26b33611eab9d2a261141"
"hash": "97cf826b271cf064182382c924188fee392ed9cff6ae446abc86170984304a25"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2",
"query": "WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),\n authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)\n SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source, disabled\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n ORDER BY super_admin DESC, devops DESC\n LIMIT $1 OFFSET $2",
"describe": {
"columns": [
{
@@ -57,6 +57,11 @@
"ordinal": 10,
"name": "role_source",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "disabled",
"type_info": "Bool"
}
],
"parameters": {
@@ -76,8 +81,9 @@
true,
true,
false,
false,
false
]
},
"hash": "60118de85463098220b1c74f667b6fedb0f3f0040844c3774145e8f1f4c023ce"
"hash": "a5fd115e7be5129d623543bbfa7b5b31f0efc6d8ef73f691009c73f833dcee10"
}

View File

@@ -0,0 +1,14 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM trashbin WHERE workspace_id = $1",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text"
]
},
"nullable": []
},
"hash": "bae31609123da68d16bea8e0f1c4624403b6f97e13f13f056501fe2f4efb0f06"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "c1fd495abb4353b46361ec94fd4ae8d224457171b1b73fe145d28e67f1fe03af"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT super_admin FROM password WHERE email = $1 AND disabled = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "super_admin",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "ccc49a2a6e11f874825365de758bdc0e1934d67d3f2b14047d434b77d370af21"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO password (email, login_type, verified, username, name) VALUES ($1, 'saml', true, $2, $3) ON CONFLICT DO NOTHING",
"query": "INSERT INTO password (email, login_type, verified, username, name) VALUES ($1, 'saml', true, $2, $3) ON CONFLICT (email) DO UPDATE SET disabled = false",
"describe": {
"columns": [],
"parameters": {
@@ -12,5 +12,5 @@
},
"nullable": []
},
"hash": "638d3c2ba1198dce5b5b0e47df59a92ff8011e19fbefcc3960d6f0fe167e55b6"
"hash": "daa1a6bf3d4a1001da88301932a7ac9019767074158e0c027988e5b0d51a3656"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password WHERE email = $1",
"query": "SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password WHERE email = $1",
"describe": {
"columns": [
{
@@ -57,6 +57,11 @@
"ordinal": 10,
"name": "role_source",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "disabled",
"type_info": "Bool"
}
],
"parameters": {
@@ -75,8 +80,9 @@
true,
null,
false,
false,
false
]
},
"hash": "65c59e224e460351c2f88261f8b1b1e7ce2bb160270b59c0f359b7952453b2b9"
"hash": "f0c9c54740cc1c0c2a6fa4e79d4d504b7b5cb7a39538ab9abeb44f781c711493"
}

View File

@@ -0,0 +1,104 @@
{
"db_name": "PostgreSQL",
"query": "SELECT DISTINCT ON (path) path, language AS \"language: ScriptLang\", content FROM script\n WHERE workspace_id = $1\n AND archived = false\n AND dedicated_worker = true\n AND language = ANY($2::SCRIPT_LANG[])\n ORDER BY path, created_at DESC",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "language: ScriptLang",
"type_info": {
"Custom": {
"name": "script_lang",
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash",
"postgresql",
"nativets",
"bun",
"mysql",
"bigquery",
"snowflake",
"graphql",
"powershell",
"mssql",
"php",
"bunnative",
"rust",
"ansible",
"csharp",
"oracledb",
"nu",
"java",
"duckdb",
"ruby"
]
}
}
}
},
{
"ordinal": 2,
"name": "content",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
{
"Custom": {
"name": "script_lang[]",
"kind": {
"Array": {
"Custom": {
"name": "script_lang",
"kind": {
"Enum": [
"python3",
"deno",
"go",
"bash",
"postgresql",
"nativets",
"bun",
"mysql",
"bigquery",
"snowflake",
"graphql",
"powershell",
"mssql",
"php",
"bunnative",
"rust",
"ansible",
"csharp",
"oracledb",
"nu",
"java",
"duckdb",
"ruby"
]
}
}
}
}
}
}
]
},
"nullable": [
false,
false,
false
]
},
"hash": "f2fa27ed5020aa9c085176b25466be1bceb79c82e7b5542f17b23b6d70cc02d6"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT disabled FROM password WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "disabled",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "fc6c6310ae8ac5eb351d7e2af1678447d0aa3d143e94e49924ff7ac8b7abf924"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO token\n (token_hash, token_prefix, token, label, super_admin, email)\n VALUES ($1, $2, $3, $4, $5, $6)",
"query": "INSERT INTO token\n (token_hash, token_prefix, token, label, super_admin, email)\n VALUES ($1, $2, $3, $4, $5, $6)",
"describe": {
"columns": [],
"parameters": {
@@ -15,5 +15,5 @@
},
"nullable": []
},
"hash": "d05f20431cd08f737bfbf904efedfdf104e3d77b0725c5355305d19f67359e90"
"hash": "fd4c5391107af34a3bf9b83b0c3f7d5ee9490240a627b20a1037444845e39c5f"
}

609
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.664.0"
version = "1.666.0"
authors.workspace = true
edition.workspace = true
@@ -82,7 +82,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.664.0"
version = "1.666.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -362,7 +362,7 @@ reqwest-middleware = { version = "^0", features = ["json"] }
bitflags = "2.9.4"
memchr = "2.7.4"
axum = { version = "^0.7", features = ["multipart", "macros"] }
axum = { version = "^0.8", features = ["multipart", "macros"] }
headers = "^0"
hyper = { version = "^1", features = ["full"] }
hyper-tls = "^0.6"
@@ -371,7 +371,7 @@ tokio = { version = "=1.46.1", features = ["full", "tracing", "time"] }
tokio-stream = { version = "0.1.17" }
tower = "^0"
tower-http = { version = "^0.6", features = ["trace", "cors", "catch-panic"] }
tower-cookies = "^0.10"
tower-cookies = "^0.11"
#stuck because of swc for now
serde = "=1.0.220"
serde_json = { version = "^1", features = ["preserve_order", "raw_value"] }
@@ -386,7 +386,7 @@ tracing = "^0"
tracing-subscriber = { version = "^0", features = ["env-filter", "json"] }
tracing-appender = "^0"
prometheus = { version = "^0", default-features = false }
cookie = { version = "0.17.0" }
cookie = { version = "0.18.0" }
phf = { version = "0.11", features = ["macros"] }
rust-embed = { version = "^6", features = ["interpolate-folder-path"] }
mime_guess = "^2"
@@ -566,18 +566,18 @@ flate2 = "^1"
http = "^1"
async-stream = "^0"
opentelemetry = "0.27.0"
tracing-opentelemetry = "0.28.0"
opentelemetry_sdk = { version = "0.27.1", features = ["rt-tokio"] }
opentelemetry-otlp = { version = "0.27.0", features = ["grpc-tonic", "tls"] }
opentelemetry-appender-tracing = "0.27.0"
opentelemetry-semantic-conventions = { version = "0.27.0", features = ["semconv_experimental"] }
opentelemetry-proto = { version = "0.29.0", features = ["with-serde", "gen-tonic"] }
opentelemetry = "0.30.0"
tracing-opentelemetry = "0.31.0"
opentelemetry_sdk = { version = "0.30.0", features = ["rt-tokio"] }
opentelemetry-otlp = { version = "0.30.0", features = ["grpc-tonic", "tls"] }
opentelemetry-appender-tracing = "0.30.0"
opentelemetry-semantic-conventions = { version = "0.30.0", features = ["semconv_experimental"] }
opentelemetry-proto = { version = "0.30.0", features = ["with-serde", "gen-tonic"] }
prost = "0.13"
bollard = "0.18.1"
tonic = { version = "=0.12.3", features = ["tls-native-roots"] }
tonic = { version = "^0.13", features = ["tls-native-roots"] }
byteorder = "1.5.0"
tikv-jemallocator = { version = "0.5" }

View File

@@ -1 +1 @@
b5c8af4df9ba2c39fdd494d7a40f9a92fbff8abc
61ae055ea31481f1899953e9d5f65566b8c707b1

View File

@@ -0,0 +1 @@
ALTER TABLE password DROP COLUMN IF EXISTS disabled;

View File

@@ -0,0 +1 @@
ALTER TABLE password ADD COLUMN disabled BOOLEAN NOT NULL DEFAULT false;

View File

@@ -0,0 +1,3 @@
-- Revoke grants for app_bundles table
REVOKE ALL ON app_bundles FROM windmill_user;
REVOKE ALL ON app_bundles FROM windmill_admin;

View File

@@ -0,0 +1,3 @@
-- Add grants for app_bundles table
GRANT ALL ON app_bundles TO windmill_user;
GRANT ALL ON app_bundles TO windmill_admin;

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS trashbin;

View File

@@ -0,0 +1,16 @@
CREATE TABLE trashbin (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
item_kind VARCHAR(50) NOT NULL,
item_path TEXT NOT NULL,
item_data JSONB NOT NULL,
deleted_by VARCHAR(255) NOT NULL,
deleted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL DEFAULT now() + INTERVAL '3 days'
);
CREATE INDEX idx_trashbin_expires_at ON trashbin(expires_at);
CREATE INDEX idx_trashbin_workspace_kind ON trashbin(workspace_id, item_kind);
GRANT ALL ON trashbin TO windmill_user;
GRANT ALL ON trashbin TO windmill_admin;

View File

@@ -0,0 +1 @@
ALTER TABLE account DROP COLUMN IF EXISTS scopes;

View File

@@ -0,0 +1 @@
ALTER TABLE account ADD COLUMN scopes TEXT[];

View File

@@ -44,17 +44,18 @@ use windmill_common::{
CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING,
INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING,
INDEXER_SETTING, INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING,
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
NPM_CONFIG_REGISTRY_SETTING, NUGET_CONFIG_SETTING, OAUTH_SETTING, OTEL_SETTING,
OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RETENTION_PERIOD_SECS_SETTING,
RUBY_REPOS_SETTING, SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, SMTP_SETTING, TEAMS_SETTING,
TIMEOUT_WAIT_RESULT_SETTING, UV_INDEX_STRATEGY_SETTING, WORKSPACE_REGISTRIES_SETTING,
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING, RESTART_COORDINATION_SETTING,
RETENTION_PERIOD_SECS_SETTING, RUBY_REPOS_SETTING, SAML_METADATA_SETTING,
SCIM_TOKEN_SETTING, SMTP_SETTING, TEAMS_SETTING, TIMEOUT_WAIT_RESULT_SETTING,
UV_INDEX_STRATEGY_SETTING, WORKSPACE_REGISTRIES_SETTING,
},
scripts::ScriptLang,
stats_oss::schedule_stats,
@@ -67,7 +68,7 @@ use windmill_common::{
is_native_mode_from_env, reload_custom_tags_setting, Connection, HUB_CACHE_DIR,
HUB_RT_CACHE_DIR, NATIVE_MODE_RESOLVED, TMP_LOGS_DIR, WINDMILL_DIR, WORKER_GROUP,
},
KillpillSender, DEFAULT_HUB_BASE_URL, METRICS_ENABLED,
KillpillSender, DEFAULT_HUB_BASE_URL, INSTANCE_NAME, METRICS_ENABLED,
};
#[cfg(feature = "enterprise")]
@@ -104,10 +105,10 @@ use crate::monitor::{
reload_base_url_setting, reload_bunfig_install_scopes_setting,
reload_critical_alert_mute_ui_setting, reload_critical_alerts_on_token_expiry_setting,
reload_critical_error_channels_setting, reload_extra_pip_index_url_setting,
reload_hub_api_secret_setting, reload_hub_base_url_setting,
reload_instance_events_webhook_setting, reload_job_default_timeout_setting,
reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key,
reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting,
reload_http_route_workspaced_route_setting, reload_hub_api_secret_setting,
reload_hub_base_url_setting, reload_instance_events_webhook_setting,
reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting,
reload_license_key, reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting,
reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting,
reload_smtp_config, reload_uv_index_strategy_setting, reload_worker_config, MonitorIteration,
};
@@ -1099,6 +1100,9 @@ Windmill Community Edition {GIT_VERSION}
}
let addr = SocketAddr::from((server_bind_address, port));
let listener = tokio::net::TcpListener::bind(addr)
.await
.context("binding main windmill server")?;
let (base_internal_tx, base_internal_rx) = tokio::sync::oneshot::channel::<String>();
@@ -1232,7 +1236,7 @@ Windmill Community Edition {GIT_VERSION}
db.clone(),
index_reader,
log_index_reader,
addr,
listener,
server_killpill_rx,
base_internal_tx,
server_mode,
@@ -1788,7 +1792,8 @@ async fn process_notify_event(
reload_otel_tracing_proxy_setting(conn).await;
if worker_mode {
tracing::info!("OTEL tracing proxy setting changed, restarting worker");
send_delayed_killpill(tx, 4, "OTEL tracing proxy setting change").await;
spawn_graceful_killpill(tx, db, 10, "OTEL tracing proxy setting change")
.await;
}
}
REQUIRE_PREEXISTING_USER_FOR_OAUTH_SETTING => {
@@ -1796,12 +1801,12 @@ async fn process_notify_event(
}
EXPOSE_METRICS_SETTING => {
tracing::info!("Metrics setting changed, restarting");
send_delayed_killpill(tx, 40, "metrics setting change").await;
spawn_graceful_killpill(tx, db, 10, "metrics setting change").await;
}
EMAIL_DOMAIN_SETTING => {
tracing::info!("Email domain setting changed");
if server_mode {
send_delayed_killpill(tx, 4, "email domain setting change").await;
spawn_graceful_killpill(tx, db, 10, "email domain setting change").await;
}
}
EXPOSE_DEBUG_METRICS_SETTING => {
@@ -1814,25 +1819,42 @@ async fn process_notify_event(
tracing::error!(error = %e, "Could not reload app workspaced route setting");
}
}
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => {
if let Err(e) = reload_http_route_workspaced_route_setting(db).await {
tracing::error!(error = %e, "Could not reload http route workspaced route setting");
}
#[cfg(feature = "http_trigger")]
match windmill_api::triggers::http::refresh_routers(db).await {
Ok((true, _)) => {
tracing::info!(
"Refreshed HTTP routers (http workspaced route setting change)"
);
}
Err(err) => {
tracing::error!("Error refreshing HTTP routers (http workspaced route setting change): {err:#}");
}
_ => {}
}
}
AI_CONFIG_SETTING => {
tracing::info!("AI config setting changed, bumping instance AI cache revision");
bump_instance_ai_config_revision();
}
OTEL_SETTING => {
tracing::info!("OTEL setting changed, restarting");
send_delayed_killpill(tx, 4, "OTEL setting change").await;
spawn_graceful_killpill(tx, db, 10, "OTEL setting change").await;
}
REQUEST_SIZE_LIMIT_SETTING => {
if server_mode {
tracing::info!("Request limit size change detected, killing server expecting to be restarted");
send_delayed_killpill(tx, 4, "request size limit change").await;
spawn_graceful_killpill(tx, db, 10, "request size limit change").await;
}
}
SAML_METADATA_SETTING => {
tracing::info!(
"SAML metadata change detected, killing server expecting to be restarted"
);
send_delayed_killpill(tx, 0, "SAML metadata change").await;
spawn_graceful_killpill(tx, db, 10, "SAML metadata change").await;
}
HUB_BASE_URL_SETTING => {
if let Err(e) = reload_hub_base_url_setting(conn, server_mode).await {
@@ -1881,6 +1903,9 @@ async fn process_notify_event(
.unwrap_or(false);
tracing::info!("Workspace telemetry setting changed: enabled={}", enabled);
}
RESTART_COORDINATION_SETTING => {
// Internal coordination key for staggered restarts, no action needed
}
_ => {
tracing::info!("Unrecognized Global Setting Change Payload: {:?}", payload);
}
@@ -2022,14 +2047,145 @@ pub async fn run_workers(
Ok(())
}
async fn send_delayed_killpill(tx: &KillpillSender, mut max_delay_secs: u64, context: &str) {
if max_delay_secs == 0 {
max_delay_secs = 1;
}
// Random delay to avoid all servers/workers shutting down simultaneously
let rd_delay = rand::rng().random_range(0..max_delay_secs);
tracing::info!("Scheduling {context} shutdown in {rd_delay}s");
tokio::time::sleep(Duration::from_secs(rd_delay)).await;
/// Schedule a graceful restart with DB-coordinated staggering.
///
/// Uses a PostgreSQL advisory lock to serialize restart scheduling across server instances.
/// Each instance records its planned restart time in the `_restart_coordination` global setting;
/// subsequent instances read existing schedules and shift their restart to maintain at least
/// `safety_margin_secs` between consecutive restarts (must exceed the server startup time).
///
/// Every server waits at least `DRAIN_DELAY_SECS` to let in-flight requests complete.
/// Each subsequent server waits an additional `safety_margin_secs` after the previous one,
/// guaranteeing zero downtime overlap.
///
/// The DB coordination is done synchronously (fast, ~ms) to reserve our restart slot,
/// then the sleep+kill is spawned in the background so the notification handler is not blocked.
///
/// Falls back to drain-only delay if DB coordination fails.
async fn spawn_graceful_killpill(
tx: &KillpillSender,
db: &Pool<Postgres>,
safety_margin_secs: u64,
context: &str,
) {
// Minimum delay before any restart to let in-flight requests drain
const DRAIN_DELAY_SECS: u64 = 3;
tx.send();
let delay = match coordinate_restart_delay(db, safety_margin_secs, DRAIN_DELAY_SECS).await {
Ok(d) => d,
Err(e) => {
tracing::warn!(
"Failed to coordinate restart for {context}: {e:#}, \
falling back to drain delay of {DRAIN_DELAY_SECS}s"
);
DRAIN_DELAY_SECS
}
};
tracing::info!("Scheduling {context} graceful shutdown in {delay}s");
let tx = tx.clone();
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(delay)).await;
tx.send();
});
}
/// Coordinate a restart delay with other instances via the DB.
///
/// Returns the delay (in seconds from now) at which this instance should restart.
/// The first server gets `drain_delay_secs` (to let in-flight requests complete).
/// Each subsequent server is spaced `safety_margin_secs` after the latest scheduled restart.
async fn coordinate_restart_delay(
db: &Pool<Postgres>,
safety_margin_secs: u64,
drain_delay_secs: u64,
) -> anyhow::Result<u64> {
const RESTART_LOCK_ID: i64 = 737_483_920;
// Stale threshold: ignore coordination entries older than this
const STALE_THRESHOLD_SECS: i64 = 120;
let now = chrono::Utc::now();
let mut tx = db.begin().await.context("begin restart coordination tx")?;
// Serialize access across all instances
sqlx::query("SELECT pg_advisory_xact_lock($1)")
.bind(RESTART_LOCK_ID)
.execute(&mut *tx)
.await
.context("acquire restart coordination lock")?;
// Read existing coordination record
let existing: Option<serde_json::Value> =
sqlx::query_scalar("SELECT value FROM global_settings WHERE name = $1")
.bind(RESTART_COORDINATION_SETTING)
.fetch_optional(&mut *tx)
.await
.context("read restart coordination")?;
// Parse existing scheduled restarts, filtering out stale entries
// Each entry is (instance_name, restart_at)
let mut scheduled: Vec<(String, chrono::DateTime<chrono::Utc>)> = Vec::new();
if let Some(val) = &existing {
if let Some(arr) = val.get("restarts").and_then(|v| v.as_array()) {
for entry in arr {
let instance = entry
.get("instance")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
if let Some(ts_str) = entry.get("restart_at").and_then(|v| v.as_str()) {
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(ts_str) {
let dt = dt.with_timezone(&chrono::Utc);
let stale_cutoff = now - chrono::Duration::seconds(STALE_THRESHOLD_SECS);
if dt > stale_cutoff {
scheduled.push((instance, dt));
}
}
}
}
}
}
// Find the latest scheduled restart
let latest = scheduled.iter().map(|(_, dt)| *dt).max();
let earliest_allowed = now + chrono::Duration::seconds(drain_delay_secs as i64);
// Our restart time: drain_delay from now, or safety_margin after the latest existing restart
let our_restart = match latest {
Some(last) => {
let after_last = last + chrono::Duration::seconds(safety_margin_secs as i64);
// Use whichever is later: drain delay or staggered position
earliest_allowed.max(after_last)
}
None => earliest_allowed,
};
// Record our restart time (deduplicate: remove any prior entry for this instance)
scheduled.retain(|(inst, _)| inst != &*INSTANCE_NAME);
scheduled.push((INSTANCE_NAME.clone(), our_restart));
let new_value = serde_json::json!({
"restarts": scheduled.iter().map(|(inst, dt)| {
serde_json::json!({
"instance": inst,
"restart_at": dt.to_rfc3339()
})
}).collect::<Vec<_>>()
});
sqlx::query(
"INSERT INTO global_settings (name, value, updated_at) \
VALUES ($1, $2, now()) \
ON CONFLICT (name) DO UPDATE SET value = $2, updated_at = now()",
)
.bind(RESTART_COORDINATION_SETTING)
.bind(&new_value)
.execute(&mut *tx)
.await
.context("write restart coordination")?;
tx.commit().await.context("commit restart coordination")?;
let delay = (our_restart - now).num_seconds().max(0) as u64;
Ok(delay)
}

View File

@@ -88,7 +88,13 @@ use windmill_common::{
MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED,
SERVICE_LOG_RETENTION_SECS,
};
use windmill_common::{client::AuthedClient, global_settings::APP_WORKSPACED_ROUTE_SETTING};
use windmill_common::{
client::AuthedClient,
global_settings::{
APP_WORKSPACED_ROUTE_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE,
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
},
};
#[cfg(feature = "parquet")]
use windmill_object_store::reload_object_store_setting;
use windmill_queue::{cancel_job, get_queued_job_v2, SameWorkerPayload};
@@ -163,6 +169,8 @@ lazy_static::lazy_static! {
static ref QUEUE_COUNT_TAGS: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(Vec::new()));
static ref QUEUE_RUNNING_COUNT_TAGS: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(Vec::new()));
static ref OTEL_QUEUE_COUNT_TAGS: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(Vec::new()));
static ref OTEL_QUEUE_RUNNING_COUNT_TAGS: Arc<RwLock<Vec<String>>> = Arc::new(RwLock::new(Vec::new()));
static ref DISABLE_CONCURRENCY_LIMIT: bool = std::env::var("DISABLE_CONCURRENCY_LIMIT").is_ok_and(|s| s == "true");
//legacy typo
@@ -296,6 +304,10 @@ pub async fn initial_load(
if let Err(e) = reload_app_workspaced_route_setting(db).await {
tracing::error!("Error reloading app workspaced route: {:?}", e)
}
if let Err(e) = reload_http_route_workspaced_route_setting(db).await {
tracing::error!("Error reloading http route workspaced route: {:?}", e)
}
}
#[cfg(feature = "parquet")]
@@ -1168,6 +1180,15 @@ pub async fn delete_expired_items(db: &DB) -> () {
tracing::error!("Error deleting custom concurrency key: {:?}", e);
}
}
match windmill_common::trashbin::delete_expired_trash(db).await {
Ok(count) => {
if count > 0 {
tracing::info!("deleted {} expired trash items", count);
}
}
Err(e) => tracing::error!("Error deleting expired trash items: {}", e.to_string()),
}
}
pub async fn check_expiring_tokens(db: &DB) {
@@ -2353,8 +2374,20 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
}
}
let otel_enabled = OTEL_METRICS_ENABLED.load(Ordering::Relaxed);
if otel_enabled {
for q in OTEL_QUEUE_COUNT_TAGS.read().await.iter() {
if queue_counts.get(q).is_none() {
otel_set_queue_count(q, 0);
}
}
}
#[allow(unused_mut)]
let mut tags_to_watch = vec![];
#[allow(unused_mut)]
let mut otel_tags_to_watch = vec![];
for q in queue_counts {
let count = q.1;
let tag = q.0;
@@ -2366,6 +2399,9 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
tags_to_watch.push(tag.to_string());
}
if otel_enabled {
otel_tags_to_watch.push(tag.to_string());
}
otel_set_queue_count(&tag, count as i64);
// save queue_count and delay metrics per tag
@@ -2400,9 +2436,13 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
let mut w = QUEUE_COUNT_TAGS.write().await;
*w = tags_to_watch;
}
if otel_enabled {
let mut w = OTEL_QUEUE_COUNT_TAGS.write().await;
*w = otel_tags_to_watch;
}
// Single DB query for running counts, shared by Prometheus and OTel
let otel_running = OTEL_METRICS_ENABLED.load(Ordering::Relaxed);
let otel_running = otel_enabled;
#[cfg(feature = "prometheus")]
let need_running_counts = metrics_enabled || otel_running;
#[cfg(not(feature = "prometheus"))]
@@ -2420,8 +2460,18 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
}
}
if otel_running {
for q in OTEL_QUEUE_RUNNING_COUNT_TAGS.read().await.iter() {
if queue_running_counts.get(q).is_none() {
otel_set_queue_running_count(q, 0);
}
}
}
#[allow(unused_mut, unused_variables)]
let mut running_tags_to_watch: Vec<String> = vec![];
#[allow(unused_mut, unused_variables)]
let mut otel_running_tags_to_watch: Vec<String> = vec![];
for (tag, count) in &queue_running_counts {
#[cfg(feature = "prometheus")]
if metrics_enabled {
@@ -2432,6 +2482,7 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
if otel_running {
otel_set_queue_running_count(tag, *count as i64);
otel_running_tags_to_watch.push(tag.to_string());
}
}
@@ -2440,6 +2491,10 @@ pub async fn expose_queue_metrics(db: &Pool<Postgres>) {
let mut w = QUEUE_RUNNING_COUNT_TAGS.write().await;
*w = running_tags_to_watch;
}
if otel_running {
let mut w = OTEL_QUEUE_RUNNING_COUNT_TAGS.write().await;
*w = otel_running_tags_to_watch;
}
}
}
@@ -3390,6 +3445,39 @@ pub async fn reload_app_workspaced_route_setting(conn: &DB) -> error::Result<()>
Ok(())
}
pub async fn reload_http_route_workspaced_route_setting(conn: &DB) -> error::Result<()> {
let http_route_workspaced_route =
load_value_from_global_settings(conn, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING).await?;
let ws_route = match http_route_workspaced_route {
Some(serde_json::Value::Bool(ws_route)) => ws_route,
None => false,
_ => {
tracing::error!(
"Expected {} to be a boolean got: {:?}. Defaulting to false",
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
http_route_workspaced_route
);
false
}
};
let mut l = HTTP_ROUTE_WORKSPACED_ROUTE.write().await;
if *l != ws_route {
*l = ws_route;
drop(l);
// Bump the HTTP trigger version so the route cache is rebuilt with
// the updated workspaced_route behavior on the next request.
sqlx::query!("SELECT nextval('http_trigger_version_seq')")
.fetch_one(conn)
.await?;
} else {
*l = ws_route;
}
Ok(())
}
pub async fn reload_critical_alerts_on_db_oversize(conn: &DB) -> error::Result<()> {
#[derive(Deserialize)]
struct DBOversize {

View File

@@ -33,7 +33,7 @@ workspace_key_kind: cloud
## Tables
_sqlx_migrations: version(bigint), description(text), installed_on(ts), success(bool), checksum(bytes), execution_time(bigint)
account: workspace_id(char), id(int), expires_at(ts), refresh_token(char), client(char), refresh_error(text), grant_type(char), cc_client_id(char), cc_client_secret(char), cc_token_url(char), mcp_server_url(text)
account: workspace_id(char), id(int), expires_at(ts), refresh_token(char), client(char), refresh_error(text), grant_type(char), cc_client_id(char), cc_client_secret(char), cc_token_url(char), mcp_server_url(text), scopes(text[])
FK: (workspace_id) -> workspace(id)
agent_token_blacklist: token(char), expires_at(ts), blacklisted_at(ts), blacklisted_by(char)
ai_agent_memory: workspace_id(char), conversation_id(uuid), step_id(char), messages(jsonb), created_at(ts), updated_at(ts)
@@ -151,6 +151,9 @@ script: workspace_id(char), hash(bigint), path(char), parent_hashes(bigint[]), s
skip_workspace_diff_tally: workspace_id(char), added_at(ts)
sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attributes(text[]), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), error(text), server_id(char), last_server_ping(ts), aws_auth_resource_type(aws_auth_resource_type), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), mode(trigger_mode)
FK: (workspace_id) -> workspace(id)
trashbin: id(bigint), workspace_id(char), item_kind(char), item_path(char), item_data(jsonb), deleted_by(char), deleted_at(ts), expires_at(ts)
FK: (workspace_id) -> workspace(id)
INDEX: idx_trashbin_expires_at (expires_at), idx_trashbin_workspace_kind (workspace_id, item_kind)
token: token_hash(char), token_prefix(char), token(char), label(char), expiration(ts), workspace_id(char), owner(char), email(char), super_admin(bool), created_at(ts), last_used_at(ts), scopes(text[]), job(uuid)
FK: (workspace_id) -> workspace(id)
token_expiry_notification: token_hash(char), expiration(ts)

View File

@@ -891,25 +891,34 @@ mod dedicated_worker_protocol {
use std::process::{Command, Stdio};
use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult};
use windmill_worker::{
build_loader, generate_dedicated_worker_wrapper, LoaderMode, BUN_DEDICATED_WORKER_ARGS,
BUN_PATH, NODE_BIN_PATH,
build_loader, compute_ts_codegen, generate_multi_script_wrapper, LoaderMode, TsScriptEntry,
BUN_DEDICATED_WORKER_ARGS, BUN_PATH, NODE_BIN_PATH,
};
const TEST_SCRIPT_PATH: &str = "f/test/script";
/// Creates test worker files and optionally bundles for Node.js (like production)
/// Returns the path to the wrapper file to execute
fn create_test_worker_files(
dir: &std::path::Path,
script: &str,
arg_names: &[&str],
bundle_for_node: bool,
) -> std::path::PathBuf {
let dir_str = dir.to_str().unwrap();
// Write main.ts at root (like production single-script)
std::fs::write(dir.join("main.ts"), script).unwrap();
let codegen = compute_ts_codegen(script);
let ext = if bundle_for_node { "js" } else { "ts" };
let scripts = [TsScriptEntry {
import_name: "main",
original_path: TEST_SCRIPT_PATH,
codegen: &codegen,
}];
let wrapper = generate_multi_script_wrapper(&scripts, ext);
if bundle_for_node {
// For Node.js: bundle to JavaScript first (like production's build_loader with LoaderMode::Node)
let wrapper = generate_dedicated_worker_wrapper(arg_names, "./main.js", None, None);
std::fs::write(dir.join("wrapper.mjs"), wrapper).unwrap();
std::fs::write(dir.join("wrapper.mjs"), &wrapper).unwrap();
// Use the exact same build_loader function as production
tokio::runtime::Runtime::new()
@@ -919,7 +928,7 @@ mod dedicated_worker_protocol {
"http://localhost:8000",
"test_token",
"test-workspace",
"f/test/script",
TEST_SCRIPT_PATH,
LoaderMode::Node,
&None,
))
@@ -945,10 +954,8 @@ mod dedicated_worker_protocol {
std::fs::rename(&bundled_path, &output_path).unwrap();
output_path
} else {
// For Bun: use TypeScript directly (like production)
let wrapper = generate_dedicated_worker_wrapper(arg_names, "./main.ts", None, None);
let wrapper_path = dir.join("wrapper.mjs");
std::fs::write(&wrapper_path, wrapper).unwrap();
std::fs::write(&wrapper_path, &wrapper).unwrap();
wrapper_path
}
}
@@ -957,14 +964,12 @@ mod dedicated_worker_protocol {
fn run_worker_test(
runtime: &str,
script: &str,
arg_names: &[&str],
jobs: Vec<serde_json::Value>,
) -> Vec<Result<serde_json::Value, String>> {
let temp_dir = tempfile::tempdir().unwrap();
// Create files and get the wrapper path (bundled for node, raw for bun)
let wrapper_path =
create_test_worker_files(temp_dir.path(), script, arg_names, runtime == "node");
let wrapper_path = create_test_worker_files(temp_dir.path(), script, runtime == "node");
let wrapper_str = wrapper_path.to_str().unwrap();
// Build args matching production behavior
@@ -1008,7 +1013,8 @@ mod dedicated_worker_protocol {
let mut results = Vec::new();
for job_args in jobs {
writeln!(stdin, "{}", job_args.to_string()).unwrap();
// Protocol: exec:<script_path>:<json_args>
writeln!(stdin, "exec:{}:{}", TEST_SCRIPT_PATH, job_args.to_string()).unwrap();
stdin.flush().unwrap();
let mut response = String::new();
@@ -1043,12 +1049,7 @@ export function main(x: number, y: number): number {
return x + y;
}
"#;
let results = run_worker_test(
"node",
script,
&["x", "y"],
vec![serde_json::json!({"x": 5, "y": 3})],
);
let results = run_worker_test("node", script, vec![serde_json::json!({"x": 5, "y": 3})]);
assert_eq!(results.len(), 1);
assert_eq!(results[0], Ok(serde_json::json!(8)));
@@ -1062,7 +1063,7 @@ export function main(n: number): number {
}
"#;
let jobs: Vec<serde_json::Value> = (1..=5).map(|i| serde_json::json!({"n": i})).collect();
let results = run_worker_test("node", script, &["n"], jobs);
let results = run_worker_test("node", script, jobs);
assert_eq!(results.len(), 5);
for (i, result) in results.iter().enumerate() {
@@ -1081,7 +1082,6 @@ export function main(msg: string): never {
let results = run_worker_test(
"node",
script,
&["msg"],
vec![serde_json::json!({"msg": "test error"})],
);
@@ -1099,12 +1099,7 @@ export function main(x: number, y: number): number {
return x + y;
}
"#;
let results = run_worker_test(
"bun",
script,
&["x", "y"],
vec![serde_json::json!({"x": 5, "y": 3})],
);
let results = run_worker_test("bun", script, vec![serde_json::json!({"x": 5, "y": 3})]);
assert_eq!(results.len(), 1);
assert_eq!(results[0], Ok(serde_json::json!(8)));
@@ -1118,7 +1113,7 @@ export function main(n: number): number {
}
"#;
let jobs: Vec<serde_json::Value> = (1..=5).map(|i| serde_json::json!({"n": i})).collect();
let results = run_worker_test("bun", script, &["n"], jobs);
let results = run_worker_test("bun", script, jobs);
assert_eq!(results.len(), 5);
for (i, result) in results.iter().enumerate() {
@@ -1137,7 +1132,6 @@ export function main(msg: string): never {
let results = run_worker_test(
"bun",
script,
&["msg"],
vec![serde_json::json!({"msg": "test error"})],
);
@@ -1145,6 +1139,721 @@ export function main(msg: string): never {
assert!(results[0].is_err());
assert_eq!(results[0], Err("test error".to_string()));
}
// ==================== Multi-Script (Runner Group) Tests ====================
/// Job to send to a specific script in a multi-script wrapper
struct MultiScriptJob {
script_path: String,
args: serde_json::Value,
}
/// Creates a multi-script wrapper with multiple scripts as flat files, returns the wrapper path
fn create_multi_script_worker_files(
dir: &std::path::Path,
scripts: &[(&str, &str)], // (original_path, script_content)
) -> std::path::PathBuf {
let mut entries_data = Vec::new();
for (path, content) in scripts {
let safe_name = format!("_wm_{}", path.replace('/', "__"));
std::fs::write(dir.join(format!("{safe_name}.ts")), content).unwrap();
entries_data.push((safe_name, path.to_string(), compute_ts_codegen(content)));
}
let entries: Vec<TsScriptEntry<'_>> = entries_data
.iter()
.map(|(safe, path, cg)| TsScriptEntry {
import_name: safe.as_str(),
original_path: path.as_str(),
codegen: cg,
})
.collect();
let wrapper = generate_multi_script_wrapper(&entries, "ts");
let wrapper_path = dir.join("wrapper.mjs");
std::fs::write(&wrapper_path, &wrapper).unwrap();
wrapper_path
}
/// Helper to run a multi-script dedicated worker test
fn run_multi_script_worker_test(
scripts: &[(&str, &str)],
jobs: Vec<MultiScriptJob>,
) -> Vec<Result<serde_json::Value, String>> {
let temp_dir = tempfile::tempdir().unwrap();
let wrapper_path = create_multi_script_worker_files(temp_dir.path(), scripts);
let wrapper_str = wrapper_path.to_str().unwrap();
let mut cmd_args: Vec<&str> = BUN_DEDICATED_WORKER_ARGS.to_vec();
cmd_args.push(wrapper_str);
let mut child = Command::new(BUN_PATH.as_str())
.args(cmd_args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.current_dir(temp_dir.path())
.spawn()
.expect("Failed to spawn worker process");
let mut stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout);
// Wait for "start" signal
let mut start_line = String::new();
reader.read_line(&mut start_line).unwrap();
assert_eq!(
parse_dedicated_worker_line(start_line.trim()),
DedicatedWorkerResult::Start,
"Expected 'start', got: {}",
start_line.trim()
);
let mut results = Vec::new();
for job in &jobs {
writeln!(stdin, "exec:{}:{}", job.script_path, job.args.to_string()).unwrap();
stdin.flush().unwrap();
let mut response = String::new();
reader.read_line(&mut response).unwrap();
match parse_dedicated_worker_line(response.trim()) {
DedicatedWorkerResult::Success(value) => results.push(Ok(value)),
DedicatedWorkerResult::Error(err) => {
let msg = err["message"]
.as_str()
.unwrap_or("Unknown error")
.to_string();
results.push(Err(msg));
}
other => panic!("Unexpected response: {:?}", other),
}
}
writeln!(stdin, "end").unwrap();
stdin.flush().unwrap();
let _ = child.wait().expect("Worker process failed to exit");
results
}
#[test]
fn test_multi_script_routing_basic() {
let script_add = r#"
export function main(a: number, b: number): number {
return a + b;
}
"#;
let script_mul = r#"
export function main(x: number, y: number): number {
return x * y;
}
"#;
let results = run_multi_script_worker_test(
&[("f/math/add", script_add), ("f/math/mul", script_mul)],
vec![
MultiScriptJob {
script_path: "f/math/add".to_string(),
args: serde_json::json!({"a": 3, "b": 4}),
},
MultiScriptJob {
script_path: "f/math/mul".to_string(),
args: serde_json::json!({"x": 5, "y": 6}),
},
// Route back to add
MultiScriptJob {
script_path: "f/math/add".to_string(),
args: serde_json::json!({"a": 10, "b": 20}),
},
],
);
assert_eq!(results.len(), 3);
assert_eq!(results[0], Ok(serde_json::json!(7))); // 3 + 4
assert_eq!(results[1], Ok(serde_json::json!(30))); // 5 * 6
assert_eq!(results[2], Ok(serde_json::json!(30))); // 10 + 20
}
#[test]
fn test_multi_script_interleaved_jobs() {
let script_upper = r#"
export function main(s: string): string {
return s.toUpperCase();
}
"#;
let script_len = r#"
export function main(s: string): number {
return s.length;
}
"#;
let results = run_multi_script_worker_test(
&[("f/str/upper", script_upper), ("f/str/len", script_len)],
vec![
MultiScriptJob {
script_path: "f/str/upper".to_string(),
args: serde_json::json!({"s": "hello"}),
},
MultiScriptJob {
script_path: "f/str/len".to_string(),
args: serde_json::json!({"s": "hello"}),
},
MultiScriptJob {
script_path: "f/str/upper".to_string(),
args: serde_json::json!({"s": "world"}),
},
MultiScriptJob {
script_path: "f/str/len".to_string(),
args: serde_json::json!({"s": "ab"}),
},
],
);
assert_eq!(results.len(), 4);
assert_eq!(results[0], Ok(serde_json::json!("HELLO")));
assert_eq!(results[1], Ok(serde_json::json!(5)));
assert_eq!(results[2], Ok(serde_json::json!("WORLD")));
assert_eq!(results[3], Ok(serde_json::json!(2)));
}
#[test]
fn test_multi_script_unknown_path_error() {
let script = r#"
export function main(x: number): number {
return x;
}
"#;
let results = run_multi_script_worker_test(
&[("f/known", script)],
vec![MultiScriptJob {
script_path: "f/unknown".to_string(),
args: serde_json::json!({"x": 1}),
}],
);
assert_eq!(results.len(), 1);
assert!(results[0].is_err());
assert!(results[0]
.as_ref()
.unwrap_err()
.contains("Script not found"));
}
#[test]
fn test_multi_script_error_doesnt_break_other_scripts() {
let script_ok = r#"
export function main(x: number): number {
return x * 2;
}
"#;
let script_err = r#"
export function main(msg: string): never {
throw new Error(msg);
}
"#;
let results = run_multi_script_worker_test(
&[("f/ok", script_ok), ("f/err", script_err)],
vec![
MultiScriptJob {
script_path: "f/ok".to_string(),
args: serde_json::json!({"x": 5}),
},
MultiScriptJob {
script_path: "f/err".to_string(),
args: serde_json::json!({"msg": "boom"}),
},
// Should still work after error in other script
MultiScriptJob {
script_path: "f/ok".to_string(),
args: serde_json::json!({"x": 10}),
},
],
);
assert_eq!(results.len(), 3);
assert_eq!(results[0], Ok(serde_json::json!(10)));
assert!(results[1].is_err());
assert_eq!(results[1], Err("boom".to_string()));
assert_eq!(results[2], Ok(serde_json::json!(20)));
}
// ==================== exec_preprocess Tests ====================
/// Raw protocol command to send to a dedicated worker
enum ProtocolCmd {
Exec { path: String, args: serde_json::Value },
ExecPreprocess { path: String, args: serde_json::Value },
}
/// Run a multi-script worker test with raw protocol commands, returning all protocol lines
fn run_raw_protocol_test(
scripts: &[(&str, &str)],
commands: Vec<ProtocolCmd>,
) -> Vec<DedicatedWorkerResult> {
let temp_dir = tempfile::tempdir().unwrap();
let wrapper_path = create_multi_script_worker_files(temp_dir.path(), scripts);
let wrapper_str = wrapper_path.to_str().unwrap();
let mut cmd_args: Vec<&str> = BUN_DEDICATED_WORKER_ARGS.to_vec();
cmd_args.push(wrapper_str);
let mut child = Command::new(BUN_PATH.as_str())
.args(cmd_args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.current_dir(temp_dir.path())
.spawn()
.expect("Failed to spawn worker process");
let mut stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout);
let mut start_line = String::new();
reader.read_line(&mut start_line).unwrap();
assert_eq!(
parse_dedicated_worker_line(start_line.trim()),
DedicatedWorkerResult::Start,
);
let mut results = Vec::new();
for cmd in &commands {
let line = match cmd {
ProtocolCmd::Exec { path, args } => format!("exec:{}:{}", path, args),
ProtocolCmd::ExecPreprocess { path, args } => {
format!("exec_preprocess:{}:{}", path, args)
}
};
writeln!(stdin, "{}", line).unwrap();
stdin.flush().unwrap();
// exec_preprocess produces 2 response lines (preprocessed_args + success/error)
// exec produces 1 response line (success/error)
let expected_lines = match cmd {
ProtocolCmd::ExecPreprocess { .. } => 2,
ProtocolCmd::Exec { .. } => 1,
};
for _ in 0..expected_lines {
let mut response = String::new();
reader.read_line(&mut response).unwrap();
let parsed = parse_dedicated_worker_line(response.trim());
// If it's an error, stop reading more lines for this command
if matches!(parsed, DedicatedWorkerResult::Error(_)) {
results.push(parsed);
break;
}
results.push(parsed);
}
}
writeln!(stdin, "end").unwrap();
stdin.flush().unwrap();
let _ = child.wait().expect("Worker process failed to exit");
results
}
#[test]
fn test_bun_exec_preprocess() {
let script = r#"
export function preprocessor(x: number) {
return { x: x * 10 };
}
export function main(x: number): number {
return x + 1;
}
"#;
let results = run_raw_protocol_test(
&[("f/test/pre", script)],
vec![ProtocolCmd::ExecPreprocess {
path: "f/test/pre".to_string(),
args: serde_json::json!({"x": 5}),
}],
);
// Should get preprocessed_args then success
assert_eq!(results.len(), 2);
assert_eq!(
results[0],
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50}))
);
// main(50) => 51
assert_eq!(
results[1],
DedicatedWorkerResult::Success(serde_json::json!(51))
);
}
#[test]
fn test_bun_exec_preprocess_missing_preprocessor() {
let script = r#"
export function main(x: number): number {
return x;
}
"#;
let results = run_raw_protocol_test(
&[("f/test/nopre", script)],
vec![ProtocolCmd::ExecPreprocess {
path: "f/test/nopre".to_string(),
args: serde_json::json!({"x": 5}),
}],
);
assert_eq!(results.len(), 1);
assert!(matches!(results[0], DedicatedWorkerResult::Error(_)));
}
#[test]
fn test_bun_exec_preprocess_then_exec() {
let script = r#"
export function preprocessor(x: number) {
return { x: x * 2 };
}
export function main(x: number): number {
return x + 100;
}
"#;
let results = run_raw_protocol_test(
&[("f/test/mixed", script)],
vec![
ProtocolCmd::ExecPreprocess {
path: "f/test/mixed".to_string(),
args: serde_json::json!({"x": 5}),
},
ProtocolCmd::Exec {
path: "f/test/mixed".to_string(),
args: serde_json::json!({"x": 7}),
},
],
);
// preprocess: preprocessor(5) => {"x":10}, main(10) => 110
// exec: main(7) => 107
assert_eq!(results.len(), 3);
assert_eq!(
results[0],
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 10}))
);
assert_eq!(
results[1],
DedicatedWorkerResult::Success(serde_json::json!(110))
);
assert_eq!(
results[2],
DedicatedWorkerResult::Success(serde_json::json!(107))
);
}
// ==================== Argument Transformation Tests ====================
#[test]
fn test_bun_date_arg_transformation() {
let script = r#"
export function main(d: Date): string {
return d instanceof Date ? d.toISOString() : typeof d;
}
"#;
let results = run_worker_test(
"bun",
script,
vec![serde_json::json!({"d": "2024-01-15T10:30:00.000Z"})],
);
assert_eq!(results.len(), 1);
assert_eq!(
results[0],
Ok(serde_json::json!("2024-01-15T10:30:00.000Z"))
);
}
#[test]
fn test_bun_null_and_undefined_args() {
let script = r#"
export function main(x?: number): string {
return x === null ? "null" : x === undefined ? "undefined" : String(x);
}
"#;
let results = run_worker_test(
"bun",
script,
vec![
serde_json::json!({"x": null}),
serde_json::json!({"x": 42}),
serde_json::json!({}),
],
);
assert_eq!(results.len(), 3);
assert_eq!(results[0], Ok(serde_json::json!("null")));
assert_eq!(results[1], Ok(serde_json::json!("42")));
// Missing arg should be undefined
assert_eq!(results[2], Ok(serde_json::json!("undefined")));
}
}
// ============================================================================
// Deno Dedicated Worker Protocol Tests
// ============================================================================
mod dedicated_worker_protocol_deno {
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult};
use windmill_worker::{generate_deno_dedicated_worker_wrapper, DENO_PATH};
const TEST_SCRIPT_PATH: &str = "f/test/script";
fn run_deno_worker_test(
script: &str,
jobs: Vec<serde_json::Value>,
) -> Vec<Result<serde_json::Value, String>> {
let temp_dir = tempfile::tempdir().unwrap();
std::fs::write(temp_dir.path().join("main.ts"), script).unwrap();
let wrapper = generate_deno_dedicated_worker_wrapper(script).unwrap();
std::fs::write(temp_dir.path().join("wrapper.ts"), &wrapper).unwrap();
let mut child = Command::new(DENO_PATH.as_str())
.args([
"run",
"--no-check",
"--unstable-unsafe-proto",
"--unstable-bare-node-builtins",
"-A",
"wrapper.ts",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.current_dir(temp_dir.path())
.spawn()
.expect("Failed to spawn deno process");
let mut stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout);
// Wait for "start" — deno outputs 'start\n' via console.log which adds
// its own newline, producing double newlines. Skip empty lines.
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
if line.trim().is_empty() {
continue;
}
assert_eq!(
parse_dedicated_worker_line(line.trim()),
DedicatedWorkerResult::Start,
"Expected 'start', got: {}",
line.trim()
);
break;
}
let mut results = Vec::new();
for job_args in jobs {
writeln!(stdin, "exec:{}:{}", TEST_SCRIPT_PATH, job_args.to_string()).unwrap();
stdin.flush().unwrap();
loop {
let mut response = String::new();
reader.read_line(&mut response).unwrap();
let trimmed = response.trim();
if trimmed.is_empty() {
continue;
}
match parse_dedicated_worker_line(trimmed) {
DedicatedWorkerResult::Success(value) => results.push(Ok(value)),
DedicatedWorkerResult::Error(err) => {
let msg = err["message"]
.as_str()
.unwrap_or("Unknown error")
.to_string();
results.push(Err(msg));
}
other => panic!("Unexpected response: {:?}", other),
}
break;
}
}
writeln!(stdin, "end").unwrap();
stdin.flush().unwrap();
let _ = child.wait().expect("Worker process failed to exit");
results
}
#[test]
fn test_deno_dedicated_worker_simple() {
let script = r#"
export function main(x: number, y: number): number {
return x + y;
}
"#;
let results = run_deno_worker_test(script, vec![serde_json::json!({"x": 5, "y": 3})]);
assert_eq!(results.len(), 1);
assert_eq!(results[0], Ok(serde_json::json!(8)));
}
#[test]
fn test_deno_dedicated_worker_multiple_jobs() {
let script = r#"
export function main(n: number): number {
return n * 2;
}
"#;
let jobs: Vec<serde_json::Value> = (1..=5).map(|i| serde_json::json!({"n": i})).collect();
let results = run_deno_worker_test(script, jobs);
assert_eq!(results.len(), 5);
for (i, result) in results.iter().enumerate() {
assert_eq!(*result, Ok(serde_json::json!(((i + 1) * 2) as i64)));
}
}
#[test]
fn test_deno_dedicated_worker_error() {
let script = r#"
export function main(msg: string): never {
throw new Error(msg);
}
"#;
let results = run_deno_worker_test(script, vec![serde_json::json!({"msg": "test error"})]);
assert_eq!(results.len(), 1);
assert!(results[0].is_err());
assert_eq!(results[0], Err("test error".to_string()));
}
// ==================== exec_preprocess Tests ====================
/// Run a raw deno protocol test, reading all output lines per command
fn run_deno_raw_protocol_test(
script: &str,
commands: Vec<(&str, serde_json::Value)>, // ("exec" or "exec_preprocess", args)
) -> Vec<DedicatedWorkerResult> {
let temp_dir = tempfile::tempdir().unwrap();
std::fs::write(temp_dir.path().join("main.ts"), script).unwrap();
let wrapper = generate_deno_dedicated_worker_wrapper(script).unwrap();
std::fs::write(temp_dir.path().join("wrapper.ts"), &wrapper).unwrap();
let mut child = Command::new(DENO_PATH.as_str())
.args([
"run",
"--no-check",
"--unstable-unsafe-proto",
"--unstable-bare-node-builtins",
"-A",
"wrapper.ts",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.current_dir(temp_dir.path())
.spawn()
.expect("Failed to spawn deno process");
let mut stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout);
// Wait for start, skip empty lines
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
if line.trim().is_empty() {
continue;
}
assert_eq!(
parse_dedicated_worker_line(line.trim()),
DedicatedWorkerResult::Start,
);
break;
}
let mut results = Vec::new();
for (cmd, args) in &commands {
writeln!(stdin, "{}:{}:{}", cmd, TEST_SCRIPT_PATH, args).unwrap();
stdin.flush().unwrap();
let expected_lines = if *cmd == "exec_preprocess" { 2 } else { 1 };
for _ in 0..expected_lines {
loop {
let mut response = String::new();
reader.read_line(&mut response).unwrap();
if response.trim().is_empty() {
continue;
}
let parsed = parse_dedicated_worker_line(response.trim());
if matches!(parsed, DedicatedWorkerResult::Error(_)) {
results.push(parsed);
break;
}
results.push(parsed);
break;
}
// If last result was an error, don't read more lines for this command
if matches!(results.last(), Some(DedicatedWorkerResult::Error(_))) {
break;
}
}
}
writeln!(stdin, "end").unwrap();
stdin.flush().unwrap();
let _ = child.wait().expect("Worker process failed to exit");
results
}
#[test]
fn test_deno_exec_preprocess() {
let script = r#"
export function preprocessor(x: number) {
return { x: x * 10 };
}
export function main(x: number): number {
return x + 1;
}
"#;
let results = run_deno_raw_protocol_test(
script,
vec![("exec_preprocess", serde_json::json!({"x": 5}))],
);
assert_eq!(results.len(), 2);
assert_eq!(
results[0],
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50}))
);
assert_eq!(
results[1],
DedicatedWorkerResult::Success(serde_json::json!(51))
);
}
// Note: no "missing preprocessor" test for Deno because the wrapper only generates
// the exec_preprocess handler when the script actually has a preprocessor function.
// Without one, exec_preprocess messages are unrecognized (by design — Rust never sends them).
// ==================== Argument Transformation Tests ====================
#[test]
fn test_deno_date_arg_transformation() {
let script = r#"
export function main(d: Date): string {
return d instanceof Date ? d.toISOString() : typeof d;
}
"#;
let results = run_deno_worker_test(
script,
vec![serde_json::json!({"d": "2024-01-15T10:30:00.000Z"})],
);
assert_eq!(results.len(), 1);
assert_eq!(
results[0],
Ok(serde_json::json!("2024-01-15T10:30:00.000Z"))
);
}
}
// ============================================================================

View File

@@ -1,9 +1,470 @@
use serde_json::json;
#[cfg(feature = "python")]
use sqlx::postgres::Postgres;
#[cfg(feature = "python")]
use sqlx::Pool;
#[cfg(feature = "python")]
use windmill_common::scripts::ScriptLang;
use windmill_test_utils::*;
// ============================================================================
// Dedicated Worker Protocol Tests (Python)
// ============================================================================
#[cfg(feature = "python")]
mod dedicated_worker_protocol_python {
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
use windmill_test_utils::{parse_dedicated_worker_line, DedicatedWorkerResult};
use windmill_worker::{compute_py_codegen, generate_py_multi_script_wrapper, PyScriptEntry};
struct MultiScriptJob {
script_path: String,
args: serde_json::Value,
}
/// Creates a multi-script Python wrapper, writes scripts to proper module paths
fn create_py_worker_files(
dir: &std::path::Path,
scripts: &[(&str, &str)], // (original_path, content)
) -> std::path::PathBuf {
let mut codegens = Vec::new();
for (path, content) in scripts {
let cg = compute_py_codegen(content, path);
let module_dir = dir.join(&cg.dirs);
std::fs::create_dir_all(&module_dir).unwrap();
std::fs::write(module_dir.join(format!("{}.py", cg.module_name)), content).unwrap();
codegens.push((path.to_string(), cg));
}
let entries: Vec<PyScriptEntry<'_>> = codegens
.iter()
.map(|(path, cg)| PyScriptEntry { original_path: path.as_str(), codegen: cg })
.collect();
let wrapper = generate_py_multi_script_wrapper(&entries, false, false);
let wrapper_path = dir.join("wrapper.py");
std::fs::write(&wrapper_path, &wrapper).unwrap();
wrapper_path
}
fn run_py_multi_script_test(
scripts: &[(&str, &str)],
jobs: Vec<MultiScriptJob>,
) -> Vec<Result<serde_json::Value, String>> {
let temp_dir = tempfile::tempdir().unwrap();
create_py_worker_files(temp_dir.path(), scripts);
let mut child = Command::new("python3")
.args(["-u", "-m", "wrapper"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.current_dir(temp_dir.path())
.spawn()
.expect("Failed to spawn python3 process");
let mut stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout);
let mut start_line = String::new();
reader.read_line(&mut start_line).unwrap();
assert_eq!(
parse_dedicated_worker_line(start_line.trim()),
DedicatedWorkerResult::Start,
"Expected 'start', got: {}",
start_line.trim()
);
let mut results = Vec::new();
for job in &jobs {
writeln!(stdin, "exec:{}:{}", job.script_path, job.args.to_string()).unwrap();
stdin.flush().unwrap();
let mut response = String::new();
reader.read_line(&mut response).unwrap();
match parse_dedicated_worker_line(response.trim()) {
DedicatedWorkerResult::Success(value) => results.push(Ok(value)),
DedicatedWorkerResult::Error(err) => {
let msg = err["message"]
.as_str()
.unwrap_or("Unknown error")
.to_string();
results.push(Err(msg));
}
other => panic!("Unexpected response: {:?}", other),
}
}
writeln!(stdin, "end").unwrap();
stdin.flush().unwrap();
let _ = child.wait().expect("Worker process failed to exit");
results
}
fn run_py_single_script_test(
script_path: &str,
content: &str,
jobs: Vec<serde_json::Value>,
) -> Vec<Result<serde_json::Value, String>> {
run_py_multi_script_test(
&[(script_path, content)],
jobs.into_iter()
.map(|args| MultiScriptJob { script_path: script_path.to_string(), args })
.collect(),
)
}
#[test]
fn test_python_dedicated_worker_simple() {
let results = run_py_single_script_test(
"f/test/add",
"def main(a: int, b: int):\n return a + b\n",
vec![serde_json::json!({"a": 3, "b": 4})],
);
assert_eq!(results.len(), 1);
assert_eq!(results[0], Ok(serde_json::json!(7)));
}
#[test]
fn test_python_dedicated_worker_multiple_jobs() {
let results = run_py_single_script_test(
"f/test/double",
"def main(n: int):\n return n * 2\n",
(1..=5).map(|i| serde_json::json!({"n": i})).collect(),
);
assert_eq!(results.len(), 5);
for (i, result) in results.iter().enumerate() {
assert_eq!(*result, Ok(serde_json::json!(((i + 1) * 2) as i64)));
}
}
#[test]
fn test_python_multi_script_routing() {
let results = run_py_multi_script_test(
&[
(
"f/math/add",
"def main(a: int, b: int):\n return a + b\n",
),
(
"f/math/mul",
"def main(x: int, y: int):\n return x * y\n",
),
],
vec![
MultiScriptJob {
script_path: "f/math/add".to_string(),
args: serde_json::json!({"a": 3, "b": 4}),
},
MultiScriptJob {
script_path: "f/math/mul".to_string(),
args: serde_json::json!({"x": 5, "y": 6}),
},
MultiScriptJob {
script_path: "f/math/add".to_string(),
args: serde_json::json!({"a": 10, "b": 20}),
},
],
);
assert_eq!(results.len(), 3);
assert_eq!(results[0], Ok(serde_json::json!(7)));
assert_eq!(results[1], Ok(serde_json::json!(30)));
assert_eq!(results[2], Ok(serde_json::json!(30)));
}
#[test]
fn test_python_multi_script_error_isolation() {
let results = run_py_multi_script_test(
&[
("f/ok", "def main(x: int):\n return x * 2\n"),
("f/err", "def main(msg: str):\n raise Exception(msg)\n"),
],
vec![
MultiScriptJob {
script_path: "f/ok".to_string(),
args: serde_json::json!({"x": 5}),
},
MultiScriptJob {
script_path: "f/err".to_string(),
args: serde_json::json!({"msg": "boom"}),
},
MultiScriptJob {
script_path: "f/ok".to_string(),
args: serde_json::json!({"x": 10}),
},
],
);
assert_eq!(results.len(), 3);
assert_eq!(results[0], Ok(serde_json::json!(10)));
assert!(results[1].is_err());
assert_eq!(results[1], Err("boom".to_string()));
assert_eq!(results[2], Ok(serde_json::json!(20)));
}
#[test]
fn test_python_multi_script_unknown_path() {
let results = run_py_multi_script_test(
&[("f/known", "def main(x: int):\n return x\n")],
vec![MultiScriptJob {
script_path: "f/unknown".to_string(),
args: serde_json::json!({"x": 1}),
}],
);
assert_eq!(results.len(), 1);
assert!(results[0].is_err());
assert!(results[0]
.as_ref()
.unwrap_err()
.contains("Script not found"));
}
// ==================== exec_preprocess Tests ====================
/// Raw protocol command for Python
enum ProtocolCmd {
Exec { path: String, args: serde_json::Value },
ExecPreprocess { path: String, args: serde_json::Value },
}
/// Run a Python worker test with raw protocol commands
fn run_py_raw_protocol_test(
scripts: &[(&str, &str)],
commands: Vec<ProtocolCmd>,
) -> Vec<DedicatedWorkerResult> {
let temp_dir = tempfile::tempdir().unwrap();
create_py_worker_files(temp_dir.path(), scripts);
let mut child = Command::new("python3")
.args(["-u", "-m", "wrapper"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.current_dir(temp_dir.path())
.spawn()
.expect("Failed to spawn python3 process");
let mut stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout);
let mut start_line = String::new();
reader.read_line(&mut start_line).unwrap();
assert_eq!(
parse_dedicated_worker_line(start_line.trim()),
DedicatedWorkerResult::Start,
);
let mut results = Vec::new();
for cmd in &commands {
let line = match cmd {
ProtocolCmd::Exec { path, args } => format!("exec:{}:{}", path, args),
ProtocolCmd::ExecPreprocess { path, args } => {
format!("exec_preprocess:{}:{}", path, args)
}
};
writeln!(stdin, "{}", line).unwrap();
stdin.flush().unwrap();
let expected_lines = match cmd {
ProtocolCmd::ExecPreprocess { .. } => 2,
ProtocolCmd::Exec { .. } => 1,
};
for _ in 0..expected_lines {
let mut response = String::new();
reader.read_line(&mut response).unwrap();
let parsed = parse_dedicated_worker_line(response.trim());
if matches!(parsed, DedicatedWorkerResult::Error(_)) {
results.push(parsed);
break;
}
results.push(parsed);
}
}
writeln!(stdin, "end").unwrap();
stdin.flush().unwrap();
let _ = child.wait().expect("Worker process failed to exit");
results
}
#[test]
fn test_python_exec_preprocess() {
let script = r#"
def preprocessor(x: int):
return {"x": x * 10}
def main(x: int):
return x + 1
"#;
let results = run_py_raw_protocol_test(
&[("f/test/pre", script)],
vec![ProtocolCmd::ExecPreprocess {
path: "f/test/pre".to_string(),
args: serde_json::json!({"x": 5}),
}],
);
assert_eq!(results.len(), 2);
assert_eq!(
results[0],
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 50}))
);
// main(50) => 51
assert_eq!(
results[1],
DedicatedWorkerResult::Success(serde_json::json!(51))
);
}
#[test]
fn test_python_exec_preprocess_missing_preprocessor() {
let script = "def main(x: int):\n return x\n";
let results = run_py_raw_protocol_test(
&[("f/test/nopre", script)],
vec![ProtocolCmd::ExecPreprocess {
path: "f/test/nopre".to_string(),
args: serde_json::json!({"x": 5}),
}],
);
assert_eq!(results.len(), 1);
assert!(matches!(results[0], DedicatedWorkerResult::Error(_)));
}
#[test]
fn test_python_exec_preprocess_then_exec() {
let script = r#"
def preprocessor(x: int):
return {"x": x * 2}
def main(x: int):
return x + 100
"#;
let results = run_py_raw_protocol_test(
&[("f/test/mixed", script)],
vec![
ProtocolCmd::ExecPreprocess {
path: "f/test/mixed".to_string(),
args: serde_json::json!({"x": 5}),
},
ProtocolCmd::Exec {
path: "f/test/mixed".to_string(),
args: serde_json::json!({"x": 7}),
},
],
);
// preprocess: preprocessor(5) => {"x":10}, main(10) => 110
// exec: main(7) => 107
assert_eq!(results.len(), 3);
assert_eq!(
results[0],
DedicatedWorkerResult::PreprocessedArgs(serde_json::json!({"x": 10}))
);
assert_eq!(
results[1],
DedicatedWorkerResult::Success(serde_json::json!(110))
);
assert_eq!(
results[2],
DedicatedWorkerResult::Success(serde_json::json!(107))
);
}
// ==================== Argument Transformation Tests ====================
#[test]
fn test_python_datetime_arg_transformation() {
let script = r#"
from datetime import datetime
def main(d: datetime):
return d.isoformat()
"#;
let results = run_py_single_script_test(
"f/test/dt",
script,
vec![serde_json::json!({"d": "2024-01-15T10:30:00+00:00"})],
);
assert_eq!(results.len(), 1);
assert_eq!(
results[0],
Ok(serde_json::json!("2024-01-15T10:30:00+00:00"))
);
}
#[test]
fn test_python_bytes_arg_transformation() {
let script = r#"
def main(data: bytes):
return len(data)
"#;
// base64 of "hello" is "aGVsbG8="
let results = run_py_single_script_test(
"f/test/bytes",
script,
vec![serde_json::json!({"data": "aGVsbG8="})],
);
assert_eq!(results.len(), 1);
assert_eq!(results[0], Ok(serde_json::json!(5)));
}
#[test]
fn test_python_kwargs_filtering() {
// Test that extra kwargs are filtered out and only declared args are passed
let script = "def main(a: int, b: int):\n return a + b\n";
let results = run_py_single_script_test(
"f/test/kwargs",
script,
vec![serde_json::json!({"a": 1, "b": 2, "extra": 99})],
);
assert_eq!(results.len(), 1);
assert_eq!(results[0], Ok(serde_json::json!(3)));
}
#[test]
fn test_python_function_call_sentinel_removal() {
// Test that '<function call>' sentinel values are removed from args
let script = "def main(a: int, b: int = 10):\n return a + b\n";
let results = run_py_single_script_test(
"f/test/sentinel",
script,
vec![serde_json::json!({"a": 5, "b": "<function call>"})],
);
assert_eq!(results.len(), 1);
// b should be removed (sentinel), default 10 used
assert_eq!(results[0], Ok(serde_json::json!(15)));
}
// ==================== Relative Import Tests ====================
#[test]
fn test_python_dedicated_worker_with_relative_import_detection() {
// Test that the wrapper includes 'import loader' when scripts have relative imports
let script_with_relative = "from f.helper import util\ndef main(x: int):\n return x\n";
let cg = compute_py_codegen(script_with_relative, "f/test/rel");
let entries = [PyScriptEntry { original_path: "f/test/rel", codegen: &cg }];
let wrapper = generate_py_multi_script_wrapper(&entries, false, true);
assert!(
wrapper.contains("import loader"),
"wrapper should contain 'import loader' when any_relative_imports=true"
);
// Without relative imports
let script_no_relative = "def main(x: int):\n return x\n";
let cg2 = compute_py_codegen(script_no_relative, "f/test/norel");
let entries2 = [PyScriptEntry { original_path: "f/test/norel", codegen: &cg2 }];
let wrapper2 = generate_py_multi_script_wrapper(&entries2, false, false);
assert!(
!wrapper2.contains("import loader"),
"wrapper should NOT contain 'import loader' when any_relative_imports=false"
);
}
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "lockfile_python"))]
async fn test_requirements_python(db: Pool<Postgres>) -> anyhow::Result<()> {

View File

@@ -1,7 +1,6 @@
#[cfg(feature = "enterprise")]
use crate::ee_oss::ExternalJwks;
use axum::{
async_trait,
extract::{FromRequestParts, OriginalUri, Query},
Extension, Json,
};
@@ -451,7 +450,11 @@ pub(crate) async fn extract_token<S: Send + Sync>(parts: &mut Parts, state: &S)
None => Extension::<Cookies>::from_request_parts(parts, state)
.await
.ok()
.and_then(|cookies| cookies.get(COOKIE_NAME).map(|c| c.value().to_owned())),
.and_then(|cookies| {
cookies
.get(COOKIE_NAME)
.map(|c| c.value_trimmed().to_owned())
}),
};
#[derive(Deserialize)]
@@ -504,7 +507,6 @@ impl BruteForceCounter {
}
}
#[async_trait]
impl<S> FromRequestParts<S> for Tokened
where
S: Send + Sync,
@@ -535,7 +537,6 @@ where
}
}
#[async_trait]
impl<S> FromRequestParts<S> for OptTokened
where
S: Send + Sync,

View File

@@ -12,8 +12,7 @@ pub mod ee;
pub mod ee_oss;
pub mod scopes;
use axum::async_trait;
use axum::extract::FromRequestParts;
use axum::extract::{FromRequestParts, OptionalFromRequestParts};
use http::request::Parts;
use windmill_audit::audit_oss::AuditAuthorable;
@@ -345,7 +344,6 @@ pub async fn maybe_refresh_folders(
// ------------ FromRequestParts impls (direct call to auth module) ------------
#[async_trait]
impl<S> FromRequestParts<S> for ApiAuthed
where
S: Send + Sync,
@@ -361,7 +359,24 @@ where
}
}
#[async_trait]
impl<S> OptionalFromRequestParts<S> for ApiAuthed
where
S: Send + Sync,
{
type Rejection = std::convert::Infallible;
async fn from_request_parts(
parts: &mut Parts,
state: &S,
) -> std::result::Result<Option<Self>, Self::Rejection> {
Ok(
<Self as FromRequestParts<S>>::from_request_parts(parts, state)
.await
.ok(),
)
}
}
impl<S> FromRequestParts<S> for OptJobAuthed
where
S: Send + Sync,
@@ -397,7 +412,6 @@ fn empty_parts() -> Parts {
#[derive(Clone, Debug)]
pub struct OptAuthed(pub Option<ApiAuthed>);
#[async_trait]
impl<S> FromRequestParts<S> for OptAuthed
where
S: Send + Sync,
@@ -408,7 +422,7 @@ where
parts: &mut Parts,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
ApiAuthed::from_request_parts(parts, state)
<ApiAuthed as FromRequestParts<S>>::from_request_parts(parts, state)
.await
.map(|authed| Self(Some(authed)))
.or_else(|_| Ok(Self(None)))

View File

@@ -28,11 +28,11 @@ use windmill_api_auth::{require_devops_role, ApiAuthed};
pub fn global_service() -> Router {
Router::new()
.route("/list_worker_groups", get(list_worker_groups))
.route("/update/:name", post(update_config).delete(delete_config))
.route("/get/:name", get(get_config))
.route("/update/{name}", post(update_config).delete(delete_config))
.route("/get/{name}", get(get_config))
.route("/list", get(list_configs))
.route(
"/list_autoscaling_events/:worker_group",
"/list_autoscaling_events/{worker_group}",
get(list_autoscaling_events),
)
.route(

View File

@@ -87,6 +87,7 @@ pub fn workspaced_service() -> Router {
Router::new()
.route("/sign", post(sign_debug_request))
.route("/sign_expression", post(sign_expression))
.route("/sign_multiplayer", post(sign_multiplayer))
}
/// JWKS response containing the public key for debug token verification
@@ -416,3 +417,62 @@ async fn sign_expression(
Ok(Json(SignedExpressionPayload { token }))
}
/// JWT claims for multiplayer session tokens
#[derive(Serialize, Deserialize)]
pub struct MultiplayerTokenClaims {
/// Workspace ID
pub workspace_id: String,
/// User email
pub email: String,
/// Issued at (Unix timestamp)
pub iat: i64,
/// Expiration (Unix timestamp)
pub exp: i64,
/// Token purpose (always "multiplayer")
pub purpose: String,
}
#[derive(Serialize)]
pub struct SignedMultiplayerPayload {
pub token: String,
}
/// Sign a multiplayer session request.
///
/// Returns a JWT that the multiplayer server will verify using the public key from /api/debug/jwks.
async fn sign_multiplayer(
authed: ApiAuthed,
Path(w_id): Path<String>,
) -> JsonResult<SignedMultiplayerPayload> {
let key_guard = DEBUG_SIGNING_KEY.read().await;
let signing_key = key_guard.as_ref().ok_or_else(|| {
windmill_common::error::Error::InternalErr("Debug signing key not initialized".to_string())
})?;
let now_ts = Utc::now().timestamp();
let exp = now_ts + DEBUG_TOKEN_TTL_SECS;
let claims = MultiplayerTokenClaims {
workspace_id: w_id,
email: authed.email,
iat: now_ts,
exp,
purpose: "multiplayer".to_string(),
};
let header = serde_json::json!({
"alg": "EdDSA",
"typ": "JWT"
});
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
let claims_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&claims).unwrap());
let message = format!("{}.{}", header_b64, claims_b64);
let signature = signing_key.sign(message.as_bytes());
let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
let token = format!("{}.{}", message, signature_b64);
Ok(Json(SignedMultiplayerPayload { token }))
}

View File

@@ -21,8 +21,8 @@ use windmill_common::{
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_conversations))
.route("/delete/:conversation_id", delete(delete_conversation))
.route("/:conversation_id/messages", get(list_messages))
.route("/delete/{conversation_id}", delete(delete_conversation))
.route("/{conversation_id}/messages", get(list_messages))
}
#[derive(Serialize, FromRow, Debug)]

View File

@@ -61,26 +61,26 @@ pub fn workspaced_service() -> Router {
.route("/list", get(list_flows))
.route("/list_search", get(list_search_flows))
.route("/create", post(create_flow))
.route("/update/*path", post(update_flow))
.route("/archive/*path", post(archive_flow_by_path))
.route("/delete/*path", delete(delete_flow_by_path))
.route("/list_tokens/*path", get(list_tokens))
.route("/get/*path", get(get_flow_by_path))
.route("/deployment_status/p/*path", get(get_deployment_status))
.route("/get/draft/*path", get(get_flow_by_path_w_draft))
.route("/exists/*path", get(exists_flow_by_path))
.route("/update/{*path}", post(update_flow))
.route("/archive/{*path}", post(archive_flow_by_path))
.route("/delete/{*path}", delete(delete_flow_by_path))
.route("/list_tokens/{*path}", get(list_tokens))
.route("/get/{*path}", get(get_flow_by_path))
.route("/deployment_status/p/{*path}", get(get_deployment_status))
.route("/get/draft/{*path}", get(get_flow_by_path_w_draft))
.route("/exists/{*path}", get(exists_flow_by_path))
.route("/list_paths", get(list_paths))
.route("/history/p/*path", get(get_flow_history))
.route("/get_latest_version/*path", get(get_latest_version))
.route("/history/p/{*path}", get(get_flow_history))
.route("/get_latest_version/{*path}", get(get_latest_version))
.route(
"/list_paths_from_workspace_runnable/:runnable_kind/*path",
"/list_paths_from_workspace_runnable/{runnable_kind}/{*path}",
get(list_paths_from_workspace_runnable),
)
.route("/history_update/v/:version", post(update_flow_history))
.route("/get/v/:version", get(get_flow_version_by_id))
.route("/get/v/:version/p/*path", get(get_flow_version))
.route("/history_update/v/{version}", post(update_flow_history))
.route("/get/v/{version}", get(get_flow_version_by_id))
.route("/get/v/{version}/p/{*path}", get(get_flow_version))
.route(
"/toggle_workspace_error_handler/*path",
"/toggle_workspace_error_handler/{*path}",
post(toggle_workspace_error_handler),
)
}
@@ -88,7 +88,7 @@ pub fn workspaced_service() -> Router {
pub fn global_service() -> Router {
Router::new()
.route("/hub/list", get(list_hub_flows))
.route("/hub/get/:id", get(get_hub_flow_by_id))
.route("/hub/get/{id}", get(get_hub_flow_by_id))
}
#[derive(Serialize, FromRow)]
@@ -1657,6 +1657,38 @@ async fn delete_flow_by_path(
}
let mut tx = user_db.begin(&authed).await?;
// Capture all related data for trashbin before deleting (CASCADE will remove flow_version, flow_node)
let trash_flow: Option<serde_json::Value> =
sqlx::query_scalar("SELECT to_jsonb(t) FROM flow t WHERE path = $1 AND workspace_id = $2")
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let trash_flow_versions: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM flow_version t WHERE path = $1 AND workspace_id = $2",
)
.bind(path)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
let trash_flow_nodes: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM flow_node t WHERE path = $1 AND workspace_id = $2",
)
.bind(path)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
let trash_drafts: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
)
.bind(path)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
path,
@@ -1673,6 +1705,28 @@ async fn delete_flow_by_path(
.execute(&mut *tx)
.await?;
if let Some(flow_data) = trash_flow {
let mut trash_data = serde_json::json!({"row": flow_data});
if !trash_flow_versions.is_empty() {
trash_data["flow_versions"] = serde_json::Value::Array(trash_flow_versions);
}
if !trash_flow_nodes.is_empty() {
trash_data["flow_nodes"] = serde_json::Value::Array(trash_flow_nodes);
}
if !trash_drafts.is_empty() {
trash_data["drafts"] = serde_json::Value::Array(trash_drafts);
}
windmill_common::trashbin::move_to_trash(
&mut *tx,
&w_id,
"flow",
path,
trash_data,
&authed.username,
)
.await?;
}
if !query.keep_captures.unwrap_or(false) {
sqlx::query!(
"DELETE FROM capture_config WHERE path = $1 AND workspace_id = $2 AND is_flow IS TRUE",

View File

@@ -22,7 +22,7 @@ use serde::Serialize;
use sqlx::FromRow;
pub fn workspaced_service() -> Router {
Router::new().route("/get/:name", get(get_folder_permission_history))
Router::new().route("/get/{name}", get(get_folder_permission_history))
}
#[derive(Serialize, FromRow)]

View File

@@ -40,14 +40,14 @@ pub fn workspaced_service() -> Router {
.route("/list", get(list_folders))
.route("/listnames", get(list_foldernames))
.route("/create", post(create_folder))
.route("/get/:name", get(get_folder))
.route("/exists/:name", get(exists_folder))
.route("/update/:name", post(update_folder))
.route("/getusage/:name", get(get_folder_usage))
.route("/delete/:name", delete(delete_folder))
.route("/addowner/:name", post(add_owner))
.route("/removeowner/:name", post(remove_owner))
.route("/is_owner/*path", get(is_owner_api))
.route("/get/{name}", get(get_folder))
.route("/exists/{name}", get(exists_folder))
.route("/update/{name}", post(update_folder))
.route("/getusage/{name}", get(get_folder_usage))
.route("/delete/{name}", delete(delete_folder))
.route("/addowner/{name}", post(add_owner))
.route("/removeowner/{name}", post(remove_owner))
.route("/is_owner/{*path}", get(is_owner_api))
}
#[derive(FromRow, Serialize, Deserialize, Clone)]

View File

@@ -48,9 +48,9 @@ const KINDS: [&str; 19] = [
pub fn workspaced_service() -> Router {
Router::new()
.route("/get/*path", get(get_granular_acls))
.route("/add/*path", post(add_granular_acl))
.route("/remove/*path", post(remove_granular_acl))
.route("/get/{*path}", get(get_granular_acls))
.route("/add/{*path}", post(add_granular_acl))
.route("/remove/{*path}", post(remove_granular_acl))
}
#[derive(Serialize, Deserialize)]

View File

@@ -33,24 +33,24 @@ pub fn workspaced_service() -> Router {
.route("/list", get(list_groups))
.route("/listnames", get(list_group_names))
.route("/create", post(create_group))
.route("/get/:name", get(get_group))
.route("/update/:name", post(update_group))
.route("/delete/:name", delete(delete_group))
.route("/adduser/:name", post(add_user))
.route("/removeuser/:name", post(remove_user))
.route("/is_owner/:name", get(is_owner))
.route("/get/{name}", get(get_group))
.route("/update/{name}", post(update_group))
.route("/delete/{name}", delete(delete_group))
.route("/adduser/{name}", post(add_user))
.route("/removeuser/{name}", post(remove_user))
.route("/is_owner/{name}", get(is_owner))
}
pub fn global_service() -> Router {
Router::new()
.route("/list", get(list_igroups))
.route("/list_with_workspaces", get(list_igroups_with_workspaces))
.route("/get/:name", get(get_igroup))
.route("/get/{name}", get(get_igroup))
.route("/create", post(create_igroup))
.route("/update/:name", post(update_igroup))
.route("/delete/:name", delete(delete_igroup))
.route("/adduser/:name", post(add_user_igroup))
.route("/removeuser/:name", post(remove_user_igroup))
.route("/update/{name}", post(update_igroup))
.route("/delete/{name}", delete(delete_igroup))
.route("/adduser/{name}", post(add_user_igroup))
.route("/removeuser/{name}", post(remove_user_igroup))
.route("/export", get(export_igroups))
.route("/overwrite", post(overwrite_igroups))
}
@@ -656,6 +656,7 @@ async fn delete_group(
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,

View File

@@ -33,9 +33,9 @@ pub fn workspaced_service() -> Router {
.route("/list", get(list_saved_inputs))
.route("/create", post(create_input))
.route("/update", post(update_input))
.route("/delete/:id", post(delete_input))
.route("/delete/{id}", post(delete_input))
.route(
"/:job_or_input_id/args",
"/{job_or_input_id}/args",
get(get_args_from_history_or_saved_input),
)
}

View File

@@ -31,6 +31,7 @@ reqwest.workspace = true
tokio.workspace = true
anyhow.workspace = true
uuid.workspace = true
futures.workspace = true
rand.workspace = true
rumqttc.workspace = true
rdkafka.workspace = true
@@ -39,3 +40,4 @@ aws-config = { workspace = true, optional = true }
aws-credential-types = { workspace = true, optional = true }
aws-sdk-sqs = { workspace = true, optional = true }
base64 = { workspace = true, optional = true }
axum.workspace = true

View File

@@ -0,0 +1,106 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
/// Start a mock AI API that echoes back a valid chat completion response.
async fn start_mock_ai_api() -> u16 {
use axum::{routing::post, Json, Router};
let app = Router::new().fallback(post(|| async {
Json(json!({
"id": "chatcmpl-test",
"object": "chat.completion",
"choices": [{"message": {"role": "assistant", "content": "hello"}}]
}))
}));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
port
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_ai_proxy_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// Start mock AI API
let mock_port = start_mock_ai_api().await;
let mock_url = format!("http://127.0.0.1:{mock_port}/v1");
// Create an openai resource pointing to the mock
let resp = authed(
client()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/resources/create"
))
.json(&json!({
"path": "f/ai/openai_config",
"resource_type": "openai",
"value": {
"api_key": "test-key",
"base_url": mock_url
}
})),
)
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"create openai resource",
);
// Set ai_config on workspace_settings directly via SQL
sqlx::query(
"UPDATE workspace_settings SET ai_config = $1::jsonb WHERE workspace_id = 'test-workspace'",
)
.bind(json!({
"providers": {
"openai": {
"resource_path": "f/ai/openai_config",
"models": ["gpt-4"]
}
}
}))
.execute(&db)
.await?;
// POST /w/{ws}/ai/proxy/chat/completions
let resp = authed(
client()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/ai/proxy/chat/completions"
))
.header("X-Provider", "openai")
.json(&json!({
"model": "gpt-4",
"messages": [{"role": "user", "content": "hi"}]
})),
)
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"POST /ai/proxy/chat/completions",
);
Ok(())
}

View File

@@ -0,0 +1,35 @@
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_audit_endpoints(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/audit");
// GET /list returns 200 (empty array)
let resp = authed(client().get(format!("{base}/list"))).send().await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /audit/list",
);
Ok(())
}

View File

@@ -0,0 +1,83 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_capture_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// POST /capture/set_config → 200 (authed)
let resp = authed(
client()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/capture/set_config"
))
.json(&json!({
"trigger_kind": "webhook",
"path": "u/test-user/test_capture",
"is_flow": false
})),
)
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "POST /capture/set_config");
// GET /capture/list/{...} → 200 (authed)
let resp = authed(client().get(format!(
"http://localhost:{port}/api/w/test-workspace/capture/list/script/u/test-user/test_capture"
)))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(
status,
&body,
"GET /capture/list/script/u/test-user/test_capture",
);
// POST /capture/ping_config/{trigger_kind}/{runnable_kind}/{*path} → 200
let resp = authed(client().post(format!(
"http://localhost:{port}/api/w/test-workspace/capture/ping_config/webhook/script/u/test-user/test_capture"
)))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"POST /capture/ping_config",
);
// GET /capture/get_configs/{runnable_kind}/{*path} → 200
let resp = authed(client().get(format!(
"http://localhost:{port}/api/w/test-workspace/capture/get_configs/script/u/test-user/test_capture"
)))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /capture/get_configs",
);
Ok(())
}

View File

@@ -0,0 +1,48 @@
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_concurrency_groups_2xx(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let resp = authed(client().get(format!(
"http://localhost:{port}/api/concurrency_groups/list"
)))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /api/concurrency_groups/list",
);
let resp = authed(client().get(format!(
"http://localhost:{port}/api/w/test-workspace/concurrency_groups/list_jobs"
)))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /api/w/test-workspace/concurrency_groups/list_jobs",
);
Ok(())
}

View File

@@ -0,0 +1,72 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_favorites_endpoints(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");
// Setup: create a script to favorite
let resp = authed(client().post(format!("{ws}/scripts/create")))
.json(&json!({
"path": "u/test-user/test_fav_script",
"summary": "test",
"description": "",
"content": "export function main() { return 1; }",
"language": "deno",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {},
"required": []
}
}))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "POST /scripts/create (setup)");
let fav_body = json!({
"favorite_kind": "script",
"path": "u/test-user/test_fav_script"
});
// POST /favorites/star → 200
let resp = authed(client().post(format!("{ws}/favorites/star")))
.json(&fav_body)
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "POST /favorites/star");
// POST /favorites/unstar → 200
let resp = authed(client().post(format!("{ws}/favorites/unstar")))
.json(&fav_body)
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "POST /favorites/unstar");
Ok(())
}

View File

@@ -0,0 +1,51 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_folder_history_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// Create a folder first
let resp = authed(
client()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/folders/create"
))
.json(&json!({"name": "test_hist_folder", "owners": ["u/test-user"]})),
)
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "POST /folders/create");
// GET /folders_history/get/{folder} → 200 (empty array)
let resp = authed(client().get(format!(
"http://localhost:{port}/api/w/test-workspace/folders_history/get/test_hist_folder"
)))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "GET /folders_history/get/test_hist_folder");
Ok(())
}

View File

@@ -0,0 +1,54 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_granular_acls_endpoints(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/acls");
// GET /acls/get/group_/all → 200
let resp = authed(client().get(format!("{base}/get/group_/all")))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "GET /acls/get/group_/all");
// POST /acls/add/group_/all → 200
let resp = authed(client().post(format!("{base}/add/group_/all")))
.json(&json!({"owner": "u/test-user-2", "write": true}))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "POST /acls/add/group_/all");
// POST /acls/remove/group_/all → 200
let resp = authed(client().post(format!("{base}/remove/group_/all")))
.json(&json!({"owner": "u/test-user-2"}))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "POST /acls/remove/group_/all");
Ok(())
}

View File

@@ -0,0 +1,32 @@
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_group_history_2xx(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/groups_history");
let resp = authed(client().get(format!("{base}/get/all")))
.send()
.await?;
assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /get/all");
Ok(())
}

View File

@@ -0,0 +1,41 @@
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_health_endpoints(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/health");
// GET /health/status → 200 (no auth required)
let resp = client().get(format!("{base}/status")).send().await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "GET /health/status");
// GET /health/detailed → 200 (authed)
let resp = authed(client().get(format!("{base}/detailed")))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "GET /health/detailed");
Ok(())
}

View File

@@ -0,0 +1,76 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_inputs_endpoints(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/inputs");
// GET /history with fake runnable → 200 empty array
let resp = authed(client().get(format!(
"{base}/history?runnable_id=u/test-user/test&runnable_type=ScriptPath"
)))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "GET /inputs/history");
// GET /list with fake runnable → 200 empty array
let resp = authed(client().get(format!(
"{base}/list?runnable_id=u/test-user/test&runnable_type=ScriptPath"
)))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "GET /inputs/list");
// POST /create → 200, returns UUID
let resp = authed(client().post(format!(
"{base}/create?runnable_id=u/test-user/test&runnable_type=ScriptPath"
)))
.json(&json!({"name": "test_input", "args": {}}))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "POST /inputs/create");
let input_id: String = serde_json::from_str(&body)?;
// GET /{id}/args → 200
let resp = authed(client().get(format!("{base}/{input_id}/args")))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "GET /inputs/{id}/args");
// POST /delete/{id} → 200
let resp = authed(client().post(format!("{base}/delete/{input_id}")))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "POST /inputs/delete/{id}");
Ok(())
}

View File

@@ -0,0 +1,59 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
const FAKE_UUID: &str = "00000000-0000-0000-0000-000000000000";
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_job_metrics_2xx(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/job_metrics");
let resp = authed(client().post(format!("{base}/get/{FAKE_UUID}")))
.json(&json!({}))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"POST /get/{id}",
);
let resp = authed(client().post(format!("{base}/set_progress/{FAKE_UUID}")))
.json(&json!({"percent": 50}))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"POST /set_progress/{id}",
);
let resp = authed(client().get(format!("{base}/get_progress/{FAKE_UUID}")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /get_progress/{id}",
);
Ok(())
}

View File

@@ -0,0 +1,309 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use uuid::Uuid;
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
fn assert_route_reachable(status: u16, body: &str, endpoint: &str) {
assert!(
status != 404 || !body.is_empty(),
"Router-level 404 for {endpoint}",
);
}
async fn insert_completed_job(db: &Pool<Postgres>) -> Uuid {
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args)
VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'script', 'deno', '{}'::jsonb)",
)
.bind(id)
.execute(db)
.await
.unwrap();
sqlx::query(
"INSERT INTO v2_job_completed (id, workspace_id, duration_ms, result, status)
VALUES ($1, 'test-workspace', 100, '42'::jsonb, 'success')",
)
.bind(id)
.execute(db)
.await
.unwrap();
id
}
#[allow(dead_code)]
async fn create_script(port: u16) -> String {
let base = format!("http://localhost:{port}/api/w/test-workspace/scripts");
let resp = authed(client().post(format!("{base}/create")))
.json(&json!({
"path": "u/test-user/test_job_script",
"summary": "test",
"description": "",
"content": "export function main() { return 42; }",
"language": "deno",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {},
"required": []
}
}))
.send()
.await
.unwrap();
assert!(
resp.status().is_success(),
"create script: {}",
resp.status()
);
"u/test-user/test_job_script".to_string()
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_jobs_authed_list_and_count(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/jobs");
// --- List/count endpoints (2xx with empty results) ---
let resp = authed(client().get(format!("{base}/list"))).send().await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /jobs/list",
);
let resp = authed(client().get(format!("{base}/queue/list")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /jobs/queue/list",
);
let resp = authed(client().get(format!("{base}/queue/count")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /jobs/queue/count",
);
let resp = authed(client().get(format!("{base}/completed/list")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /jobs/completed/list",
);
let resp = authed(client().get(format!("{base}/completed/count")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /jobs/completed/count",
);
// --- Global endpoints ---
let resp = client()
.get(format!("http://localhost:{port}/api/jobs/db_clock"))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /jobs/db_clock",
);
let resp = authed(client().get(format!(
"http://localhost:{port}/api/jobs/completed/count_by_tag"
)))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /jobs/completed/count_by_tag",
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_jobs_authed_completed_endpoints(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/jobs");
let job_id = insert_completed_job(&db).await;
let resp = authed(client().get(format!("{base}/completed/get/{job_id}")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /jobs/completed/get",
);
let resp = authed(client().get(format!("{base}/completed/get_result/{job_id}")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /jobs/completed/get_result",
);
let resp = authed(client().get(format!("{base}/completed/get_result_maybe/{job_id}")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /jobs/completed/get_result_maybe",
);
let resp = authed(client().get(format!("{base}/completed/get_timing/{job_id}")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /jobs/completed/get_timing",
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_jobs_authed_run_endpoints(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/jobs");
// Run preview — no pre-existing script needed
let resp = authed(client().post(format!("{base}/run/preview")))
.json(&json!({
"content": "export function main() { return 1; }",
"language": "deno",
"args": {}
}))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"POST /jobs/run/preview",
);
// Run preview flow
let resp = authed(client().post(format!("{base}/run/preview_flow")))
.json(&json!({
"value": {"modules": []},
"args": {}
}))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"POST /jobs/run/preview_flow",
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_jobs_authed_reachability(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/jobs");
let fake = Uuid::nil();
// These need complex runtime but should hit the handler (not 404)
let resp = authed(client().post(format!("{base}/flow/resume/{fake}")))
.json(&json!({}))
.send()
.await?;
assert_route_reachable(
resp.status().as_u16(),
&resp.text().await?,
"POST /jobs/flow/resume",
);
let resp = authed(client().get(format!("{base}/job_signature/{fake}/1")))
.send()
.await?;
assert_route_reachable(
resp.status().as_u16(),
&resp.text().await?,
"GET /jobs/job_signature",
);
let resp = authed(client().get(format!("{base}/resume_urls/{fake}/1")))
.send()
.await?;
assert_route_reachable(
resp.status().as_u16(),
&resp.text().await?,
"GET /jobs/resume_urls",
);
let resp = authed(client().get(format!("{base}/result_by_id/{fake}/step1")))
.send()
.await?;
assert_route_reachable(
resp.status().as_u16(),
&resp.text().await?,
"GET /jobs/result_by_id",
);
let resp = authed(client().post(format!("{base}/restart/f/{fake}")))
.send()
.await?;
assert_route_reachable(
resp.status().as_u16(),
&resp.text().await?,
"POST /jobs/restart/f",
);
let resp = authed(client().post(format!("{base}/run/workflow_as_code/{fake}/main")))
.json(&json!({}))
.send()
.await?;
assert_route_reachable(
resp.status().as_u16(),
&resp.text().await?,
"POST /jobs/run/workflow_as_code",
);
Ok(())
}

View File

@@ -0,0 +1,250 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use uuid::Uuid;
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
/// Insert a minimal completed job directly into the database for testing.
async fn insert_completed_job(db: &Pool<Postgres>) -> Uuid {
let id = Uuid::new_v4();
sqlx::query(
"INSERT INTO v2_job (id, workspace_id, created_by, permissioned_as, kind, tag, args)
VALUES ($1, 'test-workspace', 'test-user', 'u/test-user', 'script', 'deno', '{}'::jsonb)",
)
.bind(id)
.execute(db)
.await
.unwrap();
sqlx::query(
"INSERT INTO v2_job_completed (id, workspace_id, duration_ms, result, status)
VALUES ($1, 'test-workspace', 100, '42'::jsonb, 'success')",
)
.bind(id)
.execute(db)
.await
.unwrap();
id
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_jobs_unauthed_endpoints(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/jobs_u");
let job_id = insert_completed_job(&db).await;
// --- No-data endpoints ---
let resp = authed(client().post(format!("{base}/queue/get_started_at_by_ids")))
.json(&json!([]))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"POST /queue/get_started_at_by_ids",
);
// --- Completed job endpoints (unauthed service, with auth header) ---
let resp = authed(client().get(format!("{base}/get/{job_id}")))
.send()
.await?;
assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /get");
let resp = authed(client().get(format!("{base}/get_logs/{job_id}")))
.send()
.await?;
assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /get_logs");
let resp = authed(client().get(format!("{base}/get_completed_logs_tail/{job_id}")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /get_completed_logs_tail",
);
let resp = authed(client().get(format!("{base}/get_args/{job_id}")))
.send()
.await?;
assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /get_args");
let resp = authed(client().get(format!("{base}/completed/get/{job_id}")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /completed/get",
);
let resp = authed(client().get(format!("{base}/completed/get_result/{job_id}")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /completed/get_result",
);
let resp = authed(client().get(format!("{base}/completed/get_result_maybe/{job_id}")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /completed/get_result_maybe",
);
let resp = authed(client().get(format!("{base}/completed/get_timing/{job_id}")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /completed/get_timing",
);
let resp = authed(client().get(format!("{base}/getupdate/{job_id}")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /getupdate",
);
Ok(())
}
const FAKE_UUID: &str = "00000000-0000-0000-0000-000000000000";
const FAKE_SECRET: &str = "aabb";
/// Reachability tests for endpoints that need complex runtime.
/// These just verify the route matches (handler runs), not 2xx.
fn assert_route_reachable(status: u16, body: &str, endpoint: &str) {
assert!(
status != 404 || !body.is_empty(),
"Router-level 404 for {endpoint}",
);
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_jobs_unauthed_complex_reachability(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/jobs_u");
let resp = authed(client().get(format!("{base}/resume/{FAKE_UUID}/1/{FAKE_SECRET}")))
.send()
.await?;
assert_route_reachable(resp.status().as_u16(), &resp.text().await?, "GET /resume");
let resp = authed(client().post(format!("{base}/cancel/{FAKE_UUID}/1/{FAKE_SECRET}")))
.send()
.await?;
assert_route_reachable(resp.status().as_u16(), &resp.text().await?, "POST /cancel");
let resp = authed(client().get(format!("{base}/get_flow/{FAKE_UUID}/1/{FAKE_SECRET}")))
.send()
.await?;
assert_route_reachable(resp.status().as_u16(), &resp.text().await?, "GET /get_flow");
let resp = authed(client().post(format!("{base}/queue/cancel/{FAKE_UUID}")))
.json(&serde_json::json!({"reason": "test"}))
.send()
.await?;
assert_route_reachable(
resp.status().as_u16(),
&resp.text().await?,
"POST /queue/cancel",
);
let resp = authed(client().post(format!("{base}/queue/force_cancel/{FAKE_UUID}")))
.json(&serde_json::json!({"reason": "test"}))
.send()
.await?;
assert_route_reachable(
resp.status().as_u16(),
&resp.text().await?,
"POST /queue/force_cancel",
);
let resp = authed(client().post(format!("{base}/flow/resume_suspended/{FAKE_UUID}")))
.send()
.await?;
assert_route_reachable(
resp.status().as_u16(),
&resp.text().await?,
"POST /flow/resume_suspended",
);
let resp = authed(client().get(format!("{base}/flow/approval_info/{FAKE_UUID}")))
.send()
.await?;
assert_route_reachable(
resp.status().as_u16(),
&resp.text().await?,
"GET /flow/approval_info",
);
let resp = authed(client().get(format!("{base}/get_root_job_id/{FAKE_UUID}")))
.send()
.await?;
assert_route_reachable(
resp.status().as_u16(),
&resp.text().await?,
"GET /get_root_job_id",
);
let resp = authed(client().get(format!("{base}/get_flow_debug_info/{FAKE_UUID}")))
.send()
.await?;
assert_route_reachable(
resp.status().as_u16(),
&resp.text().await?,
"GET /get_flow_debug_info",
);
let resp = authed(client().get(format!("{base}/get_log_file/{FAKE_UUID}/test.txt")))
.send()
.await?;
assert_route_reachable(
resp.status().as_u16(),
&resp.text().await?,
"GET /get_log_file",
);
let resp = authed(client().post(format!("{base}/queue/cancel_persistent/u/test-user/fake")))
.json(&serde_json::json!({"reason": "test"}))
.send()
.await?;
assert_route_reachable(
resp.status().as_u16(),
&resp.text().await?,
"POST /queue/cancel_persistent",
);
Ok(())
}

View File

@@ -0,0 +1,85 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
/// Start a mock npm registry that returns valid JSON for any GET request.
async fn start_mock_registry() -> u16 {
use axum::{routing::get, Json, Router};
let app = Router::new().fallback(get(|| async {
Json(json!({
"name": "test-package",
"versions": {"1.0.0": {"name": "test-package", "version": "1.0.0"}},
"dist-tags": {"latest": "1.0.0"}
}))
}));
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
port
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_npm_proxy_endpoints(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/npm_proxy");
// Start mock npm registry
let mock_port = start_mock_registry().await;
let mock_url = format!("http://127.0.0.1:{mock_port}");
// Configure the npm registry to point to our mock
let resp = authed(
client()
.post(format!(
"http://localhost:{port}/api/settings/global/npm_config_registry"
))
.json(&json!({"value": mock_url})),
)
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"POST /settings/global/npm_config_registry",
);
// GET /metadata/{package}
let resp = authed(client().get(format!("{base}/metadata/lodash")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /npm_proxy/metadata/lodash",
);
// GET /resolve/{package}
let resp = authed(client().get(format!("{base}/resolve/lodash")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /npm_proxy/resolve/lodash",
);
Ok(())
}

View File

@@ -0,0 +1,33 @@
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_raw_apps_endpoints(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/raw_apps");
// GET /raw_apps/list → 200 (empty array)
let resp = authed(client().get(format!("{base}/list"))).send().await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "GET /raw_apps/list");
Ok(())
}

View File

@@ -108,7 +108,10 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
let resp = authed_get(port, "raw/p", "u/test-user/test_script.ts").await;
assert_eq!(resp.status(), 200);
let body = resp.text().await?;
assert!(body.contains("return 42"), "expected script content, got: {body}");
assert!(
body.contains("return 42"),
"expected script content, got: {body}"
);
// --- raw by hash (requires .ts suffix) ---
let resp = authed_get(port, "raw/h", &format!("{hash}.ts")).await;
@@ -131,12 +134,10 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
assert!(list.iter().any(|s| s["path"] == "u/test-user/test_script"));
// list with path_start filter
let resp = authed(client().get(format!(
"{base}/list?path_start=u/test-user/another"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("{base}/list?path_start=u/test-user/another")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let list = resp.json::<Vec<serde_json::Value>>().await?;
assert_eq!(list.len(), 1);
@@ -233,12 +234,7 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"history_update: {}",
resp.text().await?
);
assert_eq!(resp.status(), 200, "history_update: {}", resp.text().await?);
// --- toggle_workspace_error_handler (EE-gated, expect 400 in OSS) ---
let resp = authed(client().post(script_url(
@@ -268,22 +264,13 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"tokened_raw: {}",
resp.text().await?
);
assert_eq!(resp.status(), 200, "tokened_raw: {}", resp.text().await?);
// --- archive by path ---
let resp = authed(client().post(script_url(
port,
"archive/p",
"u/test-user/another_script",
)))
.send()
.await
.unwrap();
let resp = authed(client().post(script_url(port, "archive/p", "u/test-user/another_script")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// archived script should still be gettable
@@ -333,12 +320,10 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
// ===== Hub endpoints (require external network, expect 500 or 200) =====
// --- hub/top ---
let resp = authed(client().get(format!(
"http://localhost:{port}/api/scripts/hub/top"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("http://localhost:{port}/api/scripts/hub/top")))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 500,
"hub/top: unexpected status {}",
@@ -372,12 +357,10 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
);
// --- integrations hub/list ---
let resp = authed(client().get(format!(
"http://localhost:{port}/api/integrations/hub/list"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("http://localhost:{port}/api/integrations/hub/list")))
.send()
.await
.unwrap();
assert!(
resp.status() == 200 || resp.status() == 500,
"integrations hub/list: unexpected status {}",
@@ -386,3 +369,97 @@ async fn test_script_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_auto_parent_resolves_parent_hash(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/scripts");
// Create v1
let resp = authed(client().post(format!("{base}/create")))
.json(&new_script(
"u/test-user/auto_parent_test",
"v1",
"export async function main() { return 1; }",
))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 201, "create v1: {}", resp.text().await?);
// Get the hash of v1
let resp = authed_get(port, "get/p", "u/test-user/auto_parent_test").await;
let body = resp.json::<serde_json::Value>().await?;
let v1_hash = body["hash"].as_str().unwrap().to_string();
// Create v2 using auto_parent (no parent_hash provided)
let mut v2 = new_script(
"u/test-user/auto_parent_test",
"v2",
"export async function main() { return 2; }",
);
v2["auto_parent"] = json!(true);
let resp = authed(client().post(format!("{base}/create")))
.json(&v2)
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
201,
"create v2 with auto_parent: {}",
resp.text().await?
);
// Get v2 and verify its parent_hash points to v1
let resp = authed_get(port, "get/p", "u/test-user/auto_parent_test").await;
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["summary"], "v2");
let v2_hash = body["hash"].as_str().unwrap().to_string();
assert_ne!(v2_hash, v1_hash);
// v2's parent_hashes should contain v1
let parent_hashes = body["parent_hashes"].as_array().unwrap();
assert!(
parent_hashes
.iter()
.any(|h| h.as_str() == Some(v1_hash.as_str())),
"v2 parent_hashes should contain v1 hash {v1_hash}, got: {parent_hashes:?}"
);
// Create v3 with auto_parent to confirm it chains correctly
let mut v3 = new_script(
"u/test-user/auto_parent_test",
"v3",
"export async function main() { return 3; }",
);
v3["auto_parent"] = json!(true);
let resp = authed(client().post(format!("{base}/create")))
.json(&v3)
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
201,
"create v3 with auto_parent: {}",
resp.text().await?
);
let resp = authed_get(port, "get/p", "u/test-user/auto_parent_test").await;
let body = resp.json::<serde_json::Value>().await?;
assert_eq!(body["summary"], "v3");
// v3's parent_hashes should contain v2 (and transitively v1)
let parent_hashes = body["parent_hashes"].as_array().unwrap();
assert!(
parent_hashes
.iter()
.any(|h| h.as_str() == Some(v2_hash.as_str())),
"v3 parent_hashes should contain v2 hash {v2_hash}, got: {parent_hashes:?}"
);
Ok(())
}

View File

@@ -0,0 +1,465 @@
//! Integration tests for sensitive log masking.
//!
//! A single comprehensive test that runs real bun scripts through real workers,
//! covering all masking scenarios: secret variables, non-secret variables,
//! multiple secrets, mid-string secrets, `$encrypted:` args, resources
//! referencing secret variables, and cross-job isolation.
//!
//! Run with:
//! cargo test -p windmill-api-integration-tests --test sensitive_log_masking -- --nocapture
//!
//! Requires: bun runtime, live database (migrations applied by sqlx::test).
use futures::StreamExt;
use serde_json::json;
use sqlx::{Pool, Postgres};
use uuid::Uuid;
use windmill_common::jobs::{JobPayload, RawCode};
use windmill_common::scripts::ScriptLang;
use windmill_common::worker::to_raw_value;
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
/// Helper: create a variable via the API.
async fn create_variable(port: u16, path: &str, value: &str, is_secret: bool) {
let base = format!("http://localhost:{port}/api/w/test-workspace/variables");
let resp = authed(client().post(format!("{base}/create")))
.json(&json!({
"path": path,
"value": value,
"is_secret": is_secret,
"description": "test variable for log masking"
}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
201,
"failed to create variable {path}: {}",
resp.text().await.unwrap_or_default()
);
}
/// Helper: create a resource via the API.
async fn create_resource(port: u16, path: &str, value: serde_json::Value) {
let base = format!("http://localhost:{port}/api/w/test-workspace/resources");
let resp = authed(client().post(format!("{base}/create")))
.json(&json!({
"path": path,
"value": value,
"resource_type": "object",
"description": "test resource for log masking"
}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
201,
"failed to create resource {path}: {}",
resp.text().await.unwrap_or_default()
);
}
/// Helper: fetch job logs from the job_logs table.
async fn get_job_logs(db: &Pool<Postgres>, job_id: Uuid) -> Option<String> {
sqlx::query_scalar!(
r#"SELECT logs as "logs!" FROM job_logs WHERE job_id = $1"#,
job_id,
)
.fetch_optional(db)
.await
.unwrap()
}
/// Helper: push a bun preview job and return its UUID.
async fn push_bun_job(db: &Pool<Postgres>, code: String) -> Uuid {
RunJob::from(JobPayload::Code(RawCode {
hash: None,
content: code,
path: None,
language: ScriptLang::Bun,
lock: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
modules: None,
}))
.push(db)
.await
}
/// Helper: push a bun preview job with encrypted args.
async fn push_bun_job_with_encrypted_arg(
db: &Pool<Postgres>,
code: String,
arg_name: &str,
plaintext_value: &str,
) -> Uuid {
// We need to know the job_id in advance to encrypt with the right key suffix.
let job_id = Uuid::new_v4();
// Encrypt the value the same way the frontend does:
// build_crypt_with_key_suffix(db, workspace, root_job_id)
let mc = windmill_common::variables::build_crypt_with_key_suffix(
db,
"test-workspace",
&job_id.to_string(),
)
.await
.expect("build_crypt_with_key_suffix");
// Encrypt the JSON-serialized string value
let json_str = serde_json::to_string(plaintext_value).unwrap();
let encrypted = windmill_common::variables::encrypt(&mc, &json_str);
let arg_value = format!("$encrypted:{encrypted}");
let mut args = std::collections::HashMap::new();
args.insert(arg_name.to_string(), to_raw_value(&json!(arg_value)));
RunJob::from(JobPayload::Code(RawCode {
hash: None,
content: code,
path: None,
language: ScriptLang::Bun,
lock: None,
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
modules: None,
}))
.job_id(job_id)
.arg(arg_name, json!(arg_value))
.push(db)
.await
}
/// Comprehensive test covering all sensitive log masking scenarios in a single
/// test function to amortize server/worker startup cost.
///
/// Scenarios covered (each as a separate job inside the same worker):
/// 1. Secret variable fetched and logged → masked
/// 2. Non-secret variable fetched and logged → NOT masked (no false positives)
/// 3. Two different secrets fetched and logged in the same job → both masked
/// 4. Secret embedded mid-string (e.g. "token=SECRET&user=bob") → masked
/// 5. Same secret logged 3 times → all occurrences masked
/// 6. `$encrypted:` password arg logged → masked
/// 7. Resource referencing a secret variable via `$var:` → secret masked when logged
/// 8. Cross-job isolation: job A's secret does NOT leak into job B's logs
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_sensitive_log_masking(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// === Setup: create variables and resources ===
let secret1 = "alpha_secret_value_9x7k2m";
let secret2 = "beta_secret_token_4j8n3p";
let plain_val = "plain_visible_value_12345";
let encrypted_password = "encrypted_pass_w0rd_zq5r";
let resource_secret = "resource_db_password_h7t2";
create_variable(port, "u/test-user/secret_alpha", secret1, true).await;
create_variable(port, "u/test-user/secret_beta", secret2, true).await;
create_variable(port, "u/test-user/plain_var", plain_val, false).await;
// Secret variable that will be referenced by a resource via $var:
create_variable(port, "u/test-user/res_secret_var", resource_secret, true).await;
// Resource whose "password" field references the secret variable
create_resource(
port,
"u/test-user/db_with_secret",
json!({"host": "db.example.com", "password": "$var:u/test-user/res_secret_var"}),
)
.await;
let mut completed = listen_for_completed_jobs(&db).await;
let db2 = db.clone();
in_test_worker(
db.clone(),
async move {
// ================================================================
// Scenario 1: Secret variable fetched and console.logged → masked
// ================================================================
let job1 = push_bun_job(
&db2,
r#"import * as wmill from "windmill-client";
export async function main() {
const secret = await wmill.getVariable("u/test-user/secret_alpha");
console.log("The secret value is: " + secret);
return "ok";
}"#
.into(),
)
.await;
completed.next().await;
let cjob1 = completed_job(job1, &db2).await;
assert!(cjob1.success, "scenario 1 job failed");
let logs1 = get_job_logs(&db2, job1).await.expect("scenario 1: no logs");
assert!(
!logs1.contains(secret1),
"scenario 1: secret value leaked in logs\nLogs:\n{logs1}"
);
assert!(
logs1.contains("The secret value is: alp*****"),
"scenario 1: expected masked output with first 3 chars\nLogs:\n{logs1}"
);
assert!(
logs1.contains("[windmill] secret value was masked for security reasons, use string transformations to display full value"),
"scenario 1: expected security notice\nLogs:\n{logs1}"
);
// ================================================================
// Scenario 2: Non-secret variable → NOT masked (no false positives)
// ================================================================
let job2 = push_bun_job(
&db2,
r#"import * as wmill from "windmill-client";
export async function main() {
const val = await wmill.getVariable("u/test-user/plain_var");
console.log("The plain value is: " + val);
return "ok";
}"#
.into(),
)
.await;
completed.next().await;
let cjob2 = completed_job(job2, &db2).await;
assert!(cjob2.success, "scenario 2 job failed");
let logs2 = get_job_logs(&db2, job2).await.expect("scenario 2: no logs");
assert!(
logs2.contains(plain_val),
"scenario 2: plain value should appear unmasked\nLogs:\n{logs2}"
);
// ================================================================
// Scenario 3: Two different secrets fetched in the same job → both masked
// ================================================================
let job3 = push_bun_job(
&db2,
r#"import * as wmill from "windmill-client";
export async function main() {
const s1 = await wmill.getVariable("u/test-user/secret_alpha");
const s2 = await wmill.getVariable("u/test-user/secret_beta");
console.log("secret1=" + s1);
console.log("secret2=" + s2);
return "ok";
}"#
.into(),
)
.await;
completed.next().await;
let cjob3 = completed_job(job3, &db2).await;
assert!(cjob3.success, "scenario 3 job failed");
let logs3 = get_job_logs(&db2, job3).await.expect("scenario 3: no logs");
assert!(
!logs3.contains(secret1),
"scenario 3: secret1 leaked\nLogs:\n{logs3}"
);
assert!(
!logs3.contains(secret2),
"scenario 3: secret2 leaked\nLogs:\n{logs3}"
);
assert!(
logs3.contains("secret1=alp*****"),
"scenario 3: secret1 not masked\nLogs:\n{logs3}"
);
assert!(
logs3.contains("secret2=bet*****"),
"scenario 3: secret2 not masked\nLogs:\n{logs3}"
);
// ================================================================
// Scenario 4: Secret embedded mid-string → masked in place
// ================================================================
let job4 = push_bun_job(
&db2,
r#"import * as wmill from "windmill-client";
export async function main() {
const secret = await wmill.getVariable("u/test-user/secret_alpha");
console.log("token=" + secret + "&user=bob&format=json");
return "ok";
}"#
.into(),
)
.await;
completed.next().await;
let cjob4 = completed_job(job4, &db2).await;
assert!(cjob4.success, "scenario 4 job failed");
let logs4 = get_job_logs(&db2, job4).await.expect("scenario 4: no logs");
assert!(
!logs4.contains(secret1),
"scenario 4: secret leaked mid-string\nLogs:\n{logs4}"
);
assert!(
logs4.contains("token=alp*****&user=bob&format=json"),
"scenario 4: mid-string masking failed\nLogs:\n{logs4}"
);
// ================================================================
// Scenario 5: Same secret logged 3 times → all occurrences masked
// ================================================================
let job5 = push_bun_job(
&db2,
r#"import * as wmill from "windmill-client";
export async function main() {
const secret = await wmill.getVariable("u/test-user/secret_beta");
console.log("First: " + secret);
console.log("Second: " + secret);
console.log("Third: " + secret);
return "ok";
}"#
.into(),
)
.await;
completed.next().await;
let cjob5 = completed_job(job5, &db2).await;
assert!(cjob5.success, "scenario 5 job failed");
let logs5 = get_job_logs(&db2, job5).await.expect("scenario 5: no logs");
assert!(
!logs5.contains(secret2),
"scenario 5: secret leaked\nLogs:\n{logs5}"
);
let mask_count = logs5.matches("bet*****").count();
assert!(
mask_count >= 3,
"scenario 5: expected >= 3 masked occurrences, found {mask_count}\nLogs:\n{logs5}"
);
// Security notice should appear only once even though masking happened 3 times
let notice_count = logs5.matches("[windmill] secret value was masked").count();
assert_eq!(
notice_count, 1,
"scenario 5: security notice should appear exactly once, found {notice_count}\nLogs:\n{logs5}"
);
// ================================================================
// Scenario 6: $encrypted: password arg → masked when logged
// ================================================================
let job6 = push_bun_job_with_encrypted_arg(
&db2,
r#"export async function main(password: string) {
console.log("password is: " + password);
return "ok";
}"#
.into(),
"password",
encrypted_password,
)
.await;
completed.next().await;
let cjob6 = completed_job(job6, &db2).await;
assert!(cjob6.success, "scenario 6 job failed");
let logs6 = get_job_logs(&db2, job6).await.expect("scenario 6: no logs");
assert!(
!logs6.contains(encrypted_password),
"scenario 6: encrypted password leaked\nLogs:\n{logs6}"
);
assert!(
logs6.contains("password is: enc*****"),
"scenario 6: encrypted password not masked\nLogs:\n{logs6}"
);
// ================================================================
// Scenario 7: Resource with $var: referencing a secret → masked
// ================================================================
let job7 = push_bun_job(
&db2,
r#"import * as wmill from "windmill-client";
export async function main() {
const res = await wmill.getResource("u/test-user/db_with_secret");
console.log("db password: " + res.password);
console.log("db host: " + res.host);
return "ok";
}"#
.into(),
)
.await;
completed.next().await;
let cjob7 = completed_job(job7, &db2).await;
assert!(cjob7.success, "scenario 7 job failed");
let logs7 = get_job_logs(&db2, job7).await.expect("scenario 7: no logs");
assert!(
!logs7.contains(resource_secret),
"scenario 7: resource secret leaked\nLogs:\n{logs7}"
);
assert!(
logs7.contains("db password: res*****"),
"scenario 7: resource secret not masked\nLogs:\n{logs7}"
);
// Non-secret field should remain visible
assert!(
logs7.contains("db host: db.example.com"),
"scenario 7: non-secret resource field should be visible\nLogs:\n{logs7}"
);
// ================================================================
// Scenario 8: Cross-job isolation — job A fetches secret_alpha,
// then job B logs "alpha_secret_value_9x7k2m" as a
// literal string (not fetched as a secret).
// Job B should NOT mask it because the secret belongs
// to job A which already completed.
// ================================================================
// Job A: fetch the secret (registers it) then completes
let job_a = push_bun_job(
&db2,
r#"import * as wmill from "windmill-client";
export async function main() {
const s = await wmill.getVariable("u/test-user/secret_alpha");
console.log("fetched secret");
return "ok";
}"#
.into(),
)
.await;
completed.next().await;
let cjob_a = completed_job(job_a, &db2).await;
assert!(cjob_a.success, "scenario 8 job A failed");
// Job B: logs the same string as a hardcoded literal (NOT fetched as secret)
// Since job A already completed and unregistered, and job B never
// fetched the secret, it should NOT be masked.
let job_b_code = format!(
r#"export async function main() {{
console.log("literal value: {secret1}");
return "ok";
}}"#
);
let job_b = push_bun_job(&db2, job_b_code).await;
completed.next().await;
let cjob_b = completed_job(job_b, &db2).await;
assert!(cjob_b.success, "scenario 8 job B failed");
let logs_b = get_job_logs(&db2, job_b)
.await
.expect("scenario 8 job B: no logs");
assert!(
logs_b.contains(secret1),
"scenario 8: job B should show the literal string unmasked (it never fetched a secret)\nLogs:\n{logs_b}"
);
},
port,
)
.await;
Ok(())
}

View File

@@ -0,0 +1,36 @@
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_service_logs_2xx(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/service_logs");
let resp = authed(client().get(format!("{base}/list_files")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /list_files",
);
Ok(())
}

View File

@@ -0,0 +1,116 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_settings_2xx(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/settings");
let resp = authed(client().get(format!("{base}/envs"))).send().await?;
assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /envs");
let resp = authed(client().get(format!("{base}/global/hub_base_url")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /global/hub_base_url",
);
let resp = authed(client().post(format!("{base}/global/test_key")))
.json(&json!({"value": "test"}))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"POST /global/test_key",
);
let resp = authed(client().get(format!("{base}/instance_config")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /instance_config",
);
let resp = authed(client().get(format!("{base}/instance_config/yaml")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /instance_config/yaml",
);
let resp = authed(client().get(format!("{base}/latest_key_renewal_attempt")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /latest_key_renewal_attempt",
);
let resp = authed(client().post(format!("{base}/sync_cached_resource_types")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"POST /sync_cached_resource_types",
);
// --- Reachability only (need external services) ---
let resp = authed(
client()
.post(format!("{base}/test_smtp"))
.json(&json!({"to": "test@test.com", "subject": "test", "content": "test"})),
)
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert!(
status != 404 || !body.is_empty(),
"Router-level 404 for POST /test_smtp"
);
let resp = authed(
client()
.post(format!("{base}/test_license_key"))
.json(&json!({"license_key": "fake"})),
)
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert!(
status != 404 || !body.is_empty(),
"Router-level 404 for POST /test_license_key"
);
Ok(())
}

View File

@@ -0,0 +1,41 @@
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_trash_endpoints(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/trash");
// GET /trash/list → 200 (admin, empty array)
let resp = authed(client().get(format!("{base}/list"))).send().await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "GET /trash/list");
// POST /trash/empty → 200 (admin)
let resp = authed(client().post(format!("{base}/empty")))
.send()
.await?;
let status = resp.status().as_u16();
let body = resp.text().await?;
assert_2xx(status, &body, "POST /trash/empty");
Ok(())
}

View File

@@ -14,6 +14,7 @@ use serde_json::json;
use sqlx::{Pool, Postgres};
use std::time::Duration;
#[allow(unused_imports)]
use windmill_test_utils::*;
/// Row shape for querying deployment callback jobs from v2_job_queue
@@ -27,6 +28,7 @@ struct DeploymentCallbackJob {
}
/// Poll for deployment callback jobs in the queue for a given script path
#[allow(dead_code)]
async fn get_deployment_callback_jobs(
db: &Pool<Postgres>,
script_path: &str,
@@ -63,6 +65,7 @@ async fn get_deployment_callback_jobs(
}
/// Configure git sync for the test workspace with workspace dependencies enabled
#[allow(dead_code)]
async fn setup_git_sync_config(db: &Pool<Postgres>, sync_script_path: &str) -> anyhow::Result<()> {
let git_sync_config = json!({
"include_type": ["workspacedependencies"],
@@ -87,6 +90,7 @@ async fn setup_git_sync_config(db: &Pool<Postgres>, sync_script_path: &str) -> a
}
/// Create a git repository resource for testing
#[allow(dead_code)]
async fn create_git_repo_resource(db: &Pool<Postgres>) -> anyhow::Result<()> {
sqlx::query(
r#"
@@ -107,6 +111,7 @@ async fn create_git_repo_resource(db: &Pool<Postgres>) -> anyhow::Result<()> {
}
/// Create a dummy sync script for testing (with version >= 28103 for debouncing support)
#[allow(dead_code)]
async fn create_sync_script(db: &Pool<Postgres>, path: &str) -> anyhow::Result<i64> {
let hash: i64 = rand::random::<i64>().unsigned_abs() as i64;
sqlx::query(
@@ -126,6 +131,7 @@ async fn create_sync_script(db: &Pool<Postgres>, path: &str) -> anyhow::Result<i
}
/// Create a folder for the versioned script path
#[allow(dead_code)]
async fn create_folder(db: &Pool<Postgres>, name: &str) -> anyhow::Result<()> {
sqlx::query(
r#"

View File

@@ -0,0 +1,39 @@
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
builder.header("Authorization", "Bearer SECRET_TOKEN")
}
fn assert_2xx(status: u16, body: &str, endpoint: &str) {
assert!(
(200..300).contains(&status),
"{endpoint} returned {status}: {body}",
);
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_workspace_deps_2xx(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/workspace_dependencies");
let resp = authed(client().get(format!("{base}/list"))).send().await?;
assert_2xx(resp.status().as_u16(), &resp.text().await?, "GET /list");
let resp = authed(client().get(format!("{base}/get_latest/python3")))
.send()
.await?;
assert_2xx(
resp.status().as_u16(),
&resp.text().await?,
"GET /get_latest/python3",
);
Ok(())
}

View File

@@ -599,59 +599,60 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.unwrap();
assert_eq!(resp.status(), 200, "tarball: {}", resp.status());
// ===== Fork operations (on the newly created workspace) =====
// ===== Fork operations (EE-only: CE limits workspace count to 2) =====
#[cfg(feature = "enterprise")]
{
let new_ws_base = format!("http://localhost:{port}/api/w/new-test-ws/workspaces");
let resp = authed(client().post(format!("{new_ws_base}/create_fork")))
.json(&json!({
"id": "wm-fork-test-ws",
"name": "Forked Test Workspace"
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "create_fork: {}", resp.text().await?);
// --- create_fork (workspace-scoped, from new-test-ws) ---
let new_ws_base = format!("http://localhost:{port}/api/w/new-test-ws/workspaces");
let resp = authed(client().post(format!("{new_ws_base}/create_fork")))
.json(&json!({
"id": "wm-fork-test-ws",
"name": "Forked Test Workspace"
}))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200, "create_fork: {}", resp.text().await?);
// verify fork exists
let resp = authed(client().post(format!("{global_base}/exists")))
.json(&json!({"id": "wm-fork-test-ws"}))
.send()
.await
.unwrap();
assert_eq!(resp.json::<bool>().await?, true);
// verify fork exists
let resp = authed(client().post(format!("{global_base}/exists")))
.json(&json!({"id": "wm-fork-test-ws"}))
.send()
.await
.unwrap();
assert_eq!(resp.json::<bool>().await?, true);
// --- change_workspace_id ---
let fork_ws_base = format!("http://localhost:{port}/api/w/wm-fork-test-ws/workspaces");
let resp = authed(client().post(format!("{fork_ws_base}/change_workspace_id")))
.json(&json!({
"new_id": "wm-fork-renamed",
"new_name": "Renamed Fork"
}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"change_workspace_id: {}",
resp.text().await?
);
// --- change_workspace_id ---
let fork_ws_base = format!("http://localhost:{port}/api/w/wm-fork-test-ws/workspaces");
let resp = authed(client().post(format!("{fork_ws_base}/change_workspace_id")))
.json(&json!({
"new_id": "wm-fork-renamed",
"new_name": "Renamed Fork"
}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"change_workspace_id: {}",
resp.text().await?
);
// verify renamed workspace exists
let resp = authed(client().post(format!("{global_base}/exists")))
.json(&json!({"id": "wm-fork-renamed"}))
.send()
.await
.unwrap();
assert_eq!(resp.json::<bool>().await?, true);
// verify renamed workspace exists
let resp = authed(client().post(format!("{global_base}/exists")))
.json(&json!({"id": "wm-fork-renamed"}))
.send()
.await
.unwrap();
assert_eq!(resp.json::<bool>().await?, true);
// clean up renamed fork
let resp = authed(client().delete(format!("{global_base}/delete/wm-fork-renamed")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
// clean up renamed fork
let resp = authed(client().delete(format!("{global_base}/delete/wm-fork-renamed")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
}
// --- archive workspace (on the newly created one, not our main test workspace) ---
let new_ws_base = format!("http://localhost:{port}/api/w/new-test-ws/workspaces");
@@ -803,3 +804,21 @@ async fn test_get_copilot_info_ignores_empty_instance_ai_row(
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_get_imports(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
let resp = authed(client().get(format!("{base}/get_imports/u/test-user/nonexistent_script")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let imports = resp.json::<Vec<String>>().await?;
assert!(imports.is_empty());
Ok(())
}

View File

@@ -25,8 +25,8 @@ use uuid::Uuid;
pub fn global_service() -> Router {
Router::new()
.route("/list", get(list_concurrency_groups))
.route("/prune/*concurrency_key", delete(prune_concurrency_group))
.route("/:job_id/key", get(get_concurrency_key))
.route("/prune/{*concurrency_key}", delete(prune_concurrency_group))
.route("/{job_id}/key", get(get_concurrency_key))
}
pub fn workspaced_service() -> Router {

View File

@@ -22,13 +22,13 @@ pub fn workspaced_service() -> Router {
.allow_origin(Any);
Router::new()
.route("/get/:id", post(get_job_metrics).layer(cors.clone()))
.route("/get/{id}", post(get_job_metrics).layer(cors.clone()))
.route(
"/set_progress/:id",
"/set_progress/{id}",
post(set_job_progress).layer(cors.clone()),
)
.route(
"/get_progress/:id",
"/get_progress/{id}",
get(get_job_progress).layer(cors.clone()),
)
}

View File

@@ -511,18 +511,14 @@ pub struct ResumeUrls {
pub struct QueryOrBody<D>(pub Option<D>);
#[axum::async_trait]
impl<S, D> FromRequest<S, axum::body::Body> for QueryOrBody<D>
impl<S, D> FromRequest<S> for QueryOrBody<D>
where
D: DeserializeOwned,
S: Send + Sync,
{
type Rejection = Response;
async fn from_request(
req: Request<axum::body::Body>,
state: &S,
) -> std::result::Result<Self, Self::Rejection> {
async fn from_request(req: Request, state: &S) -> std::result::Result<Self, Self::Rejection> {
return if req.method() == axum::http::Method::GET {
let Query(InPayload { payload }) = Query::from_request(req, state)
.await

View File

@@ -119,10 +119,10 @@ struct FileEntry {
pub fn workspaced_service() -> Router {
Router::new()
// Use wildcards for package names to support scoped packages like @scope/package
.route("/metadata/*package", get(get_package_metadata))
.route("/resolve/*package", get(resolve_package_version))
.route("/filetree/*package_version", get(get_package_filetree))
.route("/file/*package_version_filepath", get(get_package_file))
.route("/metadata/{*package}", get(get_package_metadata))
.route("/resolve/{*package}", get(resolve_package_version))
.route("/filetree/{*package_version}", get(get_package_filetree))
.route("/file/{*package_version_filepath}", get(get_package_file))
.layer(
CorsLayer::new()
.allow_origin(Any)

View File

@@ -56,12 +56,12 @@ pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_schedule))
.route("/list_with_jobs", get(list_schedule_with_jobs))
.route("/get/*path", get(get_schedule))
.route("/exists/*path", get(exists_schedule))
.route("/get/{*path}", get(get_schedule))
.route("/exists/{*path}", get(exists_schedule))
.route("/create", post(create_schedule))
.route("/update/*path", post(edit_schedule))
.route("/delete/*path", delete(delete_schedule))
.route("/setenabled/*path", post(set_enabled))
.route("/update/{*path}", post(edit_schedule))
.route("/delete/{*path}", delete(delete_schedule))
.route("/setenabled/{*path}", post(set_enabled))
.route("/setdefaulthandler", post(set_default_error_handler))
// .route("/catchup/*path", post(do_catchup).get(list_catchup))
}
@@ -963,6 +963,15 @@ async fn delete_schedule(
)));
}
// Capture row for trashbin before deleting
let trash_data: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT jsonb_build_object('row', to_jsonb(t)) FROM schedule t WHERE path = $1 AND workspace_id = $2",
)
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let del = sqlx::query_scalar!(
"DELETE FROM schedule WHERE path = $1 AND workspace_id = $2 RETURNING 1",
path,
@@ -979,6 +988,18 @@ async fn delete_schedule(
)));
}
if let Some(data) = trash_data {
windmill_common::trashbin::move_to_trash(
&mut *tx,
&w_id,
"schedule",
path,
data,
&authed.username,
)
.await?;
}
audit_log(
&mut *tx,
&authed,

View File

@@ -190,18 +190,18 @@ impl ScriptWDraft<ScriptRunnableSettingsHandle> {
pub fn global_service() -> Router {
Router::new()
.route("/hub/top", get(get_top_hub_scripts))
.route("/hub/get/*path", get(get_hub_script_by_path))
.route("/hub/get_full/*path", get(get_full_hub_script_by_path))
.route("/hub/pick/*path", get(pick_hub_script_by_path))
.route("/hub/get/{*path}", get(get_hub_script_by_path))
.route("/hub/get_full/{*path}", get(get_full_hub_script_by_path))
.route("/hub/pick/{*path}", get(pick_hub_script_by_path))
}
pub fn global_unauthed_service() -> Router {
Router::new()
.route(
"/tokened_raw/:workspace/:token/*path",
"/tokened_raw/{workspace}/{token}/{*path}",
get(get_tokened_raw_script_by_path),
)
.route("/empty_ts/*path", get(get_empty_ts_script_by_path))
.route("/empty_ts/{*path}", get(get_empty_ts_script_by_path))
}
pub fn workspaced_service() -> Router {
@@ -210,35 +210,36 @@ pub fn workspaced_service() -> Router {
.route("/list_search", get(list_search_scripts))
.route("/create", post(create_script))
.route("/create_snapshot", post(create_snapshot_script))
.route("/archive/p/*path", post(archive_script_by_path))
.route("/get/draft/*path", get(get_script_by_path_w_draft))
.route("/get/p/*path", get(get_script_by_path))
.route("/list_tokens/*path", get(list_tokens))
.route("/raw/p/*path", get(raw_script_by_path))
.route("/raw_unpinned/p/*path", get(raw_script_by_path_unpinned))
.route("/exists/p/*path", get(exists_script_by_path))
.route("/archive/h/:hash", post(archive_script_by_hash))
.route("/delete/h/:hash", post(delete_script_by_hash))
.route("/delete/p/*path", post(delete_script_by_path))
.route("/archive/p/{*path}", post(archive_script_by_path))
.route("/get/draft/{*path}", get(get_script_by_path_w_draft))
.route("/get/p/{*path}", get(get_script_by_path))
.route("/list_tokens/{*path}", get(list_tokens))
.route("/raw/p/{*path}", get(raw_script_by_path))
.route("/raw_unpinned/p/{*path}", get(raw_script_by_path_unpinned))
.route("/exists/p/{*path}", get(exists_script_by_path))
.route("/archive/h/{hash}", post(archive_script_by_hash))
.route("/delete/h/{hash}", post(delete_script_by_hash))
.route("/delete/p/{*path}", post(delete_script_by_path))
.route("/delete_bulk", delete(delete_scripts_bulk))
.route("/get/h/:hash", get(get_script_by_hash))
.route("/raw/h/:hash", get(raw_script_by_hash))
.route("/deployment_status/h/:hash", get(get_deployment_status))
.route("/get/h/{hash}", get(get_script_by_hash))
.route("/raw/h/{hash}", get(raw_script_by_hash))
.route("/deployment_status/h/{hash}", get(get_deployment_status))
.route("/list_paths", get(list_paths))
.route(
"/toggle_workspace_error_handler/p/*path",
"/toggle_workspace_error_handler/p/{*path}",
post(toggle_workspace_error_handler),
)
.route("/history/p/*path", get(get_script_history))
.route("/get_latest_version/*path", get(get_latest_version))
.route("/history/p/{*path}", get(get_script_history))
.route("/get_latest_version/{*path}", get(get_latest_version))
.route(
"/list_paths_from_workspace_runnable/*path",
"/list_paths_from_workspace_runnable/{*path}",
get(list_paths_from_workspace_runnable),
)
.route(
"/history_update/h/:hash/p/*path",
"/history_update/h/{hash}/p/{*path}",
post(update_script_history),
)
.route("/list_dedicated_with_deps", get(list_dedicated_with_deps))
// Temporary raw script storage for CLI lock generation
.route("/raw_temp/store", post(store_raw_script_temp))
.route("/raw_temp/diff", post(diff_raw_scripts_with_deployed))
@@ -604,7 +605,7 @@ impl HandleDeploymentMetadata {
}
async fn create_script_internal<'c>(
ns: NewScript,
mut ns: NewScript,
w_id: String,
authed: ApiAuthed,
db: sqlx::Pool<Postgres>,
@@ -674,6 +675,17 @@ async fn create_script_internal<'c>(
.to_owned(),
));
};
// When auto_parent is set, serialize concurrent creates for the same (workspace, path)
// so the clashing_script query always sees the latest committed head.
if ns.auto_parent.unwrap_or(false) {
sqlx::query_scalar!(
"SELECT pg_advisory_xact_lock(hashtext($1 || '/' || $2))",
&w_id,
&ns.path
)
.fetch_one(&mut *tx)
.await?;
}
let clashing_script = sqlx::query_as::<_, Script<ScriptRunnableSettingsHandle>>(
"SELECT * FROM script WHERE path = $1 AND archived = false AND workspace_id = $2",
)
@@ -686,6 +698,15 @@ async fn create_script_internal<'c>(
perms: serde_json::Value,
p_path: String,
}
// When auto_parent is set, resolve parent_hash to the current head for this path
// within the transaction. The advisory lock above ensures the second concurrent
// request waits until the first commits, so this query sees the updated head.
if ns.auto_parent.unwrap_or(false) {
if let Some(ref cs) = clashing_script {
ns.parent_hash = Some(cs.hash.clone());
}
}
let parent_hashes_and_perms: Option<ParentInfo> = match (&ns.parent_hash, clashing_script) {
(None, None) => Ok(None),
(None, Some(s)) if !s.draft_only.unwrap_or(false) => Err(Error::BadRequest(format!(
@@ -2239,33 +2260,58 @@ async fn delete_script_by_path(
.await?
.unwrap_or(false);
let script = if !draft_only {
if !draft_only {
require_admin(authed.is_admin, &authed.username)?;
sqlx::query_scalar!(
"DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path",
}
// Capture all script versions and drafts for trashbin before deleting
let trash_scripts: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM script t WHERE path = $1 AND workspace_id = $2",
)
.bind(path)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
let trash_drafts: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
)
.bind(path)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
let script = sqlx::query_scalar!(
"DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path",
path,
w_id
)
.fetch_one(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))?;
if !trash_scripts.is_empty() {
let mut trash_data = serde_json::json!({"scripts": trash_scripts});
if !trash_drafts.is_empty() {
trash_data["drafts"] = serde_json::Value::Array(trash_drafts);
}
windmill_common::trashbin::move_to_trash(
&mut *tx,
&w_id,
"script",
path,
w_id
trash_data,
&authed.username,
)
.fetch_one(&db)
.await
.map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))?
} else {
sqlx::query_scalar!(
"DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path",
path,
w_id
)
.fetch_one(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))?
};
.await?;
}
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
path,
w_id
)
.execute(&db)
.execute(&mut *tx)
.await?;
if !query.keep_captures.unwrap_or(false) {
@@ -2274,7 +2320,7 @@ async fn delete_script_by_path(
path,
w_id
)
.execute(&db)
.execute(&mut *tx)
.await?;
sqlx::query!(
@@ -2282,7 +2328,7 @@ async fn delete_script_by_path(
path,
w_id
)
.execute(&db)
.execute(&mut *tx)
.await?;
}
@@ -2369,6 +2415,30 @@ async fn delete_scripts_bulk(
let mut tx = db.begin().await?;
// Capture scripts for trashbin per path before bulk delete
for path in &request.paths {
let trash_scripts: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM script t WHERE path = $1 AND workspace_id = $2",
)
.bind(path)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
if !trash_scripts.is_empty() {
let trash_data = serde_json::json!({"scripts": trash_scripts});
windmill_common::trashbin::move_to_trash(
&mut *tx,
&w_id,
"script",
path,
trash_data,
&authed.username,
)
.await?;
}
}
let mut deleted_paths = sqlx::query_scalar!(
"DELETE FROM script WHERE workspace_id = $1 AND path = ANY($2) RETURNING path",
w_id,
@@ -2480,6 +2550,62 @@ async fn guard_script_from_debounce_data(ns: &NewScript) -> Result<()> {
}
}
#[derive(Serialize)]
struct DedicatedScriptDeps {
path: String,
language: ScriptLang,
workspace_dep_names: Vec<String>,
}
async fn list_dedicated_with_deps(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<DedicatedScriptDeps>> {
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query!(
"SELECT DISTINCT ON (path) path, language AS \"language: ScriptLang\", content FROM script
WHERE workspace_id = $1
AND archived = false
AND dedicated_worker = true
AND language = ANY($2::SCRIPT_LANG[])
ORDER BY path, created_at DESC",
&w_id,
&[
ScriptLang::Python3,
ScriptLang::Bun,
ScriptLang::Bunnative,
ScriptLang::Deno,
] as &[ScriptLang],
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
let result = rows
.into_iter()
.map(|row| {
let dep_names =
windmill_common::scripts::extract_workspace_dependencies_annotated_refs(
&row.language,
&row.content,
&row.path,
)
.map(|refs| refs.external)
.unwrap_or_default();
DedicatedScriptDeps {
path: row.path,
language: row.language,
workspace_dep_names: dep_names,
}
})
.collect();
Ok(Json(result))
}
// ============================================================================
// Temporary Raw Script Storage for CLI Lock Generation
// ============================================================================
@@ -2508,11 +2634,9 @@ async fn store_raw_script_temp(
.await?;
// Clean up old entries (1 week TTL)
sqlx::query!(
"DELETE FROM raw_script_temp WHERE created_at < NOW() - INTERVAL '1 week'"
)
.execute(&db)
.await?;
sqlx::query!("DELETE FROM raw_script_temp WHERE created_at < NOW() - INTERVAL '1 week'")
.execute(&db)
.await?;
Ok(Json(hash))
}
@@ -2560,7 +2684,7 @@ async fn diff_raw_scripts_with_deployed(
FROM script s \
WHERE s.path = local.path AND s.workspace_id = $3 AND s.archived = false \
ORDER BY s.created_at DESC LIMIT 1 \
) deployed ON deployed.deployed_hash = local.hash"
) deployed ON deployed.deployed_hash = local.hash",
)
.bind(&paths)
.bind(&hashes)
@@ -2582,7 +2706,7 @@ async fn diff_raw_scripts_with_deployed(
AND wd.language = $3::SCRIPT_LANG \
AND wd.name IS NOT DISTINCT FROM $4 \
AND encode(sha256(convert_to(wd.content, 'UTF8')), 'hex') = $5 \
)"
)",
)
.bind(&dep.path)
.bind(&w_id)

View File

@@ -45,8 +45,8 @@ use windmill_common::{
global_settings::{
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING,
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
WS_BASE_URL_SETTING,
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, WS_BASE_URL_SETTING,
},
instance_config::{self, ApplyMode, InstanceConfig},
server::Smtp,
@@ -58,7 +58,7 @@ pub fn global_service() -> Router {
let r = Router::new()
.route("/envs", get(get_local_settings))
.route(
"/global/:key",
"/global/{key}",
post(set_global_setting).get(get_global_setting),
)
.route("/list_global", get(list_global_settings))
@@ -80,7 +80,7 @@ pub fn global_service() -> Router {
.route("/test_critical_channels", post(test_critical_channels))
.route("/critical_alerts", get(get_critical_alerts))
.route(
"/critical_alerts/:id/acknowledge",
"/critical_alerts/{id}/acknowledge",
post(acknowledge_critical_alert),
)
.route(
@@ -92,7 +92,7 @@ pub fn global_service() -> Router {
post(refresh_custom_instance_user_pwd),
)
.route(
"/setup_custom_instance_pg_database/:name",
"/setup_custom_instance_pg_database/{name}",
post(setup_custom_instance_pg_database),
)
.route(
@@ -424,6 +424,74 @@ async fn run_setting_pre_write_hook(
}
}
}
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => {
let serde_json::Value::Bool(workspaced_route) = value else {
return Err(error::Error::BadRequest(format!(
"{} setting expected to be boolean",
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING
)));
};
if !*workspaced_route {
#[derive(Debug, Deserialize, Serialize)]
#[allow(unused)]
struct DuplicateRoute {
route_path: String,
workspace_id: String,
http_method: String,
}
let duplicate_routes = sqlx::query_as!(
DuplicateRoute,
r#"
SELECT
route_path,
workspace_id,
http_method::TEXT AS "http_method!"
FROM
http_trigger
WHERE
workspaced_route IS FALSE
AND route_path_key IN (
SELECT
route_path_key
FROM
http_trigger
WHERE
workspaced_route IS FALSE
GROUP BY
route_path_key, http_method
HAVING COUNT(*) > 1
)
ORDER BY route_path_key
"#
)
.fetch_all(db)
.await?;
if !duplicate_routes.is_empty() {
tracing::error!(
"Cannot disable {} setting as duplicate http routes were found: {:?}",
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
&duplicate_routes
);
#[derive(Serialize)]
struct ErrorResponse {
error: String,
details: Vec<DuplicateRoute>,
}
let error_response = ErrorResponse {
error: "Duplicate HTTP route paths detected".to_string(),
details: duplicate_routes,
};
return Err(error::Error::JsonErr(
serde_json::to_value(error_response).unwrap(),
));
}
}
}
_ => {}
}
Ok(())
@@ -541,6 +609,7 @@ pub async fn get_global_setting(
&& key != DISABLE_HUB_SETTING
&& key != EMAIL_DOMAIN_SETTING
&& key != APP_WORKSPACED_ROUTE_SETTING
&& key != HTTP_ROUTE_WORKSPACED_ROUTE_SETTING
&& key != WS_BASE_URL_SETTING
{
require_super_admin(&db, &authed.email).await?;
@@ -1124,6 +1193,59 @@ struct CachedResourceType {
description: Option<String>,
}
#[derive(serde::Deserialize)]
struct HubResourceTypeRaw {
id: i64,
name: String,
schema: Option<String>,
app: String,
description: Option<String>,
}
async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType>> {
let response = HTTP_CLIENT
.get(format!(
"{}/resource_types/list",
windmill_common::DEFAULT_HUB_BASE_URL
))
.header("Accept", "application/json")
.send()
.await
.map_err(|e| error::Error::InternalErr(format!("Failed to fetch from hub: {}", e)))?;
if !response.status().is_success() {
return Err(error::Error::InternalErr(format!(
"Hub returned status {}",
response.status()
)));
}
let raw_types: Vec<HubResourceTypeRaw> = response
.json()
.await
.map_err(|e| error::Error::InternalErr(format!("Failed to parse hub response: {}", e)))?;
Ok(raw_types
.into_iter()
.filter_map(|rt| {
let schema = match rt.schema {
Some(s) => match serde_json::from_str(&s) {
Ok(v) => Some(v),
Err(_) => return None,
},
None => None,
};
Some(CachedResourceType {
id: rt.id,
name: rt.name,
schema,
app: rt.app,
description: rt.description,
})
})
.collect())
}
async fn sync_cached_resource_types(
Extension(db): Extension<DB>,
authed: ApiAuthed,
@@ -1133,16 +1255,12 @@ async fn sync_cached_resource_types(
use windmill_common::worker::HUB_RT_CACHE_DIR;
let cache_path = format!("{}/resource_types.json", *HUB_RT_CACHE_DIR);
let content = tokio::fs::read_to_string(&cache_path).await.map_err(|e| {
error::Error::NotFound(format!(
"No cached resource types found at {}: {}",
cache_path, e
))
})?;
let cached_types: Vec<CachedResourceType> = serde_json::from_str(&content).map_err(|e| {
error::Error::InternalErr(format!("Failed to parse cached resource types: {}", e))
})?;
let cached_types = match tokio::fs::read_to_string(&cache_path).await {
Ok(content) => serde_json::from_str::<Vec<CachedResourceType>>(&content).map_err(|e| {
error::Error::InternalErr(format!("Failed to parse cached resource types: {}", e))
})?,
Err(_) => fetch_resource_types_from_hub().await?,
};
let mut synced_count = 0;

View File

@@ -34,3 +34,4 @@ time.workspace = true
tokio.workspace = true
tower-cookies.workspace = true
tracing.workspace = true
url.workspace = true

View File

@@ -49,13 +49,13 @@ use windmill_common::users::truncate_token;
use windmill_common::users::COOKIE_NAME;
use windmill_common::utils::paginate;
use windmill_common::worker::CLOUD_HOSTED;
use windmill_common::BASE_URL;
use windmill_common::{
auth::{get_folders_for_user, get_groups_for_user},
db::UserDB,
error::{self, Error, JsonResult, Result},
utils::{not_found_if_none, rd_string, require_admin, Pagination, StripPath},
};
use windmill_common::{BASE_URL, HUB_BASE_URL};
use windmill_git_sync::handle_deployment_metadata;
const COOKIE_PATH: &str = "/";
@@ -66,32 +66,32 @@ pub fn workspaced_service() -> Router {
.route("/list_usage", get(list_user_usage))
.route("/list_usernames", get(list_usernames))
.route("/exists", post(exists_username))
.route("/get/:user", get(get_workspace_user))
.route("/update/:user", post(update_workspace_user))
.route("/delete/:user", delete(delete_workspace_user))
.route("/convert_to_group/:user", post(convert_user_to_group))
.route("/is_owner/*path", get(is_owner_of_path))
.route("/whois/:username", get(whois))
.route("/get/{user}", get(get_workspace_user))
.route("/update/{user}", post(update_workspace_user))
.route("/delete/{user}", delete(delete_workspace_user))
.route("/convert_to_group/{user}", post(convert_user_to_group))
.route("/is_owner/{*path}", get(is_owner_of_path))
.route("/whois/{username}", get(whois))
.route("/whoami", get(whoami))
.route("/leave", post(leave_workspace))
.route("/username_to_email/:username", get(username_to_email))
.route("/username_to_email/{username}", get(username_to_email))
}
pub fn global_service() -> Router {
Router::new()
.route("/exists/:email", get(exists_email))
.route("/exists/{email}", get(exists_email))
.route("/email", get(get_email))
.route("/whoami", get(global_whoami))
.route("/list_invites", get(list_invites))
.route("/decline_invite", post(decline_invite))
.route("/accept_invite", post(accept_invite))
.route("/list_as_super_admin", get(list_users_as_super_admin))
.route("/set_login_type/:user", post(set_login_type))
.route("/update/:user", post(update_user))
.route("/delete/:user", delete(delete_user))
.route("/username_info/:user", get(get_instance_username_info))
.route("/set_login_type/{user}", post(set_login_type))
.route("/update/{user}", post(update_user))
.route("/delete/{user}", delete(delete_user))
.route("/username_info/{user}", get(get_instance_username_info))
.route("/tokens/create", post(create_token))
.route("/tokens/delete/:token_prefix", delete(delete_token))
.route("/tokens/delete/{token_prefix}", delete(delete_token))
.route("/tokens/list", get(list_tokens))
.route("/tokens/impersonate", post(impersonate))
.route("/usage", get(get_usage))
@@ -157,6 +157,7 @@ pub struct GlobalUserInfo {
operator_only: Option<bool>,
first_time_user: bool,
role_source: String,
disabled: bool,
}
#[derive(Serialize, Debug)]
@@ -213,6 +214,7 @@ pub struct EditUser {
pub is_super_admin: Option<bool>,
pub is_devops: Option<bool>,
pub name: Option<String>,
pub disabled: Option<bool>,
}
#[derive(Deserialize)]
@@ -396,7 +398,7 @@ async fn list_users_as_super_admin(
GlobalUserInfo,
"WITH active_users AS (SELECT distinct username as email FROM (SELECT username, timestamp, operation FROM audit_partitioned UNION ALL SELECT username, timestamp, operation FROM audit) AS a WHERE timestamp > NOW() - INTERVAL '1 month' AND (operation = 'users.login' OR operation = 'oauth.login' OR operation = 'users.token.refresh')),
authors as (SELECT distinct email FROM usr WHERE usr.operator IS false)
SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source
SELECT email, email NOT IN (SELECT email FROM authors) as operator_only, login_type::text, verified, super_admin, devops, name, company, username, first_time_user, role_source, disabled
FROM password
WHERE email IN (SELECT email FROM active_users)
ORDER BY super_admin DESC, devops DESC
@@ -409,7 +411,7 @@ async fn list_users_as_super_admin(
} else {
sqlx::query_as!(
GlobalUserInfo,
"SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \
"SELECT email, login_type::text, verified, super_admin, devops, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password ORDER BY super_admin DESC, devops DESC, email LIMIT \
$1 OFFSET $2",
per_page as i32,
offset as i32
@@ -577,12 +579,44 @@ async fn logout(
}
tx.commit().await?;
if let Some(rd) = rd {
Ok((StatusCode::TEMPORARY_REDIRECT, [(LOCATION, rd)]).into_response())
if is_valid_logout_redirect(&rd).await {
Ok((StatusCode::TEMPORARY_REDIRECT, [(LOCATION, rd)]).into_response())
} else {
tracing::warn!("Blocked logout redirect to non-whitelisted URL: {}", rd);
Ok((StatusCode::OK, "logged out successfully".to_string()).into_response())
}
} else {
Ok((StatusCode::OK, "logged out successfully".to_string()).into_response())
}
}
async fn is_valid_logout_redirect(rd: &str) -> bool {
// Allow relative paths (same-origin redirects)
if rd.starts_with('/') && !rd.starts_with("//") {
return true;
}
let parsed = match url::Url::parse(rd) {
Ok(u) => u,
Err(_) => return false,
};
let host: &str = match parsed.host_str() {
Some(h) => h,
None => return false,
};
if host == "windmill.dev" || host.ends_with(".windmill.dev") {
return true;
}
let hub_url = HUB_BASE_URL.read().await.clone();
if let Ok(hub_parsed) = url::Url::parse(&hub_url) {
if let Some(hub_host) = hub_parsed.host_str() {
if host == hub_host {
return true;
}
}
}
false
}
async fn whoami(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
@@ -625,7 +659,7 @@ async fn global_whoami(
) -> JsonResult<GlobalUserInfo> {
let user = sqlx::query_as!(
GlobalUserInfo,
"SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source FROM password WHERE \
"SELECT email, login_type::TEXT, super_admin, devops, verified, name, company, username, NULL::bool as operator_only, first_time_user, role_source, disabled FROM password WHERE \
email = $1",
email
)
@@ -648,6 +682,7 @@ async fn global_whoami(
operator_only: None,
first_time_user: false,
role_source: "manual".to_string(),
disabled: false,
}))
} else {
Err(user.unwrap_err())
@@ -1407,6 +1442,22 @@ async fn update_user(
.await?;
}
if let Some(d) = eu.disabled {
sqlx::query_scalar!(
"UPDATE password SET disabled = $1 WHERE email = $2",
d,
&email_to_update
)
.execute(&mut *tx)
.await?;
if d {
// Delete all tokens for immediate session revocation
sqlx::query!("DELETE FROM token WHERE email = $1", &email_to_update)
.execute(&mut *tx)
.await?;
}
}
audit_log(
&mut *tx,
&authed,
@@ -1429,6 +1480,9 @@ async fn delete_user(
require_super_admin(&db, &authed.email).await?;
let mut tx = db.begin().await?;
sqlx::query!("DELETE FROM token WHERE email = $1", &email_to_delete)
.execute(&mut *tx)
.await?;
sqlx::query!("DELETE FROM password WHERE email = $1", &email_to_delete)
.execute(&mut *tx)
.await?;
@@ -1687,7 +1741,7 @@ async fn login(
};
let email_w_h: Option<(String, String, bool)> = sqlx::query_as(
"SELECT email, password_hash, super_admin FROM password WHERE email = $1 AND login_type = \
'password'",
'password' AND disabled = false",
)
.bind(&email)
.fetch_optional(&mut *tx)
@@ -1776,7 +1830,7 @@ async fn refresh_token(
}
let super_admin = sqlx::query_scalar!(
"SELECT super_admin FROM password WHERE email = $1",
"SELECT super_admin FROM password WHERE email = $1 AND disabled = false",
&authed.email
)
.fetch_optional(&mut *tx)

View File

@@ -78,7 +78,8 @@ pub fn workspaced_service() -> Router {
.route("/delete_invite", post(delete_invite))
.route("/rebuild_dependency_map", post(rebuild_dependency_map))
.route("/get_dependency_map", get(get_dependency_map))
.route("/get_dependents/*imported_path", get(get_dependents))
.route("/get_dependents/{*imported_path}", get(get_dependents))
.route("/get_imports/{*importer_path}", get(get_imports))
.route("/get_dependents_amounts", post(get_dependents_amounts))
.route("/get_settings", get(get_settings))
.route(
@@ -151,14 +152,14 @@ pub fn workspaced_service() -> Router {
post(create_workspace_fork_branch),
)
.route(
"/reset_diff_tally/:fork_workspace_id",
"/reset_diff_tally/{fork_workspace_id}",
post(reset_workspace_diffs),
)
.route("/compare/:target_workspace_id", get(compare_workspaces))
.route("/compare/{target_workspace_id}", get(compare_workspaces))
.route("/protection_rules", get(list_protection_rules))
.route("/protection_rules", post(create_protection_rule))
.route(
"/protection_rules/:rule_name",
"/protection_rules/{rule_name}",
post(update_protection_rule).delete(delete_protection_rule),
)
.route("/log_chat", post(log_ai_chat))
@@ -175,9 +176,9 @@ pub fn global_service() -> Router {
.route("/exists", post(exists_workspace))
.route("/exists_username", post(exists_username))
.route("/allowed_domain_auto_invite", get(is_allowed_auto_domain))
.route("/unarchive/:workspace", post(unarchive_workspace))
.route("/unarchive/{workspace}", post(unarchive_workspace))
.route(
"/delete/:workspace",
"/delete/{workspace}",
delete(crate::workspaces_extra::delete_workspace),
)
.route(
@@ -651,25 +652,23 @@ async fn get_settings(
}
async fn get_copilot_settings_state(
authed: ApiAuthed,
_authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
Extension(db): Extension<DB>,
) -> JsonResult<CopilotSettingsState> {
let mut tx = user_db.begin(&authed).await?;
let workspace_ai_config = sqlx::query_scalar!(
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_optional(&mut *tx)
.fetch_optional(&db)
.await
.map_err(|e| Error::internal_err(format!("getting workspace ai settings: {e:#}")))?;
let workspace_ai_config = not_found_if_none(workspace_ai_config, "workspace settings", &w_id)?;
let instance_ai_config: Option<serde_json::Value> =
sqlx::query_scalar("SELECT value FROM global_settings WHERE name = 'ai_config'")
.fetch_optional(&mut *tx)
.fetch_optional(&db)
.await
.map_err(|e| Error::internal_err(format!("getting instance ai settings: {e:#}")))?;
tx.commit().await?;
Ok(Json(build_copilot_settings_state(
has_ai_providers(workspace_ai_config.as_ref()),
@@ -1690,6 +1689,18 @@ async fn check_git_sync_access(_db: &DB, _w_id: &str) -> Result<()> {
Ok(())
}
// Anchor the CE-only query for `cargo sqlx prepare` (which runs with --features enterprise)
#[cfg(feature = "enterprise")]
#[allow(dead_code)]
async fn _sqlx_anchor_ce_user_count(db: &DB, w_id: &str) {
let _ = sqlx::query_scalar!(
"SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
w_id
)
.fetch_one(db)
.await;
}
#[cfg(not(feature = "enterprise"))]
async fn check_git_sync_access(db: &DB, w_id: &str) -> Result<()> {
let user_count: i64 = sqlx::query_scalar!(
@@ -4346,6 +4357,30 @@ async fn get_dependents(
Ok(Json(dependents))
}
async fn get_imports(
Extension(db): Extension<DB>,
Path((w_id, importer_path)): Path<(String, String)>,
_authed: ApiAuthed,
) -> JsonResult<Vec<String>> {
tracing::debug!(
workspace_id = %w_id,
importer_path = %importer_path,
"API: Getting imports for importer path"
);
let imports = ScopedDependencyMap::get_imports(&importer_path, &w_id, &db).await?;
tracing::debug!(
workspace_id = %w_id,
importer_path = %importer_path,
imports_count = imports.len(),
"API: Found imports: {:?}",
imports
);
Ok(Json(imports))
}
#[derive(Serialize, Debug)]
struct DependentsAmount {
imported_path: String,

View File

@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.664.0
version: 1.666.0
title: Windmill API
contact:
@@ -588,6 +588,8 @@ paths:
type: boolean
name:
type: string
disabled:
type: boolean
responses:
"200":
description: user updated
@@ -2712,6 +2714,30 @@ paths:
items:
$ref: "#/components/schemas/DependencyDependent"
/w/{workspace}/workspaces/get_imports/{importer_path}:
get:
summary: get script imports for an importer path
operationId: getImports
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: importer_path
in: path
required: true
schema:
type: string
description: The script path to get imports for
responses:
"200":
description: list of imported script paths
content:
application/json:
schema:
type: array
items:
type: string
/w/{workspace}/workspaces/get_dependents_amounts:
post:
summary: get dependents amounts for multiple imported paths
@@ -4832,6 +4858,11 @@ paths:
mcp_server_url:
type: string
description: "MCP server URL for MCP OAuth token refresh"
scopes:
type: array
items:
type: string
description: "OAuth scopes to use for token refresh. Overrides instance-level scopes."
required:
- refresh_token
- expires_in
@@ -6940,6 +6971,60 @@ paths:
schema:
type: string
/w/{workspace}/scripts/list_dedicated_with_deps:
get:
summary: list dedicated worker scripts with workspace dependency annotations
operationId: listDedicatedWithDeps
tags:
- script
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: list of dedicated scripts with their workspace dependency names
content:
application/json:
schema:
type: array
items:
type: object
properties:
path:
type: string
language:
type: string
enum:
- python3
- deno
- go
- bash
- powershell
- postgresql
- mysql
- bigquery
- snowflake
- mssql
- graphql
- nativets
- bun
- bunnative
- php
- rust
- ansible
- csharp
- oracledb
- duckdb
- java
- ruby
workspace_dep_names:
type: array
items:
type: string
required:
- path
- language
- workspace_dep_names
/w/{workspace}/scripts/raw/p/{path}:
get:
summary: raw script by path
@@ -10644,6 +10729,10 @@ paths:
in: query
schema:
type: boolean
- name: fast
in: query
schema:
type: boolean
responses:
"200":
@@ -23412,6 +23501,8 @@ components:
role_source:
type: string
enum: ["manual", "instance_group"]
disabled:
type: boolean
required:
- email
@@ -23420,6 +23511,7 @@ components:
- verified
- first_time_user
- role_source
- disabled
Flow:
allOf:

View File

@@ -607,11 +607,11 @@ fn transform_fim_to_chat_completions(body: &Bytes) -> Result<(Bytes, String)> {
}
pub fn global_service() -> Router {
Router::new().route("/proxy/*ai", post(global_proxy).get(global_proxy))
Router::new().route("/proxy/{*ai}", post(global_proxy).get(global_proxy))
}
pub fn workspaced_service() -> Router {
let router = Router::new().route("/proxy/*ai", post(proxy).get(proxy));
let router = Router::new().route("/proxy/{*ai}", post(proxy).get(proxy));
#[cfg(feature = "bedrock")]
let router = router.route("/check_bedrock_credentials", get(check_bedrock_credentials));

View File

@@ -259,7 +259,7 @@ pub async fn get_approval_form_details(
}
};
let flow_value = &flow_data.flow;
let flow_value = flow_data.value();
let flow_step_id = flow_step_id.unwrap_or("");
let module = flow_value.modules.iter().find(|m| m.id == flow_step_id);

View File

@@ -83,48 +83,54 @@ pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_apps))
.route("/list_search", get(list_search_apps))
.route("/get/p/*path", get(get_app))
.route("/get/lite/*path", get(get_app_lite))
.route("/get/draft/*path", get(get_app_w_draft))
.route("/secret_of/*path", get(get_secret_id))
.route("/get/p/{*path}", get(get_app))
.route("/get/lite/{*path}", get(get_app_lite))
.route("/get/draft/{*path}", get(get_app_w_draft))
.route("/secret_of/{*path}", get(get_secret_id))
.route(
"/secret_of_latest_version/*path",
"/secret_of_latest_version/{*path}",
get(get_latest_version_secret_id),
)
.route("/get/v/*id", get(get_app_by_id))
.route("/get_data/v/*id", get(get_raw_app_data))
.route("/exists/*path", get(exists_app))
.route("/update/*path", post(update_app))
.route("/update_raw/*path", post(update_app_raw))
.route("/delete/*path", delete(delete_app))
.route("/get/v/{*id}", get(get_app_by_id))
.route("/get_data/v/{*id}", get(get_raw_app_data))
.route("/exists/{*path}", get(exists_app))
.route("/update/{*path}", post(update_app))
.route("/update_raw/{*path}", post(update_app_raw))
.route("/delete/{*path}", delete(delete_app))
.route("/create", post(create_app))
.route("/create_raw", post(create_app_raw))
.route("/history/p/*path", get(get_app_history))
.route("/get_latest_version/*path", get(get_latest_version))
.route("/history_update/a/:id/v/:version", post(update_app_history))
.route("/history/p/{*path}", get(get_app_history))
.route("/get_latest_version/{*path}", get(get_latest_version))
.route(
"/list_paths_from_workspace_runnable/:runnable_kind/*path",
"/history_update/a/{id}/v/{version}",
post(update_app_history),
)
.route(
"/list_paths_from_workspace_runnable/{runnable_kind}/{*path}",
get(list_paths_from_workspace_runnable),
)
.route("/custom_path_exists/*custom_path", get(custom_path_exists))
.route(
"/custom_path_exists/{*custom_path}",
get(custom_path_exists),
)
.route("/sign_s3_objects", post(sign_s3_objects))
}
pub fn unauthed_service() -> Router {
Router::new()
.route("/execute_component/*path", post(execute_component))
.route("/upload_s3_file/*path", post(upload_s3_file_from_app))
.route("/execute_component/{*path}", post(execute_component))
.route("/upload_s3_file/{*path}", post(upload_s3_file_from_app))
.route("/delete_s3_file", delete(delete_s3_file_from_app))
.route("/download_s3_file/*path", get(download_s3_file_from_app))
.route("/public_app/:secret", get(get_public_app_by_secret))
.route("/public_resource/*path", get(get_public_resource))
.route("/get_data/v/*id", get(get_raw_app_data))
.route("/download_s3_file/{*path}", get(download_s3_file_from_app))
.route("/public_app/{secret}", get(get_public_app_by_secret))
.route("/public_resource/{*path}", get(get_public_resource))
.route("/get_data/v/{*id}", get(get_raw_app_data))
}
pub fn global_service() -> Router {
Router::new()
.route("/hub/list", get(list_hub_apps))
.route("/hub/get/:id", get(get_hub_app_by_id))
.route("/hub/get_raw/:id", get(get_hub_raw_app_by_id))
.route("/hub/get/{id}", get(get_hub_app_by_id))
.route("/hub/get_raw/{id}", get(get_hub_raw_app_by_id))
}
#[derive(FromRow, Deserialize, Serialize)]
@@ -1451,6 +1457,30 @@ async fn delete_app(
let mut tx = user_db.begin(&authed).await?;
// Capture all related data for trashbin before deleting (CASCADE will remove app_version, etc.)
let trash_app: Option<serde_json::Value> =
sqlx::query_scalar("SELECT to_jsonb(t) FROM app t WHERE path = $1 AND workspace_id = $2")
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let trash_app_versions: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM app_version t WHERE app_id = (SELECT id FROM app WHERE path = $1 AND workspace_id = $2)",
)
.bind(path)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
let trash_drafts: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
)
.bind(path)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
path,
@@ -1467,6 +1497,25 @@ async fn delete_app(
.execute(&mut *tx)
.await?;
if let Some(app_data) = trash_app {
let mut trash_data = serde_json::json!({"row": app_data});
if !trash_app_versions.is_empty() {
trash_data["app_versions"] = serde_json::Value::Array(trash_app_versions);
}
if !trash_drafts.is_empty() {
trash_data["drafts"] = serde_json::Value::Array(trash_drafts);
}
windmill_common::trashbin::move_to_trash(
&mut *tx,
&w_id,
"app",
path,
trash_data,
&authed.username,
)
.await?;
}
audit_log(
&mut *tx,
&authed,

View File

@@ -451,8 +451,7 @@ where
}
}
#[axum::async_trait]
impl<S> FromRequest<S, axum::body::Body> for RawWebhookArgs
impl<S> FromRequest<S> for RawWebhookArgs
where
S: Send + Sync,
{

View File

@@ -19,7 +19,7 @@ use crate::db::ApiAuthed;
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_audit))
.route("/get/:id", get(get_audit))
.route("/get/{id}", get(get_audit))
}
async fn get_audit(

View File

@@ -93,22 +93,22 @@ pub fn workspaced_service() -> Router {
Router::new()
.route("/set_config", post(set_config))
.route(
"/ping_config/:trigger_kind/:runnable_kind/*path",
"/ping_config/{trigger_kind}/{runnable_kind}/{*path}",
post(ping_config),
)
.route("/get_configs/:runnable_kind/*path", get(get_configs))
.route("/list/:runnable_kind/*path", get(list_captures))
.route("/get_configs/{runnable_kind}/{*path}", get(get_configs))
.route("/list/{runnable_kind}/{*path}", get(list_captures))
.route(
"/move/:runnable_kind/*path",
"/move/{runnable_kind}/{*path}",
post(move_captures_and_configs),
)
.route("/:id", delete(delete_capture))
.route("/:id", get(get_capture))
.route("/{id}", delete(delete_capture))
.route("/{id}", get(get_capture))
}
pub fn workspaced_unauthed_service() -> Router {
let router = Router::new().route(
"/webhook/:runnable_kind/*path",
"/webhook/{runnable_kind}/{*path}",
head(|| async {}).post(webhook_payload),
);
@@ -118,12 +118,12 @@ pub fn workspaced_unauthed_service() -> Router {
))]
{
#[cfg(feature = "http_trigger")]
let router = router.route("/http/:runnable_kind/:path/*route_path", {
let router = router.route("/http/{runnable_kind}/{path}/{*route_path}", {
head(|| async {}).fallback(http_payload)
});
#[cfg(all(feature = "enterprise", feature = "gcp_trigger", feature = "private"))]
let router = router.route("/gcp/:runnable_kind/*path", post(gcp_payload));
let router = router.route("/gcp/{runnable_kind}/{*path}", post(gcp_payload));
router
}

View File

@@ -23,7 +23,7 @@ use windmill_common::{db::UserDB, error::Result, utils::StripPath};
pub fn workspaced_service() -> Router {
Router::new()
.route("/create", post(create_draft))
.route("/delete/:kind/*path", delete(delete_draft))
.route("/delete/{kind}/{*path}", delete(delete_draft))
}
#[derive(sqlx::Type, Serialize, Deserialize, Debug, PartialEq, Clone)]

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