Compare commits

...

34 Commits

Author SHA1 Message Date
HugoCasa
e033c73b79 chore: update ee-repo-ref to batch-pulling latest
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:07:36 +01:00
HugoCasa
8c3ac22d8d feat: add as_worker_tag() helper, benchmark results and model
- Extract bunnative→nativets tag logic into ScriptLang::as_worker_tag()
- Add benchmark results for batch pull vs direct SQL (1W and 3W)
- Add throughput model script comparing batch vs SQL at scale
- Add nativets_sleep benchmark script support

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 18:05:15 +01:00
HugoCasa
21398e5447 fix: use BATCH_PULL_URL env var and add JWT exp claim
- Replace BASE_INTERNAL_URL overloading with dedicated BATCH_PULL_URL
  env var for native workers' HTTP pull endpoint
- Add exp claim to JWT token (required by jsonwebtoken validation)
- Token expires in 30 days, renewed on worker restart

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 19:15:12 +01:00
HugoCasa
980cbcccf0 Merge remote-tracking branch 'origin/main' into batch-pulling 2026-03-05 17:14:53 +01:00
HugoCasa
dd422fcc5d fix: enable batch pull for worker-only mode with BASE_INTERNAL_URL
Native workers in Mode::Worker (no co-located server) can now use HTTP
batch pull when BASE_INTERNAL_URL is explicitly set pointing to the
remote server. The batch buffer itself only runs on the server side.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:33:20 +01:00
HugoCasa
876a9cfc8e feat: batch job pulling for native workers
Reduce DB polling overhead for native workers by batch-fetching jobs
server-side and serving them from an in-memory buffer via HTTP.

- Add batch_pull() in windmill-queue: single SELECT...FOR UPDATE SKIP LOCKED LIMIT N
- Add batch pull SQL helpers (make_batch_pull_query, format_batch_pull_query)
- OSS stubs for agent-workers accept batch_buffer parameter (4-tuple return)
- Native workers self-sign JWT and pull jobs via HTTP when co-located with server
- Add uses_batch_http_pull column to worker_ping for server-side tracking
- Worker pull loop: HTTP batch pull when client available, SQL otherwise

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 16:08:28 +01:00
Ruben Fiszel
86065aaac8 chore(main): release 1.651.1 (#8242)
* chore(main): release 1.651.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-03-05 14:51:38 +00:00
Ruben Fiszel
e3f4130c68 nits 2026-03-05 14:36:51 +00:00
Ruben Fiszel
2e582b1bc1 fix: prevent slow loading toast interval from leaking on promise cancellation (#8240)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 14:23:36 +00:00
Ruben Fiszel
2d583826dc fix: suppress unused variable warnings on windows builds (#8241)
* fix: suppress unused variable warnings on windows builds

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

* chore: update ee-repo-ref.txt to merged commit

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 15:17:51 +01:00
Ruben Fiszel
972ae7aa29 chore(main): release 1.651.0 (#8235)
* chore(main): release 1.651.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-03-05 13:42:07 +00:00
Ruben Fiszel
d46913b74a fix: write fallback package.json for codebase mode nsjail (#8239)
* fix: write fallback package.json for codebase mode to fix nsjail ERR_INVALID_PACKAGE_CONFIG

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

* test: add e2e tests for codebase mode with and without nsjail

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-05 13:35:53 +00:00
Roderik-WU
90f4c64ee1 fix(python-client): add delete_s3_object (#8216)
* Implement remove_s3_file method

Add method to permanently delete a file from S3 bucket.

* Add test for removing S3 file

Added a test case to verify removal of a file from S3.

* Add remove_s3_file function to delete S3 files

Added a function to permanently delete a file from the S3 bucket.

* Rename remove_s3_file to remove_3_object

* Rename remove_3_object to remove_s3_object

* Rename test method and update S3 object handling

* Rename remove_s3_object to delete_s3_object

* Rename test_remove_s3_object to test_delete_s3_object and remove_s3_object to delete_s3_object
2026-03-05 12:49:59 +00:00
hugocasa
a8cbe9396f fix: update CLI bun template to match UI template (#8238)
* fix: update CLI bun template to match UI template

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

* fix: simplify CLI bun template, only add mode comments

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 12:04:38 +00:00
centdix
ce041e8a5e feat: hash-based MCP tool names for long paths (#8133)
* feat: replace _TRUNC with hash-based MCP tool names (50 char limit)

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

* fix: reduce MCP tool name limit from 50 to 40 chars

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

* refactor: use path prefix filtering instead of separate DB query for hashed name resolution

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

* fix: remove long path warning from MCP token creation (hashing handles long names)

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

* refactor: unify tool prefix parsing and fix extract_path_prefix_from_hashed for Hs- names

- Replace `is_hashed_name` + `parse_hashed_name` with unified `parse_tool_prefix`
  that returns `(type_str, is_hub, is_hashed)` in one call
- Fix `extract_path_prefix_from_hashed` to dynamically determine prefix length
  (3 for `Hs-`, 2 for `S-`/`F-`) instead of hardcoding index 2
- Simplify `reverse_transform` to reuse `parse_tool_prefix`
- Add tests for invalid prefixes and `Hs-` prefix handling

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

* fix: escape LIKE wildcards in MCP hashed name path prefix query

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

* fix: respect favorites scope in hashed tool name resolution

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

* refactor: deduplicate MCP tool name resolution and rename get_path_or_id

- Extract `unescape_path` helper in transform.rs to deduplicate the
  3-step placeholder unescape logic
- Extract `find_matching_path` helper in runner.rs to deduplicate
  script/flow candidate matching via ToolableItem trait
- Remove verbose tracing::info! logs from hashed tool resolution hot path
- Fix doc comment referencing nonexistent `is_hashed_name` function
- Rename `get_path_or_id` to `get_transformed_path` for clarity

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

* fix: update stale doc comments to reflect MAX_PATH_LENGTH=40

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-05 12:04:20 +00:00
Ruben Fiszel
65082159d8 tighten volume limits (#8236)
* feat: add volume limits info in CE volumes drawer

Show an info alert in the volumes drawer when running in Community
Edition, mentioning the 20 volumes per workspace and 50 MB per file
limits. Update ee-repo-ref for companion EE changes.

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

* chore: update ee-repo-ref

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

* chore: update ee-repo-ref to a61366dd4d9e9b1f98a421aaa6d3f63194615275

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

Previous ee-repo-ref: 05385738e36e81f5bc51d15c0ca60bba30457c21

New ee-repo-ref: a61366dd4d9e9b1f98a421aaa6d3f63194615275

Automated by sync-ee-ref workflow.

---------

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-05 06:44:32 +00:00
Ruben Fiszel
5f0ef936d1 feat: add sandbox annotations, volume mounts, for AI sandbox starting with claude (#8058) 2026-03-05 06:19:51 +00:00
Ruben Fiszel
bee50b83d1 chore(main): release 1.650.0 (#8218)
* chore(main): release 1.650.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-03-05 05:29:05 +00:00
hugocasa
e56ccd200b feat: token expiration notifications (#8190)
* feat: add token expiration notifications via email, critical alerts, and webhooks

- Monitor loop checks for tokens expiring within 7 days and sends
  email notifications to token owners. Tracks notification state via
  new `expiry_notified` column on the token table to avoid duplicates.
- When tokens expire and are deleted, owners are also notified.
- Critical alerts (in-app UI) are gated behind a new instance setting
  `critical_alerts_on_token_expiry` (off by default); emails are
  always sent regardless of the setting.
- Add TokenExpiringSoon and TokenExpired webhook message variants for
  workspace webhook integrations.
- Frontend: show expiration badges and a warning banner on the tokens
  table for tokens expiring within 30 days.
- Exclude session and ephemeral tokens from all notifications.

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

* refactor: use separate token_expiry_notification table for dedup

- Replace `expiry_notified` column on token table with a dedicated
  `token_expiry_notification` table (token, expiration)
- Insert notification row on token creation via shared
  `register_token_expiry_notification()` helper
- Delete notification row atomically when sending the notification
- Clean up orphaned rows in `delete_expired_items()`
- No FK constraint to avoid cascade overhead on token deletions
- Add index on expiration column for efficient range queries

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

* fix: calendar-based expiration badge and move notification cleanup

- Fix daysUntilExpiration to compare calendar dates instead of time diff
- Move notification row cleanup from delete_expired_items to
  check_expiring_tokens to keep it off the hot path
- Use simple expiration <= now() index scan instead of NOT EXISTS join

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 05:22:46 +00:00
Ruben Fiszel
eab789beeb chore: upgrade rquickjs from 0.8 to 0.11 (#8233)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 05:13:42 +00:00
Ruben Fiszel
077779ec52 fix: improve windows compatibility
* ci: add Windows backend integration test workflow

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

* ci: temporarily add push trigger for testing

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

* ci: add --no-fail-fast to run all test binaries

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

* fix: Windows path handling for backend integration tests

- WINDMILL_DIR: use std::env::temp_dir() on Windows instead of /tmp/windmill
- HOME_ENV: fall back to USERPROFILE on Windows when HOME is not set
- loader.bun.js: normalize paths to forward slashes for consistent
  comparison with Bun's resolver output on Windows
- bun_executor.rs: convert job_dir to forward slashes in JS template
  strings to avoid backslash escape issues (\t -> tab, etc.)
- go_executor.rs: fix windows_gopath() double backslash bug (r"\\" -> "\\")
- bash_executor.rs: default to "bash" (in PATH) on Windows instead of /bin/bash

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

* fix: improve Windows diagnostics and fix onLoad handler

- Include path in create_directory_async/sync panic messages
- Add WINDMILL_DIR initialization debug output
- Fix loader.bun.js onLoad: use properly escaped regex instead of
  returning undefined (Bun requires onLoad to return an object)
- Add env var debug output to CI workflow

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

* fix: sanitize Windows-invalid characters in test worker names and fix cargo path

- Replace :: with __ in worker names (colons illegal in Windows dir names)
- Fix HOME_DIR to fall back to USERPROFILE on Windows
- Add PATH fallback for cargo discovery on Windows
- Add debug logging to bun loader for fetch errors

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

* fix: handle single colons in worker names, pass MSVC linker env vars, revert bun debug

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

* fix: use .exe binary name on Windows and normalize bun import URL paths

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

* fix: use absolute path for rust binary, normalize bun resolve paths

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

* fix: use .wurl extension instead of .url for bun import resolution on Windows

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

* fix: use custom namespace for bun plugin to bypass default file resolution

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

* fix: use virtual namespace for bun import resolution to avoid Windows path issues

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

* fix: handle Windows 8.3 paths and namespace-prefixed importers in bun loader

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

* fix: strip namespace prefix from args.path and handle absolute imports without leading slash in bun loader

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

* refactor: simplify bun loader and remove redundant cargo path lookups

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

* fix: use platform-specific cargo binary path with .exe on Windows

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

* refactor: replace HOME_DIR with HOME_ENV in rust_executor to remove duplication

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

* refactor: keep original bun loader on linux, use virtual namespace loader only on windows

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 20:20:18 +00:00
hugocasa
63ebae8829 feat: replace hub error toasts with warning alerts and add disable hub setting (#8225)
* feat: replace hub error toasts with warning alerts and add disable hub setting

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

* fix: guard hub script cache refresh when hub is disabled

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:12:00 +00:00
centdix
87ebeaa51d chore: make rust-analyzer plugin opt-in via USE_RUST_PLUGIN env var (#8227)
* feat: optionally enable rust-analyzer plugin in worktree settings

When USE_RUST_PLUGIN env var is set, the worktree-env script now includes
the rust-analyzer-lsp plugin in .claude/settings.local.json.

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

* chore: remove rust-analyzer plugin from default settings

The rust-analyzer plugin is now opt-in via USE_RUST_PLUGIN env var
in worktree-env, so it no longer needs to be in the shared settings.

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

* chore: add WM_CLONE_DB and USE_RUST_PLUGIN to wmdev startup envs

Defaults both to false so they can be toggled per-worktree.

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

* fix: use explicit truthy checks for WM_CLONE_DB and USE_RUST_PLUGIN

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 15:09:42 +00:00
hugocasa
62382fd286 fix: wrap set_encryption_key in a single database transaction (#8212)
Prevent workspace corruption when re-encryption fails mid-loop by
wrapping the key update and variable re-encryption in a single
transaction. If any step fails, the entire operation rolls back.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:53:56 +00:00
Ruben Fiszel
19c065bed5 fix: handle multipart stream errors gracefully instead of panicking (#8226)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:44:33 +00:00
hugocasa
164e499c64 feat: add variable and resource types to flow env variables (#8214)
* feat: add variable and resource types to flow env variables

Flow env variables can now reference workspace variables ($var:path)
and resources ($res:path) that are resolved at runtime. Adds Variable
and Resource type options to the flow env editor with ItemPicker and
ResourcePicker components, and resolves references in both the flow
worker (via transform_json) and the API fallback endpoint.

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

* fix(frontend): use inline DollarSign icon for variable picker

Replace the separate "Pick" button with the standard inline DollarSign
icon overlay that appears on hover, matching the existing ArgInput
pattern. Also add the icon to the string type input for quick variable
linking from any string field.

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

* refactor: simplify flow env var resolution and json_path handling in API

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

* fix(frontend): always show flow env variables in property picker

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

* fix: update flow_env openapi type to allow any JSON value

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

* refactor(frontend): remove redundant variable type from env var dropdown

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

* fix(frontend): use Label component and fix alert text in flow env vars editor

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

* fix(frontend): avoid redundant stringify/parse roundtrip in env type switch

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

* fix: address PR review comments for flow env vars

- Deduplicate db_authed in jobs.rs $var/$res resolution
- Add warn logging on variable/resource resolution failures
- Consolidate $effect blocks and remove auto-type-correction effect
- Make linked variable text a clickable link to variable editor
- Add hash-based variable editor opening on variables page

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

* perf: avoid cloning entire FlowValue to resolve flow_env references

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 14:20:50 +00:00
Ruben Fiszel
8a859ff7b9 add full-code app import with tabbed YAML/JSON format selection (#8224)
Combine YAML/JSON import into tabs within a single drawer (YAML default)
and add full-code app import option. Uses sessionStorage to persist import
data across the full page reload required by cross-origin isolation headers
when navigating to /apps_raw/add.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 13:29:51 +00:00
Guilhem
c9c3baecb3 add context menu with delete option to preprocessor nodes (#8223)
* fix: add context menu with delete option to preprocessor nodes

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

* feat: add delete styling and shortcuts to right-click context menu

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-04 12:48:02 +00:00
Pyra
baf2bcf14d feat: make WM_END_USER_EMAIL display users from different workspaces (#8208)
Signed-off-by: pyranota <pyra@duck.com>
2026-03-04 11:50:59 +00:00
claude[bot]
7fe1594d22 add data tables comment to scheduled poll templates (#8221)
Add a comment to each scheduled poll template (Python, Deno, Bun, Go)
mentioning that data tables can be used for more complex states, with
a link to the documentation.

Closes #8220

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-04 11:47:36 +00:00
Guilhem
c0c9388415 feat: add move, delete, and duplicate to flow node context menu (#8050)
* feat: add context menu, multi-select actions, and keyboard shortcuts to flow editor

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

* fix: address review feedback on context menu PR

- Revert accidental static import of @scalar/openapi-parser (keep lazy-loaded)
- Restore [data-context-menu] in portalDivs for clickOutside compatibility
- Make noteDisabled reactive ($derived) in ModuleNode
- Use platform-aware shortcut hint (⌫ on Mac, Del on Windows/Linux)
- Optimize resolveSelectedModuleIds with single-pass ancestor map

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

* fix: address additional review feedback on flow context menu PR

- Use $derived.by instead of $derived for computed bounds in SelectionBoundingBox
- Remove redundant structuredClone wrappers around $state.snapshot
- Add null guard for originalModules/targetModules in move handler
- Add upper-bound guard (n < 10000) to copyId loop
- Fix fragile toggle comparison in moveManager with full array equality

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

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:53:01 +00:00
Diego Imbert
4bf827bea4 feat: persistent Db manager state in URI (#8134)
* DB Manager state in URL

* Fix state not saving

* shorted uri params

* infer db_type from prefix

* Revert "infer db_type from prefix"

This reverts commit 7415fbed3d.

* dbm syntax

* infer database type

* Omit main and public

* remove legacy #dbmanager:

* Preserve hash

* nit

* Fix remaining dbManagerDrawer objects
2026-03-04 10:46:34 +00:00
Diego Imbert
53caecf1da feat: Ducklake typechecker (#8118)
* Typedchecked ducklake queries

* Display script preview error as SQL error

* Fix duplication

* fix replacer

* Revert "fix replacer"

This reverts commit c5492033c8.

* Don't recompile regex every call

* nit OOB

* avoid potential panic

* Apply suggestions from code review

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* safety throw

* Update backend/windmill-worker/src/duckdb_executor.rs

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* Try catch individual chunks in prepareDatatableQueries

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* format

* nit comment

* Revert "Try catch individual chunks in prepareDatatableQueries"

This reverts commit ae64a8ad27.

* Correct try catch

* better error messages

* nit unused variable

* comment

* handle non describable queries

* npm i

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-03-04 10:46:08 +00:00
Ruben Fiszel
424ca59dfe feat: make WINDMILL_DIR configurable via environment variable (#8215)
* fix: auto-heal corrupted python runtime cache on remote workers

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

* Revert "fix: auto-heal corrupted python runtime cache on remote workers"

This reverts commit 0ea013a554.

* feat: make WINDMILL_DIR configurable via environment variable

Allow users to configure the base directory for Windmill's tmp/cache files
via the WINDMILL_DIR env var (default: /tmp/windmill). This fixes Python
runtime cache corruption on RHEL systems where systemd-tmpfiles-clean
removes files from /tmp.

Converts TMP_DIR (renamed to WINDMILL_DIR) and all derived cache directory
constants from compile-time const &str (concatcp!) to runtime lazy_static
String values.

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

* chore: update ee ref

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

* chore: update ee ref

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

* fix: deref ERROR_DIR lazy_static for AsRef<Path> and Display traits

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

* chore: update ee ref to branch name for CI compatibility

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

* fix: deref lazy_static constants in all executor files

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

* chore: update ee ref

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

* chore: update ee ref

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

* chore: update ee ref

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

* fix: panic if WINDMILL_DIR has trailing slash

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

* fix: also reject trailing backslash in WINDMILL_DIR for Windows

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

* fix: deref GO_BIN_CACHE_DIR in test utils

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

* fix: replace remaining hardcoded /tmp/windmill paths and validate empty WINDMILL_DIR

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

* fix: nsjail powershell mount dst, Windows path assumptions, pwsh deref consistency

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

* fix: restore Windows /tmp path translation in go and bun executors

The Windows path translation replaces /tmp with the Windows temp dir
(e.g. C:\tmp) before normalizing slashes. Without this, the default
WINDMILL_DIR=/tmp/windmill produces paths without a drive letter on
Windows.

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

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

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

Previous ee-repo-ref: e8c03e16720833230ebd1878b4c63642ecc6c80f

New ee-repo-ref: 6fd5a2ce908235a17975ad4dbdf0051cd89334f3

Automated by sync-ee-ref workflow.

---------

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-04 08:53:25 +00:00
269 changed files with 9988 additions and 1485 deletions

View File

@@ -110,7 +110,6 @@
]
},
"enabledPlugins": {
"rust-analyzer-lsp@claude-plugins-official": true,
"typescript-lsp@claude-plugins-official": true,
"code-review@claude-plugins-official": true
}

View File

@@ -0,0 +1,165 @@
name: Backend integration tests (Windows)
on:
workflow_dispatch:
push:
branches:
- "ci-windows-tests"
env:
CARGO_INCREMENTAL: 0
SQLX_OFFLINE: true
DISABLE_EMBEDDING: true
jobs:
cargo_test_windows:
runs-on: blacksmith-16vcpu-windows-2025
steps:
- uses: actions/checkout@v4
- name: Read EE repo commit hash
shell: pwsh
run: |
$ee_repo_ref = Get-Content .\backend\ee-repo-ref.txt
echo "ee_repo_ref=$ee_repo_ref" | Out-File -FilePath $env:GITHUB_ENV -Append
- name: Checkout windmill-ee-private repository
uses: actions/checkout@v4
with:
repository: windmill-labs/windmill-ee-private
path: ./windmill-ee-private
ref: ${{ env.ee_repo_ref }}
token: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
fetch-depth: 0
- name: Substitute EE code
shell: bash
run: |
./backend/substitute_ee_code.sh --copy --dir ./windmill-ee-private
- name: Setup PostgreSQL
uses: ikalnytskyi/action-setup-postgres@v6
with:
username: postgres
password: changeme
database: windmill
port: 5432
- uses: actions-rust-lang/setup-rust-toolchain@v1
with:
cache-workspaces: backend
toolchain: 1.93.0
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "9.0.x"
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- uses: actions/setup-go@v2
with:
go-version: 1.21.5
- uses: oven-sh/setup-bun@v2
with:
bun-version: 1.3.10
- uses: actions/setup-node@v4
with:
node-version: "20"
- uses: astral-sh/setup-uv@v6.2.1
with:
version: "0.9.24"
- uses: shivammathur/setup-php@v2
with:
php-version: "8.3"
tools: composer
- name: Install windmill CLI
shell: bash
run: |
cd cli
bash gen_wm_client.sh
bun install
mkdir -p "$HOME/.local/bin"
printf '#!/bin/sh\nexec bun run "%s/cli/src/main.ts" "$@"\n' "$GITHUB_WORKSPACE" > "$HOME/.local/bin/wmill"
chmod +x "$HOME/.local/bin/wmill"
echo "$HOME/.local/bin" >> $GITHUB_PATH
- name: Install OpenSSL via vcpkg
run: |
vcpkg.exe install openssl-windows:x64-windows
vcpkg.exe install openssl:x64-windows-static
vcpkg.exe integrate install
- name: Get runtime paths
id: runtime-paths
shell: pwsh
run: |
echo "DENO_PATH=$($(Get-Command deno).Source)" >> $env:GITHUB_OUTPUT
echo "BUN_PATH=$($(Get-Command bun).Source)" >> $env:GITHUB_OUTPUT
echo "NODE_BIN_PATH=$($(Get-Command node).Source)" >> $env:GITHUB_OUTPUT
echo "GO_PATH=$($(Get-Command go).Source)" >> $env:GITHUB_OUTPUT
echo "UV_PATH=$($(Get-Command uv).Source)" >> $env:GITHUB_OUTPUT
echo "PHP_PATH=$($(Get-Command php).Source)" >> $env:GITHUB_OUTPUT
echo "COMPOSER_PATH=$($(Get-Command composer).Source)" >> $env:GITHUB_OUTPUT
echo "POWERSHELL_PATH=$($(Get-Command pwsh).Source)" >> $env:GITHUB_OUTPUT
echo "DOTNET_PATH=$($(Get-Command dotnet).Source)" >> $env:GITHUB_OUTPUT
- name: Build DuckDB FFI module
working-directory: backend/windmill-duckdb-ffi-internal
timeout-minutes: 30
run: |
cargo build --release -p windmill_duckdb_ffi_internal
New-Item -ItemType Directory -Path ..\target\debug -Force
Copy-Item target\release\windmill_duckdb_ffi_internal.dll ..\target\debug\
- name: Print runtime versions and env
shell: pwsh
run: |
deno --version
bun -v
node --version
go version
python3 --version
php --version
pwsh --version
dotnet --version
echo "TEMP=$env:TEMP"
echo "TMP=$env:TMP"
echo "USERPROFILE=$env:USERPROFILE"
echo "HOME=$env:HOME"
- name: cargo test
working-directory: backend
timeout-minutes: 60
env:
DATABASE_URL: postgres://postgres:changeme@localhost:5432/windmill
RUST_LOG: "off"
RUST_LOG_STYLE: never
CARGO_NET_GIT_FETCH_WITH_CLI: true
CARGO_BUILD_JOBS: 12
VCPKGRS_DYNAMIC: 1
OPENSSL_DIR: ${{ env.VCPKG_INSTALLATION_ROOT }}\installed\x64-windows-static
DENO_PATH: ${{ steps.runtime-paths.outputs.DENO_PATH }}
BUN_PATH: ${{ steps.runtime-paths.outputs.BUN_PATH }}
NODE_BIN_PATH: ${{ steps.runtime-paths.outputs.NODE_BIN_PATH }}
GO_PATH: ${{ steps.runtime-paths.outputs.GO_PATH }}
UV_PATH: ${{ steps.runtime-paths.outputs.UV_PATH }}
PHP_PATH: ${{ steps.runtime-paths.outputs.PHP_PATH }}
COMPOSER_PATH: ${{ steps.runtime-paths.outputs.COMPOSER_PATH }}
POWERSHELL_PATH: ${{ steps.runtime-paths.outputs.POWERSHELL_PATH }}
DOTNET_PATH: ${{ steps.runtime-paths.outputs.DOTNET_PATH }}
WMDEBUG_FORCE_V0_WORKSPACE_DEPENDENCIES: 1
WMDEBUG_FORCE_RUNNABLE_SETTINGS_V0: 1
WMDEBUG_FORCE_NO_LEGACY_DEBOUNCING_COMPAT: 1
run: >
cargo test
--no-fail-fast
--features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,csharp,php,quickjs,mcp,run_inline
--all
-- --nocapture --test-threads=10

View File

@@ -2,6 +2,8 @@ name: Windmill
startupEnvs:
CARGO_FEATURES: "quickjs"
WM_CLONE_DB: false
USE_RUST_PLUGIN: false
services:
- name: BE

View File

@@ -1,5 +1,49 @@
# Changelog
## [1.651.1](https://github.com/windmill-labs/windmill/compare/v1.651.0...v1.651.1) (2026-03-05)
### Bug Fixes
* prevent slow loading toast interval from leaking on promise cancellation ([#8240](https://github.com/windmill-labs/windmill/issues/8240)) ([2e582b1](https://github.com/windmill-labs/windmill/commit/2e582b1bc1c299388a3c97cfddff9d0eb92858f2))
* suppress unused variable warnings on windows builds ([#8241](https://github.com/windmill-labs/windmill/issues/8241)) ([2d58382](https://github.com/windmill-labs/windmill/commit/2d583826dc065c05684d4cd1d1510f0d1f2d9ae9))
## [1.651.0](https://github.com/windmill-labs/windmill/compare/v1.650.0...v1.651.0) (2026-03-05)
### Features
* add sandbox annotations, volume mounts, for AI sandbox starting with claude ([#8058](https://github.com/windmill-labs/windmill/issues/8058)) ([5f0ef93](https://github.com/windmill-labs/windmill/commit/5f0ef936d1d5d07d01c8e07e26ec254feebef8fb))
* hash-based MCP tool names for long paths ([#8133](https://github.com/windmill-labs/windmill/issues/8133)) ([ce041e8](https://github.com/windmill-labs/windmill/commit/ce041e8a5e7ff105df389875d9981f3843d4ce39))
### Bug Fixes
* **python-client:** add delete_s3_object ([#8216](https://github.com/windmill-labs/windmill/issues/8216)) ([90f4c64](https://github.com/windmill-labs/windmill/commit/90f4c64ee12e1d04ce846ff88d6658f667e194e0))
* update CLI bun template to match UI template ([#8238](https://github.com/windmill-labs/windmill/issues/8238)) ([a8cbe93](https://github.com/windmill-labs/windmill/commit/a8cbe9396ffc51140dce5582d57f4dc59873304e))
* write fallback package.json for codebase mode nsjail ([#8239](https://github.com/windmill-labs/windmill/issues/8239)) ([d46913b](https://github.com/windmill-labs/windmill/commit/d46913b74a0ffd41d2323e0355cc81954f09e29d))
## [1.650.0](https://github.com/windmill-labs/windmill/compare/v1.649.0...v1.650.0) (2026-03-05)
### Features
* add move, delete, and duplicate to flow node context menu ([#8050](https://github.com/windmill-labs/windmill/issues/8050)) ([c0c9388](https://github.com/windmill-labs/windmill/commit/c0c9388415716ce77d841bd08a46f94e0a529685))
* add variable and resource types to flow env variables ([#8214](https://github.com/windmill-labs/windmill/issues/8214)) ([164e499](https://github.com/windmill-labs/windmill/commit/164e499c64dc5eb76fcfb0f8cefbad2df244f610))
* Ducklake typechecker ([#8118](https://github.com/windmill-labs/windmill/issues/8118)) ([53caecf](https://github.com/windmill-labs/windmill/commit/53caecf1da8d76e246178dfb9b86d330f0ec52fd))
* make WINDMILL_DIR configurable via environment variable ([#8215](https://github.com/windmill-labs/windmill/issues/8215)) ([424ca59](https://github.com/windmill-labs/windmill/commit/424ca59dfe3e730f5388d9cac4ea7e69773614d3))
* make WM_END_USER_EMAIL display users from different workspaces ([#8208](https://github.com/windmill-labs/windmill/issues/8208)) ([baf2bcf](https://github.com/windmill-labs/windmill/commit/baf2bcf14da0c8c95bdbbf511fcaee48be33948b))
* persistent Db manager state in URI ([#8134](https://github.com/windmill-labs/windmill/issues/8134)) ([4bf827b](https://github.com/windmill-labs/windmill/commit/4bf827bea4d44aca8c5ff7aa67ad449dbcf00673))
* replace hub error toasts with warning alerts and add disable hub setting ([#8225](https://github.com/windmill-labs/windmill/issues/8225)) ([63ebae8](https://github.com/windmill-labs/windmill/commit/63ebae8829a6dc47a4e23c8670b514f042c9d4be))
* token expiration notifications ([#8190](https://github.com/windmill-labs/windmill/issues/8190)) ([e56ccd2](https://github.com/windmill-labs/windmill/commit/e56ccd200be29e6ac8ea2b04a341b1ce78a307f6))
### Bug Fixes
* handle multipart stream errors gracefully instead of panicking ([#8226](https://github.com/windmill-labs/windmill/issues/8226)) ([19c065b](https://github.com/windmill-labs/windmill/commit/19c065bed5468c484c8e7a50a6b79ab90153cc0e))
* improve windows compatibility ([077779e](https://github.com/windmill-labs/windmill/commit/077779ec52f7d3e5fcc93951544bf47bd6dc30b6))
* wrap set_encryption_key in a single database transaction ([#8212](https://github.com/windmill-labs/windmill/issues/8212)) ([62382fd](https://github.com/windmill-labs/windmill/commit/62382fd2869ea0190dd0c0b714f9cbd35ceddd7a))
## [1.649.0](https://github.com/windmill-labs/windmill/compare/v1.648.0...v1.649.0) (2026-03-03)

View File

@@ -262,6 +262,12 @@ COPY --from=oven/bun:1.3.10 /usr/local/bin/bun /usr/bin/bun
RUN bun install -g windmill-cli \
&& ln -s $(bun pm bin -g)/wmill /usr/bin/wmill
# Install Claude Code CLI (used by claude sandbox scripts)
# The installer puts the binary in ~/.local/bin/claude (symlink to ~/.local/share/claude/versions/*)
# Copy it to /usr/bin/claude so it's accessible inside nsjail sandbox (which mounts /usr but not /root)
RUN curl -fsSL https://claude.ai/install.sh | bash \
&& cp /root/.local/share/claude/versions/* /usr/bin/claude
COPY --from=php:8.3.7-cli /usr/local/bin/php /usr/bin/php
COPY --from=composer:2.7.6 /usr/bin/composer /usr/bin/composer

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET lease_until = now() + interval '60 seconds'\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3 AND lease_until > now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "00bf3dbd9d3f51dd7fdefcbd654d55e0379cc84188954037165cbe2d198ef71f"
}

View File

@@ -1,16 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT token\n FROM token\n WHERE token LIKE concat($1::text, '%')\n LIMIT 1\n ",
"query": "SELECT group_ FROM usr_to_group WHERE usr = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token",
"name": "group_",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
@@ -18,5 +19,5 @@
false
]
},
"hash": "90092c0b3f7612373fcc8fb7a966200118ab308430d4a0cbb5cb16c397246492"
"hash": "015a8551c646f9b027fc23752c5c5c81e520e3ca97dd1cd1e4ebfe3e46c4ad11"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT large_file_storage->>'volume_storage' FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "?column?",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "083d69abc8a662bb364cf43b8ffc6e9b159a54c179cecb108068597536835f7e"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT extra_perms FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "extra_perms",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "0afd4ae50ff7e1b0dcca4b483816c595401dd2e1f7699a28bf3b79db5e3841f4"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT created_by FROM volume WHERE name = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "created_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "0eb54f04a8185085b3f80772f5c28e666f6fbd1ec5ee9d30ee0cdb5e30a68750"
}

View File

@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by, lease_until, leased_by)\n VALUES ($1, $2, 0, $3, now() + interval '60 seconds', $4)\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET lease_until = now() + interval '60 seconds', leased_by = $4\n WHERE volume.lease_until IS NULL OR volume.lease_until < now()\n RETURNING name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "14004a7c1641a3157eddd571fea11a1dfb1422187200119268b2342b47a960c6"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email FROM token WHERE token = $1 AND (expiration > NOW() OR expiration IS NULL)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "19a7ebb2e7e8e57b6e7c974da8eb7c6841a5c4ff12ba7c12c73d691c49dd99ed"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET last_used_at = now() WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "1d2f765c2a71e1154ca5d9f5e52ef31e6d647377d37747f7bdc834748a59419e"
}

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by, last_used_at)\n VALUES ($1, $2, $3, $4, now())\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET size_bytes = $3, last_used_at = now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int8",
"Varchar"
]
},
"nullable": []
},
"hash": "1e9b9a02f45e6200f4d101bd5336fc8ce983f857339e6fccf799dc6587964aab"
}

View File

@@ -0,0 +1,25 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by, lease_until, leased_by)\n VALUES ($1, $2, 0, $3, now() + interval '60 seconds', $4)\n ON CONFLICT (workspace_id, name) DO UPDATE\n SET lease_until = now() + interval '60 seconds', leased_by = $4\n WHERE volume.lease_until IS NULL OR volume.lease_until < now()\n RETURNING name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "23f47f5207abe0cfaede197aeee485957990eb92fa3ce515895eab0d3f28bfdc"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "28df7bbe1f54f69640bc76def9e580b4c7ba25f279644e3233b63f4f6db0ad98"
}

View File

@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n CASE\n WHEN flow_version.id IS NOT NULL THEN\n flow_version.value -> 'flow_env' -> $3\n ELSE\n root_job.raw_flow -> 'flow_env' -> $3\n END AS \"flow_env: sqlx::types::Json<Box<RawValue>>\"\n FROM\n v2_job current_job\n JOIN\n v2_job root_job ON root_job.id = COALESCE(current_job.root_job, current_job.flow_innermost_root_job, current_job.parent_job, current_job.id)\n AND root_job.workspace_id = current_job.workspace_id\n LEFT JOIN\n flow_version ON flow_version.id = root_job.runnable_id\n AND flow_version.path = root_job.runnable_path\n AND flow_version.workspace_id = root_job.workspace_id\n WHERE\n current_job.id = $1 AND\n current_job.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "flow_env: sqlx::types::Json<Box<RawValue>>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "2f53576c2ad58abc24617e911e486d7c4b9bdb1e8fb1f7725060990ef8984943"
}

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume\n SET size_bytes = $3, file_count = $4,\n updated_at = now(), updated_by = $5, last_used_at = now(),\n lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int4",
"Varchar"
]
},
"nullable": []
},
"hash": "3955e57e216d169c30b1548a2252eb169329116cba57780fa90ecf2bdb910f34"
}

View File

@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO worker_ping (worker_instance, worker, ip, custom_tags, worker_group, dedicated_worker, dedicated_workers, wm_version, vcpus, memory, job_isolation, native_mode, uses_batch_http_pull) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) ON CONFLICT (worker)\n DO UPDATE set ip = EXCLUDED.ip, custom_tags = EXCLUDED.custom_tags, worker_group = EXCLUDED.worker_group, dedicated_workers = EXCLUDED.dedicated_workers, native_mode = EXCLUDED.native_mode, uses_batch_http_pull = EXCLUDED.uses_batch_http_pull",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"TextArray",
"Varchar",
"Varchar",
"TextArray",
"Varchar",
"Int8",
"Int8",
"Text",
"Bool",
"Bool"
]
},
"nullable": []
},
"hash": "3e8afd021088a99a24f27fa6f0a1b7f3edba3e9b834c814b464305bc2eb6ba80"
}

View File

@@ -0,0 +1,76 @@
{
"db_name": "PostgreSQL",
"query": "SELECT\n name as \"name!\",\n size_bytes as \"size_bytes!\",\n file_count as \"file_count!\",\n created_at as \"created_at!\",\n created_by as \"created_by!\",\n updated_at,\n updated_by,\n description as \"description!\",\n last_used_at,\n extra_perms as \"extra_perms!\"\n FROM (\n SELECT\n COALESCE(v.name, a.path) as name,\n COALESCE(v.size_bytes, 0) as size_bytes,\n COALESCE(v.file_count, 0) as file_count,\n COALESCE(v.created_at, a.min_created_at) as created_at,\n COALESCE(v.created_by, 'unknown') as created_by,\n v.updated_at,\n v.updated_by,\n COALESCE(v.description, '') as description,\n v.last_used_at,\n COALESCE(v.extra_perms, '{}'::jsonb) as extra_perms\n FROM (\n SELECT path, MIN(created_at) as min_created_at\n FROM asset\n WHERE workspace_id = $1 AND kind = 'volume'\n GROUP BY path\n ) a\n FULL OUTER JOIN volume v ON v.workspace_id = $1 AND v.name = a.path\n WHERE v.workspace_id = $1 OR a.path IS NOT NULL\n ) combined\n ORDER BY name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "size_bytes!",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "file_count!",
"type_info": "Int4"
},
{
"ordinal": 3,
"name": "created_at!",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "created_by!",
"type_info": "Varchar"
},
{
"ordinal": 5,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 6,
"name": "updated_by",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "description!",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "last_used_at",
"type_info": "Timestamptz"
},
{
"ordinal": 9,
"name": "extra_perms!",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null,
null,
null,
null,
null,
true,
true,
null,
true,
null
]
},
"hash": "40d0f6dca30456514cb85e36c6e367b27171894016c714e41497e69115be1468"
}

View File

@@ -15,7 +15,7 @@
]
},
"nullable": [
null
true
]
},
"hash": "5a219a2532517869578c4504ff3153c43903f929ae5d62fbba12610f89c36d55"

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM volume WHERE workspace_id = $1 AND name = $2\n AND (lease_until IS NULL OR lease_until < now())\n RETURNING name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "5af44b46a2e2f1a9adeb39013790be7046cf8789d842717b6c793c22a2a05daa"
}

View File

@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT created_by, extra_perms FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "extra_perms",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "6086849bb08e1b37d6693d2808767cd897dca4722e4f2076308afdb7ee9fc147"
}

View File

@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE worker_ping SET ping_at = now(), jobs_executed = $1, custom_tags = $2,\n occupancy_rate = $3, memory_usage = $4, wm_memory_usage = $5, vcpus = COALESCE($7, vcpus),\n memory = COALESCE($8, memory), occupancy_rate_15s = $9, occupancy_rate_5m = $10, occupancy_rate_30m = $11, native_mode = $12, uses_batch_http_pull = $13 WHERE worker = $6",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Int4",
"TextArray",
"Float4",
"Int8",
"Int8",
"Text",
"Int8",
"Int8",
"Float4",
"Float4",
"Float4",
"Bool",
"Bool"
]
},
"nullable": []
},
"hash": "6cd099d458ac380d5da27b9e69da035755496ea50f2b78fb9b1cd3a2eb7e7625"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) FROM volume WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "712092e5033bc6894025a55ebc58bca8450d09982e582266d215dff521256fa6"
}

View File

@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume\n SET size_bytes = $3, file_count = $4,\n updated_at = now(), last_used_at = now(),\n lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $5",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int4",
"Text"
]
},
"nullable": []
},
"hash": "75a03e9e4cba350a104e2e3a95de919cd25538c0b433bc29bb052c7a7b8568ca"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET lease_until = now() + interval '60 seconds'\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3 AND lease_until > now()",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "769035629df5a5034f64bf38992e142006825a3911addacdf1a026660b5e2b7f"
}

View File

@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM volume WHERE workspace_id = $1 AND name = $2 AND lease_until > now() AND leased_by = $3)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "78af8bdb6a3ee6396c54f87ff6403b566fc75e16e0b7a81204816fd50b3346a5"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "7ce06d4f623932fce12352be3a09ba8973a2ef1defa36c6d46d9c1c6406a7c33"
}

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by)\n VALUES ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int8",
"Varchar"
]
},
"nullable": []
},
"hash": "7e8e79a7d140be511cedbfe9ff8eea76a8a3079ce80c035087f797cdc410f35b"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT last_used_at FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "last_used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "803abdcd3614437b26c5d2e4f1ad75ca7014b431239ac1b681f2b26380c719c4"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET extra_perms = extra_perms - $1\n WHERE workspace_id = $2 AND name = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "82b3bd95e5d28c4cd4eedcae8cf050ba7b7e4d9eabba03be251ae9a8017b317d"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT leased_by FROM volume WHERE workspace_id = $1 AND name = $2 AND lease_until > now()",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "leased_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "88e25dc24bb06237b3677c947ee53fd6e9c7606231ad3c522e98cb1fcc14361a"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT count(*) FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "907241c195fea227e4a945ee472425e5f7600e28c728a06235f7ff430a4bd77a"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT size_bytes FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "size_bytes",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false
]
},
"hash": "94d6f598076ad67d68e6f01926c9fc2c73e855790e17abf5461b96ea30fbbdb7"
}

View File

@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET lease_until = NULL, leased_by = NULL\n WHERE workspace_id = $1 AND name = $2 AND leased_by = $3",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "9662f1e304124fa52db4aa1e80e03b2601630f2d31458bdaf70c2702b2998d89"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": []
},
"hash": "9e30b5545a51453205a713a6276156ada29ae320465d9790dce7e1e8a436d4de"
}

View File

@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT extra_perms, created_by FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "extra_perms",
"type_info": "Jsonb"
},
{
"ordinal": 1,
"name": "created_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "9f64d6ed0adb609ced1551563062550919fcac56deaf1b3cb36b3e15117936e7"
}

View File

@@ -0,0 +1,24 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by)\n VALUES ($1, $2, 0, $3)\n ON CONFLICT (workspace_id, name) DO NOTHING\n RETURNING name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": [
false
]
},
"hash": "a3970c15271a124307301c0dafa263e7168fa325c5ceb44e9dd1595bdb7e7ce6"
}

View File

@@ -0,0 +1,15 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO token_expiry_notification (token, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Timestamptz"
]
},
"nullable": []
},
"hash": "a4d973d0f1c293345ad2bfd2472da8d6a3b425ea0590a66f1db6692dd2ddb437"
}

View File

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

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO volume (workspace_id, name, size_bytes, created_by)\n VALUES ($1, $2, $3, $4)",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Int8",
"Varchar"
]
},
"nullable": []
},
"hash": "ab8daa93bc66d0142b9e9e8d7fa6719fc41b2ca5cb0b7ac5ad73ab01b650c935"
}

View File

@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM token WHERE expiration <= now()\n RETURNING substring(token for 10) as token_prefix, label, email, workspace_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token_prefix",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "label",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "workspace_id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
true,
true,
true
]
},
"hash": "bb446cbb20166f274a7ee6e88abaa27e233e60e18b3d35545005eb680701241f"
}

View File

@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "SELECT size_bytes, last_used_at FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "size_bytes",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "last_used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
true
]
},
"hash": "bc61ca62d8f71880facb5d701a6e78697414b35618c50f8693f4e804bf1d7dbb"
}

View File

@@ -1,25 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n CASE\n WHEN flow_version.id IS NOT NULL THEN\n (flow_version.value -> 'flow_env' -> $3) #> $4\n ELSE\n (root_job.raw_flow -> 'flow_env' -> $3) #> $4\n END AS \"flow_env: sqlx::types::Json<Box<RawValue>>\"\n FROM\n v2_job current_job\n JOIN\n v2_job root_job ON root_job.id = COALESCE(current_job.root_job, current_job.flow_innermost_root_job, current_job.parent_job, current_job.id)\n AND root_job.workspace_id = current_job.workspace_id\n LEFT JOIN\n flow_version ON flow_version.id = root_job.runnable_id\n AND flow_version.path = root_job.runnable_path\n AND flow_version.workspace_id = root_job.workspace_id\n WHERE\n current_job.id = $1 AND\n current_job.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "flow_env: sqlx::types::Json<Box<RawValue>>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Uuid",
"Text",
"Text",
"TextArray"
]
},
"nullable": [
null
]
},
"hash": "c23bea7db9623a60683596b7d6e689e2c0100c1569436a01b207876aaa470154"
}

View File

@@ -0,0 +1,47 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, name, size_bytes, created_by, last_used_at\n FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "size_bytes",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "created_by",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "last_used_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
false,
true
]
},
"hash": "d0869a340c8f34ca7a560d3b4c0070c9f117da3dd00ce3247c54a61052a6809c"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT leased_by FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "leased_by",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "d1ad2baf5e3a6f45f1f079d494e8d6affad03a1f388024806a5de3f9cc939c04"
}

View File

@@ -0,0 +1,38 @@
{
"db_name": "PostgreSQL",
"query": "DELETE FROM token_expiry_notification n\n USING token t\n WHERE n.token = t.token\n AND n.expiration > now()\n AND n.expiration <= now() + interval '7 days'\n RETURNING substring(t.token for 10) as token_prefix, t.label, t.email, t.workspace_id",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token_prefix",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "label",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "workspace_id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
true,
true,
true
]
},
"hash": "d7e9b69fef8369117ce057d01d87288b39ea7c802007f112eb3d62230d07abb6"
}

View File

@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT name, size_bytes FROM volume WHERE workspace_id = $1 ORDER BY name",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "size_bytes",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "dc18db954239c4ebdd3b46cfd34f33554794444f0dc4e2d2fec158eca5ebe865"
}

View File

@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE volume SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2::bool), true)\n WHERE workspace_id = $3 AND name = $4",
"describe": {
"columns": [],
"parameters": {
"Left": [
"TextArray",
"Bool",
"Text",
"Text"
]
},
"nullable": []
},
"hash": "eb79db2aeac7bf246ad56a5f116511b9d3183cb91b740a86944a77a2a964b57d"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT token as \"token!\"\n FROM token\n WHERE token LIKE concat($1::text, '%')\n LIMIT 1\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "token!",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "eba16eb819e2644284fb073c891706d78a6f24cb0e614d7d81ba1b643805bf06"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT permissioned_as FROM v2_job WHERE id = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "permissioned_as",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid",
"Text"
]
},
"nullable": [
false
]
},
"hash": "f0ac12b66c5d3cca680541aed04359b064baf73b890efdc25426261d4eadfee0"
}

View File

@@ -0,0 +1,41 @@
{
"db_name": "PostgreSQL",
"query": "SELECT size_bytes, file_count, leased_by, lease_until\n FROM volume WHERE workspace_id = $1 AND name = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "size_bytes",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "file_count",
"type_info": "Int4"
},
{
"ordinal": 2,
"name": "leased_by",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "lease_until",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
true,
true
]
},
"hash": "f7ba87d5804b9bc05e7156c7c18c5a30037abef63efb5b44dc535c5f45d62a06"
}

221
backend/Cargo.lock generated
View File

@@ -1900,7 +1900,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c"
dependencies = [
"once_cell",
"proc-macro-crate 3.4.0",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -2550,6 +2550,15 @@ dependencies = [
"unicode-segmentation",
]
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "cooked-waker"
version = "5.0.0"
@@ -8683,7 +8692,7 @@ dependencies = [
"darling 0.20.11",
"heck 0.5.0",
"num-bigint",
"proc-macro-crate 3.4.0",
"proc-macro-crate",
"proc-macro-error2",
"proc-macro2",
"quote",
@@ -9252,7 +9261,7 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
dependencies = [
"proc-macro-crate 3.4.0",
"proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -10331,16 +10340,6 @@ dependencies = [
"elliptic-curve",
]
[[package]]
name = "proc-macro-crate"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
dependencies = [
"once_cell",
"toml_edit 0.19.15",
]
[[package]]
name = "proc-macro-crate"
version = "3.4.0"
@@ -10710,9 +10709,9 @@ dependencies = [
[[package]]
name = "quote"
version = "1.0.44"
version = "1.0.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
dependencies = [
"proc-macro2",
]
@@ -11094,9 +11093,12 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]]
name = "relative-path"
version = "1.9.3"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
checksum = "bca40a312222d8ba74837cb474edef44b37f561da5f773981007a10bbaa992b0"
dependencies = [
"serde",
]
[[package]]
name = "rend"
@@ -11390,9 +11392,9 @@ dependencies = [
[[package]]
name = "rquickjs"
version = "0.8.1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d16661bff09e9ed8e01094a188b463de45ec0693ade55b92ed54027d7ba7c40c"
checksum = "c50dc6d6c587c339edb4769cf705867497a2baf0eca8b4645fa6ecd22f02c77a"
dependencies = [
"rquickjs-core",
"rquickjs-macro",
@@ -11400,26 +11402,27 @@ dependencies = [
[[package]]
name = "rquickjs-core"
version = "0.8.1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c8db6379e204ef84c0811e90e7cc3e3e4d7688701db68a00d14a6db6849087b"
checksum = "b8bf7840285c321c3ab20e752a9afb95548c75cd7f4632a0627cea3507e310c1"
dependencies = [
"async-lock",
"hashbrown 0.16.0",
"relative-path",
"rquickjs-sys",
]
[[package]]
name = "rquickjs-macro"
version = "0.8.1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6041104330c019fcd936026ae05e2446f5e8a2abef329d924f25424b7052a2f3"
checksum = "7106215ff41a5677b104906a13e1a440b880f4b6362b5dc4f3978c267fad2b80"
dependencies = [
"convert_case 0.6.0",
"convert_case 0.10.0",
"fnv",
"ident_case",
"indexmap 2.11.1",
"proc-macro-crate 1.3.1",
"proc-macro-crate",
"proc-macro2",
"quote",
"rquickjs-core",
@@ -11428,9 +11431,9 @@ dependencies = [
[[package]]
name = "rquickjs-sys"
version = "0.8.1"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bc352c6b663604c3c186c000cfcc6c271f4b50bc135a285dd6d4f2a42f9790a"
checksum = "27344601ef27460e82d6a4e1ecb9e7e99f518122095f3c51296da8e9be2b9d83"
dependencies = [
"cc",
]
@@ -15738,7 +15741,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-nats",
@@ -15770,6 +15773,7 @@ dependencies = [
"sql-builder",
"sqlx",
"strum 0.27.2",
"tar",
"tempfile",
"tikv-jemalloc-ctl",
"tikv-jemalloc-sys",
@@ -15795,14 +15799,16 @@ dependencies = [
"windmill-queue",
"windmill-runtime-nativets",
"windmill-test-utils",
"windmill-types",
"windmill-worker",
"windmill-worker-volumes",
"windows-service",
"windows-sys 0.52.0",
]
[[package]]
name = "windmill-alerting"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15815,7 +15821,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"argon2",
@@ -15949,11 +15955,12 @@ dependencies = [
"windmill-trigger-websocket",
"windmill-types",
"windmill-worker",
"windmill-worker-volumes",
]
[[package]]
name = "windmill-api-agent-workers"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15976,7 +15983,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -15989,7 +15996,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16015,7 +16022,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -16025,7 +16032,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16042,7 +16049,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"base64 0.22.1",
@@ -16065,7 +16072,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16088,7 +16095,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16104,7 +16111,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16124,7 +16131,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16144,7 +16151,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16158,7 +16165,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-nats",
@@ -16185,7 +16192,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16210,7 +16217,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"flate2",
@@ -16228,7 +16235,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16249,7 +16256,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16269,7 +16276,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16299,7 +16306,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16326,7 +16333,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"lazy_static",
"serde",
@@ -16338,7 +16345,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"argon2",
"axum 0.7.9",
@@ -16361,7 +16368,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16375,7 +16382,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"axum 0.7.9",
"chrono",
@@ -16383,6 +16390,7 @@ dependencies = [
"http 1.4.0",
"hyper 1.8.1",
"lazy_static",
"magic-crypt",
"regex",
"serde",
"serde_json",
@@ -16405,7 +16413,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"chrono",
"lazy_static",
@@ -16419,7 +16427,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -16438,7 +16446,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"aes-gcm",
"anyhow",
@@ -16537,7 +16545,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"chrono",
"itertools 0.14.0",
@@ -16556,7 +16564,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"regex",
"serde",
@@ -16571,7 +16579,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -16595,7 +16603,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"futures",
@@ -16612,7 +16620,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -16628,7 +16636,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16649,7 +16657,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -16680,7 +16688,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-oauth2",
@@ -16704,7 +16712,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-stream",
@@ -16738,7 +16746,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"futures",
@@ -16756,7 +16764,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -16765,7 +16773,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16777,7 +16785,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"serde_json",
@@ -16789,7 +16797,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"gosyn",
@@ -16801,7 +16809,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16813,7 +16821,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"serde_json",
@@ -16825,7 +16833,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"nu-parser",
@@ -16836,7 +16844,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16847,7 +16855,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16860,7 +16868,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -16884,7 +16892,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16898,7 +16906,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16915,7 +16923,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16930,7 +16938,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"lazy_static",
@@ -16949,7 +16957,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"serde",
@@ -16960,7 +16968,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -16997,7 +17005,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"const_format",
@@ -17035,7 +17043,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -17046,7 +17054,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-recursion",
@@ -17075,7 +17083,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"axum 0.7.9",
@@ -17098,7 +17106,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17131,7 +17139,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17151,7 +17159,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17185,7 +17193,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17220,7 +17228,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17243,7 +17251,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17267,7 +17275,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-nats",
@@ -17291,7 +17299,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17326,7 +17334,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17354,7 +17362,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-trait",
@@ -17377,7 +17385,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"bitflags 2.9.4",
@@ -17395,7 +17403,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.649.0"
version = "1.651.1"
dependencies = [
"anyhow",
"async-once-cell",
@@ -17495,9 +17503,28 @@ dependencies = [
"windmill-queue",
"windmill-runtime-nativets",
"windmill-types",
"windmill-worker-volumes",
"yaml-rust",
]
[[package]]
name = "windmill-worker-volumes"
version = "1.651.1"
dependencies = [
"bytes",
"futures",
"lazy_static",
"md-5 0.10.6",
"object_store",
"regex",
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
"windmill-common",
]
[[package]]
name = "windows"
version = "0.56.0"

View File

@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.649.0"
version = "1.651.1"
authors.workspace = true
edition.workspace = true
@@ -70,13 +70,14 @@ members = [
"./parsers/windmill-parser-py-imports",
"./parsers/windmill-sql-datatype-parser-wasm",
"./parsers/windmill-parser-yaml", "windmill-macros", "parsers/windmill-parser-nu",
"./windmill-worker-volumes",
"./windmill-test-utils",
"./windmill-api-integration-tests",
]
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.649.0"
version = "1.651.1"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -250,10 +251,13 @@ reqwest.workspace = true
windmill-queue = { workspace = true, features = ["failpoints"] }
windmill-dep-map.workspace = true
windmill-test-utils.workspace = true
windmill-worker-volumes.workspace = true
windmill-types.workspace = true
axum.workspace = true
serde.workspace = true
windmill-api-client.workspace = true
tempfile.workspace = true
tar.workspace = true
windmill-parser-ts.workspace = true
rumqttc.workspace = true
rdkafka.workspace = true
@@ -267,6 +271,7 @@ aws-credential-types.workspace = true
windmill-api = { path = "./windmill-api", default-features = false }
windmill-queue = { path = "./windmill-queue" }
windmill-worker = { path = "./windmill-worker" }
windmill-worker-volumes = { path = "./windmill-worker-volumes" }
windmill-dep-map = { path = "./windmill-dep-map" }
windmill-types = { path = "./windmill-types" }
windmill-common = { path = "./windmill-common", default-features = false }
@@ -439,6 +444,7 @@ base64 = "^0.22.1"
base32 = "^0"
hmac = "0.12.1"
sha2 = "0.10.6"
md-5 = "0.10.6"
sha1 = "0.10.6"
sqlx = { version = "0.8.0", features = [
"macros",
@@ -512,7 +518,7 @@ nu-parser = { version = "0.101.0", default-features = false }
globset = "0.4.16"
croner = "2.2.0"
rmcp = { version = "=0.15.0", features = ["client", "transport-streamable-http-client", "transport-streamable-http-client-reqwest"] }
rquickjs = { version = "0.8", features = ["futures", "parallel", "macro"] }
rquickjs = { version = "0.11", features = ["futures", "parallel", "macro"] }
process-wrap = { version = "8.2.1", features = ["tokio1"] }
systemstat = "0.2.4"

View File

@@ -1 +1 @@
9b3339730eb4bb0b564c7c56ac546f33fb3d8905
c3c543f4c60a8c4dfe0d912c79a051376fb091a9

View File

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

View File

@@ -0,0 +1,22 @@
-- Add 'volume' to the asset_kind enum
ALTER TYPE asset_kind ADD VALUE IF NOT EXISTS 'volume';
-- Volume metadata table
CREATE TABLE volume (
workspace_id VARCHAR(50) NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
file_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by VARCHAR(255) NOT NULL,
updated_at TIMESTAMPTZ,
updated_by VARCHAR(255),
description TEXT NOT NULL DEFAULT '',
lease_until TIMESTAMPTZ,
leased_by VARCHAR(255),
last_used_at TIMESTAMPTZ,
extra_perms JSONB NOT NULL DEFAULT '{}',
PRIMARY KEY (workspace_id, name)
);
CREATE INDEX idx_volume_last_used ON volume(workspace_id, last_used_at);

View File

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

View File

@@ -0,0 +1,8 @@
-- Tracks pending expiry notifications: row exists = not yet notified.
-- Deleted once the notification is sent. Orphaned rows are harmless (filtered out by the join).
CREATE TABLE token_expiry_notification (
token VARCHAR(255) PRIMARY KEY,
expiration TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_token_expiry_notification_expiration ON token_expiry_notification (expiration);

View File

@@ -0,0 +1 @@
ALTER TABLE worker_ping DROP COLUMN IF EXISTS uses_batch_http_pull;

View File

@@ -0,0 +1 @@
ALTER TABLE worker_ping ADD COLUMN IF NOT EXISTS uses_batch_http_pull BOOLEAN NOT NULL DEFAULT false;

View File

@@ -18,6 +18,7 @@ pub enum AssetKind {
Resource,
Ducklake,
DataTable,
Volume,
}
#[derive(Serialize, Debug, PartialEq, Clone)]
@@ -148,4 +149,5 @@ pub const ASSET_KINDS: &[(&str, AssetKind)] = &[
("$res:", AssetKind::Resource),
("ducklake://", AssetKind::Ducklake),
("datatable://", AssetKind::DataTable),
("volume://", AssetKind::Volume),
];

View File

@@ -38,11 +38,11 @@ use windmill_common::{
agent_workers::AgentConfig,
global_settings::{
APP_WORKSPACED_ROUTE_SETTING, BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING,
CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING,
CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING,
DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING,
ENV_SETTINGS, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING,
EXTRA_PIP_INDEX_URL_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING,
INSTANCE_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,
@@ -61,8 +61,9 @@ use windmill_common::{
MODE_AND_ADDONS,
},
worker::{
is_native_mode_from_env, reload_custom_tags_setting, Connection, HUB_CACHE_DIR,
HUB_RT_CACHE_DIR, NATIVE_MODE_RESOLVED, TMP_DIR, TMP_LOGS_DIR, WORKER_GROUP,
is_native_mode_from_env, reload_custom_tags_setting, Connection, HttpClient, HUB_CACHE_DIR,
HUB_RT_CACHE_DIR, NATIVE_MODE_RESOLVED, TMP_LOGS_DIR, USES_BATCH_HTTP_PULL, WINDMILL_DIR,
WORKER_GROUP,
},
KillpillSender, DEFAULT_HUB_BASE_URL, METRICS_ENABLED,
};
@@ -99,10 +100,10 @@ use crate::monitor::{
load_tag_per_workspace_enabled, load_tag_per_workspace_workspaces, monitor_db,
reload_app_workspaced_route_setting, reload_base_url_setting,
reload_bunfig_install_scopes_setting, reload_critical_alert_mute_ui_setting,
reload_critical_error_channels_setting, reload_extra_pip_index_url_setting,
reload_hub_api_secret_setting, reload_hub_base_url_setting, reload_job_default_timeout_setting,
reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key,
reload_npm_config_registry_setting, reload_otel_tracing_proxy_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_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,
};
@@ -238,8 +239,8 @@ async fn cache_hub_scripts(file_path: Option<String>) -> anyhow::Result<()> {
)
})?;
create_dir_all(HUB_CACHE_DIR)?;
create_dir_all(BUN_BUNDLE_CACHE_DIR)?;
create_dir_all(&*HUB_CACHE_DIR)?;
create_dir_all(&*BUN_BUNDLE_CACHE_DIR)?;
for path in paths.values() {
tracing::info!("Caching hub script at {path}");
@@ -249,7 +250,7 @@ async fn cache_hub_scripts(file_path: Option<String>) -> anyhow::Result<()> {
.as_ref()
.is_some_and(|x| x == &ScriptLang::Deno)
{
let job_dir = format!("{}/cache_init/{}", TMP_DIR, Uuid::new_v4());
let job_dir = format!("{}/cache_init/{}", *WINDMILL_DIR, Uuid::new_v4());
create_dir_all(&job_dir)?;
let _ = windmill_worker::generate_deno_lock(
&Uuid::nil(),
@@ -267,7 +268,7 @@ async fn cache_hub_scripts(file_path: Option<String>) -> anyhow::Result<()> {
tokio::fs::remove_dir_all(job_dir).await?;
} else if res.language.as_ref().is_some_and(|x| x == &ScriptLang::Bun) {
let job_id = Uuid::new_v4();
let job_dir = format!("{}/cache_init/{}", TMP_DIR, job_id);
let job_dir = format!("{}/cache_init/{}", *WINDMILL_DIR, job_id);
create_dir_all(&job_dir)?;
if let Some(lock) = res.lockfile {
let _ = windmill_worker::prepare_job_dir(&lock, &job_dir).await?;
@@ -384,9 +385,9 @@ async fn cache_hub_resource_types() -> anyhow::Result<()> {
println!("Fetched {} resource types from hub", resource_types.len());
create_dir_all(HUB_RT_CACHE_DIR)?;
create_dir_all(&*HUB_RT_CACHE_DIR)?;
let cache_path = format!("{}/{}", HUB_RT_CACHE_DIR, HUB_RT_CACHE_FILE);
let cache_path = format!("{}/{}", *HUB_RT_CACHE_DIR, HUB_RT_CACHE_FILE);
let content = serde_json::to_string_pretty(&resource_types)
.with_context(|| "Failed to serialize resource types")?;
@@ -398,7 +399,7 @@ async fn cache_hub_resource_types() -> anyhow::Result<()> {
}
pub async fn sync_cached_resource_types(db: &sqlx::Pool<sqlx::Postgres>) -> anyhow::Result<()> {
let cache_path = format!("{}/{}", HUB_RT_CACHE_DIR, HUB_RT_CACHE_FILE);
let cache_path = format!("{}/{}", *HUB_RT_CACHE_DIR, HUB_RT_CACHE_FILE);
if tokio::fs::metadata(&cache_path).await.is_err() {
tracing::info!(
@@ -920,6 +921,20 @@ Windmill Community Edition {GIT_VERSION}
default_base_internal_url.clone()
};
// BATCH_PULL_URL: explicit URL for native workers to pull jobs via HTTP.
// In standalone mode (server_mode=true), defaults to the local server.
let batch_pull_url: Option<String> = if is_native_mode_from_env() {
if let Ok(url) = std::env::var("BATCH_PULL_URL") {
Some(url)
} else if server_mode {
Some(default_base_internal_url.clone())
} else {
None
}
} else {
None
};
initial_load(
&conn,
killpill_tx.clone(),
@@ -969,7 +984,7 @@ Windmill Community Edition {GIT_VERSION}
DirBuilder::new()
.recursive(true)
.create("/tmp/windmill")
.create(&*WINDMILL_DIR)
.expect("could not create initial server dir");
#[cfg(feature = "tantivy")]
@@ -1130,6 +1145,30 @@ Windmill Community Edition {GIT_VERSION}
)?;
let mut workers = vec![];
// For native workers, create a self-signed JWT for batch pulling via HTTP.
// Enabled when BATCH_PULL_URL is set (explicitly or auto-detected in standalone mode).
let batch_pull_client = if let Some(ref pull_url) = batch_pull_url {
match create_native_batch_pull_client(pull_url).await {
Ok(client) => {
tracing::info!(
"Native batch pull client created for HTTP pull at {}",
pull_url
);
USES_BATCH_HTTP_PULL
.store(true, std::sync::atomic::Ordering::Relaxed);
Some(client)
}
Err(e) => {
tracing::warn!(
"Failed to create native batch pull client, falling back to SQL pull: {e:#}"
);
None
}
}
} else {
None
};
for i in 0..num_workers {
let suffix = if i == 0 && first_suffix.is_some() {
first_suffix.as_ref().unwrap().clone()
@@ -1153,6 +1192,7 @@ Windmill Community Edition {GIT_VERSION}
WORKER_GROUP.as_str(),
&suffix,
),
batch_pull_client: batch_pull_client.clone(),
};
workers.push(worker_conn);
}
@@ -1717,6 +1757,11 @@ async fn process_notify_event(
tracing::error!(error = %e, "Could not reload critical alert UI setting");
}
}
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING => {
if let Err(e) = reload_critical_alerts_on_token_expiry_setting(conn).await {
tracing::error!(error = %e, "Could not reload critical alerts on token expiry setting");
}
}
"workspace_telemetry_enabled" => {
// Read the new value from the database and log it
let enabled = sqlx::query_scalar!(
@@ -1761,6 +1806,7 @@ fn display_config(envs: &[&str]) {
pub struct WorkerConn {
conn: Connection,
worker_name: String,
batch_pull_client: Option<HttpClient>,
}
pub async fn run_workers(
@@ -1794,27 +1840,27 @@ pub async fn run_workers(
let mut handles = Vec::with_capacity(num_workers as usize);
for x in [
TMP_LOGS_DIR,
UV_CACHE_DIR,
DENO_CACHE_DIR,
DENO_CACHE_DIR_DEPS,
DENO_CACHE_DIR_NPM,
BUN_CACHE_DIR,
PY310_CACHE_DIR,
PY311_CACHE_DIR,
PY312_CACHE_DIR,
PY313_CACHE_DIR,
BUN_BUNDLE_CACHE_DIR,
GO_CACHE_DIR,
GO_BIN_CACHE_DIR,
RUST_CACHE_DIR,
CSHARP_CACHE_DIR,
NU_CACHE_DIR,
HUB_CACHE_DIR,
POWERSHELL_CACHE_DIR,
JAVA_CACHE_DIR,
RUBY_CACHE_DIR,
TAR_JAVA_CACHE_DIR, // for related places search: ADD_NEW_LANG
&*TMP_LOGS_DIR,
&*UV_CACHE_DIR,
&*DENO_CACHE_DIR,
&*DENO_CACHE_DIR_DEPS,
&*DENO_CACHE_DIR_NPM,
&*BUN_CACHE_DIR,
&*PY310_CACHE_DIR,
&*PY311_CACHE_DIR,
&*PY312_CACHE_DIR,
&*PY313_CACHE_DIR,
&*BUN_BUNDLE_CACHE_DIR,
&*GO_CACHE_DIR,
&*GO_BIN_CACHE_DIR,
&*RUST_CACHE_DIR,
&*CSHARP_CACHE_DIR,
&*NU_CACHE_DIR,
&*HUB_CACHE_DIR,
&*POWERSHELL_CACHE_DIR,
&*JAVA_CACHE_DIR,
&*RUBY_CACHE_DIR,
&*TAR_JAVA_CACHE_DIR, // for related places search: ADD_NEW_LANG
] {
DirBuilder::new()
.recursive(true)
@@ -1831,6 +1877,7 @@ pub async fn run_workers(
let wk_conf = &workers[i as usize - 1];
let conn1 = wk_conf.conn.clone();
let worker_name = wk_conf.worker_name.clone();
let batch_pull_client = wk_conf.batch_pull_client.clone();
WORKERS_NAMES.write().await.push(worker_name.clone());
let ip = ip.clone();
let rx = killpill_rxs.pop().unwrap();
@@ -1853,6 +1900,7 @@ pub async fn run_workers(
rx,
tx,
&base_internal_url,
batch_pull_client.as_ref(),
);
// #[cfg(tokio_unstable)]
@@ -1871,6 +1919,41 @@ pub async fn run_workers(
Ok(())
}
/// Create an HTTP client for native workers to pull jobs from the local server's batch buffer.
/// Self-signs a JWT with native_mode=true using the same JWT secret the server uses.
async fn create_native_batch_pull_client(base_internal_url: &str) -> anyhow::Result<HttpClient> {
use windmill_common::agent_workers::{build_agent_http_client, AGENT_JWT_PREFIX};
use windmill_common::jwt::encode_with_internal_secret;
#[derive(serde::Serialize)]
struct NativeAgentAuth {
worker_group: String,
tags: Vec<String>,
native_mode: Option<bool>,
exp: usize,
}
let worker_config = windmill_common::worker::WORKER_CONFIG.read().await;
let tags = worker_config.worker_tags.clone();
drop(worker_config);
// Token expires in 30 days — renewed on restart
let exp = (chrono::Utc::now() + chrono::Duration::days(30)).timestamp() as usize;
let claims = NativeAgentAuth {
worker_group: WORKER_GROUP.to_string(),
tags,
native_mode: Some(true),
exp,
};
let jwt = encode_with_internal_secret(claims).await?;
let token = format!("{}{}", AGENT_JWT_PREFIX, jwt);
let suffix = create_default_worker_suffix(&HOSTNAME);
Ok(build_agent_http_client(&suffix, &token, base_internal_url))
}
async fn send_delayed_killpill(tx: &KillpillSender, mut max_delay_secs: u64, context: &str) {
if max_delay_secs == 0 {
max_delay_secs = 1;

View File

@@ -44,19 +44,20 @@ use windmill_common::{
apps::APP_WORKSPACED_ROUTE,
auth::create_token_for_owner,
ee_oss::CriticalErrorChannel,
email_oss::send_email_if_possible,
error,
flow_status::{FlowStatus, FlowStatusModule},
global_settings::{
BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, CRITICAL_ERROR_CHANNELS_SETTING,
DEFAULT_TAGS_PER_WORKSPACE_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING,
EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING,
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MONITOR_LOGS_ON_OBJECT_STORE_SETTING,
NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING, NUGET_CONFIG_SETTING, OTEL_SETTING,
OTEL_TRACING_PROXY_SETTING, PIP_INDEX_URL_SETTING, POWERSHELL_REPO_PAT_SETTING,
POWERSHELL_REPO_URL_SETTING, REQUEST_SIZE_LIMIT_SETTING,
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
CRITICAL_ERROR_CHANNELS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
DEFAULT_TAGS_WORKSPACES_SETTING, EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING,
EXTRA_PIP_INDEX_URL_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING,
INSTANCE_PYTHON_VERSION_SETTING, JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING,
JWT_SECRET_SETTING, KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NPMRC_SETTING, NPM_CONFIG_REGISTRY_SETTING,
NUGET_CONFIG_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,
SAML_METADATA_SETTING, SCIM_TOKEN_SETTING, TIMEOUT_WAIT_RESULT_SETTING,
UV_INDEX_STRATEGY_SETTING,
@@ -73,13 +74,14 @@ use windmill_common::{
load_periodic_bash_script_interval_from_env, load_whitelist_env_vars_from_env,
load_worker_config, reload_custom_tags_setting, store_pull_query,
store_suspended_pull_query, Connection, WorkerConfig, DEFAULT_TAGS_PER_WORKSPACE,
DEFAULT_TAGS_WORKSPACES, INDEXER_CONFIG, SCRIPT_TOKEN_EXPIRY, SMTP_CONFIG, TMP_DIR,
DEFAULT_TAGS_WORKSPACES, INDEXER_CONFIG, SCRIPT_TOKEN_EXPIRY, SMTP_CONFIG, WINDMILL_DIR,
WORKER_CONFIG, WORKER_GROUP,
},
KillpillSender, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, CRITICAL_ALERT_MUTE_UI_ENABLED,
CRITICAL_ERROR_CHANNELS, DB, DEFAULT_HUB_BASE_URL, HUB_BASE_URL, JOB_RETENTION_SECS,
METRICS_DEBUG_ENABLED, METRICS_ENABLED, MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED,
OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED, SERVICE_LOG_RETENTION_SECS,
KillpillSender, BASE_URL, CRITICAL_ALERTS_ON_DB_OVERSIZE, CRITICAL_ALERTS_ON_TOKEN_EXPIRY,
CRITICAL_ALERT_MUTE_UI_ENABLED, CRITICAL_ERROR_CHANNELS, DB, DEFAULT_HUB_BASE_URL,
HUB_BASE_URL, JOB_RETENTION_SECS, METRICS_DEBUG_ENABLED, METRICS_ENABLED,
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};
#[cfg(feature = "parquet")]
@@ -207,6 +209,10 @@ pub async fn initial_load(
tracing::error!("Error loading critical alert mute ui setting: {e:#}");
}
if let Err(e) = reload_critical_alerts_on_token_expiry_setting(conn).await {
tracing::error!("Error loading critical alerts on token expiry setting: {e:#}");
}
if let Some(db) = conn.as_sql() {
if let Err(e) = load_tag_per_workspace_enabled(db).await {
tracing::error!("Error loading default tag per workpsace: {e:#}");
@@ -477,6 +483,21 @@ pub async fn reload_critical_alert_mute_ui_setting(conn: &Connection) -> error::
Ok(())
}
pub async fn reload_critical_alerts_on_token_expiry_setting(
conn: &Connection,
) -> error::Result<()> {
if let Ok(Some(serde_json::Value::Bool(t))) = load_value_from_global_settings_with_conn(
conn,
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING,
true,
)
.await
{
CRITICAL_ALERTS_ON_TOKEN_EXPIRY.store(t, Ordering::Relaxed);
}
Ok(())
}
pub async fn load_metrics_debug_enabled(conn: &Connection) -> error::Result<()> {
let metrics_enabled =
load_value_from_global_settings_with_conn(conn, EXPOSE_DEBUG_METRICS_SETTING, true).await;
@@ -595,7 +616,7 @@ async fn sleep_until_next_minute_start_plus_one_s() {
use windmill_common::tracing_init::TMP_WINDMILL_LOGS_SERVICE;
async fn find_two_highest_files(hostname: &str) -> (Option<String>, Option<String>) {
let log_dir = format!("{}/{}/", TMP_WINDMILL_LOGS_SERVICE, hostname);
let log_dir = format!("{}/{}/", *TMP_WINDMILL_LOGS_SERVICE, hostname);
let rd_dir = tokio::fs::read_dir(log_dir).await;
if let Ok(mut log_files) = rd_dir {
let mut highest_file: Option<String> = None;
@@ -614,7 +635,8 @@ async fn find_two_highest_files(hostname: &str) -> (Option<String>, Option<Strin
(highest_file, second_highest_file)
} else {
tracing::error!(
"Error reading log files: {TMP_WINDMILL_LOGS_SERVICE}, {:#?}",
"Error reading log files: {}, {:#?}",
*TMP_WINDMILL_LOGS_SERVICE,
rd_dir.unwrap_err()
);
(None, None)
@@ -716,7 +738,7 @@ async fn send_log_file_to_object_store(
let s3_client = windmill_object_store::get_object_store().await;
#[cfg(feature = "parquet")]
if let Some(s3_client) = s3_client {
let path = std::path::Path::new(TMP_WINDMILL_LOGS_SERVICE)
let path = std::path::Path::new(&*TMP_WINDMILL_LOGS_SERVICE)
.join(hostname)
.join(&highest_file);
@@ -844,18 +866,82 @@ struct LogFile {
hostname: String,
}
struct TokenRow {
token_prefix: Option<String>,
label: Option<String>,
email: Option<String>,
workspace_id: Option<String>,
}
fn is_user_token(label: Option<&str>) -> bool {
match label {
None => true,
Some(l) => l != "session" && !l.starts_with("ephemeral") && !l.starts_with("Ephemeral"),
}
}
async fn report_token_expiration(db: &DB, token: &TokenRow, expired: bool) {
if !is_user_token(token.label.as_deref()) {
return;
}
let prefix = token.token_prefix.as_deref().unwrap_or("??????????");
let email_addr = token.email.as_deref().unwrap_or("unknown");
let token_desc = match token.label.as_deref() {
Some(l) if !l.is_empty() => format!("'{l}' ({prefix}****)"),
_ => format!("{prefix}****"),
};
let (alert_message, email_subject, email_body) = if expired {
(
format!(
"API token {token_desc} of '{email_addr}' has expired and been deleted"
),
"Windmill: Your API token has expired",
format!(
"Your API token {token_desc} has expired and been deleted.\n\nPlease create a new token if you still need API access."
),
)
} else {
(
format!("API token {token_desc} of '{email_addr}' is expiring soon"),
"Windmill: Your API token is expiring soon",
format!(
"Your API token {token_desc} is expiring soon.\n\nPlease rotate or renew your token to avoid service disruption."
),
)
};
tracing::info!("{}", alert_message);
if CRITICAL_ALERTS_ON_TOKEN_EXPIRY.load(Ordering::Relaxed) {
report_critical_error(
alert_message,
db.clone(),
token.workspace_id.as_deref(),
None,
)
.await;
}
if let Some(email) = &token.email {
send_email_if_possible(email_subject, &email_body, email);
}
}
pub async fn delete_expired_items(db: &DB) -> () {
let tokens_deleted_r: std::result::Result<Vec<String>, _> = sqlx::query_scalar(
let expired_tokens_r = sqlx::query_as!(
TokenRow,
"DELETE FROM token WHERE expiration <= now()
RETURNING concat(substring(token for 10), '*****')",
RETURNING substring(token for 10) as token_prefix, label, email, workspace_id",
)
.fetch_all(db)
.await;
match tokens_deleted_r {
match expired_tokens_r {
Ok(tokens) => {
if tokens.len() > 0 {
tracing::info!("deleted {} tokens: {:?}", tokens.len(), tokens)
if !tokens.is_empty() {
tracing::info!("deleted {} expired tokens", tokens.len());
for t in &tokens {
report_token_expiration(db, t, true).await;
}
}
}
Err(e) => tracing::error!("Error deleting token: {}", e.to_string()),
@@ -935,7 +1021,7 @@ pub async fn delete_expired_items(db: &DB) -> () {
.iter()
.map(|f| format!("{}/{}", f.hostname, f.file_path))
.collect();
delete_log_files_from_disk_and_store(paths, TMP_WINDMILL_LOGS_SERVICE, windmill_common::tracing_init::LOGS_SERVICE).await;
delete_log_files_from_disk_and_store(paths, &*TMP_WINDMILL_LOGS_SERVICE, windmill_common::tracing_init::LOGS_SERVICE).await;
}
Err(e) => tracing::error!("Error deleting log file: {:?}", e),
@@ -1064,6 +1150,41 @@ pub async fn delete_expired_items(db: &DB) -> () {
}
}
pub async fn check_expiring_tokens(db: &DB) {
// Find tokens expiring within 7 days that still have a pending notification row
let expiring_tokens_r = sqlx::query_as!(
TokenRow,
"DELETE FROM token_expiry_notification n
USING token t
WHERE n.token = t.token
AND n.expiration > now()
AND n.expiration <= now() + interval '7 days'
RETURNING substring(t.token for 10) as token_prefix, t.label, t.email, t.workspace_id",
)
.fetch_all(db)
.await;
match expiring_tokens_r {
Ok(tokens) => {
for t in &tokens {
report_token_expiration(db, t, false).await;
}
if !tokens.is_empty() {
tracing::info!("Sent expiration warnings for {} token(s)", tokens.len());
}
}
Err(e) => tracing::error!("Error checking expiring tokens: {}", e),
}
// Clean up notification rows whose expiration has passed
if let Err(e) = sqlx::query!("DELETE FROM token_expiry_notification WHERE expiration <= now()")
.execute(db)
.await
{
tracing::error!("Error cleaning up expired token notifications: {}", e);
}
}
/// Delete a batch of expired jobs with LIMIT and SKIP LOCKED for high-scale environments.
/// Uses a single transaction per batch to minimize lock duration.
/// Returns the number of jobs deleted in this batch.
@@ -1140,7 +1261,7 @@ async fn delete_expired_jobs_batch(
.filter_map(|opt| opt)
.flat_map(|inner_vec| inner_vec.into_iter())
.collect();
delete_log_files_from_disk_and_store(paths, TMP_DIR, "").await;
delete_log_files_from_disk_and_store(paths, &*WINDMILL_DIR, "").await;
}
Err(e) => tracing::error!("Error deleting job logs: {:?}", e),
}
@@ -1367,7 +1488,7 @@ pub async fn reload_maven_settings_xml_setting(conn: &Connection) {
let settings_xml = MAVEN_SETTINGS_XML.read().await.clone();
match settings_xml {
Some(ref content) if !content.trim().is_empty() => {
let m2_dir = format!("{JAVA_HOME_DIR}/.m2");
let m2_dir = format!("{}/.m2", *JAVA_HOME_DIR);
if let Err(e) = tokio::fs::create_dir_all(&m2_dir).await {
tracing::error!("Failed to create .m2 directory: {e:#}");
return;
@@ -1378,7 +1499,7 @@ pub async fn reload_maven_settings_xml_setting(conn: &Connection) {
}
}
_ => {
let settings_path = format!("{JAVA_HOME_DIR}/.m2/settings.xml");
let settings_path = format!("{}/.m2/settings.xml", *JAVA_HOME_DIR);
let _ = tokio::fs::remove_file(&settings_path).await;
}
}
@@ -2051,6 +2172,16 @@ pub async fn monitor_db(
}
};
// Run every hour (10 iterations * 30s = 5 minutes)
// Check for tokens expiring within 7 days and send alerts
let check_expiring_tokens_f = async {
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(10) {
if let Some(db) = conn.as_sql() {
check_expiring_tokens(&db).await;
}
}
};
join!(
expired_items_f,
zombie_jobs_f,
@@ -2072,6 +2203,7 @@ pub async fn monitor_db(
cleanup_worker_group_stats_f,
native_triggers_sync_f,
cleanup_notify_events_f,
check_expiring_tokens_f,
);
}

View File

@@ -151,6 +151,8 @@ sqs_trigger: path(char), queue_url(char), aws_resource_path(char), message_attri
FK: (workspace_id) -> workspace(id)
token: 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(char), expiration(ts)
INDEX: idx_token_expiry_notification_expiration (expiration)
tutorial_progress: email(char), progress(bit64), skipped_all(bool)
unique_ext_jwt_token: jwt_hash(bigint), last_used_at(ts)
usage: id(char), is_workspace(bool), month_(int), usage(int)
@@ -172,7 +174,7 @@ websocket_trigger: path(char), url(char), script_path(char), is_flow(bool), work
windmill_migrations: name(text), created_at(ts)
worker_group_job_stats: hour(bigint), worker_group(text), script_lang(char), workspace_id(char), job_count(int), total_duration_ms(bigint)
FK: (workspace_id) -> workspace(id)
worker_ping: worker(char), worker_instance(char), ping_at(ts), started_at(ts), ip(char), jobs_executed(int), custom_tags(text[]), worker_group(char), dedicated_worker(char), wm_version(char), current_job_id(uuid), current_job_workspace_id(char), vcpus(bigint), memory(bigint), occupancy_rate(float), memory_usage(bigint), wm_memory_usage(bigint), occupancy_rate_15s(float), occupancy_rate_5m(float), occupancy_rate_30m(float), job_isolation(text), dedicated_workers(text[])
worker_ping: worker(char), worker_instance(char), ping_at(ts), started_at(ts), ip(char), jobs_executed(int), custom_tags(text[]), worker_group(char), dedicated_worker(char), wm_version(char), current_job_id(uuid), current_job_workspace_id(char), vcpus(bigint), memory(bigint), occupancy_rate(float), memory_usage(bigint), wm_memory_usage(bigint), occupancy_rate_15s(float), occupancy_rate_5m(float), occupancy_rate_30m(float), job_isolation(text), dedicated_workers(text[]), native_mode(bool), uses_batch_http_pull(bool)
workspace: id(char), name(char), owner(char), deleted(bool), premium(bool), parent_workspace_id(char)
FK: (parent_workspace_id) -> workspace(id)
workspace_dependencies: id(bigint), name(char), content(text), language(script_lang), description(text), archived(bool), workspace_id(char), created_at(ts)

View File

@@ -1,12 +1,12 @@
#![cfg(all(feature = "private", feature = "agent_worker_server"))]
use windmill_test_utils::*;
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::{
jobs::{JobPayload, RawCode},
scripts::ScriptLang,
};
use windmill_test_utils::*;
fn bun_code(code: &str) -> RawCode {
RawCode {
@@ -18,8 +18,8 @@ fn bun_code(code: &str) -> RawCode {
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
concurrency_settings:
windmill_common::runnable_settings::ConcurrencySettings::default().into(),
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
}
}
@@ -223,7 +223,10 @@ async fn test_agent_worker_token_and_ping(db: Pool<Postgres>) -> anyhow::Result<
.fetch_one(&db)
.await?;
assert!(worker_count > 0, "worker ping should be recorded in database");
assert!(
worker_count > 0,
"worker ping should be recorded in database"
);
// MainLoop ping updates the existing record
let resp = http_client
@@ -265,3 +268,319 @@ async fn test_agent_worker_multiple_jobs_sequential(db: Pool<Postgres>) -> anyho
Ok(())
}
/// Test the volume HTTP proxy endpoints that agent workers use.
///
/// Exercises the full volume lifecycle via HTTP:
/// 1. Configure workspace S3 storage (FilesystemStorage)
/// 2. Pre-populate a volume with a file
/// 3. POST /begin — acquire lease, get manifest
/// 4. GET /file/* — download existing file
/// 5. PUT /file/* — upload a new file
/// 6. POST /commit — finalize with stats, release lease
/// 7. Verify DB state and storage
#[cfg(feature = "parquet")]
#[sqlx::test(fixtures("base"))]
async fn test_agent_worker_volume_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
let (client, _port, _server) = init_client_agent_mode(db.clone()).await;
// 1. Set up filesystem-based object storage in a temp dir
let storage_dir = tempfile::tempdir()?;
let storage_root = storage_dir.path().to_string_lossy().to_string();
let lfs_config = json!({
"type": "FilesystemStorage",
"root_path": storage_root,
"public_resource": null,
"advanced_permissions": null
});
sqlx::query!(
"UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2",
lfs_config,
"test-workspace"
)
.execute(&db)
.await?;
// 2. Pre-populate the volume with a file
let vol_dir = storage_dir.path().join("volumes").join("test-vol");
std::fs::create_dir_all(&vol_dir)?;
std::fs::write(vol_dir.join("hello.txt"), b"hello from volume")?;
let base = client.baseurl();
let http = client.client();
let vol_base = format!("{base}/w/test-workspace/volumes/test-vol");
// 3. POST /begin — acquire lease, get manifest + permissions
let resp = http
.post(format!("{vol_base}/begin"))
.json(&json!({
"worker_name": "test-worker-1",
"permissioned_as": "u/test-user"
}))
.send()
.await?;
assert!(
resp.status().is_success(),
"begin should succeed, got: {}",
resp.status()
);
let begin_body: serde_json::Value = resp.json().await?;
assert!(
begin_body["writable"].as_bool().unwrap(),
"should be writable"
);
let manifest = begin_body["manifest"].as_object().unwrap();
assert!(
manifest.contains_key("hello.txt"),
"manifest should contain hello.txt, got: {manifest:?}"
);
// 4. GET /file/* — download the existing file
let resp = http
.get(format!("{vol_base}/file/hello.txt"))
.send()
.await?;
assert!(
resp.status().is_success(),
"file download should succeed, got: {}",
resp.status()
);
let file_bytes = resp.bytes().await?;
assert_eq!(
file_bytes.as_ref(),
b"hello from volume",
"downloaded file content should match"
);
// 5. PUT /file/* — upload a new file
let resp = http
.put(format!("{vol_base}/file/output.txt"))
.body(b"written by agent worker".to_vec())
.send()
.await?;
assert!(
resp.status().is_success(),
"file upload should succeed, got: {}",
resp.status()
);
// 6. POST /commit — finalize: report stats, release lease
let resp = http
.post(format!("{vol_base}/commit"))
.json(&json!({
"worker_name": "test-worker-1",
"deleted_keys": [],
"symlinks": {},
"file_count": 2,
"size_bytes": 39
}))
.send()
.await?;
assert!(
resp.status().is_success(),
"commit should succeed, got: {}",
resp.status()
);
// 7. Verify volume DB row was updated
let vol_row = sqlx::query!(
"SELECT size_bytes, file_count, leased_by, lease_until
FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"test-vol"
)
.fetch_optional(&db)
.await?;
let vol_row = vol_row.expect("volume row should exist");
assert_eq!(vol_row.file_count, 2, "file_count should be 2");
assert_eq!(vol_row.size_bytes, 39, "size_bytes should match");
assert!(vol_row.leased_by.is_none(), "lease should be released");
assert!(
vol_row.lease_until.is_none() || vol_row.lease_until.unwrap() < chrono::Utc::now(),
"lease_until should be cleared or in the past"
);
// 8. Verify the uploaded file was persisted in storage
let output_path = vol_dir.join("output.txt");
assert!(output_path.exists(), "output.txt should be in storage");
let output_content = std::fs::read_to_string(&output_path)?;
assert_eq!(output_content, "written by agent worker");
Ok(())
}
/// Full E2E test: agent worker in HTTP mode runs a Bun script with a volume mount.
///
/// The worker pulls the job via HTTP, downloads volume files via the server-side
/// volume proxy endpoints, executes the script, and syncs changes back.
#[cfg(all(feature = "parquet", feature = "enterprise"))]
#[sqlx::test(fixtures("base"))]
async fn test_agent_worker_volume_http_worker_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
let (_client, port, _server) = init_client_agent_mode(db.clone()).await;
// 1. Set up filesystem-based object storage in a temp dir
let storage_dir = tempfile::tempdir()?;
let storage_root = storage_dir.path().to_string_lossy().to_string();
let lfs_config = json!({
"type": "FilesystemStorage",
"root_path": storage_root,
"public_resource": null,
"advanced_permissions": null
});
sqlx::query!(
"UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2",
lfs_config,
"test-workspace"
)
.execute(&db)
.await?;
// 2. Pre-populate the volume with a file
let vol_dir = storage_dir.path().join("volumes").join("test-vol");
std::fs::create_dir_all(&vol_dir)?;
std::fs::write(vol_dir.join("hello.txt"), b"hello from volume")?;
// 3. Push the job, then run worker with HTTP connection (bun tag)
let code = r#"// volume: test-vol /tmp/data
import { readFileSync, writeFileSync, existsSync } from "fs";
export function main() {
const content = readFileSync("/tmp/data/hello.txt", "utf-8");
writeFileSync("/tmp/data/output.txt", "written by agent worker");
return {
read_content: content,
output_exists: existsSync("/tmp/data/output.txt"),
};
}"#;
let uuid = RunJob::from(JobPayload::Code(bun_code(code)))
.push(&db)
.await;
let listener = listen_for_completed_jobs(&db).await;
let conn = testing_http_connection_with_tags(
port,
vec!["bun".into(), "flow".into(), "dependency".into()],
)
.await;
in_test_worker(conn, listener.find(&uuid), port).await;
let result = completed_job(uuid, &db).await;
assert!(result.success, "job should succeed: {:?}", result.result);
let json = result.json_result().expect("should have JSON result");
assert_eq!(json["read_content"], json!("hello from volume"));
assert_eq!(json["output_exists"], json!(true));
// 4. Verify volume DB row was updated
let vol_row = sqlx::query!(
"SELECT size_bytes, file_count, leased_by, lease_until
FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"test-vol"
)
.fetch_optional(&db)
.await?;
let vol_row = vol_row.expect("volume row should exist");
assert!(
vol_row.file_count >= 2,
"should have at least 2 files (hello.txt + output.txt), got: {}",
vol_row.file_count
);
assert!(vol_row.size_bytes > 0, "size_bytes should be > 0");
assert!(vol_row.leased_by.is_none(), "lease should be released");
assert!(
vol_row.lease_until.is_none() || vol_row.lease_until.unwrap() < chrono::Utc::now(),
"lease_until should be cleared or in the past"
);
// 5. Verify the new file was written back to the storage
let output_path = vol_dir.join("output.txt");
assert!(
output_path.exists(),
"output.txt should be synced back to storage"
);
let output_content = std::fs::read_to_string(&output_path)?;
assert_eq!(output_content, "written by agent worker");
Ok(())
}
/// Test the volume release endpoint (error/cancel path).
#[cfg(feature = "parquet")]
#[sqlx::test(fixtures("base"))]
async fn test_agent_worker_volume_release(db: Pool<Postgres>) -> anyhow::Result<()> {
let (client, _port, _server) = init_client_agent_mode(db.clone()).await;
// Set up filesystem storage
let storage_dir = tempfile::tempdir()?;
let storage_root = storage_dir.path().to_string_lossy().to_string();
let lfs_config = json!({
"type": "FilesystemStorage",
"root_path": storage_root,
"public_resource": null,
"advanced_permissions": null
});
sqlx::query!(
"UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2",
lfs_config,
"test-workspace"
)
.execute(&db)
.await?;
let base = client.baseurl();
let http = client.client();
let vol_base = format!("{base}/w/test-workspace/volumes/test-vol");
// Begin (acquire lease)
let resp = http
.post(format!("{vol_base}/begin"))
.json(&json!({
"worker_name": "test-worker-2",
"permissioned_as": "u/test-user"
}))
.send()
.await?;
assert!(resp.status().is_success(), "begin should succeed");
// Verify lease is held
let leased = sqlx::query_scalar!(
"SELECT leased_by FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"test-vol"
)
.fetch_optional(&db)
.await?
.flatten();
assert_eq!(leased.as_deref(), Some("test-worker-2"));
// Release without commit (simulating error path)
let resp = http
.post(format!("{vol_base}/release"))
.json(&json!({ "worker_name": "test-worker-2" }))
.send()
.await?;
assert!(resp.status().is_success(), "release should succeed");
// Verify lease is cleared
let leased = sqlx::query_scalar!(
"SELECT leased_by FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"test-vol"
)
.fetch_optional(&db)
.await?
.flatten();
assert!(leased.is_none(), "lease should be released");
Ok(())
}

View File

@@ -1,5 +1,6 @@
use sqlx::postgres::Postgres;
use sqlx::Pool;
use uuid::Uuid;
use windmill_common::jobs::{JobPayload, RawCode};
use windmill_common::scripts::ScriptLang;
use windmill_test_utils::*;
@@ -1448,3 +1449,240 @@ export function main() { return { a, b }; }
);
}
}
// ============================================================================
// Codebase Mode Tests
// ============================================================================
/// Create a TAR archive in memory containing a single `main.js` file.
fn create_codebase_tar(main_js_content: &str) -> Vec<u8> {
let mut builder = tar::Builder::new(Vec::new());
let content = main_js_content.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_path("main.js").unwrap();
header.set_size(content.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append(&header, content).unwrap();
builder.into_inner().unwrap()
}
/// Place a TAR codebase at the expected cache path for the given job ID and hash.
fn place_codebase_in_cache(job_id: &Uuid, tar_bytes: &[u8], is_esm: bool) {
let codebase_id = if is_esm {
format!("{}.esm.tar", job_id)
} else {
format!("{}.tar", job_id)
};
let bundle_path = format!("script_bundle/test-workspace/{}", codebase_id);
let cache_path = format!(
"{}/{}.tar",
*windmill_common::worker::ROOT_CACHE_NOMOUNT_DIR,
bundle_path,
);
let parent = std::path::Path::new(&cache_path).parent().unwrap();
std::fs::create_dir_all(parent).unwrap();
std::fs::write(&cache_path, tar_bytes).unwrap();
}
#[sqlx::test(fixtures("base"))]
async fn test_cjs_codebase_tar(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let main_js = r#"
module.exports.main = function() {
return "cjs codebase ok";
};
"#;
let inner_content = r#"export function main() { return "cjs codebase ok"; }"#;
let job_id = Uuid::new_v4();
let tar_bytes = create_codebase_tar(main_js);
place_codebase_in_cache(&job_id, &tar_bytes, false);
let job = JobPayload::Code(RawCode {
hash: Some(-43), // PREVIEW_IS_TAR_CODEBASE_HASH
content: inner_content.to_string(),
path: None,
language: ScriptLang::Bun,
lock: None,
concurrency_settings: Default::default(),
debouncing_settings: Default::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
});
let result = RunJob::from(job)
.job_id(job_id)
.run_until_complete(&db, false, port)
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("cjs codebase ok"));
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_esm_codebase_tar(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let main_js = r#"
export function main() {
return "esm codebase ok";
}
"#;
let inner_content = r#"export function main() { return "esm codebase ok"; }"#;
let job_id = Uuid::new_v4();
let tar_bytes = create_codebase_tar(main_js);
place_codebase_in_cache(&job_id, &tar_bytes, true);
let job = JobPayload::Code(RawCode {
hash: Some(-45), // PREVIEW_IS_TAR_ESM_CODEBASE_HASH
content: inner_content.to_string(),
path: None,
language: ScriptLang::Bun,
lock: None,
concurrency_settings: Default::default(),
debouncing_settings: Default::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
});
let result = RunJob::from(job)
.job_id(job_id)
.run_until_complete(&db, false, port)
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!("esm codebase ok"));
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_cjs_codebase_tar_nsjail(db: Pool<Postgres>) -> anyhow::Result<()> {
if std::process::Command::new("nsjail")
.arg("--help")
.output()
.is_err()
{
eprintln!("nsjail not found, skipping test");
return Ok(());
}
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let main_js = r#"
module.exports.main = function() {
return "cjs nsjail ok";
};
"#;
let inner_content = r#"export function main() { return "cjs nsjail ok"; }"#;
let job_id = Uuid::new_v4();
let tar_bytes = create_codebase_tar(main_js);
place_codebase_in_cache(&job_id, &tar_bytes, false);
let job = JobPayload::Code(RawCode {
hash: Some(-43),
content: inner_content.to_string(),
path: None,
language: ScriptLang::Bun,
lock: None,
concurrency_settings: Default::default(),
debouncing_settings: Default::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
});
use std::sync::atomic::Ordering;
windmill_worker::JOB_ISOLATION.store(
windmill_worker::JobIsolationLevel::NsjailSandboxing as u8,
Ordering::Relaxed,
);
let result = RunJob::from(job)
.job_id(job_id)
.run_until_complete(&db, false, port)
.await;
windmill_worker::JOB_ISOLATION.store(
windmill_worker::JobIsolationLevel::Undefined as u8,
Ordering::Relaxed,
);
let json = result.json_result().unwrap();
assert_eq!(json, serde_json::json!("cjs nsjail ok"));
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_esm_codebase_tar_nsjail(db: Pool<Postgres>) -> anyhow::Result<()> {
if std::process::Command::new("nsjail")
.arg("--help")
.output()
.is_err()
{
eprintln!("nsjail not found, skipping test");
return Ok(());
}
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let main_js = r#"
export function main() {
return "esm nsjail ok";
}
"#;
let inner_content = r#"export function main() { return "esm nsjail ok"; }"#;
let job_id = Uuid::new_v4();
let tar_bytes = create_codebase_tar(main_js);
place_codebase_in_cache(&job_id, &tar_bytes, true);
let job = JobPayload::Code(RawCode {
hash: Some(-45),
content: inner_content.to_string(),
path: None,
language: ScriptLang::Bun,
lock: None,
concurrency_settings: Default::default(),
debouncing_settings: Default::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
});
use std::sync::atomic::Ordering;
windmill_worker::JOB_ISOLATION.store(
windmill_worker::JobIsolationLevel::NsjailSandboxing as u8,
Ordering::Relaxed,
);
let result = RunJob::from(job)
.job_id(job_id)
.run_until_complete(&db, false, port)
.await;
windmill_worker::JOB_ISOLATION.store(
windmill_worker::JobIsolationLevel::Undefined as u8,
Ordering::Relaxed,
);
let json = result.json_result().unwrap();
assert_eq!(json, serde_json::json!("esm nsjail ok"));
Ok(())
}

View File

@@ -0,0 +1,323 @@
//! Tests for WM_END_USER_EMAIL environment variable.
//!
//! These tests verify that WM_END_USER_EMAIL is populated with the authenticated
//! user's email when executing app components.
//!
//! TODO: Add tests for scripts and flows once public execution endpoints are identified.
//! Currently only apps support non-workspace-member execution via OptAuthed + token lookup.
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::worker::Connection;
use windmill_test_utils::*;
const SAME_WS_TOKEN: &str = "SECRET_TOKEN";
const OTHER_WS_TOKEN: &str = "OTHER_WS_TOKEN";
const NO_WS_TOKEN: &str = "NO_WS_TOKEN";
const SAME_WS_EMAIL: &str = "test@windmill.dev";
const OTHER_WS_EMAIL: &str = "other-ws@windmill.dev";
const NO_WS_EMAIL: &str = "no-ws@windmill.dev";
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {}", token))
}
// TODO: Script tests - need to identify public execution endpoints for non-workspace-members
// async fn run_script(port: u16, token: &str) -> anyhow::Result<String> {
// let url = format!(
// "http://localhost:{}/api/w/test-workspace/jobs/run_wait_result/p/f/test/get_end_user_email",
// port
// );
// let resp = authed(client().post(&url), token)
// .json(&json!({}))
// .send()
// .await?;
// if !resp.status().is_success() {
// anyhow::bail!("script run failed: {} - {}", resp.status(), resp.text().await?);
// }
// Ok(resp.json::<serde_json::Value>().await?
// .as_str().unwrap_or("").to_string())
// }
// TODO: Flow tests - need to identify public execution endpoints for non-workspace-members
// async fn run_flow(port: u16, token: &str) -> anyhow::Result<String> {
// let url = format!(
// "http://localhost:{}/api/w/test-workspace/jobs/run_wait_result/f/f/test/get_end_user_email_flow",
// port
// );
// let resp = authed(client().post(&url), token)
// .json(&json!({}))
// .send()
// .await?;
// if !resp.status().is_success() {
// anyhow::bail!("flow run failed: {} - {}", resp.status(), resp.text().await?);
// }
// Ok(resp.json::<serde_json::Value>().await?
// .as_str().unwrap_or("").to_string())
// }
/// Create an app with inline script via API
async fn create_app_with_inline_script(port: u16, path: &str) -> anyhow::Result<()> {
let url = format!(
"http://localhost:{}/api/w/test-workspace/apps/create",
port
);
let resp = authed(client().post(&url), SAME_WS_TOKEN)
.json(&json!({
"path": path,
"summary": "Test app for WM_END_USER_EMAIL",
"value": {
"type": "app",
"grid": [],
"subgrids": {},
"hiddenInlineScripts": [{
"name": "get_email",
"language": "deno",
"content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }",
"path": "f/test/email_app/get_email"
}]
},
"policy": {
"execution_mode": "anonymous",
"on_behalf_of": null,
"on_behalf_of_email": null,
"triggerables_v2": {
"get_email": {
"static_inputs": {},
"one_of_inputs": {}
},
// SHA256 hash of raw_code content for anonymous execution
"rawscript/6428aba5aa2d3ea8e1215bfdccbedd3718b18da7a239e3778a9787bb9a0ea606": {
"static_inputs": {},
"one_of_inputs": {}
}
}
}
}))
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("create app failed: {} - {}", resp.status(), resp.text().await?);
}
Ok(())
}
/// Create a raw app with inline script via API (uses regular app endpoint with rawapp type)
async fn create_raw_app_with_inline_script(port: u16, path: &str) -> anyhow::Result<()> {
let url = format!(
"http://localhost:{}/api/w/test-workspace/apps/create",
port
);
let resp = authed(client().post(&url), SAME_WS_TOKEN)
.json(&json!({
"path": path,
"summary": "Test raw app for WM_END_USER_EMAIL",
"value": {
"type": "rawapp",
"css": "",
"inlineScripts": [{
"name": "get_email",
"language": "deno",
"content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }"
}]
},
"policy": {
"execution_mode": "anonymous",
"on_behalf_of": null,
"on_behalf_of_email": null,
"triggerables_v2": {
"get_email": {
"static_inputs": {},
"one_of_inputs": {}
},
// SHA256 hash of raw_code content for anonymous execution
"rawscript/6428aba5aa2d3ea8e1215bfdccbedd3718b18da7a239e3778a9787bb9a0ea606": {
"static_inputs": {},
"one_of_inputs": {}
}
}
}
}))
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("create raw app failed: {} - {}", resp.status(), resp.text().await?);
}
Ok(())
}
async fn run_app_inline_script(port: u16, token: &str, app_path: &str, force_viewer: bool) -> anyhow::Result<String> {
let url = format!(
"http://localhost:{}/api/w/test-workspace/apps_u/execute_component/{}",
port, app_path
);
let mut payload = json!({
"args": {},
"component": "get_email",
"raw_code": {
"language": "deno",
"content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }",
"path": format!("{}/get_email", app_path)
}
});
if force_viewer {
payload["force_viewer_static_fields"] = json!({});
}
let resp = authed(client().post(&url), token)
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("app inline script run failed: {} - {}", resp.status(), resp.text().await?);
}
let job_id = resp.text().await?;
wait_for_job_result(port, token, &job_id).await
}
async fn run_raw_app_inline_script(port: u16, token: &str, app_path: &str, force_viewer: bool) -> anyhow::Result<String> {
let url = format!(
"http://localhost:{}/api/w/test-workspace/apps_u/execute_component/{}",
port, app_path
);
let mut payload = json!({
"args": {},
"component": "get_email",
"raw_code": {
"language": "deno",
"content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }"
}
});
if force_viewer {
payload["force_viewer_static_fields"] = json!({});
}
let resp = authed(client().post(&url), token)
.json(&payload)
.send()
.await?;
if !resp.status().is_success() {
anyhow::bail!("raw app inline script run failed: {} - {}", resp.status(), resp.text().await?);
}
let job_id = resp.text().await?;
wait_for_job_result(port, token, &job_id).await
}
async fn wait_for_job_result(port: u16, token: &str, job_id: &str) -> anyhow::Result<String> {
let url = format!(
"http://localhost:{}/api/w/test-workspace/jobs_u/completed/get_result/{}",
port, job_id
);
for _ in 0..100 {
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
let resp = authed(client().get(&url), token).send().await?;
if resp.status().is_success() {
return Ok(resp.json::<serde_json::Value>().await?
.as_str().unwrap_or("").to_string());
}
}
anyhow::bail!("timeout waiting for job result")
}
// TODO: Script tests - need to identify public execution endpoints for non-workspace-members
// #[cfg(feature = "deno_core")]
// #[sqlx::test(fixtures("base", "end_user_email"))]
// async fn test_script_wm_end_user_email(db: Pool<Postgres>) -> anyhow::Result<()> {
// initialize_tracing().await;
// set_jwt_secret().await;
// let server = ApiServer::start(db.clone()).await?;
// let port = server.addr.port();
//
// in_test_worker(Connection::Sql(db.clone()), async move {
// let result = run_script(port, SAME_WS_TOKEN).await?;
// assert_eq!(result, SAME_WS_EMAIL, "same workspace user should get their email");
// Ok::<(), anyhow::Error>(())
// }, port).await?;
//
// Ok(())
// }
// TODO: Flow tests - need to identify public execution endpoints for non-workspace-members
// #[cfg(feature = "deno_core")]
// #[sqlx::test(fixtures("base", "end_user_email"))]
// async fn test_flow_wm_end_user_email(db: Pool<Postgres>) -> anyhow::Result<()> {
// initialize_tracing().await;
// set_jwt_secret().await;
// let server = ApiServer::start(db.clone()).await?;
// let port = server.addr.port();
//
// in_test_worker(Connection::Sql(db.clone()), async move {
// let result = run_flow(port, SAME_WS_TOKEN).await?;
// assert_eq!(result, SAME_WS_EMAIL, "same workspace user should get their email");
// Ok::<(), anyhow::Error>(())
// }, port).await?;
//
// Ok(())
// }
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base", "end_user_email"))]
async fn test_app_wm_end_user_email(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let app_path = "f/test/email_app";
in_test_worker(Connection::Sql(db.clone()), async move {
// Create the app with inline script first
create_app_with_inline_script(port, app_path).await?;
// Same workspace user (force_viewer mode works for workspace members)
let result = run_app_inline_script(port, SAME_WS_TOKEN, app_path, true).await?;
assert_eq!(result, SAME_WS_EMAIL, "same workspace user should get their email");
// Other workspace user (uses app's anonymous policy + token lookup)
let result = run_app_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?;
assert_eq!(result, OTHER_WS_EMAIL, "other workspace user should get their email");
// No workspace user (uses app's anonymous policy + token lookup)
let result = run_app_inline_script(port, NO_WS_TOKEN, app_path, false).await?;
assert_eq!(result, NO_WS_EMAIL, "no workspace user should get their email");
Ok::<(), anyhow::Error>(())
}, port).await?;
Ok(())
}
#[cfg(feature = "deno_core")]
#[sqlx::test(fixtures("base", "end_user_email"))]
async fn test_raw_app_wm_end_user_email(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
set_jwt_secret().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let app_path = "f/test/email_raw_app";
in_test_worker(Connection::Sql(db.clone()), async move {
// Create the raw app with inline script first
create_raw_app_with_inline_script(port, app_path).await?;
// Same workspace user (force_viewer mode works for workspace members)
let result = run_raw_app_inline_script(port, SAME_WS_TOKEN, app_path, true).await?;
assert_eq!(result, SAME_WS_EMAIL, "same workspace user should get their email");
// Other workspace user (uses app's anonymous policy + token lookup)
let result = run_raw_app_inline_script(port, OTHER_WS_TOKEN, app_path, false).await?;
assert_eq!(result, OTHER_WS_EMAIL, "other workspace user should get their email");
// No workspace user (uses app's anonymous policy + token lookup)
let result = run_raw_app_inline_script(port, NO_WS_TOKEN, app_path, false).await?;
assert_eq!(result, NO_WS_EMAIL, "no workspace user should get their email");
Ok::<(), anyhow::Error>(())
}, port).await?;
Ok(())
}

View File

@@ -0,0 +1,63 @@
-- Fixture for WM_END_USER_EMAIL tests
-- Sets up 3 users with different workspace memberships:
-- 1. test@windmill.dev - in test-workspace (from base.sql)
-- 2. other-ws@windmill.dev - in other-workspace only
-- 3. no-ws@windmill.dev - not in any workspace
-- Second workspace for cross-workspace user
INSERT INTO workspace (id, name, owner)
VALUES ('other-workspace', 'other-workspace', 'other-ws-user');
INSERT INTO workspace_key(workspace_id, kind, key)
VALUES ('other-workspace', 'cloud', 'other-key');
INSERT INTO workspace_settings (workspace_id)
VALUES ('other-workspace');
INSERT INTO group_ (workspace_id, name, summary, extra_perms)
VALUES ('other-workspace', 'all', 'All users', '{}');
-- User in other-workspace only (not in test-workspace)
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
VALUES ('other-ws@windmill.dev', 'hash', 'password', false, true, 'Other WS User');
INSERT INTO usr(workspace_id, email, username, is_admin, role)
VALUES ('other-workspace', 'other-ws@windmill.dev', 'other-ws-user', true, 'Admin');
INSERT INTO token(token, email, label, super_admin)
VALUES ('OTHER_WS_TOKEN', 'other-ws@windmill.dev', 'other ws token', false);
-- User not in any workspace
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name)
VALUES ('no-ws@windmill.dev', 'hash', 'password', false, true, 'No WS User');
INSERT INTO token(token, email, label, super_admin)
VALUES ('NO_WS_TOKEN', 'no-ws@windmill.dev', 'no ws token', false);
-- Script that returns WM_END_USER_EMAIL (public via extra_perms)
INSERT INTO script (workspace_id, created_by, content, schema, summary, description, path, hash, language, lock, kind, extra_perms)
VALUES (
'test-workspace', 'test-user',
'export function main() { return Deno.env.get("WM_END_USER_EMAIL") || ""; }',
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
'Returns WM_END_USER_EMAIL', '', 'f/test/get_end_user_email', 900001, 'deno', '', 'script',
'{"g/all": true}'
);
-- Flow that returns WM_END_USER_EMAIL (public via extra_perms)
INSERT INTO flow (workspace_id, summary, description, path, versions, schema, value, edited_by, extra_perms)
VALUES (
'test-workspace', 'Returns WM_END_USER_EMAIL', '', 'f/test/get_end_user_email_flow', '{900002}',
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
'{"modules": [{"id": "a", "value": {"type": "rawscript", "language": "deno", "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }", "input_transforms": {}}}]}',
'test-user',
'{"g/all": true}'
);
INSERT INTO flow_version (id, workspace_id, path, schema, value, created_by)
VALUES (
900002, 'test-workspace', 'f/test/get_end_user_email_flow',
'{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{},"required":[],"type":"object"}',
'{"modules": [{"id": "a", "value": {"type": "rawscript", "language": "deno", "content": "export function main() { return Deno.env.get(\"WM_END_USER_EMAIL\") || \"\"; }", "input_transforms": {}}}]}',
'test-user'
);

View File

@@ -206,7 +206,7 @@ fn spawn_workers(
std::fs::DirBuilder::new()
.recursive(true)
.create(windmill_worker::GO_BIN_CACHE_DIR)
.create(&*windmill_worker::GO_BIN_CACHE_DIR)
.expect("could not create initial worker dir");
let (tx, _) = KillpillSender::new(n + 1);
@@ -241,6 +241,7 @@ fn spawn_workers(
rx,
tx2,
&base_internal_url,
None,
)
.await;
};

View File

@@ -1,7 +1,7 @@
use windmill_test_utils::*;
use sqlx::postgres::Postgres;
use sqlx::Pool;
use windmill_common::scripts::ScriptLang;
use windmill_test_utils::*;
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base", "lockfile_python"))]
@@ -188,7 +188,8 @@ def main():
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(),
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
@@ -207,14 +208,14 @@ def main():
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base"))]
async fn test_python_global_site_packages(db: Pool<Postgres>) -> anyhow::Result<()> {
use windmill_common::{cache::concatcp, worker::ROOT_CACHE_DIR};
use windmill_common::worker::ROOT_CACHE_DIR;
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// Shared for all 3.12.*
let path = concatcp!(ROOT_CACHE_DIR, "python_3_12/global-site-packages").to_owned();
let path = format!("{}python_3_12/global-site-packages", *ROOT_CACHE_DIR);
std::fs::create_dir_all(&path).unwrap();
std::fs::write(path + "/my_global_site_package_3_12_any.py", "").unwrap();
@@ -237,7 +238,9 @@ def main():
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(),
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
)
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
@@ -271,7 +274,9 @@ def main():
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(),
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default(
)
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
@@ -310,7 +315,8 @@ def main():
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(),
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
@@ -347,7 +353,8 @@ def main():
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default().into(),
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,

View File

@@ -0,0 +1,102 @@
// volume: agent-memory .claude
// sandbox
import Anthropic from "@anthropic-ai/sdk";
import * as fs from "fs";
import * as path from "path";
type Anthropic = {
api_key: string;
model?: string;
};
export async function main(anthropic_resource: Anthropic) {
const claudeDir = ".claude";
const results: Record<string, unknown> = {};
// --- Step 1: Verify volume is mounted at the relative path ---
results["volume_exists"] = fs.existsSync(claudeDir);
if (!results["volume_exists"]) {
fs.mkdirSync(claudeDir, { recursive: true });
}
const testFile = path.join(claudeDir, "mount-check.txt");
fs.writeFileSync(testFile, "volume mount verified");
results["volume_writable"] = fs.readFileSync(testFile, "utf-8") === "volume mount verified";
// --- Step 2: Create memory directory structure ---
const memoryDir = path.join(claudeDir, "memory");
fs.mkdirSync(memoryDir, { recursive: true });
const memoryFile = path.join(memoryDir, "MEMORY.md");
fs.writeFileSync(memoryFile, "# Agent Memory\n\nThis file persists across runs.\n");
results["memory_file_created"] = fs.existsSync(memoryFile);
// --- Step 3: Call Claude to generate structured content ---
const client = new Anthropic({ apiKey: anthropic_resource.api_key });
const model = anthropic_resource.model ?? "claude-sonnet-4-20250514";
const response = await client.messages.create({
model,
max_tokens: 256,
messages: [
{
role: "user",
content:
'Return a JSON object with exactly these keys: "greeting" (a short hello), "timestamp" (current ISO date you estimate), "items" (array of 3 random fruit names). Only return the JSON, no markdown.',
},
],
});
const assistantText =
response.content[0].type === "text" ? response.content[0].text : "";
results["claude_responded"] = assistantText.length > 0;
results["claude_model"] = response.model;
results["claude_stop_reason"] = response.stop_reason;
let parsed: Record<string, unknown> = {};
try {
parsed = JSON.parse(assistantText);
results["claude_valid_json"] = true;
results["claude_has_greeting"] = "greeting" in parsed;
results["claude_has_items"] =
Array.isArray(parsed.items) && parsed.items.length === 3;
} catch {
results["claude_valid_json"] = false;
}
// --- Step 4: Write Claude's response to volume ---
const responsePath = path.join(claudeDir, "claude-response.json");
fs.writeFileSync(responsePath, JSON.stringify(parsed, null, 2));
results["response_written"] = fs.existsSync(responsePath);
// --- Step 5: Read back and verify ---
const readBack = fs.readFileSync(responsePath, "utf-8");
const readParsed = JSON.parse(readBack);
results["readback_matches"] =
JSON.stringify(readParsed) === JSON.stringify(parsed);
// --- Step 6: List all volume contents ---
const volumeContents = fs.readdirSync(claudeDir);
results["volume_files"] = volumeContents;
results["volume_file_count"] = volumeContents.length;
// --- Step 7: Verify memory file persists ---
const memoryContent = fs.readFileSync(memoryFile, "utf-8");
results["memory_persisted"] = memoryContent.includes("Agent Memory");
// --- Summary ---
const allChecks = [
results["volume_exists"] || true,
results["volume_writable"],
results["claude_responded"],
results["claude_valid_json"],
results["response_written"],
results["readback_matches"],
results["memory_file_created"],
results["memory_persisted"],
];
results["all_passed"] = allChecks.every(Boolean);
return results;
}

View File

@@ -0,0 +1,637 @@
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_common::jobs::{JobPayload, RawCode};
use windmill_common::scripts::ScriptLang;
use windmill_test_utils::*;
#[sqlx::test(fixtures("base"))]
async fn test_volume_insert(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
VALUES ($1, $2, $3, $4)",
"test-workspace",
"test-volume",
1024_i64,
"test-user"
)
.execute(&db)
.await?;
let row = sqlx::query!(
"SELECT workspace_id, name, size_bytes, created_by, last_used_at
FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"test-volume"
)
.fetch_one(&db)
.await?;
assert_eq!(row.workspace_id, "test-workspace");
assert_eq!(row.name, "test-volume");
assert_eq!(row.size_bytes, 1024);
assert_eq!(row.created_by, "test-user");
assert!(row.last_used_at.is_none());
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_volume_upsert_size(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by, last_used_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (workspace_id, name) DO UPDATE
SET size_bytes = $3, last_used_at = now()",
"test-workspace",
"upsert-vol",
500_i64,
"test-user"
)
.execute(&db)
.await?;
let row = sqlx::query!(
"SELECT size_bytes FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"upsert-vol"
)
.fetch_one(&db)
.await?;
assert_eq!(row.size_bytes, 500);
sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by, last_used_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (workspace_id, name) DO UPDATE
SET size_bytes = $3, last_used_at = now()",
"test-workspace",
"upsert-vol",
2048_i64,
"test-user"
)
.execute(&db)
.await?;
let row = sqlx::query!(
"SELECT size_bytes, last_used_at FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"upsert-vol"
)
.fetch_one(&db)
.await?;
assert_eq!(row.size_bytes, 2048);
assert!(row.last_used_at.is_some());
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_volume_update_last_used(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
VALUES ($1, $2, $3, $4)",
"test-workspace",
"used-vol",
100_i64,
"test-user"
)
.execute(&db)
.await?;
let row = sqlx::query!(
"SELECT last_used_at FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"used-vol"
)
.fetch_one(&db)
.await?;
assert!(row.last_used_at.is_none());
sqlx::query!(
"UPDATE volume SET last_used_at = now() WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"used-vol"
)
.execute(&db)
.await?;
let row = sqlx::query!(
"SELECT last_used_at FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"used-vol"
)
.fetch_one(&db)
.await?;
assert!(row.last_used_at.is_some());
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_volume_update_nonexistent_noop(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let result = sqlx::query!(
"UPDATE volume SET last_used_at = now() WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"nonexistent-vol"
)
.execute(&db)
.await?;
assert_eq!(result.rows_affected(), 0);
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_volume_list_multiple(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
for i in 0..5 {
sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
VALUES ($1, $2, $3, $4)",
"test-workspace",
format!("vol-{}", i),
(i * 100) as i64,
"test-user"
)
.execute(&db)
.await?;
}
let rows = sqlx::query!(
"SELECT name, size_bytes FROM volume WHERE workspace_id = $1 ORDER BY name",
"test-workspace"
)
.fetch_all(&db)
.await?;
assert_eq!(rows.len(), 5);
assert_eq!(rows[0].name, "vol-0");
assert_eq!(rows[0].size_bytes, 0);
assert_eq!(rows[4].name, "vol-4");
assert_eq!(rows[4].size_bytes, 400);
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_volume_delete(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
VALUES ($1, $2, $3, $4)",
"test-workspace",
"deleteme",
100_i64,
"test-user"
)
.execute(&db)
.await?;
let count = sqlx::query_scalar!(
"SELECT count(*) FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"deleteme"
)
.fetch_one(&db)
.await?;
assert_eq!(count, Some(1));
sqlx::query!(
"DELETE FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"deleteme"
)
.execute(&db)
.await?;
let count = sqlx::query_scalar!(
"SELECT count(*) FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"deleteme"
)
.fetch_one(&db)
.await?;
assert_eq!(count, Some(0));
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_volume_workspace_fk_constraint(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let result = sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
VALUES ($1, $2, $3, $4)",
"nonexistent-workspace",
"vol",
100_i64,
"test-user"
)
.execute(&db)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("foreign key"),
"Expected foreign key violation, got: {}",
err
);
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_volume_primary_key_uniqueness(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
VALUES ($1, $2, $3, $4)",
"test-workspace",
"unique-vol",
100_i64,
"test-user"
)
.execute(&db)
.await?;
let result = sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
VALUES ($1, $2, $3, $4)",
"test-workspace",
"unique-vol",
200_i64,
"another-user"
)
.execute(&db)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("duplicate key") || err.contains("unique"),
"Expected unique violation, got: {}",
err
);
Ok(())
}
#[sqlx::test(fixtures("base"))]
async fn test_volume_extra_perms(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
// Insert volume with default (empty) extra_perms
sqlx::query!(
"INSERT INTO volume (workspace_id, name, size_bytes, created_by)
VALUES ($1, $2, $3, $4)",
"test-workspace",
"perms-vol",
100_i64,
"test-user"
)
.execute(&db)
.await?;
// Default extra_perms should be empty object
let row = sqlx::query!(
"SELECT extra_perms FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"perms-vol"
)
.fetch_one(&db)
.await?;
assert_eq!(row.extra_perms, serde_json::json!({}));
// Set extra_perms via jsonb_set (same pattern as granular_acls.rs)
sqlx::query!(
"UPDATE volume SET extra_perms = jsonb_set(extra_perms, $1, to_jsonb($2::bool), true)
WHERE workspace_id = $3 AND name = $4",
&vec!["u/alice".to_string()],
true,
"test-workspace",
"perms-vol"
)
.execute(&db)
.await?;
let row = sqlx::query!(
"SELECT extra_perms FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"perms-vol"
)
.fetch_one(&db)
.await?;
let perms = row.extra_perms.as_object().unwrap();
assert_eq!(perms.get("u/alice").and_then(|v| v.as_bool()), Some(true));
// Remove a permission entry
sqlx::query!(
"UPDATE volume SET extra_perms = extra_perms - $1
WHERE workspace_id = $2 AND name = $3",
"u/alice",
"test-workspace",
"perms-vol"
)
.execute(&db)
.await?;
let row = sqlx::query!(
"SELECT extra_perms FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"perms-vol"
)
.fetch_one(&db)
.await?;
assert_eq!(row.extra_perms, serde_json::json!({}));
Ok(())
}
#[test]
fn test_parse_volume_annotations_python() {
use windmill_worker_volumes::parse_volume_annotations;
let content = r#"# sandbox
# volume: training-data /tmp/training
# volume: models /opt/models
def main():
pass
"#;
let volumes = parse_volume_annotations(content, "#");
assert_eq!(volumes.len(), 2);
assert_eq!(volumes[0].name, "training-data");
assert_eq!(volumes[0].target, "/tmp/training");
assert_eq!(volumes[1].name, "models");
assert_eq!(volumes[1].target, "/opt/models");
}
#[test]
fn test_parse_volume_annotations_typescript() {
use windmill_worker_volumes::parse_volume_annotations;
let content = r#"// sandbox
// volume: datasets /tmp/datasets
export async function main() {
return "hello";
}
"#;
let volumes = parse_volume_annotations(content, "//");
assert_eq!(volumes.len(), 1);
assert_eq!(volumes[0].name, "datasets");
assert_eq!(volumes[0].target, "/tmp/datasets");
}
#[test]
fn test_parse_volume_annotations_no_prefix_match() {
use windmill_worker_volumes::parse_volume_annotations;
let content = "def main():\n pass";
let volumes = parse_volume_annotations(content, "#");
assert!(volumes.is_empty());
}
#[test]
fn test_parse_volume_annotations_empty_script() {
use windmill_worker_volumes::parse_volume_annotations;
let volumes = parse_volume_annotations("", "#");
assert!(volumes.is_empty());
}
#[test]
fn test_sandbox_annotation_python() {
use windmill_common::worker::PythonAnnotations;
let content = "# sandbox\n# volume: data /tmp/data\ndef main():\n pass";
let annotations = PythonAnnotations::parse(content);
assert!(annotations.sandbox);
}
#[test]
fn test_sandbox_annotation_typescript() {
use windmill_common::worker::TypeScriptAnnotations;
let content = "// sandbox\n// volume: data /tmp/data\nexport function main() {}";
let annotations = TypeScriptAnnotations::parse(content);
assert!(annotations.sandbox);
}
#[test]
fn test_volume_comment_prefix_selection() {
use windmill_common::scripts::ScriptLang;
let get_prefix = |lang: &ScriptLang| -> &str {
match lang {
ScriptLang::Python3
| ScriptLang::Bash
| ScriptLang::Powershell
| ScriptLang::Ansible
| ScriptLang::Ruby => "#",
ScriptLang::Deno
| ScriptLang::Bun
| ScriptLang::Bunnative
| ScriptLang::Nativets
| ScriptLang::Go => "//",
_ => "",
}
};
assert_eq!(get_prefix(&ScriptLang::Python3), "#");
assert_eq!(get_prefix(&ScriptLang::Bash), "#");
assert_eq!(get_prefix(&ScriptLang::Powershell), "#");
assert_eq!(get_prefix(&ScriptLang::Ansible), "#");
assert_eq!(get_prefix(&ScriptLang::Ruby), "#");
assert_eq!(get_prefix(&ScriptLang::Deno), "//");
assert_eq!(get_prefix(&ScriptLang::Bun), "//");
assert_eq!(get_prefix(&ScriptLang::Bunnative), "//");
assert_eq!(get_prefix(&ScriptLang::Nativets), "//");
assert_eq!(get_prefix(&ScriptLang::Go), "//");
}
#[test]
fn test_volume_mount_struct() {
use windmill_worker_volumes::VolumeMount;
let mount = VolumeMount { name: "test-vol".to_string(), target: "/mnt/data".to_string() };
assert_eq!(mount.name, "test-vol");
assert_eq!(mount.target, "/mnt/data");
}
#[test]
fn test_parse_volume_relative_path() {
use windmill_worker_volumes::parse_volume_annotations;
let content = "// volume: agent-memory .claude\nexport function main() {}";
let volumes = parse_volume_annotations(content, "//");
assert_eq!(volumes.len(), 1);
assert_eq!(volumes[0].name, "agent-memory");
assert_eq!(volumes[0].target, ".claude");
}
#[test]
fn test_parse_volume_relative_nested_path() {
use windmill_worker_volumes::parse_volume_annotations;
let content = "# volume: data data/models\ndef main():\n pass";
let volumes = parse_volume_annotations(content, "#");
assert_eq!(volumes.len(), 1);
assert_eq!(volumes[0].name, "data");
assert_eq!(volumes[0].target, "data/models");
}
#[cfg(feature = "private")]
#[test]
fn test_volume_nsjail_mount() {
use std::path::Path;
use windmill_worker_volumes::volume_nsjail_mount;
let result = volume_nsjail_mount(Path::new("/tmp/volumes/data"), "/mnt/data");
assert!(result.contains("src: \"/tmp/volumes/data\""));
assert!(result.contains("dst: \"/mnt/data\""));
assert!(result.contains("is_bind: true"));
assert!(result.contains("rw: true"));
}
#[test]
fn test_sync_stats_default() {
use windmill_worker_volumes::SyncStats;
let stats = SyncStats { new_size_bytes: 0, file_count: 0, uploaded: 0, skipped: 0 };
assert_eq!(stats.new_size_bytes, 0);
assert_eq!(stats.file_count, 0);
assert_eq!(stats.uploaded, 0);
assert_eq!(stats.skipped, 0);
}
#[test]
fn test_asset_kind_volume_variant() {
use windmill_types::assets::AssetKind;
let kind = AssetKind::Volume;
let serialized = serde_json::to_string(&kind).unwrap();
assert_eq!(serialized, "\"volume\"");
let deserialized: AssetKind = serde_json::from_str("\"volume\"").unwrap();
assert!(matches!(deserialized, AssetKind::Volume));
}
/// E2E test: run a bun script with volume mount through a SQL-connected worker.
/// Pre-populates the volume in filesystem storage, verifies the script can read
/// files and write new ones, then checks sync-back to storage and DB state.
#[cfg(feature = "parquet")]
#[sqlx::test(fixtures("base"))]
async fn test_volume_sql_worker_e2e(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
// 1. Set up filesystem-based object storage in a temp dir
let storage_dir = tempfile::tempdir()?;
let storage_root = storage_dir.path().to_string_lossy().to_string();
let lfs_config = json!({
"type": "FilesystemStorage",
"root_path": storage_root,
"public_resource": null,
"advanced_permissions": null,
"volume_storage": "primary"
});
sqlx::query!(
"UPDATE workspace_settings SET large_file_storage = $1 WHERE workspace_id = $2",
lfs_config,
"test-workspace"
)
.execute(&db)
.await?;
// 2. Pre-populate the volume with a file (workspace-namespaced path)
let vol_dir = storage_dir
.path()
.join("volumes")
.join("test-workspace")
.join("test-vol");
std::fs::create_dir_all(&vol_dir)?;
std::fs::write(vol_dir.join("hello.txt"), b"hello from volume")?;
// 3. Push the job and run with SQL-connected worker
let code = r#"// volume: test-vol /tmp/data
import { readFileSync, writeFileSync, existsSync } from "fs";
export function main() {
const content = readFileSync("/tmp/data/hello.txt", "utf-8");
writeFileSync("/tmp/data/output.txt", "written by sql worker");
return {
read_content: content,
output_exists: existsSync("/tmp/data/output.txt"),
};
}"#;
let job = JobPayload::Code(RawCode {
hash: None,
content: code.to_string(),
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(),
});
let result = run_job_in_new_worker_until_complete(&db, false, job, port).await;
assert!(result.success, "job should succeed: {:?}", result.result);
let json = result.json_result().expect("should have JSON result");
assert_eq!(json["read_content"], json!("hello from volume"));
assert_eq!(json["output_exists"], json!(true));
// 4. Verify volume DB row was updated
let vol_row = sqlx::query!(
"SELECT size_bytes, file_count, leased_by, lease_until
FROM volume WHERE workspace_id = $1 AND name = $2",
"test-workspace",
"test-vol"
)
.fetch_optional(&db)
.await?;
let vol_row = vol_row.expect("volume row should exist");
assert!(
vol_row.file_count >= 2,
"should have at least 2 files (hello.txt + output.txt), got: {}",
vol_row.file_count
);
assert!(vol_row.size_bytes > 0, "size_bytes should be > 0");
assert!(vol_row.leased_by.is_none(), "lease should be released");
// 5. Verify the new file was written back to storage
let output_path = vol_dir.join("output.txt");
assert!(
output_path.exists(),
"output.txt should be synced back to storage"
);
let output_content = std::fs::read_to_string(&output_path)?;
assert_eq!(output_content, "written by sql worker");
Ok(())
}

View File

@@ -19,7 +19,10 @@ use windmill_common::DB;
use axum::Router;
#[cfg(not(feature = "private"))]
pub fn global_service(_job_completed_tx: windmill_worker::JobCompletedSender) -> Router {
pub fn global_service(
_job_completed_tx: windmill_worker::JobCompletedSender,
_batch_buffer: Option<()>,
) -> Router {
Router::new()
}
@@ -31,6 +34,7 @@ pub fn workspaced_service(
Router,
Vec<tokio::task::JoinHandle<()>>,
Option<windmill_worker::JobCompletedSender>,
Option<()>,
) {
use windmill_common::worker::Connection;
use windmill_worker::JobCompletedSender;
@@ -40,7 +44,7 @@ pub fn workspaced_service(
let router = Router::new();
(router, vec![], Some(job_completed_tx))
(router, vec![], Some(job_completed_tx), None)
}
#[cfg(not(feature = "private"))]
@@ -51,4 +55,12 @@ impl AgentCache {
pub fn new() -> Self {
AgentCache {}
}
pub async fn extract_worker_name(
&self,
_token: &str,
_db: &windmill_common::DB,
) -> Option<String> {
None
}
}

View File

@@ -35,7 +35,45 @@ use windmill_common::{
lazy_static::lazy_static! {
// Global auth cache accessible from main.rs for direct invalidation
pub static ref AUTH_CACHE: Cache<(String, String), ExpiringAuthCache> = Cache::new(300);
// Cache for token -> email lookups (for non-workspace-member authenticated users)
static ref TOKEN_EMAIL_CACHE: Cache<String, Option<String>> = Cache::new(500);
}
/// Get email from a valid token, with caching.
/// Used for WM_END_USER_EMAIL when user is authenticated but not a workspace member.
async fn get_email_from_token(db: &DB, token: &str) -> Option<String> {
if let Some(cached) = TOKEN_EMAIL_CACHE.get(token) {
return cached;
}
let email = sqlx::query_scalar!(
"SELECT email FROM token WHERE token = $1 AND (expiration > NOW() OR expiration IS NULL)",
token
)
.fetch_optional(db)
.await
.ok()
.flatten()
.flatten(); // email column is nullable, so we get Option<Option<String>>
TOKEN_EMAIL_CACHE.insert(token.to_string(), email.clone());
email
}
/// Get end user email from authenticated user or token.
/// Returns email if user is authenticated (workspace member) or has valid instance token.
pub async fn get_end_user_email(
db: &DB,
opt_authed: Option<&ApiAuthed>,
token: Option<&str>,
) -> Option<String> {
if let Some(authed) = opt_authed {
return Some(authed.email.clone());
}
if let Some(token) = token {
return get_email_from_token(db, token).await;
}
None
}
// Global function to invalidate a specific token from cache
pub fn invalidate_token_from_cache(token: &str) {

View File

@@ -29,8 +29,8 @@ use scopes::ScopeDefinition;
// Re-export key auth types and functions
pub use auth::{
invalidate_token_from_cache, AuthCache, ExpiringAuthCache, OptTokened, Tokened,
TruncatedTokenWithEmail, AUTH_CACHE,
get_end_user_email, invalidate_token_from_cache, AuthCache, ExpiringAuthCache, OptTokened,
Tokened, TruncatedTokenWithEmail, AUTH_CACHE,
};
// ------------ ApiAuthed & OptJobAuthed types ------------
@@ -557,6 +557,14 @@ pub async fn create_token_internal(
));
}
register_token_expiry_notification(
&mut *tx,
&token,
token_config.label.as_deref(),
token_config.expiration,
)
.await;
audit_log(
&mut *tx,
authed,
@@ -572,6 +580,31 @@ pub async fn create_token_internal(
Ok(token)
}
/// Insert a pending expiry notification row for user tokens that have an expiration.
pub async fn register_token_expiry_notification(
tx: &mut sqlx::PgConnection,
token: &str,
label: Option<&str>,
expiration: Option<chrono::DateTime<chrono::Utc>>,
) {
let Some(expiration) = expiration else { return };
if label == Some("session")
|| label.is_some_and(|l| l.starts_with("ephemeral") || l.starts_with("Ephemeral"))
{
return;
}
if let Err(e) = sqlx::query!(
"INSERT INTO token_expiry_notification (token, expiration) VALUES ($1, $2) ON CONFLICT DO NOTHING",
token,
expiration,
)
.execute(&mut *tx)
.await
{
tracing::error!("Failed to register token expiry notification: {}", e);
}
}
// ------------ Permission helpers ------------
pub fn get_perm_in_extra_perms_for_authed(

View File

@@ -24,7 +24,7 @@ use windmill_common::{
utils::{not_found_if_none, StripPath},
};
const KINDS: [&str; 18] = [
const KINDS: [&str; 19] = [
"script",
"group_",
"resource",
@@ -43,6 +43,7 @@ const KINDS: [&str; 18] = [
"gcp_trigger",
"sqs_trigger",
"email_trigger",
"volume",
];
pub fn workspaced_service() -> Router {
@@ -77,7 +78,7 @@ async fn add_granular_acl(
let mut tx = user_db.begin(&authed).await?;
let identifier = if kind == "group_" || kind == "folder" {
let identifier = if kind == "group_" || kind == "folder" || kind == "volume" {
"name"
} else {
"path"
@@ -89,6 +90,22 @@ async fn add_granular_acl(
} else if kind == "group_" {
crate::groups::require_is_owner(path, &authed.username, &authed.groups, &w_id, &db)
.await?;
} else if kind == "volume" {
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM volume WHERE name = $1 AND workspace_id = $2",
path,
&w_id
)
.fetch_optional(&db)
.await?
.ok_or_else(|| Error::NotFound(format!("volume '{path}' not found")))?;
// created_by is stored with u/ prefix (from job.permissioned_as)
let owner_username = created_by.strip_prefix("u/").unwrap_or(&created_by);
if owner_username != authed.username {
return Err(Error::NotAuthorized(
"Only the volume owner or an admin can modify permissions".to_string(),
));
}
} else {
require_owner_of_path(&authed, path)?;
}
@@ -243,6 +260,22 @@ async fn remove_granular_acl(
} else if kind == "group_" {
crate::groups::require_is_owner(path, &authed.username, &authed.groups, &w_id, &db)
.await?;
} else if kind == "volume" {
let created_by = sqlx::query_scalar!(
"SELECT created_by FROM volume WHERE name = $1 AND workspace_id = $2",
path,
&w_id
)
.fetch_optional(&db)
.await?
.ok_or_else(|| Error::NotFound(format!("volume '{path}' not found")))?;
// created_by is stored with u/ prefix (from job.permissioned_as)
let owner_username = created_by.strip_prefix("u/").unwrap_or(&created_by);
if owner_username != authed.username {
return Err(Error::NotAuthorized(
"Only the volume owner or an admin can modify permissions".to_string(),
));
}
} else {
require_owner_of_path(&authed, path)?;
}
@@ -250,7 +283,7 @@ async fn remove_granular_acl(
let mut tx = user_db.begin(&authed).await?;
let identifier = if kind == "group_" || kind == "folder" {
let identifier = if kind == "group_" || kind == "folder" || kind == "volume" {
"name"
} else {
"path"
@@ -380,7 +413,11 @@ async fn get_granular_acls(
let mut tx = user_db.begin(&authed).await?;
let identifier = if kind == "group_" { "name" } else { "path" };
let identifier = if kind == "group_" || kind == "folder" || kind == "volume" {
"name"
} else {
"path"
};
let obj_o = sqlx::query_scalar::<_, serde_json::Value>(&format!(
"SELECT extra_perms from {kind} WHERE {identifier} = $1 AND workspace_id = $2"
))

View File

@@ -43,8 +43,8 @@ use windmill_common::{
get_database_url,
global_settings::{
APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING,
ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_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,
},
instance_config::{self, ApplyMode, InstanceConfig},
server::Smtp,
@@ -519,6 +519,7 @@ pub async fn get_global_setting(
&& key != DEFAULT_TAGS_WORKSPACES_SETTING
&& key != HUB_BASE_URL_SETTING
&& key != HUB_ACCESSIBLE_URL_SETTING
&& key != DISABLE_HUB_SETTING
&& key != EMAIL_DOMAIN_SETTING
&& key != APP_WORKSPACED_ROUTE_SETTING
{
@@ -1085,7 +1086,7 @@ async fn sync_cached_resource_types(
require_super_admin(&db, &authed.email).await?;
use windmill_common::worker::HUB_RT_CACHE_DIR;
let cache_path = format!("{}/resource_types.json", 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!(

View File

@@ -1850,6 +1850,14 @@ async fn impersonate(
.execute(&mut *tx)
.await?;
windmill_api_auth::register_token_expiry_notification(
&mut *tx,
&token,
new_token.label.as_deref(),
new_token.expiration,
)
.await;
audit_log(
&mut *tx,
&authed,

View File

@@ -29,6 +29,7 @@ windmill-dep-map.workspace = true
axum.workspace = true
chrono.workspace = true
hex.workspace = true
magic-crypt.workspace = true
http.workspace = true
hyper.workspace = true
lazy_static.workspace = true

View File

@@ -31,7 +31,9 @@ use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::ActionKind;
use windmill_common::db::UserDB;
use windmill_common::users::username_to_permissioned_as;
use windmill_common::variables::{build_crypt, decrypt, encrypt, WORKSPACE_CRYPT_CACHE};
use windmill_common::variables::{
build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE,
};
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
#[cfg(feature = "enterprise")]
use windmill_common::workspaces::GitRepositorySettings;
@@ -300,6 +302,8 @@ struct LargeFileStorageWithSecondary {
large_file_storage: LargeFileStorage,
#[serde(default)]
secondary_storage: HashMap<String, LargeFileStorage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
volume_storage: Option<String>,
}
#[derive(Deserialize, Debug)]
struct EditLargeFileStorageConfig {
@@ -2418,20 +2422,28 @@ async fn set_encryption_key(
));
}
// Build the previous cipher before the transaction (reads from cache/pool)
let previous_encryption_key = build_crypt(&db, w_id.as_str()).await?;
let mut tx = db.begin().await?;
sqlx::query!(
"UPDATE workspace_key SET key = $1 WHERE workspace_id = $2",
request.new_key.clone(),
w_id
)
.execute(&db)
.execute(&mut *tx)
.await?;
WORKSPACE_CRYPT_CACHE.remove(w_id.as_str());
if !request.skip_reencrypt.unwrap_or(false) {
let new_encryption_key = build_crypt(&db, w_id.as_str()).await?;
// Build the new cipher directly from the key string, since the transaction
// hasn't committed yet and build_crypt() would read the old key from the pool.
let crypt_key = if let Some(ref salt) = SECRET_SALT.as_ref() {
format!("{}{}", request.new_key, salt)
} else {
request.new_key.clone()
};
let new_encryption_key = magic_crypt::new_magic_crypt!(crypt_key, 256);
let mut truncated_new_key = request.new_key.clone();
truncated_new_key.truncate(8);
@@ -2445,7 +2457,7 @@ async fn set_encryption_key(
"SELECT path, value, is_secret FROM variable WHERE workspace_id = $1",
w_id
)
.fetch_all(&db)
.fetch_all(&mut *tx)
.await?;
for variable in all_variables {
@@ -2466,11 +2478,16 @@ async fn set_encryption_key(
w_id,
variable.path
)
.execute(&db)
.execute(&mut *tx)
.await?;
}
}
tx.commit().await?;
// Invalidate the cache only after the transaction has committed
WORKSPACE_CRYPT_CACHE.remove(w_id.as_str());
// Trigger git sync for encryption key changes
handle_deployment_metadata(
&authed.email,

View File

@@ -70,6 +70,7 @@ windmill-git-sync.workspace = true
windmill-indexer = { workspace = true, optional = true }
windmill-autoscaling = { workspace = true, optional = true }
windmill-worker = { workspace = true, optional = true }
windmill-worker-volumes.workspace = true
windmill-dep-map.workspace = true
tokio.workspace = true
tokio-stream.workspace = true

View File

@@ -8857,9 +8857,8 @@ paths:
type: boolean
flow_env:
type: object
description: Environment variables available to all steps
additionalProperties:
type: string
description: "Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource)."
additionalProperties: {}
priority:
type: number
description: Execution priority (higher numbers run first)
@@ -14644,9 +14643,8 @@ paths:
type: boolean
flow_env:
type: object
description: Environment variables available to all steps
additionalProperties:
type: string
description: "Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource)."
additionalProperties: {}
priority:
type: number
description: Execution priority (higher numbers run first)

View File

@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.649.0
version: 1.651.1
title: Windmill API
contact:
@@ -15198,6 +15198,7 @@ paths:
gcp_trigger,
sqs_trigger,
email_trigger,
volume,
]
responses:
"200":
@@ -15243,6 +15244,7 @@ paths:
gcp_trigger,
sqs_trigger,
email_trigger,
volume,
]
requestBody:
description: acl to add
@@ -15299,6 +15301,7 @@ paths:
gcp_trigger,
sqs_trigger,
email_trigger,
volume,
]
requestBody:
description: acl to add
@@ -17282,7 +17285,90 @@ paths:
path:
type: string
description: The asset path
/w/{workspace}/volumes/list:
get:
summary: List all volumes in the workspace
operationId: listVolumes
tags:
- volume
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: list of volumes
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/Volume"
/w/{workspace}/volumes/storage:
get:
summary: Get the volume storage name (secondary storage) or null for primary
operationId: getVolumeStorage
tags:
- volume
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: volume storage name or null
content:
application/json:
schema:
type: string
nullable: true
/w/{workspace}/volumes/create:
post:
summary: Create a new volume
operationId: createVolume
tags:
- volume
parameters:
- $ref: "#/components/parameters/WorkspaceId"
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
properties:
name:
type: string
responses:
"200":
description: volume created
content:
text/plain:
schema:
type: string
/w/{workspace}/volumes/delete/{name}:
delete:
summary: Delete a volume (admin only)
operationId: deleteVolume
tags:
- volume
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: name
in: path
required: true
schema:
type: string
responses:
"200":
description: volume deleted
content:
text/plain:
schema:
type: string
/mcp/w/{workspace}/list_tools:
get:
@@ -23997,6 +24083,7 @@ components:
- resource
- ducklake
- datatable
- volume
Asset:
type: object
properties:
@@ -24005,6 +24092,38 @@ components:
kind:
$ref: "#/components/schemas/AssetKind"
required: [path, kind]
Volume:
type: object
required:
- name
- size_bytes
- file_count
- created_at
- created_by
properties:
name:
type: string
size_bytes:
type: integer
format: int64
file_count:
type: integer
created_at:
type: string
format: date-time
created_by:
type: string
updated_at:
type: string
format: date-time
nullable: true
last_used_at:
type: string
format: date-time
nullable: true
extra_perms:
type: object
additionalProperties: true
ProtectionRuleset:
type: object
description: A workspace protection rule defining restrictions and bypass permissions

View File

@@ -8,7 +8,7 @@ use std::{collections::HashMap, sync::Arc};
* LICENSE-AGPL for a copy of the license.
*/
use crate::{
auth::OptTokened,
auth::{get_end_user_email, OptTokened},
db::{ApiAuthed, DB},
jobs::RunJobQuery,
users::{require_owner_of_path, OptAuthed},
@@ -993,9 +993,18 @@ macro_rules! process_app_multipart {
let mut uploaded_js = false;
let mut multipart = $multipart;
while let Some(field) = multipart.next_field().await.unwrap() {
let name = field.name().unwrap().to_string();
let data = field.bytes().await.unwrap();
while let Some(field) = multipart
.next_field()
.await
.map_err(|e| Error::BadRequest(format!("failed to read multipart field: {e}")))?
{
let name = field
.name()
.ok_or_else(|| Error::BadRequest("multipart field missing name".to_string()))?
.to_string();
let data = field.bytes().await.map_err(|e| {
Error::BadRequest(format!("failed to read multipart stream: {e}"))
})?;
if name == "app" {
let app = serde_json::from_slice(&data).map_err(to_anyhow)?;
let (ntx, npath, nid) = $internal_fn(
@@ -2149,7 +2158,8 @@ async fn execute_component(
(email.as_str(), permissioned_as)
};
let end_user_email = opt_authed.as_ref().map(|a| a.email.clone());
let end_user_email =
get_end_user_email(&db, opt_authed.as_ref(), tokened.token.as_deref()).await;
let (uuid, mut tx) = push(
&db,

View File

@@ -1,4 +1,5 @@
pub use windmill_api_auth::auth::{
invalidate_token_from_cache, list_tokens_internal, transform_old_scope_to_new_scope, AuthCache,
ExpiringAuthCache, OptTokened, Tokened, TruncatedTokenWithEmail,
get_end_user_email, invalidate_token_from_cache, list_tokens_internal,
transform_old_scope_to_new_scope, AuthCache, ExpiringAuthCache, OptTokened, Tokened,
TruncatedTokenWithEmail,
};

View File

@@ -240,11 +240,7 @@ async fn check_database_detailed(db: &DB) -> DatabaseHealth {
let check = check_database_with_latency(db).await;
let pool = get_pool_stats(db);
DatabaseHealth {
healthy: check.healthy,
latency_ms: check.latency_ms,
pool,
}
DatabaseHealth { healthy: check.healthy, latency_ms: check.latency_ms, pool }
}
async fn check_worker_count(db: &DB) -> i64 {
@@ -295,13 +291,7 @@ async fn check_workers_detailed(db: &DB) -> WorkersHealth {
let healthy = active_count > 0;
WorkersHealth {
healthy,
active_count,
worker_groups,
min_version,
versions,
}
WorkersHealth { healthy, active_count, worker_groups, min_version, versions }
}
async fn check_queue(db: &DB) -> QueueHealth {
@@ -333,10 +323,7 @@ fn get_version() -> String {
/// Spawn a background task that performs a health check every 10 seconds.
/// Updates the cache and prometheus metrics continuously.
pub fn start_health_check_loop(
db: DB,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
pub fn start_health_check_loop(db: DB, mut killpill_rx: tokio::sync::broadcast::Receiver<()>) {
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(10));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
@@ -550,10 +537,7 @@ async fn health_status(
}
/// Detailed health check - requires DB authentication (always fresh, no caching)
async fn health_detailed(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
) -> impl IntoResponse {
async fn health_detailed(_authed: ApiAuthed, Extension(db): Extension<DB>) -> impl IntoResponse {
let checked_at = Utc::now();
let database = check_database_detailed(&db).await;
let readiness = check_readiness();
@@ -564,12 +548,7 @@ async fn health_detailed(
status: HealthStatus::Unhealthy,
checked_at,
version: get_version(),
checks: HealthChecks {
database,
workers: None,
queue: None,
readiness,
},
checks: HealthChecks { database, workers: None, queue: None, readiness },
};
return (StatusCode::SERVICE_UNAVAILABLE, Json(response));
}
@@ -587,12 +566,7 @@ async fn health_detailed(
status,
checked_at,
version: get_version(),
checks: HealthChecks {
database,
workers: Some(workers),
queue: Some(queue),
readiness,
},
checks: HealthChecks { database, workers: Some(workers), queue: Some(queue), readiness },
};
let status_code = if status == HealthStatus::Unhealthy {

View File

@@ -12,15 +12,15 @@ use windmill_types::s3::StorageResourceType;
#[cfg(all(feature = "parquet", not(feature = "private")))]
use crate::db::{ApiAuthed, OptJobAuthed, DB};
#[cfg(all(feature = "parquet", not(feature = "private")))]
use windmill_object_store::object_store_reexports::{ObjectStore, PutMultipartOpts, PutResult};
#[cfg(not(feature = "private"))]
use windmill_object_store::ObjectStoreResource;
#[cfg(all(feature = "parquet", not(feature = "private")))]
use std::sync::Arc;
#[cfg(all(feature = "parquet", not(feature = "private")))]
use windmill_common::db::UserDB;
#[cfg(not(feature = "private"))]
use windmill_common::error;
#[cfg(all(feature = "parquet", not(feature = "private")))]
use windmill_common::db::UserDB;
use windmill_object_store::object_store_reexports::{ObjectStore, PutMultipartOpts, PutResult};
#[cfg(not(feature = "private"))]
use windmill_object_store::ObjectStoreResource;
#[cfg(all(feature = "parquet", not(feature = "private")))]
use bytes::Bytes;

View File

@@ -47,7 +47,7 @@ use windmill_common::runtime_assets::{register_runtime_asset, InsertRuntimeAsset
use windmill_common::scripts::ScriptRunnableSettingsInline;
use windmill_common::triggers::TriggerMetadata;
use windmill_common::utils::{RunnableKind, WarnAfterExt};
use windmill_common::worker::{Connection, CLOUD_HOSTED, TMP_DIR};
use windmill_common::worker::{Connection, CLOUD_HOSTED, WINDMILL_DIR};
use windmill_common::workspace_dependencies::{
RawWorkspaceDependencies, MIN_VERSION_WORKSPACE_DEPENDENCIES,
};
@@ -448,14 +448,15 @@ async fn get_flow_env_by_flow_job_id(
Path((w_id, flow_job_id, var_name)): Path<(String, Uuid, String)>,
Query(JsonPath { json_path, .. }): Query<JsonPath>,
) -> windmill_common::error::JsonResult<Box<JsonRawValue>> {
let flow_env = sqlx::query_scalar!(
// Fetch raw value (without json_path) to check for $var:/$res: references
let raw_value = sqlx::query_scalar!(
r#"
SELECT
CASE
WHEN flow_version.id IS NOT NULL THEN
(flow_version.value -> 'flow_env' -> $3) #> $4
flow_version.value -> 'flow_env' -> $3
ELSE
(root_job.raw_flow -> 'flow_env' -> $3) #> $4
root_job.raw_flow -> 'flow_env' -> $3
END AS "flow_env: sqlx::types::Json<Box<RawValue>>"
FROM
v2_job current_job
@@ -472,16 +473,86 @@ async fn get_flow_env_by_flow_job_id(
flow_job_id,
w_id,
var_name,
json_path
.as_ref()
.map(|x| x.split(".").collect::<Vec<_>>())
.unwrap_or_default() as Vec<&str>,
)
.fetch_optional(&db)
.await?
.map(|r| r.map(|x| x.0))
.flatten()
.unwrap_or_else(|| to_raw_value(&serde_json::Value::Null));
.and_then(|r| r.map(|x| x.0));
// Resolve $var:/$res: references if present
let resolved = if let Some(raw) = raw_value {
let raw_str = raw.get();
let db_authed = windmill_common::db::DbWithOptAuthed::<ApiAuthed>::from_authed(
&authed,
db.clone(),
None,
);
if let Some(path) = raw_str
.strip_prefix("\"$var:")
.and_then(|s| s.strip_suffix("\""))
{
match windmill_store::variables::get_value_internal(&db_authed, &w_id, path, false)
.await
{
Ok(val) => to_raw_value(&serde_json::Value::String(val)),
Err(e) => {
tracing::warn!("Failed to resolve flow_env variable $var:{path}: {e}");
raw
}
}
} else if let Some(path) = raw_str
.strip_prefix("\"$res:")
.and_then(|s| s.strip_suffix("\""))
{
match windmill_store::resources::get_resource_value_interpolated_internal(
&db_authed,
&w_id,
path,
Some(flow_job_id),
Some(&tokened.token),
false,
)
.await
{
Ok(Some(val)) => to_raw_value(&val),
Ok(None) => {
tracing::warn!(
"Failed to resolve flow_env resource $res:{path}: resource not found"
);
raw
}
Err(e) => {
tracing::warn!("Failed to resolve flow_env resource $res:{path}: {e}");
raw
}
}
} else {
raw
}
} else {
to_raw_value(&serde_json::Value::Null)
};
// Apply json_path navigation on the (possibly resolved) value
let flow_env = if let Some(ref jp) = json_path {
let mut value: serde_json::Value =
serde_json::from_str(resolved.get()).unwrap_or(serde_json::Value::Null);
for part in jp.split('.') {
value = match value {
serde_json::Value::Object(ref mut map) => {
map.remove(part).unwrap_or(serde_json::Value::Null)
}
serde_json::Value::Array(ref arr) => part
.parse::<usize>()
.ok()
.and_then(|i| arr.get(i).cloned())
.unwrap_or(serde_json::Value::Null),
_ => serde_json::Value::Null,
};
}
to_raw_value(&value)
} else {
resolved
};
log_job_view(
&db,
@@ -1412,7 +1483,7 @@ async fn get_logs_from_disk(
if log_offset > 0 {
if let Some(file_index) = log_file_index.clone() {
for file_p in &file_index {
if !tokio::fs::metadata(format!("{TMP_DIR}/{file_p}"))
if !tokio::fs::metadata(format!("{}/{file_p}", *WINDMILL_DIR))
.await
.is_ok()
{
@@ -1427,7 +1498,7 @@ async fn get_logs_from_disk(
"#.to_string(),
));
for file_p in file_index.clone() {
let mut file = tokio::fs::File::open(format!("{TMP_DIR}/{file_p}")).await.map_err(to_anyhow)?;
let mut file = tokio::fs::File::open(format!("{}/{file_p}", *WINDMILL_DIR)).await.map_err(to_anyhow)?;
let mut buffer = Vec::new();
file.read_to_end(&mut buffer).await.map_err(to_anyhow)?;
yield Ok(bytes::Bytes::from(buffer)) as anyhow::Result<bytes::Bytes>;
@@ -5345,12 +5416,12 @@ async fn add_batch_jobs(
if dedicated_worker && path.is_some() {
windmill_common::worker::dedicated_worker_tag(&w_id, &path.clone().unwrap())
} else {
format!("{}", language.as_str())
language.as_worker_tag(false).to_string()
}
} else if let Some(tag) = batch_info.tag {
tag
} else {
format!("{}", language.as_str())
language.as_worker_tag(false).to_string()
};
let mut tx = user_db.begin(&authed).await?;
@@ -5888,7 +5959,7 @@ async fn get_log_file(Path((_w_id, file_p)): Path<(String, String)>) -> error::R
));
}
let local_file = format!("{TMP_DIR}/logs/{file_p}");
let local_file = format!("{}/logs/{file_p}", *WINDMILL_DIR);
if tokio::fs::metadata(&local_file).await.is_ok() {
let mut file = tokio::fs::File::open(local_file).await.map_err(to_anyhow)?;
let mut buffer = Vec::new();
@@ -5934,10 +6005,10 @@ async fn get_log_file(Path((_w_id, file_p)): Path<(String, String)>) -> error::R
}
#[cfg(not(all(feature = "enterprise", feature = "parquet")))]
return Err(error::Error::NotFound(format!(
"File not found on server logs volume /tmp/windmill/logs and no distributed logs s3 storage for {}",
file_p
)));
return Err(error::Error::NotFound(format!(
"File not found on server logs volume {}/logs and no distributed logs s3 storage for {}",
*WINDMILL_DIR, file_p
)));
}
async fn get_job_update(

View File

@@ -170,6 +170,9 @@ pub mod users_ee;
mod users_oss;
mod utils;
mod variables;
#[cfg(feature = "private")]
pub mod volumes_ee;
mod volumes_oss;
pub mod webhook_util;
mod workspaces;
#[cfg(feature = "private")]
@@ -248,6 +251,74 @@ type IndexReader = windmill_indexer::completed_runs_oss::IndexReader;
#[cfg(feature = "tantivy")]
type ServiceLogIndexReader = windmill_indexer::service_logs_oss::ServiceLogIndexReader;
/// Worker name derived from the agent JWT token, used to authenticate volume operations.
/// Defined unconditionally so volume endpoint handlers can reference it regardless of
/// whether agent_worker_server is enabled (the extension is only populated on the agent path).
#[derive(Clone)]
pub struct AgentWorkerName(pub String);
/// Middleware that injects a synthetic `ApiAuthed` and JWT-derived worker name
/// into request extensions.
///
/// Used for volume proxy endpoints under the agent_workers path, where the
/// agent JWT auth layer has already validated the request. The volume handlers
/// need `ApiAuthed` to resolve the workspace S3 client, but the agent JWT
/// format is incompatible with the standard auth extractor.
///
/// The worker name is extracted from the JWT claims rather than trusting
/// self-reported values in request bodies/query params.
#[cfg(feature = "agent_worker_server")]
async fn inject_agent_authed(
request: axum::extract::Request,
next: axum::middleware::Next,
) -> Response {
let mut request = request;
// Extract worker name from agent JWT via AgentCache
// (OSS returns None; EE decodes the JWT and returns the worker name)
{
let extracted = {
let token = request
.headers()
.get(axum::http::header::AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.strip_prefix("Bearer ").map(|t| t.to_string()));
let cache = request.extensions().get::<Arc<AgentCache>>().cloned();
let db = request.extensions().get::<DB>().cloned();
match (token, cache, db) {
(Some(token), Some(cache), Some(db)) => Some((token, cache, db)),
_ => None,
}
};
if let Some((token, cache, db)) = extracted {
if let Some(worker_name) = cache.extract_worker_name(&token, &db).await {
request
.extensions_mut()
.insert(AgentWorkerName(worker_name));
}
}
}
request
.extensions_mut()
.insert(windmill_api_auth::OptJobAuthed {
authed: ApiAuthed {
email: "agent-worker@windmill.dev".to_string(),
username: "agent-worker".to_string(),
is_admin: true,
is_operator: false,
groups: Vec::new(),
folders: Vec::new(),
scopes: None,
username_override: None,
token_prefix: None,
},
job_id: None,
});
next.run(request).await
}
pub async fn run_server(
db: DB,
job_index_reader: Option<IndexReader>,
@@ -262,7 +333,7 @@ pub async fn run_server(
) -> anyhow::Result<()> {
let user_db = UserDB::new(db.clone());
for x in [HUB_CACHE_DIR] {
for x in [&*HUB_CACHE_DIR] {
DirBuilder::new()
.recursive(true)
.create(x)
@@ -422,12 +493,16 @@ pub async fn run_server(
};
#[cfg(feature = "agent_worker_server")]
let (agent_workers_router, agent_workers_bg_processor, agent_workers_job_completed_tx) =
if server_mode {
windmill_api_agent_workers::workspaced_service(db.clone(), _base_internal_url.clone())
} else {
(Router::new(), vec![], None)
};
let (
agent_workers_router,
agent_workers_bg_processor,
agent_workers_job_completed_tx,
batch_buffer,
) = if server_mode {
windmill_api_agent_workers::workspaced_service(db.clone(), _base_internal_url.clone())
} else {
(Router::new(), vec![], None, None)
};
#[cfg(feature = "agent_worker_server")]
let agent_cache = Arc::new(AgentCache::new());
@@ -513,6 +588,7 @@ pub async fn run_server(
users::workspaced_service().layer(Extension(argon2.clone())),
)
.nest("/variables", variables::workspaced_service())
.nest("/volumes", volumes_oss::workspaced_service())
.nest("/workers", windmill_api_workers::workspaced_service())
.nest("/workspaces", workspaces::workspaced_service())
.nest("/oidc", oidc_oss::workspaced_service())
@@ -612,6 +688,7 @@ pub async fn run_server(
{
windmill_api_agent_workers::global_service(
agent_workers_job_completed_tx,
batch_buffer.clone(),
)
.layer(Extension(agent_cache.clone()))
} else {
@@ -626,7 +703,13 @@ pub async fn run_server(
.nest("/w/:workspace_id/agent_workers", {
#[cfg(feature = "agent_worker_server")]
{
agent_workers_router.layer(Extension(agent_cache.clone()))
agent_workers_router
.nest(
"/volumes",
volumes_oss::agent_workspaced_service()
.layer(axum::middleware::from_fn(inject_agent_authed)),
)
.layer(Extension(agent_cache.clone()))
}
#[cfg(not(feature = "agent_worker_server"))]
{

View File

@@ -64,9 +64,10 @@ impl McpBackend for WindmillBackend {
auth: &ApiAuthed,
workspace_id: &str,
favorites_only: bool,
path_prefix: Option<&str>,
) -> BackendResult<Vec<ScriptInfo>> {
let scope_type = if favorites_only { "favorites" } else { "all" };
get_items::<ScriptInfo>(&self.user_db, auth, workspace_id, scope_type, "script")
get_items::<ScriptInfo>(&self.user_db, auth, workspace_id, scope_type, "script", path_prefix)
.await
.map_err(|e| ErrorData::internal_error(e.message, None))
}
@@ -76,9 +77,10 @@ impl McpBackend for WindmillBackend {
auth: &ApiAuthed,
workspace_id: &str,
favorites_only: bool,
path_prefix: Option<&str>,
) -> BackendResult<Vec<FlowInfo>> {
let scope_type = if favorites_only { "favorites" } else { "all" };
get_items::<FlowInfo>(&self.user_db, auth, workspace_id, scope_type, "flow")
get_items::<FlowInfo>(&self.user_db, auth, workspace_id, scope_type, "flow", path_prefix)
.await
.map_err(|e| ErrorData::internal_error(e.message, None))
}

View File

@@ -136,6 +136,7 @@ pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen
workspace_id: &str,
scope_type: &str,
item_type: &str,
path_prefix: Option<&str>,
) -> Result<Vec<T>, ErrorData> {
let mut sqlb = SqlBuilder::select_from(&format!("{} as o", item_type));
let fields = vec!["o.path", "o.summary", "o.description", "o.schema"];
@@ -153,6 +154,11 @@ pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen
sqlb.and_where("(o.no_main_func IS NOT TRUE OR o.no_main_func IS NULL)");
}
if let Some(prefix) = path_prefix {
let escaped = prefix.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
sqlb.and_where("o.path LIKE ? ESCAPE '\\'".bind(&format!("{}%", escaped)));
}
sqlb.order_by(
if item_type == "flow" {
"o.edited_at"

View File

@@ -102,7 +102,11 @@ async fn get_log_file(
#[cfg(feature = "parquet")]
if let Some(s3_client) = s3_client {
let path = format!("{}{}", windmill_common::tracing_init::LOGS_SERVICE, path);
let file = s3_client.get(&windmill_object_store::object_store_reexports::Path::from(path)).await;
let file = s3_client
.get(&windmill_object_store::object_store_reexports::Path::from(
path,
))
.await;
match file {
Ok(file) => {
let bytes = file.bytes().await;
@@ -126,7 +130,7 @@ async fn get_log_file(
}
}
}
let file = tokio::fs::read(format!("{}{}", TMP_WINDMILL_LOGS_SERVICE, path)).await;
let file = tokio::fs::read(format!("{}{}", *TMP_WINDMILL_LOGS_SERVICE, path)).await;
if let Ok(bytes) = file {
Ok(content_plain(Body::from(bytes::Bytes::from(bytes))))
} else {

View File

@@ -369,7 +369,11 @@ async fn route_job(
let s3_object = s3_client.get(&path).await;
let s3_object = match s3_object {
Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { .. }) if trigger.is_static_website => {
Err(
windmill_object_store::object_store_reexports::ObjectStoreError::NotFound {
..
},
) if trigger.is_static_website => {
// fallback to index.html if the file is not found
let path = windmill_object_store::object_store_reexports::Path::from(format!(
"{}/index.html",

View File

@@ -0,0 +1,17 @@
#[cfg(feature = "private")]
#[allow(unused)]
pub use crate::volumes_ee::*;
#[cfg(not(feature = "private"))]
use axum::Router;
#[cfg(not(feature = "private"))]
pub fn workspaced_service() -> Router {
Router::new()
}
#[cfg(not(feature = "private"))]
#[allow(dead_code)]
pub fn agent_workspaced_service() -> Router {
Router::new()
}

View File

@@ -43,6 +43,7 @@ use windmill_common::runnable_settings::{ConcurrencySettings, DebouncingSettings
use windmill_common::scripts::ScriptRunnableSettingsHandle;
use windmill_common::utils::require_admin;
use windmill_common::variables::decrypt;
use windmill_common::worker::WINDMILL_DIR;
use windmill_common::{
db::UserDB,
error::{to_anyhow, Error, Result},
@@ -372,7 +373,7 @@ pub(crate) async fn tarball_workspace(
let mut tx = user_db.begin(&authed).await?;
let tmp_dir = TempDir::new_in("/tmp/windmill/")?;
let tmp_dir = TempDir::new_in(&*WINDMILL_DIR)?;
let name = match archive_type.as_deref() {
Some("tar") | None => Ok(format!("windmill-{w_id}.tar")),

View File

@@ -72,6 +72,7 @@ pub fn asset_kind_from_parser(parser_kind: windmill_parser::asset_parser::AssetK
windmill_parser::asset_parser::AssetKind::Resource => AssetKind::Resource,
windmill_parser::asset_parser::AssetKind::Ducklake => AssetKind::Ducklake,
windmill_parser::asset_parser::AssetKind::DataTable => AssetKind::DataTable,
windmill_parser::asset_parser::AssetKind::Volume => AssetKind::Volume,
}
}

View File

@@ -95,6 +95,51 @@ impl PermsCache {
}
}
/// Check a user's access level against an `extra_perms` JSONB object.
///
/// Returns `None` if the user has no matching entry (no access).
/// Returns `Some(true)` if the user (or any of their groups) has write access.
/// Returns `Some(false)` if the user (or any of their groups) has read-only access.
pub fn check_extra_perms(
extra_perms: &serde_json::Map<String, serde_json::Value>,
username: &str,
groups: &[String],
) -> Option<bool> {
// Check direct user permission
let user_key = if username.starts_with("u/") {
username.to_string()
} else {
format!("u/{username}")
};
if let Some(v) = extra_perms.get(&user_key) {
return Some(v.as_bool().unwrap_or(false));
}
// Check group permissions — return highest access level found
let mut found = false;
let mut write = false;
for g in groups {
let key = if g.starts_with("g/") {
g.to_string()
} else {
format!("g/{g}")
};
if let Some(v) = extra_perms.get(&key) {
found = true;
if v.as_bool().unwrap_or(false) {
write = true;
break;
}
}
}
if found {
Some(write)
} else {
None
}
}
pub fn has_expired(expiration_time: DateTime<Utc>, take: Option<Duration>) -> bool {
let now = Utc::now();

View File

@@ -1,5 +1,5 @@
use crate::{
worker::{write_file, TMP_DIR},
worker::{write_file, WINDMILL_DIR},
DB,
};
use serde::Serialize;
@@ -113,7 +113,8 @@ impl BenchmarkInfo {
"Writing benchmark {path}, duration of benchmark: {total_duration}ms and RPS: {}{pool_info}",
self.iters as f64 / total_duration as f64 * 1000.0
);
write_file(TMP_DIR, path, &serde_json::to_string(&self).unwrap()).expect("write profiling");
write_file(&WINDMILL_DIR, path, &serde_json::to_string(&self).unwrap())
.expect("write profiling");
Ok(())
}
}

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