Compare commits

...

60 Commits

Author SHA1 Message Date
centdix
3768d8bb1b fix: add base64 dependency to windmill-ai for bedrock PDF support
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:19:04 +01:00
centdix
ede427d108 refactor: move QueryBuilder trait and StreamEventSink abstraction to windmill-ai
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:12:51 +01:00
centdix
f1a2e19dbb refactor: move worker AI types to windmill-ai crate
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:08:04 +01:00
centdix
a4cac47085 refactor: create windmill-ai crate and move base AI types from windmill-common
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:06:53 +01:00
centdix
1a73012e07 fix: filter null entries in FileUpload initialValue to prevent s3 access error (#8544)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-26 14:59:45 +01:00
centdix
e44504c6e9 feat: add PDF input support to AI agent (#8525)
* feat: add PDF input support to AI agent with user_attachments field

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

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

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

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

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

* refactor: address review feedback on PDF support

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

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

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

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

---------

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

* Apply automatic changes

---------

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

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

* chore: update ee-repo-ref.txt

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

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

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

Previous ee-repo-ref: c9277992608537155a9505a089aca91403d91159

New ee-repo-ref: 6db424512b0d02f86489e85f0026581b7637d6e6

Automated by sync-ee-ref workflow.

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

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

* fix: update sqlx cache for latest EE changes

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

* fix: rename migration to avoid timestamp collision with trashbin

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

* refactor: collapse duplicate match arms and simplify effective_scopes

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

---------

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

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

* test: replace artificial unit tests with real integration tests

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

---------

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

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

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

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

* Update SQLx metadata

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

* fix: immediately refresh HTTP routers on setting change

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

* chore: update ee-repo-ref

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

* nit sqlx

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

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

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

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

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

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

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

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

* chore: update ee-repo-ref

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

* chore: update ee-repo-ref

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

* chore: add down migration for scim_deactivated_user

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

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

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

* chore: update ee-repo-ref

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

* chore: update ee-repo-ref

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

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

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

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

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

* feat: add confirmation modal when disabling instance user

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

* fix: improve disable user confirmation text

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

* fix: revert toggle state when disable confirmation is cancelled

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

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

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

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

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

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

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

* chore: update ee-repo-ref

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

* chore: remove accidentally committed gen files

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

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

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

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

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

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

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

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

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

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

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

* chore: update ee-repo-ref to a1274aa11a83f608eacc32c0d449ca3527d98c15

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

Previous ee-repo-ref: 30f8c53b101b9e25107e793cdc038b0e07061739

New ee-repo-ref: a1274aa11a83f608eacc32c0d449ca3527d98c15

Automated by sync-ee-ref workflow.

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* chore: update ee-repo-ref

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

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

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

* chore: remove dead generate_dedicated_worker_wrapper function

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

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

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

* chore: update ee-repo-ref

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

* chore: update ee-repo-ref

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

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

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

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

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

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

* chore: update ee-repo-ref

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

* refactor: extract DENO_UNSTABLE_ARGS constant to avoid repeating flags

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

* chore: regenerate system prompts

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

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

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

* fix: move format strings before handle_dedicated_process to fix lifetime

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

* chore: regenerate sqlx offline cache

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

* fix sqlx

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

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

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

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

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

* refactor: remove deno from runner groups and associated tests

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: group shared runners visually in dedicated workers list

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: use gray badge for language everywhere

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

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

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

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

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

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

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

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

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

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

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

* fix: dedicated worker review fixes and test coverage

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

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

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

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

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

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

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

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

* fix: remove hanging deno missing-preprocessor test

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

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

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

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

Previous ee-repo-ref: 995f701fe3754be6260fc6b679e5de8fc636e68a

New ee-repo-ref: 182943e5ad9bf2a905ccdf07d4e346437fb329a9

Automated by sync-ee-ref workflow.

---------

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

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

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

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

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

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

* fix: use sync_cached_resource_types endpoint instead of hub_sync script

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

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

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:51:34 +00:00
Samuel Wilk
0904d7fffe Add 'fast' query parameter to API definition (#8521) 2026-03-25 13:51:18 +00:00
centdix
520706b640 chore: use workingdir in webmux panes (#8516)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-25 13:01:03 +01:00
Ruben Fiszel
b7d14c8614 regenerate sqlx offline query cache for integration tests (#8518)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-25 11:20:39 +00:00
wendrul
34e3115bcb fix: raw apps bundle not found during deployment error (#8515) 2026-03-25 10:59:48 +00:00
centdix
60804a96c6 refactor: unify eval pipeline with production chat code path (#8504)
* refactor: unify eval pipeline with production chat code path

Extract a shared headless runChatLoop() that both AIChatManager
(production) and the eval runner use, with injectable SDK clients.
Drop OpenRouter — evals now use direct provider APIs (OpenAI SDK,
Anthropic SDK) with streaming, matching production behavior.

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

* fix: re-read tools/helpers/systemMessage/model on each loop iteration

The old chatRequest() re-read this.tools, this.helpers, this.systemMessage,
and getCurrentModel() on every iteration. This matters because changeModeTool
(Navigator → Script/Flow) reassigns all of these mid-loop. Use JS getters
in the config object so runChatLoop picks up changes each iteration.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 08:50:12 +00:00
Ruben Fiszel
10c5c97d37 nit frontend 2026-03-25 08:48:05 +00:00
Ruben Fiszel
79d2bd51a0 feat: move basic git sync from EE to CE with runtime user count gating (#8493)
* feat: move basic git sync from EE to CE with runtime user count gating

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

* chore: update ee-repo-ref.txt for git sync CE migration

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

* refactor: keep git sync impl in private repo, revert oss to stub

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

* chore: update ee-repo-ref.txt

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

* chore: update ee-repo-ref.txt

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

* chore: update ee-repo-ref.txt after merge

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

* fix: use LICENSE_KEY check instead of get_license_plan for runtime gating

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

* chore: update ee-repo-ref.txt

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

* fix: improve git sync CE UX — use "Community Edition" wording, mention user limit

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

* fix: use "workspace members" instead of "users" in git sync messaging

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

* fix: lower CE git sync limit from 3 to 2 workspace members

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

* chore: update ee-repo-ref.txt

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

* chore: update ee-repo-ref.txt

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

* fix: simplify git sync CE alerts to warn about EE feature with member limit

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

* fix: add EE feature restrictions detail to CE git sync warning

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

* fix: show git sync settings even when >2 members, with disabled warning

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

* fix: show error alert when git sync settings exist but members exceed CE limit

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

* fix: mention CE git sync limit is for testing and hobbyist use

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

* chore: update ee-repo-ref to 79eeacccc0438010d7dfa60207a5cbdaf2eda08d

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

Previous ee-repo-ref: c4d69c6e700c16d44f909d9c7b6738b07043db98

New ee-repo-ref: 79eeacccc0438010d7dfa60207a5cbdaf2eda08d

Automated by sync-ee-ref workflow.

* chore: update sqlx cache

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

* chore: regenerate full sqlx cache after main merge

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

* chore: update sqlx cache

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

* chore: update ee-repo-ref and regenerate sqlx cache with private feature

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

* fix: use LICENSE_KEY_VALID for EE check, allow delete without access check, extract helpers

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

* chore: update ee-repo-ref.txt

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

* refactor: use compile-time cfg(enterprise) gating instead of runtime license checks

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

* chore: update ee-repo-ref.txt

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

* chore: update ee-repo-ref to 6171a91da38d6d16a88aeb1a3a4f4df78f995383

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

Previous ee-repo-ref: 52681940cda6d70f65aeeb7144288f060b4d736e

New ee-repo-ref: 6171a91da38d6d16a88aeb1a3a4f4df78f995383

Automated by sync-ee-ref workflow.

* chore: update ee-repo-ref.txt

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

* chore: update ee-repo-ref to b5c8af4df9ba2c39fdd494d7a40f9a92fbff8abc

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

Previous ee-repo-ref: 6e5b2741831468a7b30b26c0df1241e6141c6833

New ee-repo-ref: b5c8af4df9ba2c39fdd494d7a40f9a92fbff8abc

Automated by sync-ee-ref workflow.

* fix: gate CE_GIT_SYNC_MAX_USERS behind cfg(not(enterprise))

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-25 08:41:29 +00:00
Ruben Fiszel
e3620e074e fix: serve index disk storage sizes from /srch/ endpoint (#8511)
* [ee] fix: serve index disk storage sizes from /srch/ endpoint

On multi-container deployments, the API server doesn't have the index
files on its local disk, so disk size was always reported as 0.0B.

Added a new GET /srch/index/storage/disk endpoint that calculates disk
sizes on the indexer process (which owns the files). The frontend now
fetches disk sizes from this endpoint in parallel with the status call.

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

* chore: update ee-repo-ref to 71aab648925f31cde37efd31d79a7f3a977fd42a

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

Previous ee-repo-ref: b3e0000e2528809302c18f36930aebf3d004747a

New ee-repo-ref: 71aab648925f31cde37efd31d79a7f3a977fd42a

Automated by sync-ee-ref workflow.

* chore: update ee-repo-ref to indexer-disk-storage-zero branch

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

* chore: update sqlx metadata and ee-repo-ref

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-25 07:56:45 +00:00
Ruben Fiszel
0db21aa6b7 samael bump 2026-03-25 07:44:49 +00:00
Ruben Fiszel
fe223bffa3 chore: update samael from 0.0.14 to 0.0.20 (#8512)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 07:34:24 +00:00
Ruben Fiszel
1341a1321d chore: update tantivy from 0.24 to 0.26 (#8510)
* [ee] chore: update tantivy from 0.24 to 0.26

- Rebase windmill-labs/tantivy fork onto upstream 0.26
- Bump serde pin from 1.0.219 to 1.0.220 (required by tantivy 0.26's time dependency)

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

* chore: update ee-repo-ref to ec613f2db9e72e32e9131181546dcd679405a782

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

Previous ee-repo-ref: 920cf601b0651b7ba94493668ea051e00f3e74bf

New ee-repo-ref: ec613f2db9e72e32e9131181546dcd679405a782

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-25 07:11:57 +00:00
Ruben Fiszel
85c52e2cde fix: use /apps_raw/get/ redirect URL for raw apps set as workspace default (#8508)
* fix: use /apps_raw/get/ redirect URL for raw apps set as workspace default

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

* chore: update sqlx cache for default_app query

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 06:40:20 +00:00
Ruben Fiszel
6060ac3adc chore(main): release 1.664.0 (#8498)
* chore(main): release 1.664.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-03-24 21:40:26 +00:00
Ruben Fiszel
d578e40101 feat: add selfApproval option to WAC + inline approval buttons (#8440)
* feat: add selfApproval option to WAC waitForApproval + inline approval buttons

Add self-approval configuration to WAC workflows and inline
approve/reject buttons in WorkflowTimeline.

- TS SDK: add selfApproval option to waitForApproval()
- Python SDK: add self_approval param to wait_for_approval()
- Backend: store approval_conditions in flow_status for WAC,
  enforce self-approval checks on resume endpoints
- Frontend: show Approve/Reject buttons in timeline with form
  support (EE), gated by user permissions

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

* fix: revert sqlx query change + regenerate system prompts

- Revert get_suspended_flow_info to use original sqlx::query_as!
  with COALESCE to avoid sqlx offline cache mismatch in CI
- Detect WAC by checking if FlowStatus parsing fails + suspend > 0
- Re-fetch flow_status column separately for WAC approval conditions
- Regenerate auto-generated system prompt files for SDK changes

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

* feat: use resume URLs for WAC inline approval buttons

- Backend generates HMAC-signed resume/cancel URLs when creating
  WAC approval, stores them in timeline entry and approval meta
- Frontend uses anonymous resume endpoint (like classic flows)
  with fallback to resumeSuspendedFlowAsOwner for admins
- Buttons show for everyone when URLs are present; server-side
  self_approval_disabled check enforces restrictions
- Show warning for admins/owners when self-approval is disabled
- selfApproval: false requires EE (errors at dispatch on CE)
- self_approval_disabled check moved outside user_auth_required
  gate so it works independently
- WAC detection no longer requires task import

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

* feat: add resume_suspended and approval_info endpoints

- New approval_token DB table for token-based approval access
- New POST /jobs_u/flow/resume_suspended/{job_id} endpoint:
  - OptAuthed: works with login or approval_token
  - Checks approval_conditions (self_approval, groups, auth)
  - Admins/owners bypass rules
- New GET /jobs_u/flow/approval_info/{job_id} endpoint:
  - Returns form, rules, can_approve status
- HMAC anonymous endpoint now bypasses all approval_conditions
  (secret = full capability)
- getResumeUrls approvalPage URL now uses token format
- WAC approval dispatch generates and stores approval tokens
- Mark resumeSuspendedFlowAsOwner as legacy

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

* feat: simplify frontend to use resume_suspended endpoint

- OpenAPI spec updated with resume_suspended and approval_info endpoints
- WorkflowTimeline: removed URL parsing, now calls single
  resumeSuspended endpoint for both approve and reject
- Buttons show for any logged-in user viewing the job (backend
  enforces authorization rules)
- Kept self-approval warning for admins

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

* feat: stateless approval tokens, new approval page, FlowStatusWaitingForEvents update

- Replace DB-stored approval tokens with stateless HMAC derivation:
  token = HMAC(workspace_key, job_id + "approval_token")
  Verifiable without DB lookup, not reversible to resume secret
- Drop approval_token migration (no DB table needed)
- FlowStatusWaitingForEvents: use resumeSuspended endpoint instead
  of URL parsing + resumeSuspendedFlowAsOwner
- New approval page route /approve/{ws}/{job}?token= that uses
  approval_info and resume_suspended endpoints
- Old approval page route kept for back-compat

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

* feat: match old approval page content in new approval page

- Add FlowMetadata, JobArgs, FlowGraphV2, DisplayResult
- Add approvers with tooltips, flow arguments section
- Add admin self-approval bypass warning
- Add "Open run details" link
- Fetch full job alongside approval_info for all UI data

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

* fix: filter _MODULES from args, show 'workflow' for WAC approvals

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

* chore: remove deno template from approval/prompt SuspendDrawer

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

* fix: approval page form display + hide deno from approval script picker

- Fix form schema rendering on new approval page by wrapping flat
  WAC form schemas in { properties, order } for SchemaForm
- Hide deno from the approval step language picker in flow editor

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

* fix: remove deno from canHaveApproval in script_helpers.ts

The insert menu uses canHaveApproval() from script_helpers.ts via
FlowInputsQuick, not the displayLang function in FlowInputs.svelte.
Revert the unnecessary FlowInputs.svelte change.

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

* fix: return form schema and description in approval_info for classic flows

The approval_info endpoint was returning None for form_schema on
classic flows. Now fetches raw_flow to get suspend.resume_form
schema, hide_cancel, and the step's completed result for description.

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

* feat: inline Login component on approval page instead of redirect

Show the Login component directly on the approval page when
authentication is required. On successful login, reloads user
and approval info without navigating away.

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

* fix: show resume buttons for all users, not just owners

The resume_suspended endpoint handles authorization server-side,
so the frontend should always show the buttons. Remove isOwner
gate and the "cannot resume" message.

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

* fix: prevent layout shift on resume by removing spinner from cancel button

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

* fix: prevent resume button expansion by using disabled instead of loading

The loading prop adds a Loader2 spinner that expands the button width.
Use disabled={loading} instead to prevent layout shift.

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

* fix: approval page login redirects back with full page reload

Set rd to the full URL (starts with http) so Login.redirectUser()
uses window.location.href instead of goto(), triggering a full page
reload after login. This ensures the approval page re-fetches data
as an authenticated user.

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

* fix: fetch flow definition from flow_version when raw_flow is null

Deployed flows don't store raw_flow on the job. Fall back to
flow_version table using runnable_id to get suspend settings
(form schema, hide_cancel) for the approval_info endpoint.

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

* feat: show specific reasons when user cannot approve

Display whether denial is due to self-approval being disabled,
required group membership, or both.

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

* feat: support both nested and flat form schema in waitForApproval

Users can now pass either:
  waitForApproval({ form: { schema: { name: { type: "string" } } } })
or:
  waitForApproval({ form: { name: { type: "string" } } })

Both WorkflowTimeline and approval page handle both formats.

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

* fix: convert sqlx query macros to non-macro for CI offline cache

Replace sqlx::query! and sqlx::query_scalar! with sqlx::query and
sqlx::query_as to avoid SQLX_OFFLINE cache misses in CI.
Also remove unused LogIn import from approval page.

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

* fix: suppress dead code warning + unused isOwner variable

- Add #[allow(dead_code)] to without_flow method (CI -D warnings)
- Rename isOwner to _isOwner in FlowStatusWaitingForEvents (unused)

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

* fix: security and robustness fixes from PR review

- Add workspace_id verification in resume_suspended to prevent
  cross-workspace approval (#3)
- Fix token leakage: use relative path for login redirect instead
  of full URL with token (#4)
- Handle getJob failure independently from approval_info so the
  page works for unauthenticated users (#7)
- Clear error state on successful data load (#13)

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

* fix: address review feedback — shared token gen, rand resume_id, UX

- Move generate_approval_token to windmill-common::variables (shared
  between windmill-api and windmill-worker, eliminates duplicate HMAC)
- Use rand::random::<u32>() for resume_id instead of DefaultHasher
- Stop polling after approve/reject on approval page
- Add cancelLoading state to WorkflowTimeline Reject button

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 21:22:35 +00:00
centdix
db5e03610d feat: add instance-level AI settings (#8453)
* feat: add instance-level AI settings with workspace fallback

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

* feat: add AI step to onboarding setup wizard

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

* fix: thread workspace prop through resource editor and disable chat offset

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

* Revert "fix: thread workspace prop through resource editor and disable chat offset"

This reverts commit 9fea9cc0c239f6432d1fef1487c45e74ab752e21.

* fix: set workspace store and disable chat offset during AI setup step

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

* fix: thread workspace and disableChatOffset props through resource editors

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

* fix: populate workspace and user stores for AI step path component

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

* fix: initialize AI clients for test key during onboarding

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

* refactor: extract AI config state into InstanceAISettings component

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

* refactor: move AI config state ownership into AISettings component

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

* Persist instance AI settings before navigation

* Reload effective workspace AI state after save

* Scope AI key tests to the rendered workspace

* Add post-create AI onboarding for new workspaces

* Unify instance AI settings header

* Fix instance AI drawer offset on workspace selection

* Add instance AI fallback settings behavior

* Update sqlx metadata

* Update sqlx metadata

* Clarify active instance AI in workspace settings

* Refresh workspace AI state after instance AI save

* Declare instance AI summary in API schema

* Normalize empty instance AI config handling

* Clean up workspace AI settings UI

* Unify AI config provider checks

* Split AI settings metadata from effective config

* Propagate instance AI cache invalidation across servers

* Fix AI settings dirty state tracking

* Update sqlx metadata

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 19:18:36 +00:00
Ruben Fiszel
a26a2e8092 defense in depth against SQL injection in folder, oauth, and SCIM queries (#8496)
* fix: use bind parameters for folder owner in jsonb_set queries

Replace format! string interpolation of owner into jsonb_set path
with proper $N bind parameters to prevent potential SQL injection.

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

* chore: update ee-repo-ref.txt

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

* chore: update ee-repo-ref to faeaa43bbe2ba4804f80b828b85fd4d6daef096c

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

Previous ee-repo-ref: 0d4444cb5825fa43629d856cc8565cc052512d4c

New ee-repo-ref: faeaa43bbe2ba4804f80b828b85fd4d6daef096c

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-03-24 16:48:56 +00:00
Guilhem
81eb446eee feat: flow group nodes with collapsible groups (#8075)
* feat: add flow group nodes core infrastructure

Add group data model (start_id/end_id boundary pairs), GroupEditor for
CRUD operations, groupDetectionUtils for membership computation and
validation, GroupedModulesProxy for reactive sync, and compound layout
support. Update openflow.openapi.yaml with group schema.

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

* feat: add group UI components and rendering

Add GroupOverlay with bounding box and z-ordering, GroupHeader with
StepCountTab and ellipsis menu, GroupNodeCard, GroupNoteArea for inline
markdown notes, CollapsedGroupNode/CollapsedSubflowNode for collapsed
rendering, GroupEndNode/GroupHeadNode boundary markers, and group
actions in NodeContextMenu and SelectionBoundingBox.

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

* feat: integrate groups into flow graph, builder, and existing components

Wire group support into FlowGraphV2 (overlays, collapsed rendering,
group-aware layout), graphBuilder (GroupedModule tree, container
collapse/expand, group boundary nodes), BaseEdge (drop targets for
group operations), ModuleNode (collapsed container rendering), and
flow map components (schema item grouping). Remove SubflowBound in
favor of CollapsedSubflowNode.

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

* fix: remove banned $bindable(default) pattern and dead ternary

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

* refactor: decouple collapse state from grouped module tree

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

* feat: pass groups prop to FlowGraphV2 and use GroupDisplayState via graphContext

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

* refactor: remove group membership system, compute nesting depth from visual bounds

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

* refactor: simplify GroupOverlay bounds, remove unused headerY and showNotes prop

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

* fix: populate innerNodeIds for expanded subflow overlay

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

* refactor: remove expanded subflow overlay feature for separate PR

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

* fix: flatten groups in getContainerModules to prevent crash on collapsed containers

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

* feat: add drag-to-move support for group nodes

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

* fix: derive group boundaries from expanded membership to prevent splitting existing groups

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

* fix: catch group validation errors and display as flow graph alert

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

* test: add unit tests for group validation in buildGroupedModules

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

* fix: reject virtual nodes (Input, Result, Trigger) from groups

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

* test: add virtual node rejection tests for buildGroupedModules

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

* fix: exclude preprocessor and failure module from groups

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

* fix: disable Create group button when preprocessor is selected

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

* fix: reject selection entirely when it contains excluded nodes

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

* refactor: remove unnecessary excludeIds from buildGroupedModules

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

* fix: remove debug console.log from FlowGraphV2

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

* fix: use cross-browser CSS grid trick for group summary input auto-sizing

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

* fix: hide group boundary edges and reformat GroupNoteArea

Hide edges between group header and first node, and between last node
and group-end, keeping them in the DOM but visually hidden.

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

* refactor: stop FlowGraphV2 from reading groups via groupEditorContext

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

* feat: show module previews with status, selection, and suspend popover in collapsed groups

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

* refactor: extract collapsible implicit containers to separate branch

Remove collapse/expand functionality for implicit containers (forloops,
while loops, branches) from this branch. Backed up as
collapsible-implicit-containers-backup for later rebase.

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

* refactor: use original reactive modules for graph node data instead of proxy snapshots

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

* fix: prevent node loss when moving into forloop inside a group

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

* refactor: replace GroupedModule proxy with structure-only FlowStructureNode tree

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

* fix: use "group-" prefix for group IDs instead of "note-"

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

* fix: update group boundaries when renaming a module ID

When a module at a group boundary (start_id or end_id) is renamed,
the group definitions now get updated before the reactive rebuild,
preventing stale references that would break the flow structure.

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

* fix: update graph layout when removing a group note

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

* fix: add opaque background behind test run button to prevent see-through

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

* fix: detect and reject duplicate group IDs

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

* fix: simplify group creation validation with early marker normalization

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

* fix: use $state.raw in MiniFlowGraph to avoid xyflow performance warning

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

* refactor: address code review feedback

- Revert backend traverse_modules change (not part of this feature)
- Use Map for node lookup in GroupOverlay (O(1) vs O(n) per group)
- Extract computeNodeExtraSpace to nodeExtraSpace.ts for testability

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

* refactor: address PR review feedback

- Compute group depths from structure tree O(n) instead of O(n²) bounds comparison
- Remove unnecessary $derived(groups) in GroupOverlay
- Remove unused collapsed field from container types in OpenAPI spec
- Use NODE.width constant in GroupNodeCard instead of hardcoded 275px
- Add comment explaining intentional stale preservation in rebuild()

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

* fix: preserve flow groups during dependency job re-serialization

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

* fix: resolve Svelte state_referenced_locally warnings in GroupHeader and FlowGraphV2

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

* feat: show subflow groups when expanding a subflow in the graph

- Store both modules and groups when expanding a subflow
- Pass groups to buildStructureTree so group nodes render
- Include subflow groups in overlay rendering and collapse tracking
- Clone modules for prefix rewriting to avoid state_unsafe_mutation
- Register expanded subflow modules in moduleMap before prefix rewriting
- Disable group editing in expanded subflows and read-only views

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

* fix: restore accidentally removed code from main

- Restore subflowBound selection handling in selectionUtils
- Restore comments in SelectionBoundingBox
- Restore deletable={false} in FirstStepInputs

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

* refactor: remove redundant adjacency check from MoveManager

The disableMoveIds check already prevents all invalid drop targets,
making the adjacencySourceId/adjacencyTargetId fields unnecessary.

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

* chore: regenerate auto-generated files after OpenAPI schema change

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

* chore: regenerate cli skills after main merge

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

* fix: include groups in view_graph localStorage state

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

* refactor: centralize canCreateGroup and replace group note with group creation

- Add canCreateGroup StateStore to GroupEditorContext, computed in FlowGraphV2
- Replace "Create group note" with "Create group" in FlowSelectionPanel
- Remove "Add note" from selection bounding box dropdown
- Remove unused NodeContextMenu component
- Wire createGroup through FlowModuleSchemaMap → FlowGraphV2

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

* fix: reject groups spanning parallel branches and surface ill-formed group errors

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

* chore: regenerate auto-generated files after main merge

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

* fix: ensure modules appears before groups in YAML export

Svelte 5's $state proxy registers groups as a tracked property before
it's explicitly set, causing it to appear before modules in Object.keys
iteration. Reorder the value object at export time for readable YAML.

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

* refactor: address second round of PR review feedback

- Add comment explaining duplicateMultiple bypasses structure tree
- Add warning log for inverted ranges in computeGroupModuleIds
- Use NODE.width constant in CollapsedGroupNode instead of hardcoded 275px
- Simplify redundant condition in getGroupsEmptiedBy

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

* refactor: remove stored group ID, derive ephemeral key from start_id:end_id

Groups no longer store an `id` field. Instead, a `groupKey(g)` helper
derives an ephemeral key from `${start_id}:${end_id}` at read time.
This simplifies the schema while preserving all runtime functionality.

When boundaries shift (module deletion), runtime state (collapse,
note heights) is remapped to the new key via GroupDisplayState.remapGroupKey.

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

* feat: add note button, save/cancel hints, and rename collapsed_by_default to autocollapse

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

* fix: propagate selection from collapsed group badges to external listeners

Pass eventHandlers to GroupModuleIcons so clicking a module badge
calls both selectionManager.selectId (visual highlight) and
eventHandlers.select (side panel propagation via onSelect).

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

* chore: regenerate auto-generated files after main merge

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

* fix: hide In/Out popovers and actions during click-to-move

Replace isDragging with isMoving derived that covers both drag-move
and click-move states, disabling popovers, delete button, and test
run button during any move operation.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 16:47:33 +00:00
Ruben Fiszel
8cfaa91d43 update cli freshness 2026-03-24 16:01:18 +00:00
Alexander Petric
bdfd5d5726 fix: add GIT_SSL_CAINFO to tracing proxy env vars (#8502)
Git uses libcurl with GnuTLS on Debian, which doesn't read
SSL_CERT_FILE or CURL_CA_BUNDLE for CA trust. When the OTEL tracing
proxy is enabled, git clone fails with "certificate signer not trusted"
because it can't verify the proxy's MITM certificate.

Adding GIT_SSL_CAINFO pointing to the proxy CA cert fixes this.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 16:00:32 +00:00
Diego Imbert
2048a36376 Fix select key bug (#8499) 2026-03-24 15:42:16 +00:00
Ruben Fiszel
3c34d19813 escape env var values in nativets/bun JS string interpolation (#8500)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-24 15:41:39 +00:00
Ruben Fiszel
7f27d996ac fix: create parent dirs and accept 'python' alias in script bootstrap (#8497)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-03-24 15:16:10 +00:00
Ruben Fiszel
6d63d9973d chore(main): release 1.663.0 (#8465)
* chore(main): release 1.663.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-03-24 13:31:06 +00:00
Rogelio Alcala Ortiz
23df390b17 allow modern email TLDs in superadmin setup form (#8472) 2026-03-24 13:27:41 +00:00
hugocasa
5089a45881 feat: add summary field for native triggers (#8476)
* feat: add summary field for native triggers (nextcloud, google)

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

* fix: add nullable to NativeTriggerData summary in openapi spec

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

* fix: include summary in native trigger search index

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:27:09 +00:00
hugocasa
f035b538bb feat: surface permissioned_as selector in trigger editor UI (#8475)
* feat: surface permissioned_as selector in trigger editor UI

Add OnBehalfOfSelector to TriggerEditorToolbar so users can see and
control who a trigger runs as. Admins/deployers can preserve the
current permissioned_as or pick a custom user; non-admins see the
current value but options are disabled.

Applies to all trigger types: schedule, kafka, http, websocket,
postgres, nats, mqtt, sqs, gcp, and email.

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

* refactor: move permissioned_as selector from toolbar to config area

Move OnBehalfOfSelector out of TriggerEditorToolbar (too cluttered)
into a new PermissionedAsLine component rendered at the top of each
trigger editor's config body. Lighter footprint, same functionality.

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

* feat: show explicit warning when saving will change permissioned_as

Use an Alert (warning/info) to clearly show who the trigger currently
runs as and whether saving will change it. Non-admin users see a
warning that it will switch to them. Admins see the OnBehalfOfSelector
to preserve or pick a custom user.

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

* fix: make permissioned_as line subtle instead of big alert box

Replace the Alert component with a small inline text line using
text-2xs. Shows warning arrow + yellow text only when saving will
actually change the permissioned_as.

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

* fix: split permissioned_as display for admin vs non-admin

Admins see just "Permissioned as" label + the OnBehalfOfSelector
(no duplicate username). Non-admins see the plain text line with
warning arrow when it will change.

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

* fix: show warning for admins too when permissioned_as will change

Admins now see a yellow warning next to the selector when their
choice differs from the current permissioned_as value.

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

* fix: use consistent warning text for permissioned_as change

Both admin and non-admin warnings now say
"will change to <user> on save" instead of using an arrow.

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

* fix: bold permission strings in permissioned_as warnings

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

* fix: bold the non-editable permissioned_as value too

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

* fix: remove mono font from non-editable permissioned_as value

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

* fix: add consistent bottom margin to permissioned_as line

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

* fix: consistent spacing for permissioned_as line

Move PermissionedAsLine outside the gap-8 div in schedule editor
and increase margin to mb-4 for consistent spacing across all
trigger types.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:26:56 +00:00
hugocasa
47c0c363f4 fix: clean up stale dependency map entries for renamed scripts (#8492)
* fix: clean up stale dependency map entries for renamed scripts

When a script is renamed, trigger_dependents_to_recompute_dependencies()
could find the archived script at the old path and create a dependency
job for it. This job would process the old code and recreate stale
dependency_map entries, causing incorrect deployment warnings.

Add `AND archived = false` to the script lookup query so that renamed
(archived) scripts at old paths trigger clear_map_for_item() cleanup
instead of spawning dependency jobs for obsolete code.

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

* fix: also filter archived flows in trigger_dependents

Apply the same archived check to the flow lookup query. The flow table
has an archived column, so when a flow is renamed/archived its
flow_version rows would still be found. Join against the flow table
and filter archived = false to trigger cleanup instead.

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

* revert: remove unnecessary flow archived check

Flow renames delete the old flow row and INSERT a new one at the new
path (for FK constraints on flow_version). There is no archived flow
row left behind, so the original query is already correct for flows.

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

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:25:07 +00:00
Ruben Fiszel
54f5a19377 fix: prevent SQL injection in job query parameters (#8494)
Replace unsafe string interpolation (format!("'{}'", t)) with
sql_builder::quote() in SQL query construction. The tags parameter in
count_completed_jobs_detail was directly interpolated without escaping,
allowing authenticated users to inject arbitrary SQL via the query string.

Also hardens LIKE clauses, JSON operators, and JOIN conditions across
query.rs and variables.rs that used manual .replace("'", "''") instead
of the crate's quote() function, and converts format-interpolated bind
values to parameterized queries where possible.

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:23:43 +00:00
349 changed files with 18484 additions and 5697 deletions

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,71 @@
# Changelog
## [1.665.0](https://github.com/windmill-labs/windmill/compare/v1.664.0...v1.665.0) (2026-03-26)
### Features
* add instance setting to enforce workspace prefix for HTTP routes ([#8528](https://github.com/windmill-labs/windmill/issues/8528)) ([9b3e558](https://github.com/windmill-labs/windmill/commit/9b3e558d84f15052e9c32695a467f8ef7e4ad1f5))
* add trashbin system for soft-deleting items ([#8519](https://github.com/windmill-labs/windmill/issues/8519)) ([69ce946](https://github.com/windmill-labs/windmill/commit/69ce946241d98ea90bc7135d44ca0c87f928be88))
* mask sensitive values in job logs ([#8520](https://github.com/windmill-labs/windmill/issues/8520)) ([0885d8c](https://github.com/windmill-labs/windmill/commit/0885d8c986f13ac210e4db3ad38febe9be391ba4))
* move basic git sync from EE to CE with runtime user count gating ([#8493](https://github.com/windmill-labs/windmill/issues/8493)) ([79d2bd5](https://github.com/windmill-labs/windmill/commit/79d2bd51a00654162754046308d7670242120df6))
* runner groups for shared-process multi-script dedicated workers ([#8434](https://github.com/windmill-labs/windmill/issues/8434)) ([c28314f](https://github.com/windmill-labs/windmill/commit/c28314f424ea0e04b86565ce88e6c91e0df1a0cf))
* SCIM user deprovisioning (active:false) + instance-level user disable ([#8484](https://github.com/windmill-labs/windmill/issues/8484)) ([0bd7568](https://github.com/windmill-labs/windmill/commit/0bd756839c0261f255111d62088bdaaecb838085))
* show groups and notes in flow status viewer ([#8535](https://github.com/windmill-labs/windmill/issues/8535)) ([167084a](https://github.com/windmill-labs/windmill/commit/167084a0ebe73384fa0d31f0b24017a47686a072))
### Bug Fixes
* auto-generate datatable SDK reference for app mode system prompt ([#8522](https://github.com/windmill-labs/windmill/issues/8522)) ([8a32322](https://github.com/windmill-labs/windmill/commit/8a32322c187ccc60ec7eafb61a9678f267a82282))
* consider wmill.yaml environments alias in git sync ([#8532](https://github.com/windmill-labs/windmill/issues/8532)) ([b7475c7](https://github.com/windmill-labs/windmill/commit/b7475c73094a28f520f798f6cb1a0c6b4807ccb7))
* GitHub Enterprise Server support for self-managed GitHub Apps ([#8507](https://github.com/windmill-labs/windmill/issues/8507)) ([935fb44](https://github.com/windmill-labs/windmill/commit/935fb44c848b8bf9430b5600dd3c3bedb2f89efd))
* raw apps bundle not found during deployment error ([#8515](https://github.com/windmill-labs/windmill/issues/8515)) ([34e3115](https://github.com/windmill-labs/windmill/commit/34e3115bcbd19a8e0b6f483435586a2ab43d0a8e))
* require admin for workspace encryption key export ([#8523](https://github.com/windmill-labs/windmill/issues/8523)) ([0317668](https://github.com/windmill-labs/windmill/commit/031766808945aefc926f0836d011c0b2a5d2243d))
* restrict logout redirect to whitelisted domains ([#8524](https://github.com/windmill-labs/windmill/issues/8524)) ([4c8edd5](https://github.com/windmill-labs/windmill/commit/4c8edd5e944d77ed2d41c2b87171c1115c0fdcdc))
* serve index disk storage sizes from /srch/ endpoint ([#8511](https://github.com/windmill-labs/windmill/issues/8511)) ([e3620e0](https://github.com/windmill-labs/windmill/commit/e3620e074e1bdb46b2b8d732f35a91d300589663))
* use /apps_raw/get/ redirect URL for raw apps set as workspace default ([#8508](https://github.com/windmill-labs/windmill/issues/8508)) ([85c52e2](https://github.com/windmill-labs/windmill/commit/85c52e2cded10606cc895d0d3b717e13c69bc9b3))
* use resource-level scope overrides during OAuth2 token refresh ([#8540](https://github.com/windmill-labs/windmill/issues/8540)) ([55ad0ff](https://github.com/windmill-labs/windmill/commit/55ad0ff5c499c33b766f47c6f32ba5d3eeb14763))
## [1.664.0](https://github.com/windmill-labs/windmill/compare/v1.663.0...v1.664.0) (2026-03-24)
### Features
* add instance-level AI settings ([#8453](https://github.com/windmill-labs/windmill/issues/8453)) ([db5e036](https://github.com/windmill-labs/windmill/commit/db5e03610da325288d53afdbca94b9cbfc7ceace))
* add selfApproval option to WAC + inline approval buttons ([#8440](https://github.com/windmill-labs/windmill/issues/8440)) ([d578e40](https://github.com/windmill-labs/windmill/commit/d578e40101a838d3dffda14157cf72ee4d5a93c0))
* flow group nodes with collapsible groups ([#8075](https://github.com/windmill-labs/windmill/issues/8075)) ([81eb446](https://github.com/windmill-labs/windmill/commit/81eb446eee359f44374b81320690e5345fd08c15))
### Bug Fixes
* add GIT_SSL_CAINFO to tracing proxy env vars ([#8502](https://github.com/windmill-labs/windmill/issues/8502)) ([bdfd5d5](https://github.com/windmill-labs/windmill/commit/bdfd5d57261a4bb760fc57ad41ee56aff9b9c0af))
* create parent dirs and accept 'python' alias in script bootstrap ([#8497](https://github.com/windmill-labs/windmill/issues/8497)) ([7f27d99](https://github.com/windmill-labs/windmill/commit/7f27d996accb3c3b471d1c50df397867d89c738a))
## [1.663.0](https://github.com/windmill-labs/windmill/compare/v1.662.0...v1.663.0) (2026-03-24)
### Features
* add summary field for native triggers ([#8476](https://github.com/windmill-labs/windmill/issues/8476)) ([5089a45](https://github.com/windmill-labs/windmill/commit/5089a458819abbc6f241bc354bebb91520bd1a52))
* add typed request body to OpenAPI spec generation ([#8481](https://github.com/windmill-labs/windmill/issues/8481)) ([37ebaf4](https://github.com/windmill-labs/windmill/commit/37ebaf4d0ac342703498733f97778a552f979f6a))
* **cli:** better stale scripts detection [#3](https://github.com/windmill-labs/windmill/issues/3) ([#8480](https://github.com/windmill-labs/windmill/issues/8480)) ([9643006](https://github.com/windmill-labs/windmill/commit/9643006f1e90b991b334bb58caf62301bc26d09d))
* Debounce node ([#8324](https://github.com/windmill-labs/windmill/issues/8324)) ([5d1c54d](https://github.com/windmill-labs/windmill/commit/5d1c54d9b33d6ff6f2c98481a2740d1e7629cdfa))
* surface permissioned_as selector in trigger editor UI ([#8475](https://github.com/windmill-labs/windmill/issues/8475)) ([f035b53](https://github.com/windmill-labs/windmill/commit/f035b538bbd786445526339f88be8f33a3628105))
### Bug Fixes
* clean up stale dependency map entries for renamed scripts ([#8492](https://github.com/windmill-labs/windmill/issues/8492)) ([47c0c36](https://github.com/windmill-labs/windmill/commit/47c0c363f4fc1d9af7efd07ea172e32989ce50d2))
* **cli:** add Svelte 5 event delegation guidance and safe push to raw-app skill ([#8466](https://github.com/windmill-labs/windmill/issues/8466)) ([911df95](https://github.com/windmill-labs/windmill/commit/911df958e78d2dab9823dfa7d7e5c9824fc2d565))
* Fix worker panic when job_isolation changed to unshare at runtime ([#8490](https://github.com/windmill-labs/windmill/issues/8490)) ([cbe47c0](https://github.com/windmill-labs/windmill/commit/cbe47c0b6c22f79452d020777e481ee26970f25b))
* improve SQS retries ([3c8d351](https://github.com/windmill-labs/windmill/commit/3c8d351c9722a089133871019d27cf3bc3cdc159))
* Move database manager SQL queries to backend ([#8306](https://github.com/windmill-labs/windmill/issues/8306)) ([aa30fd2](https://github.com/windmill-labs/windmill/commit/aa30fd252dcf40233d191c43a6293fb9feabf010))
* prevent SQL injection in job query parameters ([#8494](https://github.com/windmill-labs/windmill/issues/8494)) ([54f5a19](https://github.com/windmill-labs/windmill/commit/54f5a19377e9df712e18f85f896e21b1776981ed))
* respect NO_COLOR env variable for stdout log output ([#8483](https://github.com/windmill-labs/windmill/issues/8483)) ([f329ee7](https://github.com/windmill-labs/windmill/commit/f329ee7aaefbae0ad344743c40825440a936bd30))
* show effective isolation level on workers page ([#8491](https://github.com/windmill-labs/windmill/issues/8491)) ([37886ed](https://github.com/windmill-labs/windmill/commit/37886edda1443293806a9b1b810196b72e076b12))
* skip debounce arg accumulation when batch table is empty (CE) ([#8485](https://github.com/windmill-labs/windmill/issues/8485)) ([010753c](https://github.com/windmill-labs/windmill/commit/010753c73ac85237af50acadf9c08567b1bc993c))
* stop_after_if with empty error_message prevents flow from stopping ([#8464](https://github.com/windmill-labs/windmill/issues/8464)) ([1503bf9](https://github.com/windmill-labs/windmill/commit/1503bf948e3340b8a6933d71885f8f2cb8dc1867))
## [1.662.0](https://github.com/windmill-labs/windmill/compare/v1.661.0...v1.662.0) (2026-03-20)

View File

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

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_hash,\n service_config\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, error = NULL, updated_at = NOW()\n ",
"query": "\n INSERT INTO native_trigger (\n external_id,\n workspace_id,\n service_name,\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n summary\n ) VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8\n )\n ON CONFLICT (external_id, workspace_id, service_name)\n DO UPDATE SET script_path = $4, is_flow = $5, webhook_token_hash = $6, service_config = $7, summary = $8, error = NULL, updated_at = NOW()\n ",
"describe": {
"columns": [],
"parameters": {
@@ -21,10 +21,11 @@
"Varchar",
"Bool",
"Varchar",
"Jsonb"
"Jsonb",
"Varchar"
]
},
"nullable": []
},
"hash": "6f9386dfcb4c201525722aee3caa25bf2f3a35d90f7354c7d3aef8a3538a03a7"
"hash": "1048d1c95270ce1f36c02bce31a2bc8a88935c613bd213b7156299811377db8e"
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ",
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND external_id = $3\n ",
"describe": {
"columns": [
{
@@ -62,6 +62,11 @@
"ordinal": 9,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 10,
"name": "summary",
"type_info": "Varchar"
}
],
"parameters": {
@@ -91,8 +96,9 @@
true,
true,
false,
false
false,
true
]
},
"hash": "bac545933a627a62b7845d8aab80702443285e4d1d11e5a0f4cd2a3d4add51bb"
"hash": "15014ce696cf2af4f719a537a4e3ca5b322cc130a35a91f8b8854f5ebdf25ad2"
}

View File

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

View File

@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "SELECT ws.default_app AS default_app_path, av.raw_app AS \"default_app_raw: Option<bool>\"\n FROM workspace_settings ws\n LEFT JOIN app ON app.path = ws.default_app AND app.workspace_id = ws.workspace_id\n LEFT JOIN app_version av ON av.id = app.versions[array_upper(app.versions, 1)]\n WHERE ws.workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "default_app_path",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "default_app_raw: Option<bool>",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true,
false
]
},
"hash": "1bc77ad29b9c68b1d339b85158bc3592deb61d1111d1430ddd2879b72e6424ef"
}

View File

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

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "21f4840f60e8310d7b7efcba7483e69e4ef8821c6cbf3b4f296b3853d95692af"
}

View File

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

View File

@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT parent_job, flow_step_id FROM v2_job WHERE id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "parent_job",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "flow_step_id",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
true,
true
]
},
"hash": "32ca7941db013dacd2479962fa9ed5c8c64daec45ba820a6c8f7d7ab76cc40c9"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ",
"query": "\n SELECT\n external_id,\n workspace_id,\n service_name AS \"service_name!: ServiceName\",\n script_path,\n is_flow,\n webhook_token_hash,\n service_config,\n error,\n created_at,\n updated_at,\n summary\n FROM\n native_trigger\n WHERE\n workspace_id = $1\n AND service_name = $2\n AND script_path = $3\n AND is_flow = $4\n LIMIT 1\n ",
"describe": {
"columns": [
{
@@ -62,6 +62,11 @@
"ordinal": 9,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 10,
"name": "summary",
"type_info": "Varchar"
}
],
"parameters": {
@@ -92,8 +97,9 @@
true,
true,
false,
false
false,
true
]
},
"hash": "1a69ef11a3f361f105c2a8af7b7fa182f3953150ade1756259b31a50e9308fce"
"hash": "6dafcc89668fb0e5740f23264b515b1724c36031da39d53ae6c329a479bdf8aa"
}

View File

@@ -1,35 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "debounced_times",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "first_started_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "job_id_to_debounce",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Varchar"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "79b82ae996fba2e2ab53fcf84c108cb1ca21fbdba3373af54fadf1f4af324073"
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,46 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH RECURSIVE chain AS (\n SELECT\n j.id,\n j.parent_job,\n j.flow_step_id,\n 1 AS depth\n FROM v2_job j\n WHERE j.id = $1\n UNION ALL\n SELECT\n pj.id,\n pj.parent_job,\n pj.flow_step_id,\n c.depth + 1\n FROM chain c\n JOIN v2_job pj ON pj.id = c.parent_job\n WHERE c.parent_job IS NOT NULL\n )\n SELECT\n c.id,\n c.parent_job,\n c.flow_step_id,\n EXISTS(SELECT 1 FROM v2_job_queue q WHERE q.id = c.parent_job) AS \"parent_in_queue!\",\n EXISTS(\n SELECT 1 FROM v2_job sib\n WHERE sib.parent_job = c.parent_job\n AND sib.id != c.id\n AND sib.id IN (SELECT sq.id FROM v2_job_queue sq)\n ) AS \"has_other_active_siblings!\"\n FROM chain c\n WHERE c.depth >= 1\n ORDER BY c.depth ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Uuid"
},
{
"ordinal": 1,
"name": "parent_job",
"type_info": "Uuid"
},
{
"ordinal": 2,
"name": "flow_step_id",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "parent_in_queue!",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "has_other_active_siblings!",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
null,
null,
null,
null,
null
]
},
"hash": "950f364c9fa3c680eea895558a559f29220c08e94e1822e3bcb5c6ed6aa7d2bb"
}

View File

@@ -0,0 +1,20 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value FROM global_settings WHERE name = 'ai_config'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": []
},
"nullable": [
false
]
},
"hash": "975099ff6b07718ea94bcb5f84a4414c59199964cee53ef2ee6b35a78cf0c49a"
}

View File

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

View File

@@ -1,35 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "debounced_times",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "first_started_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "job_id_to_debounce",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Varchar"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "98033aae3182bde22d5b2ff08ef6e8a4f8f3a9bf04238b33e9caf46836df73d9"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false ORDER BY created_at DESC LIMIT 1",
"query": "SELECT hash FROM script WHERE path = $1 AND workspace_id = $2 AND deleted = false AND archived = false ORDER BY created_at DESC LIMIT 1",
"describe": {
"columns": [
{
@@ -19,5 +19,5 @@
false
]
},
"hash": "d5661c7557cf3a8dee7cf799cd364d21d38edb827d2c08b0ca7d72311b78d574"
"hash": "a32d7ba43745226fd65328475731526e0b20ea6eeafeb937eb01cdc2cdfcb859"
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n )\n LIMIT $3\n OFFSET $4\n ",
"query": "\n SELECT\n nt.external_id,\n nt.workspace_id,\n nt.service_name AS \"service_name!: ServiceName\",\n nt.script_path,\n nt.is_flow,\n nt.webhook_token_hash,\n nt.service_config,\n nt.error,\n nt.created_at,\n nt.updated_at,\n nt.summary\n FROM\n native_trigger nt\n WHERE\n nt.workspace_id = $1 AND\n nt.service_name = $2 AND\n ($5::text IS NULL OR nt.script_path = $5) AND\n ($6::bool IS NULL OR nt.is_flow = $6) AND\n (\n (nt.is_flow = false AND EXISTS (\n SELECT 1 FROM script s\n WHERE s.workspace_id = nt.workspace_id\n AND s.path = nt.script_path\n ))\n OR\n (nt.is_flow = true AND EXISTS (\n SELECT 1 FROM flow f\n WHERE f.workspace_id = nt.workspace_id\n AND f.path = nt.script_path\n ))\n )\n LIMIT $3\n OFFSET $4\n ",
"describe": {
"columns": [
{
@@ -62,6 +62,11 @@
"ordinal": 9,
"name": "updated_at",
"type_info": "Timestamptz"
},
{
"ordinal": 10,
"name": "summary",
"type_info": "Varchar"
}
],
"parameters": {
@@ -94,8 +99,9 @@
true,
true,
false,
false
false,
true
]
},
"hash": "a115d8ea786907561afdbbc07d11dc715d80b00c0e79b61b0057a3ae3886a85e"
"hash": "b0775af41a9b54cce040bf37cae770e63dab12a1383a0855d105c061e4e4ca48"
}

View File

@@ -1,16 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE v2_job_status SET flow_status = (\n SELECT jsonb_set(\n flow_status,\n ARRAY['modules', (idx - 1)::text],\n $2::jsonb\n )\n FROM jsonb_array_elements(flow_status->'modules')\n WITH ORDINALITY arr(elem, idx)\n WHERE elem->>'id' = $3\n LIMIT 1\n ) WHERE id = $1 AND (\n SELECT COUNT(*) FROM jsonb_array_elements(flow_status->'modules')\n WITH ORDINALITY arr(elem, idx)\n WHERE elem->>'id' = $3\n ) > 0",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Uuid",
"Jsonb",
"Text"
]
},
"nullable": []
},
"hash": "b1979a8249557d29e9055fde06191688f3ed0efd3a43e81f4ea296255248092c"
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n UPDATE native_trigger\n SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, error = NULL, updated_at = NOW()\n WHERE\n workspace_id = $5\n AND service_name = $6\n AND external_id = $7\n ",
"query": "\n UPDATE native_trigger\n SET script_path = $1, is_flow = $2, webhook_token_hash = $3, service_config = $4, summary = $8, error = NULL, updated_at = NOW()\n WHERE\n workspace_id = $5\n AND service_name = $6\n AND external_id = $7\n ",
"describe": {
"columns": [],
"parameters": {
@@ -21,10 +21,11 @@
}
}
},
"Text"
"Text",
"Varchar"
]
},
"nullable": []
},
"hash": "40a8bf6a5a42c275d73221bc5f386f2e18cb911352551d0a34bf1933e558674e"
"hash": "bf224f6441c36187f1402f9f01bfe15bb9edfa1dc9052f8a829e486b7334d708"
}

View File

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

View File

@@ -1,35 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n WITH dk AS (\n INSERT INTO debounce_key (job_id, key)\n VALUES ($1, $2)\n ON CONFLICT (key)\n DO UPDATE SET\n previous_job_id = debounce_key.job_id,\n job_id = EXCLUDED.job_id,\n debounced_times = debounce_key.debounced_times + 1\n RETURNING\n debounced_times,\n first_started_at,\n previous_job_id AS job_id_to_debounce\n ), _batch AS (\n INSERT INTO v2_job_debounce_batch (id, debounce_batch)\n SELECT\n $1,\n COALESCE(\n (SELECT debounce_batch FROM v2_job_debounce_batch WHERE id = dk.job_id_to_debounce LIMIT 1),\n nextval('debounce_batch_seq')\n )\n FROM dk\n )\n SELECT debounced_times, first_started_at, job_id_to_debounce FROM dk\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "debounced_times",
"type_info": "Int4"
},
{
"ordinal": 1,
"name": "first_started_at",
"type_info": "Timestamptz"
},
{
"ordinal": 2,
"name": "job_id_to_debounce",
"type_info": "Uuid"
}
],
"parameters": {
"Left": [
"Uuid",
"Varchar"
]
},
"nullable": [
false,
false,
true
]
},
"hash": "c2347460b73ae9d3167031c263032e97ebefb46be9e58bd3da9067748075311b"
}

View File

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

View File

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

View File

@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(\n SELECT 1 FROM v2_job\n WHERE parent_job = $1 AND id != $2\n AND id IN (SELECT id FROM v2_job_queue)\n ) as has",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "has",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Uuid",
"Uuid"
]
},
"nullable": [
null
]
},
"hash": "ecf67b08d327c351909b7ba80218903bec93ef79a71053c00227e17c6f0415a2"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT default_app FROM workspace_settings WHERE workspace_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "default_app",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
true
]
},
"hash": "ed1a053c7b22d9cb69767be40d33f3be67b6160cd258c86b8e8f22a6d601afd0"
}

View File

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

View File

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

View File

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

View File

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

720
backend/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,13 @@
[package]
name = "windmill"
version = "1.662.0"
version = "1.665.0"
authors.workspace = true
edition.workspace = true
[workspace]
resolver = "2"
members = [
"./windmill-ai",
"./windmill-object-store",
"./windmill-api",
"./windmill-api-scripts",
@@ -82,7 +83,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal"]
[workspace.package]
version = "1.662.0"
version = "1.665.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -132,8 +133,8 @@ dind = ["windmill-worker/dind"]
websocket = ["windmill-api/websocket"]
http_trigger = ["windmill-api/http_trigger"]
postgres_trigger = ["windmill-api/postgres_trigger"]
mcp = ["windmill-api/mcp", "windmill-worker/mcp"]
bedrock = ["windmill-api/bedrock", "windmill-worker/bedrock", "windmill-common/bedrock"]
mcp = ["windmill-ai/mcp", "windmill-api/mcp", "windmill-worker/mcp"]
bedrock = ["windmill-ai/bedrock", "windmill-api/bedrock", "windmill-worker/bedrock", "windmill-common/bedrock"]
mqtt_trigger = ["windmill-api/mqtt_trigger"]
native_trigger = ["windmill-api/native_trigger"]
sqs_trigger = ["windmill-api/sqs_trigger", "windmill-common/aws_auth", "windmill-api/openidconnect"]
@@ -206,6 +207,7 @@ anyhow.workspace = true
tokio.workspace = true
tokio-stream.workspace = true
dotenv.workspace = true
windmill-ai = { workspace = true, default-features = false }
windmill-queue.workspace = true
windmill-common = { workspace = true, default-features = false }
windmill-object-store.workspace = true
@@ -284,6 +286,7 @@ aws-credential-types.workspace = true
[workspace.dependencies]
windmill-ai = { path = "./windmill-ai", default-features = false }
windmill-api = { path = "./windmill-api", default-features = false }
windmill-queue = { path = "./windmill-queue" }
windmill-worker = { path = "./windmill-worker" }
@@ -373,7 +376,7 @@ tower = "^0"
tower-http = { version = "^0.6", features = ["trace", "cors", "catch-panic"] }
tower-cookies = "^0.10"
#stuck because of swc for now
serde = "=1.0.219"
serde = "=1.0.220"
serde_json = { version = "^1", features = ["preserve_order", "raw_value"] }
serde_yml = "0.0.12"
uuid = { version = "^1", features = ["serde", "v4", "js"] }
@@ -510,7 +513,7 @@ native-tls = ">=0.2, <0.2.17"
# samael will break compilation on MacOS. Use this fork instead to make it work
# samael = { git="https://github.com/njaremko/samael", rev="464d015e3ae393e4b5dd00b4d6baa1b617de0dd6", features = ["xmlsec"] }
libxml = { version = "=0.3.3" }
samael = { version="0.0.14", features = ["xmlsec"] }
samael = { git="https://github.com/njaremko/samael", rev="f879f1942ec1b34b6d3027ce7e4724ad95d15dfa", features = ["xmlsec"] }
gcp_auth = "0.9.0"
rust_decimal = { version = "^1", features = ["db-postgres", "serde-float"]}
jsonwebtoken = "8.3.0"
@@ -587,7 +590,7 @@ tikv-jemalloc-ctl = { version = "^0.5" }
triomphe = "^0"
pin-project-lite = "^0"
tantivy = { git="https://github.com/windmill-labs/tantivy", rev="6a24621231202ccd77bec90d8787e2281fb94e4e" }
tantivy = { git="https://github.com/windmill-labs/tantivy", rev="6ae7c70bc603b8e69e27f3240e08bd00a93fb12c" }
backon = "1.3.0"

View File

@@ -1 +1 @@
c04f3851c03758662e4936ff4b6e71bc56dbae7e
6db424512b0d02f86489e85f0026581b7637d6e6

View File

@@ -0,0 +1 @@
ALTER TABLE native_trigger DROP COLUMN IF EXISTS summary;

View File

@@ -0,0 +1 @@
ALTER TABLE native_trigger ADD COLUMN summary VARCHAR(1000);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,17 +34,18 @@ use windmill_common::ee_oss::{
maybe_renew_license_key_on_start, LICENSE_KEY_ID, LICENSE_KEY_VALID,
};
use windmill_ai::ai_cache::bump_instance_ai_config_revision;
use windmill_common::{
agent_workers::AgentConfig,
global_settings::{
APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING, BASE_URL_SETTING,
BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING,
BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
CRITICAL_ERROR_CHANNELS_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_PER_WORKSPACE_SETTING,
DEFAULT_TAGS_WORKSPACES_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS,
EXPOSE_DEBUG_METRICS_SETTING, EXPOSE_METRICS_SETTING, EXTRA_PIP_INDEX_URL_SETTING,
HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING, INDEXER_SETTING,
INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING, HUB_API_SECRET_SETTING, HUB_BASE_URL_SETTING,
INDEXER_SETTING, INSTANCE_EVENTS_WEBHOOK_SETTING, INSTANCE_PYTHON_VERSION_SETTING,
JOB_DEFAULT_TIMEOUT_SECS_SETTING, JOB_ISOLATION_SETTING, JWT_SECRET_SETTING,
KEEP_JOB_DIR_SETTING, LICENSE_KEY_SETTING, MAVEN_REPOS_SETTING, MAVEN_SETTINGS_XML_SETTING,
MONITOR_LOGS_ON_OBJECT_STORE_SETTING, NO_DEFAULT_MAVEN_SETTING,
@@ -103,10 +104,10 @@ use crate::monitor::{
reload_base_url_setting, reload_bunfig_install_scopes_setting,
reload_critical_alert_mute_ui_setting, reload_critical_alerts_on_token_expiry_setting,
reload_critical_error_channels_setting, reload_extra_pip_index_url_setting,
reload_hub_api_secret_setting, reload_hub_base_url_setting,
reload_instance_events_webhook_setting, reload_job_default_timeout_setting,
reload_job_isolation_setting, reload_jwt_secret_setting, reload_license_key,
reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting,
reload_http_route_workspaced_route_setting, reload_hub_api_secret_setting,
reload_hub_base_url_setting, reload_instance_events_webhook_setting,
reload_job_default_timeout_setting, reload_job_isolation_setting, reload_jwt_secret_setting,
reload_license_key, reload_npm_config_registry_setting, reload_otel_tracing_proxy_setting,
reload_pip_index_url_setting, reload_retention_period_setting, reload_scim_token_setting,
reload_smtp_config, reload_uv_index_strategy_setting, reload_worker_config, MonitorIteration,
};
@@ -1098,6 +1099,9 @@ Windmill Community Edition {GIT_VERSION}
}
let addr = SocketAddr::from((server_bind_address, port));
let listener = tokio::net::TcpListener::bind(addr)
.await
.context("binding main windmill server")?;
let (base_internal_tx, base_internal_rx) = tokio::sync::oneshot::channel::<String>();
@@ -1231,7 +1235,7 @@ Windmill Community Edition {GIT_VERSION}
db.clone(),
index_reader,
log_index_reader,
addr,
listener,
server_killpill_rx,
base_internal_tx,
server_mode,
@@ -1813,6 +1817,27 @@ async fn process_notify_event(
tracing::error!(error = %e, "Could not reload app workspaced route setting");
}
}
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => {
if let Err(e) = reload_http_route_workspaced_route_setting(db).await {
tracing::error!(error = %e, "Could not reload http route workspaced route setting");
}
#[cfg(feature = "http_trigger")]
match windmill_api::triggers::http::refresh_routers(db).await {
Ok((true, _)) => {
tracing::info!(
"Refreshed HTTP routers (http workspaced route setting change)"
);
}
Err(err) => {
tracing::error!("Error refreshing HTTP routers (http workspaced route setting change): {err:#}");
}
_ => {}
}
}
AI_CONFIG_SETTING => {
tracing::info!("AI config setting changed, bumping instance AI cache revision");
bump_instance_ai_config_revision();
}
OTEL_SETTING => {
tracing::info!("OTEL setting changed, restarting");
send_delayed_killpill(tx, 4, "OTEL setting change").await;

View File

@@ -88,7 +88,13 @@ use windmill_common::{
MONITOR_LOGS_ON_OBJECT_STORE, OTEL_LOGS_ENABLED, OTEL_METRICS_ENABLED, OTEL_TRACING_ENABLED,
SERVICE_LOG_RETENTION_SECS,
};
use windmill_common::{client::AuthedClient, global_settings::APP_WORKSPACED_ROUTE_SETTING};
use windmill_common::{
client::AuthedClient,
global_settings::{
APP_WORKSPACED_ROUTE_SETTING, HTTP_ROUTE_WORKSPACED_ROUTE,
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
},
};
#[cfg(feature = "parquet")]
use windmill_object_store::reload_object_store_setting;
use windmill_queue::{cancel_job, get_queued_job_v2, SameWorkerPayload};
@@ -296,6 +302,10 @@ pub async fn initial_load(
if let Err(e) = reload_app_workspaced_route_setting(db).await {
tracing::error!("Error reloading app workspaced route: {:?}", e)
}
if let Err(e) = reload_http_route_workspaced_route_setting(db).await {
tracing::error!("Error reloading http route workspaced route: {:?}", e)
}
}
#[cfg(feature = "parquet")]
@@ -1168,6 +1178,15 @@ pub async fn delete_expired_items(db: &DB) -> () {
tracing::error!("Error deleting custom concurrency key: {:?}", e);
}
}
match windmill_common::trashbin::delete_expired_trash(db).await {
Ok(count) => {
if count > 0 {
tracing::info!("deleted {} expired trash items", count);
}
}
Err(e) => tracing::error!("Error deleting expired trash items: {}", e.to_string()),
}
}
pub async fn check_expiring_tokens(db: &DB) {
@@ -3390,6 +3409,39 @@ pub async fn reload_app_workspaced_route_setting(conn: &DB) -> error::Result<()>
Ok(())
}
pub async fn reload_http_route_workspaced_route_setting(conn: &DB) -> error::Result<()> {
let http_route_workspaced_route =
load_value_from_global_settings(conn, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING).await?;
let ws_route = match http_route_workspaced_route {
Some(serde_json::Value::Bool(ws_route)) => ws_route,
None => false,
_ => {
tracing::error!(
"Expected {} to be a boolean got: {:?}. Defaulting to false",
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
http_route_workspaced_route
);
false
}
};
let mut l = HTTP_ROUTE_WORKSPACED_ROUTE.write().await;
if *l != ws_route {
*l = ws_route;
drop(l);
// Bump the HTTP trigger version so the route cache is rebuilt with
// the updated workspaced_route behavior on the next request.
sqlx::query!("SELECT nextval('http_trigger_version_seq')")
.fetch_one(conn)
.await?;
} else {
*l = ws_route;
}
Ok(())
}
pub async fn reload_critical_alerts_on_db_oversize(conn: &DB) -> error::Result<()> {
#[derive(Deserialize)]
struct DBOversize {

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,37 @@
[package]
name = "windmill-ai"
version.workspace = true
authors.workspace = true
edition.workspace = true
[features]
default = []
bedrock = ["dep:aws-sdk-bedrockruntime", "dep:aws-credential-types", "dep:aws-smithy-types", "dep:aws-config"]
mcp = ["dep:windmill-mcp"]
[lib]
name = "windmill_ai"
path = "src/lib.rs"
[dependencies]
windmill-common = { workspace = true, default-features = false }
windmill-types.workspace = true
windmill-parser.workspace = true
windmill-mcp = { workspace = true, optional = true }
async-trait.workspace = true
base64.workspace = true
reqwest.workspace = true
serde.workspace = true
serde_json.workspace = true
sqlx = { workspace = true, features = ["postgres"] }
uuid.workspace = true
lazy_static.workspace = true
tracing.workspace = true
tokio.workspace = true
# Bedrock (optional)
aws-config = { workspace = true, optional = true }
aws-credential-types = { workspace = true, optional = true }
aws-smithy-types = { workspace = true, optional = true }
aws-sdk-bedrockruntime = { workspace = true, optional = true }

View File

@@ -11,13 +11,14 @@ use aws_config::BehaviorVersion;
use aws_credential_types::provider::token::ProvideToken;
use aws_credential_types::provider::ProvideCredentials;
use aws_sdk_bedrockruntime::types::{
ContentBlock, ConversationRole, ConverseStreamOutput, ImageBlock, ImageFormat, ImageSource,
InferenceConfiguration, Message, SystemContentBlock, Tool, ToolInputSchema, ToolSpecification,
ContentBlock, ConversationRole, ConverseStreamOutput, DocumentBlock, DocumentFormat,
DocumentSource, ImageBlock, ImageFormat, ImageSource, InferenceConfiguration, Message,
SystemContentBlock, Tool, ToolInputSchema, ToolSpecification,
};
use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient;
use serde::{Deserialize, Serialize};
use crate::error::Error;
use windmill_common::error::Error;
use crate::ai_types::{
ContentPart, OpenAIContent, OpenAIFunction, OpenAIMessage, OpenAIToolCall, ToolDef,
@@ -356,13 +357,12 @@ pub fn content_to_text(content: &OpenAIContent) -> String {
}
}
/// Parse image data URL and extract format and base64 data
fn parse_image_data_url(url: &str) -> Result<(ImageFormat, Vec<u8>), Error> {
/// Parse a data URL and extract MIME type and decoded bytes.
fn parse_data_url_bytes(url: &str) -> Result<(String, Vec<u8>), Error> {
if !url.starts_with("data:") {
return Err(Error::internal_err("Image URL must be a data URL"));
return Err(Error::internal_err("URL must be a data URL"));
}
// Parse data:image/png;base64,<data>
let base64_start = url
.find("base64,")
.ok_or_else(|| Error::internal_err("Invalid data URL format"))?;
@@ -372,30 +372,51 @@ fn parse_image_data_url(url: &str) -> Result<(ImageFormat, Vec<u8>), Error> {
.split(';')
.next()
.and_then(|s| s.strip_prefix("data:"))
.unwrap_or("image/png");
.unwrap_or("application/octet-stream");
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_data)
.map_err(|e| Error::internal_err(format!("Failed to decode base64 data: {}", e)))?;
Ok((mime_type.to_string(), bytes))
}
/// Parse an image data URL and extract ImageFormat and decoded bytes.
fn parse_image_data_url(url: &str) -> Result<(ImageFormat, Vec<u8>), Error> {
let (mime_type, bytes) = parse_data_url_bytes(url)?;
// Extract format from MIME type (e.g., "image/png" -> "png")
let format_str = mime_type
.rsplit_once('/')
.map(|(_, format)| format)
.unwrap_or("png");
// Map to ImageFormat enum
let format = match format_str {
"png" => ImageFormat::Png,
"jpeg" | "jpg" => ImageFormat::Jpeg,
"gif" => ImageFormat::Gif,
"webp" => ImageFormat::Webp,
_ => ImageFormat::Png, // Default to PNG
_ => ImageFormat::Png,
};
// Decode base64
let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_data)
.map_err(|e| Error::internal_err(format!("Failed to decode base64 image: {}", e)))?;
Ok((format, bytes))
}
/// Map a MIME type to a Bedrock DocumentFormat.
fn mime_to_document_format(mime_type: &str) -> DocumentFormat {
match mime_type {
"application/pdf" => DocumentFormat::Pdf,
"text/csv" => DocumentFormat::Csv,
"text/html" => DocumentFormat::Html,
"text/plain" => DocumentFormat::Txt,
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => {
DocumentFormat::Docx
}
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" => {
DocumentFormat::Xlsx
}
_ => DocumentFormat::Pdf,
}
}
/// Convert a ContentPart to Bedrock ContentBlock
fn content_part_to_block(part: &ContentPart) -> Result<Option<ContentBlock>, Error> {
match part {
@@ -418,8 +439,21 @@ fn content_part_to_block(part: &ContentPart) -> Result<Option<ContentBlock>, Err
Ok(Some(ContentBlock::Image(image_block)))
}
ContentPart::File { file } => {
let (mime_type, bytes) = parse_data_url_bytes(&file.file_data)?;
let doc_source = DocumentSource::Bytes(bytes.into());
let doc_block = DocumentBlock::builder()
.format(mime_to_document_format(&mime_type))
.name(file.filename.replace('.', "_"))
.source(doc_source)
.build()
.map_err(|e| {
Error::internal_err(format!("Failed to build document block: {}", e))
})?;
Ok(Some(ContentBlock::Document(doc_block)))
}
ContentPart::S3Object { .. } => {
// S3Objects should be converted to ImageUrl before calling this function
// S3Objects should be converted before calling this function
Ok(None)
}
}

View File

@@ -0,0 +1,11 @@
use std::sync::atomic::{AtomicU64, Ordering};
static INSTANCE_AI_CONFIG_REVISION: AtomicU64 = AtomicU64::new(0);
pub fn current_instance_ai_config_revision() -> u64 {
INSTANCE_AI_CONFIG_REVISION.load(Ordering::SeqCst)
}
pub fn bump_instance_ai_config_revision() -> u64 {
INSTANCE_AI_CONFIG_REVISION.fetch_add(1, Ordering::SeqCst) + 1
}

View File

@@ -9,8 +9,11 @@
use serde::{Deserialize, Serialize};
use crate::ai_types::{ContentPart, ExtraContent, GoogleExtraContent, OpenAIContent, OpenAIMessage, ToolDef, UrlCitation};
use crate::error::Error;
use crate::ai_types::{
ContentPart, ExtraContent, GoogleExtraContent, OpenAIContent, OpenAIMessage, ToolDef,
UrlCitation,
};
use windmill_common::error::Error;
// ============================================================================
// Request / Content Types
@@ -87,7 +90,10 @@ pub struct GeminiTextRequest {
/// Tool definition — function declarations and/or Google Search grounding.
#[derive(Serialize)]
pub struct GeminiTool {
#[serde(rename = "functionDeclarations", skip_serializing_if = "Option::is_none")]
#[serde(
rename = "functionDeclarations",
skip_serializing_if = "Option::is_none"
)]
pub function_declarations: Option<Vec<GeminiFunctionDeclaration>>,
#[serde(rename = "googleSearch", skip_serializing_if = "Option::is_none")]
pub google_search: Option<serde_json::Value>,
@@ -115,7 +121,10 @@ pub struct GeminiToolConfig {
#[derive(Serialize)]
pub struct GeminiFunctionCallingConfig {
pub mode: String,
#[serde(rename = "allowedFunctionNames", skip_serializing_if = "Option::is_none")]
#[serde(
rename = "allowedFunctionNames",
skip_serializing_if = "Option::is_none"
)]
pub allowed_function_names: Option<Vec<String>>,
}
@@ -341,7 +350,12 @@ pub fn convert_content_to_gemini_parts(content: &OpenAIContent) -> Vec<GeminiPar
Some(GeminiPart::Text { text: text.clone() })
}
ContentPart::ImageUrl { image_url } => {
parse_data_url(&image_url.url).map(|(mime_type, data)| {
parse_data_url(&image_url.url).map(|(mime_type, data)| GeminiPart::InlineData {
inline_data: GeminiInlineData { mime_type, data },
})
}
ContentPart::File { file } => {
parse_data_url(&file.file_data).map(|(mime_type, data)| {
GeminiPart::InlineData {
inline_data: GeminiInlineData { mime_type, data },
}
@@ -372,15 +386,12 @@ pub fn openai_messages_to_gemini(
if let Some(content) = &msg.content {
let parts = convert_content_to_gemini_parts(content);
if !parts.is_empty() {
system_instruction =
Some(GeminiContentMessage { role: None, parts });
system_instruction = Some(GeminiContentMessage { role: None, parts });
}
}
}
"tool" => {
if let (Some(tool_call_id), Some(content)) =
(&msg.tool_call_id, &msg.content)
{
if let (Some(tool_call_id), Some(content)) = (&msg.tool_call_id, &msg.content) {
let func_name = find_gemini_function_name(messages, tool_call_id);
let response_text = match content {
OpenAIContent::Text(text) => text.clone(),
@@ -435,10 +446,8 @@ pub fn openai_messages_to_gemini(
}
if !parts.is_empty() {
contents.push(GeminiContentMessage {
role: Some(gemini_role.to_string()),
parts,
});
contents
.push(GeminiContentMessage { role: Some(gemini_role.to_string()), parts });
}
}
}
@@ -469,10 +478,8 @@ pub fn openai_tools_to_gemini(
.collect();
if !declarations.is_empty() {
gemini_tools.push(GeminiTool {
function_declarations: Some(declarations),
google_search: None,
});
gemini_tools
.push(GeminiTool { function_declarations: Some(declarations), google_search: None });
}
if has_websearch {

View File

@@ -2,8 +2,8 @@
* This file contains shared AI provider utilities used by both the API and worker.
*/
use crate::db::DB;
use crate::error::{Error, Result};
use windmill_common::db::DB;
use windmill_common::error::{Error, Result};
use serde::{Deserialize, Deserializer, Serialize};
/// Deserializes an Option<String> where empty strings become None.

View File

@@ -6,7 +6,7 @@
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use crate::flow_status::AgentAction;
use windmill_common::flow_status::AgentAction;
use windmill_types::s3::S3Object;
// ============================================================================
@@ -33,6 +33,11 @@ pub enum ContentPart {
ImageUrl {
image_url: ImageUrlData,
},
/// File content block for OpenAI Chat Completions format (PDFs, etc.)
#[serde(rename = "file")]
File {
file: FileData,
},
#[serde(rename = "s3_object")]
S3Object {
s3_object: S3Object,
@@ -44,6 +49,25 @@ pub struct ImageUrlData {
pub url: String, // data:image/png;base64,... or https://...
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct FileData {
pub filename: String,
pub file_data: String, // data:application/pdf;base64,...
}
/// Check if a MIME type represents a document (as opposed to an image).
pub fn is_document_mime(mime_type: &str) -> bool {
matches!(
mime_type,
"application/pdf"
| "text/csv"
| "text/html"
| "text/plain"
| "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
| "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(untagged)]
pub enum OpenAIContent {

View File

@@ -0,0 +1,8 @@
#[cfg(feature = "bedrock")]
pub mod ai_bedrock;
pub mod ai_cache;
pub mod ai_google;
pub mod ai_providers;
pub mod ai_types;
pub mod query_builder;
pub mod types;

View File

@@ -0,0 +1,99 @@
use async_trait::async_trait;
use windmill_common::{client::AuthedClient, error::Error};
use windmill_types::s3::S3Object;
use crate::ai_types::OpenAIToolCall;
use crate::types::*;
/// Arguments for building an AI request
pub struct BuildRequestArgs<'a> {
pub messages: &'a [OpenAIMessage],
pub tools: Option<&'a [ToolDef]>,
pub model: &'a str,
pub temperature: Option<f32>,
pub max_tokens: Option<u32>,
pub output_schema: Option<&'a OpenAPISchema>,
pub output_type: &'a OutputType,
pub system_prompt: Option<&'a str>,
pub user_message: &'a str,
pub attachments: Option<&'a [S3Object]>,
pub has_websearch: bool,
}
/// Response from AI provider
pub enum ParsedResponse {
Text {
content: Option<String>,
tool_calls: Vec<OpenAIToolCall>,
events_str: Option<String>,
annotations: Vec<UrlCitation>,
used_websearch: bool,
usage: Option<TokenUsage>,
},
Image {
base64_data: String,
},
}
/// Trait for streaming AI events to a sink (e.g., database persistence).
/// Implemented by the worker's StreamEventProcessor.
#[async_trait]
pub trait StreamEventSink: Send + Sync {
async fn send(&self, event: StreamingEvent, events_str: &mut String) -> Result<(), Error>;
}
/// Trait for building provider-specific AI requests
#[async_trait]
pub trait QueryBuilder: Send + Sync {
/// Check if this provider supports tools with the given output type
fn supports_tools_with_output_type(&self, output_type: &OutputType) -> bool;
/// Build the request body for the provider
async fn build_request(
&self,
args: &BuildRequestArgs<'_>,
client: &AuthedClient,
workspace_id: &str,
) -> Result<String, Error>;
/// Build the request body without usage tracking (for retry on incompatible providers)
/// Default implementation just calls build_request (most providers don't need this)
async fn build_request_without_usage(
&self,
args: &BuildRequestArgs<'_>,
client: &AuthedClient,
workspace_id: &str,
) -> Result<String, Error> {
self.build_request(args, client, workspace_id).await
}
/// Whether this provider supports retry without usage tracking
/// Only OtherQueryBuilder (OpenAI-compatible providers) needs this
fn supports_retry_without_usage(&self) -> bool {
false
}
/// Parse the image response from the provider
async fn parse_image_response(
&self,
response: reqwest::Response,
) -> Result<ParsedResponse, Error>;
/// Parse streaming response from the provider
async fn parse_streaming_response(
&self,
response: reqwest::Response,
stream_event_sink: Box<dyn StreamEventSink>,
) -> Result<ParsedResponse, Error>;
/// Get the API endpoint for this provider
fn get_endpoint(&self, base_url: &str, model: &str, output_type: &OutputType) -> String;
/// Get the authentication headers for this provider
fn get_auth_headers(
&self,
api_key: &str,
base_url: &str,
output_type: &OutputType,
) -> Vec<(&'static str, String)>;
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1657,6 +1657,38 @@ async fn delete_flow_by_path(
}
let mut tx = user_db.begin(&authed).await?;
// Capture all related data for trashbin before deleting (CASCADE will remove flow_version, flow_node)
let trash_flow: Option<serde_json::Value> =
sqlx::query_scalar("SELECT to_jsonb(t) FROM flow t WHERE path = $1 AND workspace_id = $2")
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let trash_flow_versions: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM flow_version t WHERE path = $1 AND workspace_id = $2",
)
.bind(path)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
let trash_flow_nodes: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM flow_node t WHERE path = $1 AND workspace_id = $2",
)
.bind(path)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
let trash_drafts: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
)
.bind(path)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'flow'",
path,
@@ -1673,6 +1705,28 @@ async fn delete_flow_by_path(
.execute(&mut *tx)
.await?;
if let Some(flow_data) = trash_flow {
let mut trash_data = serde_json::json!({"row": flow_data});
if !trash_flow_versions.is_empty() {
trash_data["flow_versions"] = serde_json::Value::Array(trash_flow_versions);
}
if !trash_flow_nodes.is_empty() {
trash_data["flow_nodes"] = serde_json::Value::Array(trash_flow_nodes);
}
if !trash_drafts.is_empty() {
trash_data["drafts"] = serde_json::Value::Array(trash_drafts);
}
windmill_common::trashbin::move_to_trash(
&mut *tx,
&w_id,
"flow",
path,
trash_data,
&authed.username,
)
.await?;
}
if !query.keep_captures.unwrap_or(false) {
sqlx::query!(
"DELETE FROM capture_config WHERE path = $1 AND workspace_id = $2 AND is_flow IS TRUE",

View File

@@ -18,7 +18,6 @@ use regex::Regex;
use windmill_api_auth::{check_scopes, ApiAuthed, AuthCache, Tokened};
use windmill_audit::audit_oss::{audit_log, AuditAuthorable};
use windmill_audit::ActionKind;
use windmill_common::{error::Error, webhook::{WebhookMessage, WebhookShared}, workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult}};
use windmill_common::DB;
use windmill_common::{
db::UserDB,
@@ -26,6 +25,11 @@ use windmill_common::{
users::username_to_permissioned_as,
utils::{not_found_if_none, paginate, Pagination},
};
use windmill_common::{
error::Error,
webhook::{WebhookMessage, WebhookShared},
workspaces::{check_user_against_rule, ProtectionRuleKind, RuleCheckResult},
};
use serde::{Deserialize, Serialize};
use sqlx::{FromRow, Postgres, Transaction};
@@ -716,13 +720,14 @@ async fn add_owner(
.await?;
validate_owner(&owner)?;
sqlx::query(&format!(
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
true) WHERE name = $2 AND workspace_id = $3 RETURNING extra_perms"
))
sqlx::query(
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, array[$4]::text[], to_jsonb($1), \
true) WHERE name = $2 AND workspace_id = $3 RETURNING extra_perms",
)
.bind(true)
.bind(&name)
.bind(&w_id)
.bind(&owner)
.fetch_optional(&mut *tx)
.await?;
@@ -787,14 +792,15 @@ async fn remove_owner(
}
if let Some(write) = write {
let old_write = sqlx::query_scalar::<_, Option<bool>>(&format!(
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, '{{\"{owner}\"}}', to_jsonb($1), \
true) FROM (SELECT (extra_perms->>'{owner}')::boolean as old_val FROM folder WHERE name = $2 AND workspace_id = $3) old \
let old_write = sqlx::query_scalar::<_, Option<bool>>(
"UPDATE folder SET extra_perms = jsonb_set(extra_perms, array[$4]::text[], to_jsonb($1), \
true) FROM (SELECT (extra_perms->>$4)::boolean as old_val FROM folder WHERE name = $2 AND workspace_id = $3) old \
WHERE name = $2 AND workspace_id = $3 RETURNING old.old_val"
))
)
.bind(write)
.bind(&name)
.bind(&w_id)
.bind(&owner)
.fetch_optional(&mut *tx)
.await?
.flatten();

View File

@@ -656,6 +656,7 @@ async fn delete_group(
)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,

View File

@@ -31,6 +31,7 @@ reqwest.workspace = true
tokio.workspace = true
anyhow.workspace = true
uuid.workspace = true
futures.workspace = true
rand.workspace = true
rumqttc.workspace = true
rdkafka.workspace = true

View File

@@ -422,6 +422,7 @@ async fn test_delete_integration_full_cascade(db: Pool<Postgres>) -> anyhow::Res
"ext-1",
&trigger_config,
json!({"triggerType": "drive"}),
None,
)
.await?;
@@ -511,6 +512,7 @@ async fn test_cleanup_preserves_triggers(db: Pool<Postgres>) -> anyhow::Result<(
"ext-1",
&trigger_config,
json!({"triggerType": "drive"}),
None,
)
.await?;

View File

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

View File

@@ -82,12 +82,10 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
assert_eq!(resp.status(), 200);
// --- allowed_domain_auto_invite ---
let resp = authed(client().get(format!(
"{global_base}/allowed_domain_auto_invite"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("{global_base}/allowed_domain_auto_invite")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
resp.json::<bool>().await?;
@@ -213,12 +211,10 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
resp.json::<Vec<String>>().await?;
// --- get_dependents (empty, no dependencies exist) ---
let resp = authed(client().get(format!(
"{base}/get_dependents/u/test-user/nonexistent"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("{base}/get_dependents/u/test-user/nonexistent")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let dependents = resp.json::<Vec<serde_json::Value>>().await?;
assert!(dependents.is_empty());
@@ -425,13 +421,11 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
);
// --- edit_large_file_storage_config ---
let resp = authed(client().post(format!(
"{base}/edit_large_file_storage_config"
)))
.json(&json!({"large_file_storage": null}))
.send()
.await
.unwrap();
let resp = authed(client().post(format!("{base}/edit_large_file_storage_config")))
.json(&json!({"large_file_storage": null}))
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
@@ -532,9 +526,7 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.unwrap();
let invites = resp.json::<Vec<serde_json::Value>>().await?;
assert!(
invites
.iter()
.any(|i| i["email"] == "invited@example.com"),
invites.iter().any(|i| i["email"] == "invited@example.com"),
"invite not found: {:?}",
invites
);
@@ -549,12 +541,7 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
201,
"delete_invite: {}",
resp.text().await?
);
assert_eq!(resp.status(), 201, "delete_invite: {}", resp.text().await?);
// ===== Critical alerts (EE-gated, returns 404 in OSS) =====
@@ -624,12 +611,7 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
.send()
.await
.unwrap();
assert_eq!(
resp.status(),
200,
"create_fork: {}",
resp.text().await?
);
assert_eq!(resp.status(), 200, "create_fork: {}", resp.text().await?);
// verify fork exists
let resp = authed(client().post(format!("{global_base}/exists")))
@@ -702,13 +684,122 @@ async fn test_workspace_endpoints(db: Pool<Postgres>) -> anyhow::Result<()> {
assert_eq!(resp.json::<bool>().await?, false);
// --- create_workspace_require_superadmin ---
let resp = authed(client().get(format!(
"{global_base}/create_workspace_require_superadmin"
)))
.send()
.await
.unwrap();
let resp = authed(client().get(format!("{global_base}/create_workspace_require_superadmin")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_get_copilot_settings_state_reports_instance_ai_fallback_flags(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
let instance_ai_config = json!({
"providers": {
"openai": {
"resource_path": "u/test-user/openai_instance",
"models": ["gpt-4o-mini"]
}
}
});
let workspace_ai_config = json!({
"providers": {
"anthropic": {
"resource_path": "u/test-user/anthropic_workspace",
"models": ["claude-3-5-haiku-latest"]
}
}
});
sqlx::query("UPDATE workspace_settings SET ai_config = NULL WHERE workspace_id = $1")
.bind("test-workspace")
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
)
.bind("ai_config")
.bind(instance_ai_config)
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/get_copilot_settings_state")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert_eq!(settings["has_instance_ai_config"], true);
assert_eq!(settings["uses_instance_ai_config"], true);
assert_eq!(
settings["instance_ai_summary"]["providers"][0]["provider"],
"openai"
);
assert_eq!(
settings["instance_ai_summary"]["providers"][0]["models"][0],
"gpt-4o-mini"
);
sqlx::query("UPDATE workspace_settings SET ai_config = $1 WHERE workspace_id = $2")
.bind(workspace_ai_config)
.bind("test-workspace")
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/get_copilot_settings_state")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert_eq!(settings["has_instance_ai_config"], true);
assert_eq!(settings["uses_instance_ai_config"], false);
assert_eq!(
settings["instance_ai_summary"]["providers"][0]["provider"],
"openai"
);
Ok(())
}
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
async fn test_get_copilot_info_ignores_empty_instance_ai_row(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace/workspaces");
sqlx::query("UPDATE workspace_settings SET ai_config = NULL WHERE workspace_id = $1")
.bind("test-workspace")
.execute(&db)
.await?;
sqlx::query(
"INSERT INTO global_settings (name, value) VALUES ($1, $2) \
ON CONFLICT (name) DO UPDATE SET value = EXCLUDED.value",
)
.bind("ai_config")
.bind(json!({}))
.execute(&db)
.await?;
let resp = authed(client().get(format!("{base}/get_copilot_info")))
.send()
.await
.unwrap();
assert_eq!(resp.status(), 200);
let settings = resp.json::<serde_json::Value>().await?;
assert!(settings["providers"].is_null());
Ok(())
}

View File

@@ -50,11 +50,11 @@ pub fn filter_list_queue_query(
.values
.iter()
.map(|v| {
let p = v.replace("*", "%").replace("'", "''");
let p = v.replace("*", "%");
if w.negated {
format!("v2_job_queue.worker NOT LIKE '{p}'")
format!("v2_job_queue.worker NOT LIKE {}", quote(&p))
} else {
format!("v2_job_queue.worker LIKE '{p}'")
format!("v2_job_queue.worker LIKE {}", quote(&p))
}
})
.collect();
@@ -77,11 +77,11 @@ pub fn filter_list_queue_query(
.values
.iter()
.map(|v| {
let e = v.replace("'", "''");
let p = format!("{}%", v);
if ps.negated {
format!("runnable_path NOT LIKE '{e}%'")
format!("runnable_path NOT LIKE {}", quote(&p))
} else {
format!("runnable_path LIKE '{e}%'")
format!("runnable_path LIKE {}", quote(&p))
}
})
.collect();
@@ -123,11 +123,11 @@ pub fn filter_list_queue_query(
.values
.iter()
.map(|v| {
let p = v.replace("*", "%").replace("'", "''");
let p = v.replace("*", "%");
if t.negated {
format!("v2_job.tag NOT LIKE '{p}'")
format!("v2_job.tag NOT LIKE {}", quote(&p))
} else {
format!("v2_job.tag LIKE '{p}'")
format!("v2_job.tag LIKE {}", quote(&p))
}
})
.collect();
@@ -287,14 +287,14 @@ pub fn filter_list_completed_query(
.values
.iter()
.map(|v| {
let p = v.replace("*", "%").replace("'", "''");
let p = v.replace("*", "%");
if label.negated {
format!(
"NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE '{p}')"
"NOT EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE {})", quote(&p)
)
} else {
format!(
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE '{p}')"
"EXISTS (SELECT 1 FROM jsonb_array_elements_text(result->'wm_labels') lbl WHERE jsonb_typeof(result->'wm_labels') = 'array' AND lbl LIKE {})", quote(&p)
)
}
})
@@ -308,14 +308,14 @@ pub fn filter_list_completed_query(
let clauses: Vec<_> = label
.values
.iter()
.map(|v| format!("NOT (result->'wm_labels' ? '{}')", v.replace("'", "''")))
.map(|v| format!("NOT (result->'wm_labels' ? {})", quote(v)))
.collect();
sqlb.and_where(format!("({})", clauses.join(" AND ")));
} else {
let clauses: Vec<_> = label
.values
.iter()
.map(|v| format!("result->'wm_labels' ? '{}'", v.replace("'", "''")))
.map(|v| format!("result->'wm_labels' ? {}", quote(v)))
.collect();
sqlb.and_where("result ? 'wm_labels'");
sqlb.and_where(format!("({})", clauses.join(" OR ")));
@@ -329,11 +329,11 @@ pub fn filter_list_completed_query(
.values
.iter()
.map(|v| {
let p = v.replace("*", "%").replace("'", "''");
let p = v.replace("*", "%");
if worker.negated {
format!("v2_job_completed.worker NOT LIKE '{p}'")
format!("v2_job_completed.worker NOT LIKE {}", quote(&p))
} else {
format!("v2_job_completed.worker LIKE '{p}'")
format!("v2_job_completed.worker LIKE {}", quote(&p))
}
})
.collect();
@@ -366,11 +366,11 @@ pub fn filter_list_completed_query(
.values
.iter()
.map(|v| {
let e = v.replace("'", "''");
let p = format!("{}%", v);
if ps.negated {
format!("runnable_path NOT LIKE '{e}%'")
format!("runnable_path NOT LIKE {}", quote(&p))
} else {
format!("runnable_path LIKE '{e}%'")
format!("runnable_path LIKE {}", quote(&p))
}
})
.collect();
@@ -400,11 +400,11 @@ pub fn filter_list_completed_query(
.values
.iter()
.map(|v| {
let p = v.replace("*", "%").replace("'", "''");
let p = v.replace("*", "%");
if t.negated {
format!("v2_job.tag NOT LIKE '{p}'")
format!("v2_job.tag NOT LIKE {}", quote(&p))
} else {
format!("v2_job.tag LIKE '{p}'")
format!("v2_job.tag LIKE {}", quote(&p))
}
})
.collect();
@@ -449,11 +449,7 @@ pub fn filter_list_completed_query(
}
if let Some(dt) = &lq.created_or_started_after {
let ts = dt.to_rfc3339();
sqlb.and_where(format!(
"(created_at >= '{}' OR started_at >= '{}')",
ts.replace("'", "''"),
ts.replace("'", "''")
));
sqlb.and_where("(created_at >= ? OR started_at >= ?)".bind(&ts).bind(&ts));
}
if let Some(dt) = &lq.created_before {

View File

@@ -963,6 +963,15 @@ async fn delete_schedule(
)));
}
// Capture row for trashbin before deleting
let trash_data: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT jsonb_build_object('row', to_jsonb(t)) FROM schedule t WHERE path = $1 AND workspace_id = $2",
)
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let del = sqlx::query_scalar!(
"DELETE FROM schedule WHERE path = $1 AND workspace_id = $2 RETURNING 1",
path,
@@ -979,6 +988,18 @@ async fn delete_schedule(
)));
}
if let Some(data) = trash_data {
windmill_common::trashbin::move_to_trash(
&mut *tx,
&w_id,
"schedule",
path,
data,
&authed.username,
)
.await?;
}
audit_log(
&mut *tx,
&authed,

View File

@@ -239,6 +239,7 @@ pub fn workspaced_service() -> Router {
"/history_update/h/:hash/p/*path",
post(update_script_history),
)
.route("/list_dedicated_with_deps", get(list_dedicated_with_deps))
// Temporary raw script storage for CLI lock generation
.route("/raw_temp/store", post(store_raw_script_temp))
.route("/raw_temp/diff", post(diff_raw_scripts_with_deployed))
@@ -2239,33 +2240,58 @@ async fn delete_script_by_path(
.await?
.unwrap_or(false);
let script = if !draft_only {
if !draft_only {
require_admin(authed.is_admin, &authed.username)?;
sqlx::query_scalar!(
"DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path",
}
// Capture all script versions and drafts for trashbin before deleting
let trash_scripts: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM script t WHERE path = $1 AND workspace_id = $2",
)
.bind(path)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
let trash_drafts: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
)
.bind(path)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
let script = sqlx::query_scalar!(
"DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path",
path,
w_id
)
.fetch_one(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))?;
if !trash_scripts.is_empty() {
let mut trash_data = serde_json::json!({"scripts": trash_scripts});
if !trash_drafts.is_empty() {
trash_data["drafts"] = serde_json::Value::Array(trash_drafts);
}
windmill_common::trashbin::move_to_trash(
&mut *tx,
&w_id,
"script",
path,
w_id
trash_data,
&authed.username,
)
.fetch_one(&db)
.await
.map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))?
} else {
sqlx::query_scalar!(
"DELETE FROM script WHERE path = $1 AND workspace_id = $2 RETURNING path",
path,
w_id
)
.fetch_one(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("deleting script by path {w_id}: {e:#}")))?
};
.await?;
}
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'script'",
path,
w_id
)
.execute(&db)
.execute(&mut *tx)
.await?;
if !query.keep_captures.unwrap_or(false) {
@@ -2274,7 +2300,7 @@ async fn delete_script_by_path(
path,
w_id
)
.execute(&db)
.execute(&mut *tx)
.await?;
sqlx::query!(
@@ -2282,7 +2308,7 @@ async fn delete_script_by_path(
path,
w_id
)
.execute(&db)
.execute(&mut *tx)
.await?;
}
@@ -2369,6 +2395,30 @@ async fn delete_scripts_bulk(
let mut tx = db.begin().await?;
// Capture scripts for trashbin per path before bulk delete
for path in &request.paths {
let trash_scripts: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM script t WHERE path = $1 AND workspace_id = $2",
)
.bind(path)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
if !trash_scripts.is_empty() {
let trash_data = serde_json::json!({"scripts": trash_scripts});
windmill_common::trashbin::move_to_trash(
&mut *tx,
&w_id,
"script",
path,
trash_data,
&authed.username,
)
.await?;
}
}
let mut deleted_paths = sqlx::query_scalar!(
"DELETE FROM script WHERE workspace_id = $1 AND path = ANY($2) RETURNING path",
w_id,
@@ -2480,6 +2530,62 @@ async fn guard_script_from_debounce_data(ns: &NewScript) -> Result<()> {
}
}
#[derive(Serialize)]
struct DedicatedScriptDeps {
path: String,
language: ScriptLang,
workspace_dep_names: Vec<String>,
}
async fn list_dedicated_with_deps(
authed: ApiAuthed,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
) -> JsonResult<Vec<DedicatedScriptDeps>> {
let mut tx = user_db.begin(&authed).await?;
let rows = sqlx::query!(
"SELECT DISTINCT ON (path) path, language AS \"language: ScriptLang\", content FROM script
WHERE workspace_id = $1
AND archived = false
AND dedicated_worker = true
AND language = ANY($2::SCRIPT_LANG[])
ORDER BY path, created_at DESC",
&w_id,
&[
ScriptLang::Python3,
ScriptLang::Bun,
ScriptLang::Bunnative,
ScriptLang::Deno,
] as &[ScriptLang],
)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
let result = rows
.into_iter()
.map(|row| {
let dep_names =
windmill_common::scripts::extract_workspace_dependencies_annotated_refs(
&row.language,
&row.content,
&row.path,
)
.map(|refs| refs.external)
.unwrap_or_default();
DedicatedScriptDeps {
path: row.path,
language: row.language,
workspace_dep_names: dep_names,
}
})
.collect();
Ok(Json(result))
}
// ============================================================================
// Temporary Raw Script Storage for CLI Lock Generation
// ============================================================================
@@ -2508,11 +2614,9 @@ async fn store_raw_script_temp(
.await?;
// Clean up old entries (1 week TTL)
sqlx::query!(
"DELETE FROM raw_script_temp WHERE created_at < NOW() - INTERVAL '1 week'"
)
.execute(&db)
.await?;
sqlx::query!("DELETE FROM raw_script_temp WHERE created_at < NOW() - INTERVAL '1 week'")
.execute(&db)
.await?;
Ok(Json(hash))
}
@@ -2560,7 +2664,7 @@ async fn diff_raw_scripts_with_deployed(
FROM script s \
WHERE s.path = local.path AND s.workspace_id = $3 AND s.archived = false \
ORDER BY s.created_at DESC LIMIT 1 \
) deployed ON deployed.deployed_hash = local.hash"
) deployed ON deployed.deployed_hash = local.hash",
)
.bind(&paths)
.bind(&hashes)
@@ -2582,7 +2686,7 @@ async fn diff_raw_scripts_with_deployed(
AND wd.language = $3::SCRIPT_LANG \
AND wd.name IS NOT DISTINCT FROM $4 \
AND encode(sha256(convert_to(wd.content, 'UTF8')), 'hex') = $5 \
)"
)",
)
.bind(&dep.path)
.bind(&w_id)

View File

@@ -16,6 +16,7 @@ parquet = ["windmill-common/parquet", "windmill-object-store/parquet"]
license = ["dep:rsa"]
[dependencies]
windmill-ai = { workspace = true, default-features = false }
windmill-alerting.workspace = true
windmill-api-auth.workspace = true
windmill-common = { workspace = true, default-features = false }

View File

@@ -37,15 +37,16 @@ use serde::{Deserialize, Serialize};
use windmill_common::ee_oss::{send_critical_alert, CriticalAlertKind, CriticalErrorChannel};
#[cfg(all(feature = "private", feature = "enterprise"))]
use windmill_common::secret_backend::{SecretMigrationReport, VaultSettings};
use windmill_ai::ai_cache::bump_instance_ai_config_revision;
use windmill_common::{
email_oss::send_email_plain_text,
error::{self, JsonResult, Result},
get_database_url,
global_settings::{
APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING, DISABLE_HUB_SETTING,
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING,
WS_BASE_URL_SETTING,
EMAIL_DOMAIN_SETTING, ENV_SETTINGS, HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
HUB_ACCESSIBLE_URL_SETTING, HUB_BASE_URL_SETTING, WS_BASE_URL_SETTING,
},
instance_config::{self, ApplyMode, InstanceConfig},
server::Smtp,
@@ -284,6 +285,7 @@ pub async fn set_global_setting_internal(
key: String,
value: serde_json::Value,
) -> error::Result<()> {
let should_bump_instance_ai_revision = key == AI_CONFIG_SETTING;
let value = if key == "retention_period_secs" {
instance_config::clamp_retention_period(value)
} else {
@@ -325,6 +327,10 @@ pub async fn set_global_setting_internal(
}
};
if should_bump_instance_ai_revision {
bump_instance_ai_config_revision();
}
Ok(())
}
@@ -418,6 +424,74 @@ async fn run_setting_pre_write_hook(
}
}
}
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING => {
let serde_json::Value::Bool(workspaced_route) = value else {
return Err(error::Error::BadRequest(format!(
"{} setting expected to be boolean",
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING
)));
};
if !*workspaced_route {
#[derive(Debug, Deserialize, Serialize)]
#[allow(unused)]
struct DuplicateRoute {
route_path: String,
workspace_id: String,
http_method: String,
}
let duplicate_routes = sqlx::query_as!(
DuplicateRoute,
r#"
SELECT
route_path,
workspace_id,
http_method::TEXT AS "http_method!"
FROM
http_trigger
WHERE
workspaced_route IS FALSE
AND route_path_key IN (
SELECT
route_path_key
FROM
http_trigger
WHERE
workspaced_route IS FALSE
GROUP BY
route_path_key, http_method
HAVING COUNT(*) > 1
)
ORDER BY route_path_key
"#
)
.fetch_all(db)
.await?;
if !duplicate_routes.is_empty() {
tracing::error!(
"Cannot disable {} setting as duplicate http routes were found: {:?}",
HTTP_ROUTE_WORKSPACED_ROUTE_SETTING,
&duplicate_routes
);
#[derive(Serialize)]
struct ErrorResponse {
error: String,
details: Vec<DuplicateRoute>,
}
let error_response = ErrorResponse {
error: "Duplicate HTTP route paths detected".to_string(),
details: duplicate_routes,
};
return Err(error::Error::JsonErr(
serde_json::to_value(error_response).unwrap(),
));
}
}
}
_ => {}
}
Ok(())
@@ -471,6 +545,10 @@ async fn set_instance_config(
let current_map = current.global_settings.to_settings_map();
let settings_diff =
instance_config::diff_global_settings(&current_map, &desired_map, ApplyMode::Merge);
let ai_config_changed = settings_diff
.upserts
.iter()
.any(|(key, _)| key == AI_CONFIG_SETTING);
for (key, value) in &settings_diff.upserts {
run_setting_pre_write_hook(&db, key, value).await?;
@@ -479,6 +557,10 @@ async fn set_instance_config(
instance_config::apply_settings_diff(&db, &settings_diff)
.await
.map_err(|e| error::Error::internal_err(e.to_string()))?;
if ai_config_changed {
bump_instance_ai_config_revision();
}
}
if !desired.worker_configs.is_empty() {
@@ -527,6 +609,7 @@ pub async fn get_global_setting(
&& key != DISABLE_HUB_SETTING
&& key != EMAIL_DOMAIN_SETTING
&& key != APP_WORKSPACED_ROUTE_SETTING
&& key != HTTP_ROUTE_WORKSPACED_ROUTE_SETTING
&& key != WS_BASE_URL_SETTING
{
require_super_admin(&db, &authed.email).await?;
@@ -1110,6 +1193,59 @@ struct CachedResourceType {
description: Option<String>,
}
#[derive(serde::Deserialize)]
struct HubResourceTypeRaw {
id: i64,
name: String,
schema: Option<String>,
app: String,
description: Option<String>,
}
async fn fetch_resource_types_from_hub() -> error::Result<Vec<CachedResourceType>> {
let response = HTTP_CLIENT
.get(format!(
"{}/resource_types/list",
windmill_common::DEFAULT_HUB_BASE_URL
))
.header("Accept", "application/json")
.send()
.await
.map_err(|e| error::Error::InternalErr(format!("Failed to fetch from hub: {}", e)))?;
if !response.status().is_success() {
return Err(error::Error::InternalErr(format!(
"Hub returned status {}",
response.status()
)));
}
let raw_types: Vec<HubResourceTypeRaw> = response
.json()
.await
.map_err(|e| error::Error::InternalErr(format!("Failed to parse hub response: {}", e)))?;
Ok(raw_types
.into_iter()
.filter_map(|rt| {
let schema = match rt.schema {
Some(s) => match serde_json::from_str(&s) {
Ok(v) => Some(v),
Err(_) => return None,
},
None => None,
};
Some(CachedResourceType {
id: rt.id,
name: rt.name,
schema,
app: rt.app,
description: rt.description,
})
})
.collect())
}
async fn sync_cached_resource_types(
Extension(db): Extension<DB>,
authed: ApiAuthed,
@@ -1119,16 +1255,12 @@ async fn sync_cached_resource_types(
use windmill_common::worker::HUB_RT_CACHE_DIR;
let cache_path = format!("{}/resource_types.json", *HUB_RT_CACHE_DIR);
let content = tokio::fs::read_to_string(&cache_path).await.map_err(|e| {
error::Error::NotFound(format!(
"No cached resource types found at {}: {}",
cache_path, e
))
})?;
let cached_types: Vec<CachedResourceType> = serde_json::from_str(&content).map_err(|e| {
error::Error::InternalErr(format!("Failed to parse cached resource types: {}", e))
})?;
let cached_types = match tokio::fs::read_to_string(&cache_path).await {
Ok(content) => serde_json::from_str::<Vec<CachedResourceType>>(&content).map_err(|e| {
error::Error::InternalErr(format!("Failed to parse cached resource types: {}", e))
})?,
Err(_) => fetch_resource_types_from_hub().await?,
};
let mut synced_count = 0;

View File

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

View File

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

View File

@@ -14,9 +14,11 @@ enterprise = ["windmill-common/enterprise"]
private = ["windmill-common/private"]
cloud = ["windmill-common/cloud"]
no_auth = ["windmill-api-auth/no_auth"]
parquet = ["windmill-object-store/parquet"]
[dependencies]
windmill-common = { workspace = true, default-features = false }
windmill-object-store = { workspace = true, optional = true }
windmill-types.workspace = true
windmill-api-auth.workspace = true
windmill-api-users.workspace = true

View File

@@ -35,7 +35,6 @@ use windmill_common::variables::{
build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE,
};
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
#[cfg(feature = "enterprise")]
use windmill_common::workspaces::GitRepositorySettings;
#[cfg(feature = "enterprise")]
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
@@ -82,6 +81,10 @@ pub fn workspaced_service() -> Router {
.route("/get_dependents/*imported_path", get(get_dependents))
.route("/get_dependents_amounts", post(get_dependents_amounts))
.route("/get_settings", get(get_settings))
.route(
"/get_copilot_settings_state",
get(get_copilot_settings_state),
)
.route("/get_deploy_to", get(get_deploy_to))
.route("/edit_slack_command", post(edit_slack_command))
.route(
@@ -111,6 +114,7 @@ pub fn workspaced_service() -> Router {
.route("/list_datatables", get(list_datatables))
.route("/list_datatable_schemas", get(list_datatable_schemas))
.route("/edit_datatable_config", post(edit_datatable_config))
.route("/git_sync_enabled", get(get_git_sync_enabled))
.route("/edit_git_sync_config", post(edit_git_sync_config))
.route("/edit_git_sync_repository", post(edit_git_sync_repository))
.route(
@@ -257,6 +261,35 @@ pub struct WorkspaceSettings {
pub public_app_execution_limit_per_minute: Option<i32>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct CopilotSettingsState {
pub has_instance_ai_config: bool,
pub uses_instance_ai_config: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance_ai_summary: Option<InstanceAISummary>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct InstanceAIProviderSummary {
pub provider: String,
pub models: Vec<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct InstanceAIModelSummary {
pub provider: String,
pub model: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
pub struct InstanceAISummary {
pub providers: Vec<InstanceAIProviderSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_model: Option<InstanceAIModelSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
pub code_completion_model: Option<InstanceAIModelSummary>,
}
/// #[derive(sqlx::Type, Serialize, Deserialize, Debug)]
// #[sqlx(type_name = "WORKSPACE_KEY_KIND", rename_all = "lowercase")]
// pub enum WorkspaceKeyKind {
@@ -608,15 +641,106 @@ async fn get_settings(
.await
.map_err(|e| Error::internal_err(format!("getting settings: {e:#}")))?;
let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?;
tx.commit().await?;
let mut settings = not_found_if_none(settings, "workspace settings", &w_id)?;
if !authed.is_admin {
settings.slack_oauth_client_secret = None;
}
Ok(Json(settings))
}
async fn get_copilot_settings_state(
authed: ApiAuthed,
Path(w_id): Path<String>,
Extension(user_db): Extension<UserDB>,
) -> JsonResult<CopilotSettingsState> {
let mut tx = user_db.begin(&authed).await?;
let workspace_ai_config = sqlx::query_scalar!(
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_optional(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("getting workspace ai settings: {e:#}")))?;
let workspace_ai_config = not_found_if_none(workspace_ai_config, "workspace settings", &w_id)?;
let instance_ai_config: Option<serde_json::Value> =
sqlx::query_scalar("SELECT value FROM global_settings WHERE name = 'ai_config'")
.fetch_optional(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("getting instance ai settings: {e:#}")))?;
tx.commit().await?;
Ok(Json(build_copilot_settings_state(
has_ai_providers(workspace_ai_config.as_ref()),
instance_ai_config.as_ref(),
)))
}
pub fn has_ai_providers(config: Option<&serde_json::Value>) -> bool {
config
.and_then(|value| value.get("providers"))
.and_then(|providers| providers.as_object())
.map(|providers| !providers.is_empty())
.unwrap_or(false)
}
pub fn build_copilot_settings_state(
has_workspace_ai_config: bool,
instance_ai_config: Option<&serde_json::Value>,
) -> CopilotSettingsState {
let has_instance_ai_config = has_ai_providers(instance_ai_config);
CopilotSettingsState {
has_instance_ai_config,
uses_instance_ai_config: !has_workspace_ai_config && has_instance_ai_config,
instance_ai_summary: build_instance_ai_summary(instance_ai_config),
}
}
pub fn build_instance_ai_summary(config: Option<&serde_json::Value>) -> Option<InstanceAISummary> {
let config = config?;
if !has_ai_providers(Some(config)) {
return None;
}
let providers = config.get("providers")?.as_object()?;
let mut provider_summaries = providers
.iter()
.map(|(provider, provider_config)| InstanceAIProviderSummary {
provider: provider.clone(),
models: provider_config
.get("models")
.and_then(|models| models.as_array())
.map(|models| {
models
.iter()
.filter_map(|model| model.as_str().map(ToOwned::to_owned))
.collect::<Vec<_>>()
})
.unwrap_or_default(),
})
.collect::<Vec<_>>();
provider_summaries.sort_by(|left, right| left.provider.cmp(&right.provider));
Some(InstanceAISummary {
providers: provider_summaries,
default_model: extract_instance_ai_model_summary(config, "default_model"),
code_completion_model: extract_instance_ai_model_summary(config, "code_completion_model"),
})
}
fn extract_instance_ai_model_summary(
config: &serde_json::Value,
key: &str,
) -> Option<InstanceAIModelSummary> {
let model_config = config.get(key)?.as_object()?;
Some(InstanceAIModelSummary {
provider: model_config.get("provider")?.as_str()?.to_owned(),
model: model_config.get("model")?.as_str()?.to_owned(),
})
}
#[derive(Serialize)]
struct DeployTo {
deploy_to: Option<String>,
@@ -1471,24 +1595,20 @@ async fn edit_datatable_config(
#[derive(Deserialize)]
pub struct EditGitSyncConfig {
#[cfg(feature = "enterprise")]
pub git_sync_settings: Option<WorkspaceGitSyncSettings>,
}
#[cfg(feature = "enterprise")]
#[derive(Deserialize, Debug)]
pub struct EditGitSyncRepository {
pub git_repo_resource_path: String,
pub repository: GitRepositorySettings,
}
#[cfg(feature = "enterprise")]
#[derive(Deserialize, Debug)]
pub struct DeleteGitSyncRepositoryRequest {
pub git_repo_resource_path: String,
}
#[cfg(feature = "enterprise")]
fn validate_git_repo_resource_path(path: &str) -> Result<()> {
// Resource paths should follow the pattern: $res:f/<folder>/<name> or $res:u/<username>/<name>
if path.is_empty() {
@@ -1537,7 +1657,6 @@ fn validate_git_repo_resource_path(path: &str) -> Result<()> {
Ok(())
}
#[cfg(feature = "enterprise")]
fn cleanup_legacy_git_sync_settings_in_memory(
git_sync_settings: &mut windmill_common::workspaces::WorkspaceGitSyncSettings,
workspace_id: &str,
@@ -1564,18 +1683,72 @@ fn cleanup_legacy_git_sync_settings_in_memory(
}
#[cfg(not(feature = "enterprise"))]
async fn edit_git_sync_config(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Path(_w_id): Path<String>,
Json(_new_config): Json<EditGitSyncConfig>,
) -> Result<String> {
return Err(Error::BadRequest(
"Git sync is only available on Windmill Enterprise Edition".to_string(),
));
const CE_GIT_SYNC_MAX_USERS: i64 = 2;
#[cfg(feature = "enterprise")]
async fn check_git_sync_access(_db: &DB, _w_id: &str) -> Result<()> {
Ok(())
}
#[cfg(not(feature = "enterprise"))]
async fn check_git_sync_access(db: &DB, w_id: &str) -> Result<()> {
let user_count: i64 = sqlx::query_scalar!(
"SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
w_id
)
.fetch_one(db)
.await?
.unwrap_or(0);
if user_count > CE_GIT_SYNC_MAX_USERS {
return Err(Error::BadRequest(format!(
"Git sync is available for workspaces with up to {} members. \
Upgrade to Windmill Enterprise Edition for unlimited workspace members.",
CE_GIT_SYNC_MAX_USERS
)));
}
Ok(())
}
#[cfg(feature = "enterprise")]
async fn get_git_sync_enabled(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Path(_w_id): Path<String>,
) -> JsonResult<serde_json::Value> {
Ok(Json(serde_json::json!({
"enabled": true,
"reason": "enterprise",
"max_repos": null,
"user_count": null,
"max_users": null,
})))
}
#[cfg(not(feature = "enterprise"))]
async fn get_git_sync_enabled(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<serde_json::Value> {
let user_count: i64 = sqlx::query_scalar!(
"SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
&w_id
)
.fetch_one(&db)
.await?
.unwrap_or(0);
let enabled = user_count <= CE_GIT_SYNC_MAX_USERS;
Ok(Json(serde_json::json!({
"enabled": enabled,
"reason": if enabled { Some("free_tier") } else { None::<&str> },
"max_repos": if enabled { Some(1) } else { None::<i32> },
"user_count": user_count,
"max_users": CE_GIT_SYNC_MAX_USERS,
})))
}
async fn edit_git_sync_config(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -1584,6 +1757,7 @@ async fn edit_git_sync_config(
Json(new_config): Json<EditGitSyncConfig>,
) -> Result<String> {
require_admin(is_admin, &username)?;
check_git_sync_access(&db, &w_id).await?;
let mut tx = db.begin().await?;
@@ -1640,19 +1814,6 @@ async fn edit_git_sync_config(
Ok(format!("Edit git sync config for workspace {}", &w_id))
}
#[cfg(not(feature = "enterprise"))]
async fn edit_git_sync_repository(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Path(_w_id): Path<String>,
Json(_new_config): Json<serde_json::Value>,
) -> Result<String> {
return Err(Error::BadRequest(
"Git sync is only available on Windmill Enterprise Edition".to_string(),
));
}
#[cfg(feature = "enterprise")]
async fn edit_git_sync_repository(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -1661,10 +1822,19 @@ async fn edit_git_sync_repository(
Json(new_config): Json<EditGitSyncRepository>,
) -> Result<String> {
require_admin(is_admin, &username)?;
check_git_sync_access(&db, &w_id).await?;
// Validate the resource path format
validate_git_repo_resource_path(&new_config.git_repo_resource_path)?;
// Promotion mode: EE only
#[cfg(not(feature = "enterprise"))]
if new_config.repository.use_individual_branch.unwrap_or(false) {
return Err(Error::BadRequest(
"Promotion mode is an Enterprise Edition feature".to_string(),
));
}
let mut tx = db.begin().await?;
// First, get the current git sync settings
@@ -1686,6 +1856,20 @@ async fn edit_git_sync_repository(
WorkspaceGitSyncSettings::default()
};
// Multi-repo: EE only
#[cfg(not(feature = "enterprise"))]
{
let is_new = !git_sync_settings
.repositories
.iter()
.any(|r| r.git_repo_resource_path == new_config.git_repo_resource_path);
if is_new && !git_sync_settings.repositories.is_empty() {
return Err(Error::BadRequest(
"Multiple git sync repositories is an Enterprise Edition feature".to_string(),
));
}
}
// Audit log before we move the repository
audit_log(
&mut *tx,
@@ -1769,19 +1953,6 @@ async fn edit_git_sync_repository(
))
}
#[cfg(not(feature = "enterprise"))]
async fn delete_git_sync_repository(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Path(_w_id): Path<String>,
Json(_request): Json<serde_json::Value>,
) -> Result<String> {
return Err(Error::BadRequest(
"Git sync is only available on Windmill Enterprise Edition".to_string(),
));
}
#[cfg(feature = "enterprise")]
async fn delete_git_sync_repository(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -1791,7 +1962,7 @@ async fn delete_git_sync_repository(
) -> Result<String> {
require_admin(is_admin, &username)?;
// For deletion, only validate that path is not empty to allow cleanup of malformed entries
// No check_git_sync_access here — admins should always be able to delete/clean up repos
if request.git_repo_resource_path.is_empty() {
return Err(Error::BadRequest(
"Resource path cannot be empty".to_string(),
@@ -2109,22 +2280,29 @@ async fn edit_default_app(
#[derive(Serialize)]
struct WorkspaceDefaultApp {
pub default_app_path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_app_raw: Option<bool>,
}
async fn get_default_app(
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<WorkspaceDefaultApp> {
let mut tx = db.begin().await?;
let default_app_path = sqlx::query_scalar!(
"SELECT default_app FROM workspace_settings WHERE workspace_id = $1",
let row = sqlx::query!(
"SELECT ws.default_app AS default_app_path, av.raw_app AS \"default_app_raw: Option<bool>\"
FROM workspace_settings ws
LEFT JOIN app ON app.path = ws.default_app AND app.workspace_id = ws.workspace_id
LEFT JOIN app_version av ON av.id = app.versions[array_upper(app.versions, 1)]
WHERE ws.workspace_id = $1",
&w_id
)
.fetch_one(&mut *tx)
.fetch_one(&db)
.await
.map_err(|err| Error::internal_err(format!("getting default_app: {err}")))?;
tx.commit().await?;
Ok(Json(WorkspaceDefaultApp { default_app_path }))
Ok(Json(WorkspaceDefaultApp {
default_app_path: row.default_app_path,
default_app_raw: row.default_app_raw,
}))
}
async fn edit_error_handler(
@@ -3296,6 +3474,11 @@ async fn clone_apps(
.fetch_all(&mut **tx)
.await?;
let mut cloned_from_db: std::collections::HashSet<(i64, String)> = HashSet::new();
for bundle in &bundles {
cloned_from_db.insert((bundle.app_version_id, bundle.file_type.clone()));
}
for bundle in bundles {
if let Some(&new_version_id) = version_id_mapping.get(&bundle.app_version_id) {
sqlx::query!(
@@ -3310,6 +3493,66 @@ async fn clone_apps(
.await?;
}
}
// Clone bundles from S3 for versions not found in DB
#[cfg(all(feature = "enterprise", feature = "parquet"))]
{
let object_store = windmill_object_store::get_object_store().await;
if let Some(os) = object_store {
for (&old_version_id, &new_version_id) in &version_id_mapping {
for file_type in &["js", "css"] {
if cloned_from_db.contains(&(old_version_id, file_type.to_string())) {
continue;
}
let src_path = format!(
"/app_bundles/{}/{}.{}",
source_workspace_id, old_version_id, file_type
);
let get_result = os
.get(&windmill_object_store::object_store_reexports::Path::from(
src_path,
))
.await;
match get_result {
Ok(result) => {
let data = result.bytes().await.map_err(
windmill_object_store::object_store_error_to_error,
)?;
let dst_path = format!(
"/app_bundles/{}/{}.{}",
target_workspace_id, new_version_id, file_type
);
os.put(
&windmill_object_store::object_store_reexports::Path::from(
dst_path.clone(),
),
data.into(),
)
.await
.map_err(
windmill_object_store::object_store_error_to_error,
)?;
tracing::info!(
"Cloned app bundle from S3: {}.{} -> {}.{}",
old_version_id,
file_type,
new_version_id,
file_type
);
}
Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound { .. }) => {
// No bundle in S3 for this version/type, skip
}
Err(e) => {
return Err(
windmill_object_store::object_store_error_to_error(e),
);
}
}
}
}
}
}
}
// Update app versions arrays

View File

@@ -18,7 +18,7 @@ agent_worker_server = ["dep:windmill-worker", "dep:windmill-api-agent-workers"]
enterprise_saml = ["dep:samael", "dep:libxml"]
benchmark = []
embedding = ["windmill-api-embeddings/embedding"]
parquet = ["dep:datafusion", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker?/parquet", "windmill-api-users/parquet", "windmill-api-settings/parquet", "dep:aws-sigv4", "dep:aws-sdk-config"]
parquet = ["dep:datafusion", "windmill-common/parquet", "windmill-object-store/parquet", "windmill-worker?/parquet", "windmill-api-users/parquet", "windmill-api-settings/parquet", "windmill-api-workspaces/parquet", "dep:aws-sigv4", "dep:aws-sdk-config"]
prometheus = ["windmill-common/prometheus", "windmill-queue/prometheus", "dep:prometheus", "windmill-worker?/prometheus"]
openidconnect = ["dep:openidconnect", "windmill-common/openidconnect", "windmill-store/openidconnect"]
tantivy = ["dep:windmill-indexer"]
@@ -39,12 +39,13 @@ sqs_trigger = ["dep:windmill-trigger-sqs", "windmill-store/sqs_trigger"]
gcp_trigger = ["dep:windmill-trigger-gcp", "windmill-store/gcp_trigger"]
cloud = ["windmill-common/cloud", "windmill-api-auth/cloud", "windmill-store/cloud", "windmill-api-workspaces/cloud"]
mcp = ["dep:windmill-mcp", "windmill-mcp/server", "windmill-mcp/auth", "windmill-api-auth/mcp", "windmill-store/mcp"]
bedrock = ["dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "windmill-common/bedrock", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types"]
bedrock = ["windmill-ai/bedrock", "dep:aws-sdk-bedrock", "dep:aws-sdk-bedrockruntime", "windmill-common/bedrock", "dep:aws-config", "dep:aws-credential-types", "dep:aws-smithy-types"]
python = ["windmill-dep-map/python", "dep:windmill-parser-py", "dep:windmill-parser-py-imports", "windmill-api-scripts/python", "windmill-api-configs/python", "windmill-api-agent-workers?/python", "windmill-trigger/python", "windmill-common/python"]
no_auth = ["windmill-api-auth/no_auth", "windmill-store/no_auth", "windmill-api-users/no_auth"]
quickjs = ["windmill-jseval/quickjs"]
[dependencies]
windmill-ai = { workspace = true, default-features = false }
windmill-mcp = { workspace = true, optional = true }
windmill-api-auth.workspace = true
windmill-api-scripts.workspace = true

View File

@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.662.0
version: 1.665.0
title: Windmill API
contact:
@@ -588,6 +588,8 @@ paths:
type: boolean
name:
type: string
disabled:
type: boolean
responses:
"200":
description: user updated
@@ -3191,9 +3193,49 @@ paths:
"200":
description: status
content:
text/plain:
application/json:
schema:
type: string
type: object
properties:
effective_ai_config:
$ref: "#/components/schemas/AIConfig"
has_instance_ai_config:
type: boolean
uses_instance_ai_config:
type: boolean
instance_ai_summary:
$ref: "#/components/schemas/InstanceAISummary"
required:
- effective_ai_config
- has_instance_ai_config
- uses_instance_ai_config
/w/{workspace}/workspaces/get_copilot_settings_state:
get:
summary: get copilot settings state
operationId: getCopilotSettingsState
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: status
content:
application/json:
schema:
type: object
properties:
has_instance_ai_config:
type: boolean
uses_instance_ai_config:
type: boolean
instance_ai_summary:
$ref: "#/components/schemas/InstanceAISummary"
required:
- has_instance_ai_config
- uses_instance_ai_config
/w/{workspace}/workspaces/get_copilot_info:
get:
@@ -3390,6 +3432,37 @@ paths:
application/json:
schema: {}
/w/{workspace}/workspaces/git_sync_enabled:
get:
summary: Check if git sync is available for this workspace
operationId: getGitSyncEnabled
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: Git sync availability status
content:
application/json:
schema:
type: object
properties:
enabled:
type: boolean
reason:
type: string
nullable: true
max_repos:
type: integer
nullable: true
user_count:
type: integer
nullable: true
max_users:
type: integer
nullable: true
/w/{workspace}/workspaces/edit_git_sync_config:
post:
summary: edit workspace git sync settings
@@ -3662,6 +3735,8 @@ paths:
properties:
default_app_path:
type: string
default_app_raw:
type: boolean
/w/{workspace}/workspaces/usage:
get:
@@ -4759,6 +4834,11 @@ paths:
mcp_server_url:
type: string
description: "MCP server URL for MCP OAuth token refresh"
scopes:
type: array
items:
type: string
description: "OAuth scopes to use for token refresh. Overrides instance-level scopes."
required:
- refresh_token
- expires_in
@@ -6867,6 +6947,60 @@ paths:
schema:
type: string
/w/{workspace}/scripts/list_dedicated_with_deps:
get:
summary: list dedicated worker scripts with workspace dependency annotations
operationId: listDedicatedWithDeps
tags:
- script
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: list of dedicated scripts with their workspace dependency names
content:
application/json:
schema:
type: array
items:
type: object
properties:
path:
type: string
language:
type: string
enum:
- python3
- deno
- go
- bash
- powershell
- postgresql
- mysql
- bigquery
- snowflake
- mssql
- graphql
- nativets
- bun
- bunnative
- php
- rust
- ansible
- csharp
- oracledb
- duckdb
- java
- ruby
workspace_dep_names:
type: array
items:
type: string
required:
- path
- language
- workspace_dep_names
/w/{workspace}/scripts/raw/p/{path}:
get:
summary: raw script by path
@@ -10571,6 +10705,10 @@ paths:
in: query
schema:
type: boolean
- name: fast
in: query
schema:
type: boolean
responses:
"200":
@@ -11048,6 +11186,129 @@ paths:
"200":
description: Interactive slack approval message sent successfully
/w/{workspace}/jobs_u/flow/resume_suspended/{job_id}:
post:
summary: resume or cancel a suspended flow/WAC job
description: >
Resume or cancel a suspended flow/WAC job. Uses approval rules to
determine authorization. Either a valid approval_token or an
authenticated session is required.
operationId: resumeSuspended
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: job_id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
payload:
description: payload to send to the resumed job
approval_token:
type: string
description: approval token for unauthenticated access
approved:
type: boolean
description: whether to approve (true) or cancel (false) the job
default: true
responses:
"201":
description: job resumed
content:
text/plain:
schema:
type: string
/w/{workspace}/jobs_u/flow/approval_info/{job_id}:
get:
summary: get approval info for a suspended flow/WAC job
description: >
Get approval info for a suspended flow/WAC job. Returns form schema,
approval rules, and whether the current user can approve. Either a
valid token query parameter or an authenticated session is required.
operationId: getApprovalInfo
tags:
- job
parameters:
- $ref: "#/components/parameters/WorkspaceId"
- name: job_id
in: path
required: true
schema:
type: string
format: uuid
- name: token
in: query
required: false
schema:
type: string
description: approval token for unauthenticated access
responses:
"200":
description: approval info
content:
application/json:
schema:
type: object
required:
- flow_id
- can_approve
- user_auth_required
- approvers
properties:
flow_id:
type: string
format: uuid
form_schema:
description: form schema for the approval step
description:
description: description of the approval step
approval_conditions:
type: object
properties:
user_auth_required:
type: boolean
user_groups_required:
type: array
items:
type: string
self_approval_disabled:
type: boolean
required:
- user_auth_required
- user_groups_required
- self_approval_disabled
can_approve:
type: boolean
description: whether the current user/token holder can approve
user_auth_required:
type: boolean
description: whether user authentication is required to approve
hide_cancel:
type: boolean
description: whether to hide the cancel button in the UI
approvers:
type: array
items:
type: object
required:
- resume_id
- approver
properties:
resume_id:
type: integer
approver:
type: string
/w/{workspace}/jobs_u/resume/{id}/{resume_id}/{signature}:
get:
summary: resume a job for a suspended flow
@@ -17264,6 +17525,27 @@ paths:
description: count of log lines that matched the query per hostname
type: object
/srch/index/storage/disk:
get:
summary: Get index disk storage sizes from the indexer.
operationId: getIndexDiskStorageSizes
tags:
- indexSearch
responses:
"200":
description: disk storage sizes for each index
content:
application/json:
schema:
type: object
properties:
job_index_disk_size_bytes:
type: integer
nullable: true
log_index_disk_size_bytes:
type: integer
nullable: true
/indexer/delete/{idx_name}:
delete:
summary: Clear an index and restart the indexer.
@@ -18792,6 +19074,33 @@ components:
minimum: 1
maximum: 2000000
InstanceAIProviderSummary:
type: object
properties:
provider:
$ref: "#/components/schemas/AIProvider"
models:
type: array
items:
type: string
required:
- provider
- models
InstanceAISummary:
type: object
properties:
providers:
type: array
items:
$ref: "#/components/schemas/InstanceAIProviderSummary"
default_model:
$ref: "#/components/schemas/AIProviderModel"
code_completion_model:
$ref: "#/components/schemas/AIProviderModel"
required:
- providers
Alert:
type: object
properties:
@@ -23168,6 +23477,8 @@ components:
role_source:
type: string
enum: ["manual", "instance_group"]
disabled:
type: boolean
required:
- email
@@ -23176,6 +23487,7 @@ components:
- verified
- first_time_user
- role_source
- disabled
Flow:
allOf:
@@ -24592,6 +24904,10 @@ components:
type: string
nullable: true
description: Error message if the trigger is in an error state
summary:
type: string
nullable: true
description: Short summary to be displayed when listed
required:
- external_id
- workspace_id
@@ -24626,6 +24942,10 @@ components:
type: string
nullable: true
description: Error message if the trigger is in an error state
summary:
type: string
nullable: true
description: Short summary to be displayed when listed
external_data:
type: object
description: Configuration data from the external service
@@ -24718,6 +25038,10 @@ components:
type: object
description: Service-specific configuration (e.g., event types, filters)
additionalProperties: true
summary:
type: string
nullable: true
description: Short summary to be displayed when listed
required:
- script_path
- is_flow

View File

@@ -16,7 +16,8 @@ use serde_json::{json, value::RawValue};
use std::collections::HashMap;
use std::time::Duration;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::ai_providers::{
use windmill_ai::ai_cache::current_instance_ai_config_revision;
use windmill_ai::ai_providers::{
empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel,
};
use windmill_common::error::{to_anyhow, Error, Result};
@@ -127,6 +128,10 @@ lazy_static::lazy_static! {
};
}
pub(crate) fn invalidate_ai_request_cache_for_workspace(workspace_id: &str) {
AI_REQUEST_CACHE.retain(|(cached_workspace_id, _), _| cached_workspace_id != workspace_id);
}
#[derive(Deserialize, Debug)]
struct AIOAuthResource {
client_id: String,
@@ -373,8 +378,7 @@ impl AIRequestConfig {
let is_azure = provider.is_azure_openai(base_url);
let is_anthropic = matches!(provider, AIProvider::Anthropic);
let is_anthropic_vertex =
is_anthropic && self.platform == AIPlatform::GoogleVertexAi;
let is_anthropic_vertex = is_anthropic && self.platform == AIPlatform::GoogleVertexAi;
let is_anthropic_sdk = headers.get("X-Anthropic-SDK").is_some();
let is_google_ai = matches!(provider, AIProvider::GoogleAI);
@@ -483,18 +487,27 @@ impl AIRequestConfig {
pub struct ExpiringAIRequestConfig {
config: AIRequestConfig,
expires_at: std::time::Instant,
instance_ai_config_revision: Option<u64>,
}
impl ExpiringAIRequestConfig {
fn new(config: AIRequestConfig) -> Self {
Self { config, expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60) }
fn new(config: AIRequestConfig, instance_ai_config_revision: Option<u64>) -> Self {
Self {
config,
expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60),
instance_ai_config_revision,
}
}
fn is_expired(&self) -> bool {
self.expires_at < std::time::Instant::now()
|| self
.instance_ai_config_revision
.is_some_and(|revision| revision != current_instance_ai_config_revision())
}
}
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Default)]
pub struct AIConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub providers: Option<HashMap<AIProvider, ProviderConfig>>,
@@ -508,6 +521,14 @@ pub struct AIConfig {
pub max_tokens_per_model: Option<HashMap<String, i32>>,
}
impl AIConfig {
pub fn has_providers(&self) -> bool {
self.providers
.as_ref()
.is_some_and(|providers| !providers.is_empty())
}
}
/// Anthropic API version for Google Vertex AI
const ANTHROPIC_VERSION_VERTEX: &str = "vertex-2023-10-16";
@@ -603,8 +624,8 @@ pub fn workspaced_service() -> Router {
async fn check_bedrock_credentials(
_authed: ApiAuthed,
Path(_w_id): Path<String>,
) -> Result<Json<windmill_common::ai_bedrock::BedrockCredentialsCheck>> {
let response = windmill_common::ai_bedrock::check_env_credentials().await;
) -> Result<Json<windmill_ai::ai_bedrock::BedrockCredentialsCheck>> {
let response = windmill_ai::ai_bedrock::check_env_credentials().await;
Ok(Json(response))
}
@@ -762,47 +783,76 @@ async fn proxy(
request_cache.config
}
_ => {
let (resource_path, save_to_cache) = if let Some(resource_path) = forced_resource_path {
// forced resource path
(resource_path, false)
} else {
let ai_config = sqlx::query_scalar!(
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
let (resource_path, save_to_cache, resource_workspace, instance_ai_config_revision) =
if let Some(resource_path) = forced_resource_path {
// forced resource path
(resource_path, false, w_id.clone(), None)
} else {
let workspace_ai_config = sqlx::query_scalar!(
"SELECT ai_config FROM workspace_settings WHERE workspace_id = $1",
&w_id
)
.fetch_one(&db)
.await?;
if ai_config.is_none() {
return Err(Error::internal_err(
"AI resource not configured".to_string(),
));
}
let (ai_config_value, resource_workspace, instance_ai_config_revision) = {
let ws_has_config = workspace_ai_config
.as_ref()
.and_then(|v| serde_json::from_value::<AIConfig>(v.clone()).ok())
.is_some_and(|config| config.has_providers());
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config.unwrap())
.map_err(|e| Error::BadRequest(e.to_string()))?;
if ws_has_config {
(workspace_ai_config.unwrap(), w_id.clone(), None)
} else {
let instance_config = sqlx::query_scalar!(
"SELECT value FROM global_settings WHERE name = 'ai_config'"
)
.fetch_optional(&db)
.await?;
let provider_config = ai_config
.providers
.as_mut()
.map(|providers| providers.remove(&provider))
.flatten()
.ok_or_else(|| {
Error::BadRequest(format!("Provider {:?} not configured", provider))
})?;
match instance_config {
Some(config) => (
config,
"admins".to_string(),
Some(current_instance_ai_config_revision()),
),
None => {
return Err(Error::internal_err(
"AI resource not configured".to_string(),
));
}
}
}
};
if provider_config.resource_path.is_empty() {
return Err(Error::BadRequest("Resource path is empty".to_string()));
}
let mut ai_config = serde_json::from_value::<AIConfig>(ai_config_value)
.map_err(|e| Error::BadRequest(e.to_string()))?;
(provider_config.resource_path, true)
};
let provider_config = ai_config
.providers
.as_mut()
.and_then(|providers| providers.remove(&provider))
.ok_or_else(|| {
Error::BadRequest(format!("Provider {:?} not configured", provider))
})?;
let resource= sqlx::query_scalar!(
"SELECT value as \"value: sqlx::types::Json<Box<RawValue>>\" FROM resource WHERE path = $1 AND workspace_id = $2",
&resource_path,
&w_id
if provider_config.resource_path.is_empty() {
return Err(Error::BadRequest("Resource path is empty".to_string()));
}
(
provider_config.resource_path,
true,
resource_workspace,
instance_ai_config_revision,
)
};
let resource = sqlx::query_scalar::<_, Option<sqlx::types::Json<Box<RawValue>>>>(
"SELECT value FROM resource WHERE path = $1 AND workspace_id = $2",
)
.bind(&resource_path)
.bind(&resource_workspace)
.fetch_optional(&db)
.await?
.ok_or_else(|| Error::NotFound(format!("Could not find the resource {}, update the resource path in the workspace settings", resource_path)))?
@@ -811,11 +861,15 @@ async fn proxy(
let resource = serde_json::from_str::<AIResource>(resource.0.get())
.map_err(|e| Error::BadRequest(e.to_string()))?;
let request_config = AIRequestConfig::new(&provider, &db, &w_id, resource).await?;
let request_config =
AIRequestConfig::new(&provider, &db, &resource_workspace, resource).await?;
if save_to_cache {
AI_REQUEST_CACHE.insert(
(w_id.clone(), provider.clone()),
ExpiringAIRequestConfig::new(request_config.clone()),
ExpiringAIRequestConfig::new(
request_config.clone(),
instance_ai_config_revision,
),
);
}
request_config
@@ -858,9 +912,7 @@ async fn proxy(
"chat/completions" => {
crate::google::handle_google_ai_chat(&body, api_key, base_url, is_vertex).await
}
"models" => {
crate::google::handle_google_ai_models(api_key, base_url, is_vertex).await
}
"models" => crate::google::handle_google_ai_models(api_key, base_url, is_vertex).await,
_ => Err(Error::BadRequest(format!(
"Unsupported Google AI path: {}",
ai_path
@@ -893,7 +945,7 @@ async fn proxy(
let region = request_config
.region
.as_deref()
.unwrap_or(windmill_common::ai_providers::USE_ENV_REGION);
.unwrap_or(windmill_ai::ai_providers::USE_ENV_REGION);
// Audit log before making the SDK request
let mut tx = db.begin().await?;
@@ -1005,3 +1057,76 @@ async fn proxy(
};
Ok((status_code, headers, body))
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{LazyLock, Mutex};
use windmill_ai::ai_cache::bump_instance_ai_config_revision;
use windmill_ai::ai_providers::AIPlatform;
static TEST_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
fn sample_request_config() -> AIRequestConfig {
AIRequestConfig {
base_url: "https://example.com".to_string(),
api_key: None,
access_token: None,
organization_id: None,
user: None,
region: None,
aws_access_key_id: None,
aws_secret_access_key: None,
aws_session_token: None,
platform: AIPlatform::Standard,
enable_1m_context: false,
custom_headers: HashMap::new(),
}
}
#[test]
fn invalidates_all_cached_providers_for_workspace() {
let _guard = TEST_LOCK.lock().unwrap();
AI_REQUEST_CACHE.clear();
AI_REQUEST_CACHE.insert(
("workspace-a".to_string(), AIProvider::OpenAI),
ExpiringAIRequestConfig::new(sample_request_config(), None),
);
AI_REQUEST_CACHE.insert(
("workspace-a".to_string(), AIProvider::Anthropic),
ExpiringAIRequestConfig::new(sample_request_config(), None),
);
AI_REQUEST_CACHE.insert(
("workspace-b".to_string(), AIProvider::OpenAI),
ExpiringAIRequestConfig::new(sample_request_config(), None),
);
invalidate_ai_request_cache_for_workspace("workspace-a");
assert!(AI_REQUEST_CACHE
.get(&("workspace-a".to_string(), AIProvider::OpenAI))
.is_none());
assert!(AI_REQUEST_CACHE
.get(&("workspace-a".to_string(), AIProvider::Anthropic))
.is_none());
assert!(AI_REQUEST_CACHE
.get(&("workspace-b".to_string(), AIProvider::OpenAI))
.is_some());
}
#[test]
fn instance_backed_cache_entries_expire_when_revision_changes() {
let _guard = TEST_LOCK.lock().unwrap();
AI_REQUEST_CACHE.clear();
let cached = ExpiringAIRequestConfig::new(
sample_request_config(),
Some(current_instance_ai_config_revision()),
);
assert!(!cached.is_expired());
bump_instance_ai_config_revision();
assert!(cached.is_expired());
}
}

View File

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

View File

@@ -440,17 +440,29 @@ async fn get_raw_app_data(
#[cfg(all(feature = "enterprise", feature = "parquet"))]
if let Some(os) = object_store {
let path = format!("/app_bundles/{}/{}.{}", w_id, id, file_type);
let stream = os
match os
.get(&windmill_object_store::object_store_reexports::Path::from(
path,
))
.await
.map_err(windmill_object_store::object_store_error_to_error)?
.bytes()
.await
.map_err(windmill_object_store::object_store_error_to_error)?;
tracing::info!("stream: {}", stream.len());
body = Some(Body::from(stream));
{
Ok(result) => {
let stream = result
.bytes()
.await
.map_err(windmill_object_store::object_store_error_to_error)?;
tracing::info!("stream: {}", stream.len());
body = Some(Body::from(stream));
}
Err(windmill_object_store::object_store_reexports::ObjectStoreError::NotFound {
..
}) => {
// S3 key not found, fall through to DB lookup below
}
Err(e) => {
return Err(windmill_object_store::object_store_error_to_error(e));
}
}
}
if body.is_none() {
@@ -1439,6 +1451,30 @@ async fn delete_app(
let mut tx = user_db.begin(&authed).await?;
// Capture all related data for trashbin before deleting (CASCADE will remove app_version, etc.)
let trash_app: Option<serde_json::Value> =
sqlx::query_scalar("SELECT to_jsonb(t) FROM app t WHERE path = $1 AND workspace_id = $2")
.bind(path)
.bind(&w_id)
.fetch_optional(&mut *tx)
.await?;
let trash_app_versions: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM app_version t WHERE app_id = (SELECT id FROM app WHERE path = $1 AND workspace_id = $2)",
)
.bind(path)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
let trash_drafts: Vec<serde_json::Value> = sqlx::query_scalar(
"SELECT to_jsonb(t) FROM draft t WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
)
.bind(path)
.bind(&w_id)
.fetch_all(&mut *tx)
.await?;
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ = 'app'",
path,
@@ -1455,6 +1491,25 @@ async fn delete_app(
.execute(&mut *tx)
.await?;
if let Some(app_data) = trash_app {
let mut trash_data = serde_json::json!({"row": app_data});
if !trash_app_versions.is_empty() {
trash_data["app_versions"] = serde_json::Value::Array(trash_app_versions);
}
if !trash_drafts.is_empty() {
trash_data["drafts"] = serde_json::Value::Array(trash_drafts);
}
windmill_common::trashbin::move_to_trash(
&mut *tx,
&w_id,
"app",
path,
trash_data,
&authed.username,
)
.await?;
}
audit_log(
&mut *tx,
&authed,

View File

@@ -18,13 +18,13 @@
use axum::body::Bytes;
use serde::Deserialize;
use windmill_common::ai_bedrock::build_tool_config;
use windmill_common::ai_bedrock::{
use windmill_ai::ai_bedrock::build_tool_config;
use windmill_ai::ai_bedrock::{
bedrock_stream_event_is_block_stop, bedrock_stream_event_to_text,
bedrock_stream_event_to_tool_delta, bedrock_stream_event_to_tool_start, format_bedrock_error,
BedrockClient,
};
use windmill_common::ai_types::{
use windmill_ai::ai_types::{
OpenAIFunction, OpenAIMessage, OpenAIToolCall, ToolDef, ToolDefFunction,
};
use windmill_common::error::{Error, Result};
@@ -182,7 +182,7 @@ async fn create_bedrock_control_client(
region: &str,
) -> Result<aws_sdk_bedrock::Client> {
use aws_config::BehaviorVersion;
use windmill_common::ai_bedrock::BearerTokenProvider;
use windmill_ai::ai_bedrock::BearerTokenProvider;
let region_provider = aws_sdk_bedrock::config::Region::new(region.to_string());
@@ -366,10 +366,10 @@ pub async fn handle_bedrock_sdk_streaming(
// Convert messages using shared conversion
let (bedrock_messages, system_prompts) =
windmill_common::ai_bedrock::openai_messages_to_bedrock(&openai_req.messages)?;
windmill_ai::ai_bedrock::openai_messages_to_bedrock(&openai_req.messages)?;
// Build inference configuration
let inference_config = windmill_common::ai_bedrock::create_inference_config(
let inference_config = windmill_ai::ai_bedrock::create_inference_config(
openai_req.temperature,
openai_req.max_tokens,
);
@@ -626,10 +626,10 @@ pub async fn handle_bedrock_sdk_non_streaming(
// Convert messages using shared conversion
let (bedrock_messages, system_prompts) =
windmill_common::ai_bedrock::openai_messages_to_bedrock(&openai_req.messages)?;
windmill_ai::ai_bedrock::openai_messages_to_bedrock(&openai_req.messages)?;
// Build inference configuration
let inference_config = windmill_common::ai_bedrock::create_inference_config(
let inference_config = windmill_ai::ai_bedrock::create_inference_config(
openai_req.temperature,
openai_req.max_tokens,
);

View File

@@ -15,15 +15,15 @@ use eventsource_stream::Eventsource;
use futures::StreamExt;
use serde::Deserialize;
use serde_json::json;
use windmill_common::{
use windmill_ai::{
ai_google::{
gemini_event_to_openai_sse_chunks, gemini_response_to_openai, openai_messages_to_gemini,
parse_gemini_response, parse_gemini_sse_event, sanitize_schema_for_google,
GeminiFunctionDeclaration, GeminiGenerationConfig, GeminiTextRequest, GeminiTool,
},
ai_types::OpenAIMessage,
error::{Error, Result},
};
use windmill_common::error::{Error, Result};
use crate::ai::{inject_keepalives, HTTP_CLIENT, KEEPALIVE_INTERVAL_SECS};

View File

@@ -103,7 +103,7 @@ use windmill_common::{
cache,
db::UserDB,
error::{self, to_anyhow, Error},
flow_status::{Approval, FlowStatus, FlowStatusModule},
flow_status::{Approval, ApprovalConditions, FlowStatus, FlowStatusModule},
flows::{add_virtual_items_if_necessary, resolve_maybe_value, FlowValue},
jobs::{script_path_to_payload, CompletedJob, JobKind, JobPayload, QueuedJob, RawCode},
oauth2::HmacSha256,
@@ -401,6 +401,8 @@ pub fn workspace_unauthed_service() -> Router {
post(cancel_persistent_script_api),
)
.route("/queue/force_cancel/:id", post(force_cancel))
.route("/flow/resume_suspended/:job_id", post(resume_suspended))
.route("/flow/approval_info/:job_id", get(get_approval_info))
}
pub fn global_root_service() -> Router {
@@ -1058,6 +1060,7 @@ impl<'a> GetQuery<'a> {
Self { with_code: false, ..self }
}
#[allow(dead_code)]
fn without_flow(self) -> Self {
Self { with_flow: false, ..self }
}
@@ -2027,10 +2030,7 @@ async fn count_completed_jobs_detail(
if let Some(tags) = query.tags {
sqlb.and_where_in(
"v2_job.tag",
&tags
.split(",")
.map(|t| format!("'{}'", t))
.collect::<Vec<_>>(),
&tags.split(",").map(|t| quote(t)).collect::<Vec<_>>(),
);
}
@@ -2184,7 +2184,7 @@ pub async fn resume_suspended_flow_as_owner(
) -> error::Result<StatusCode> {
let mut tx = db.begin().await?;
let (flow, job_id) = get_suspended_flow_info(flow_id, &mut tx).await?;
let (flow, job_id, is_wac) = get_suspended_flow_info(flow_id, &mut tx).await?;
let flow_path = flow.script_path.as_deref().unwrap_or_else(|| "");
require_owner_of_path(&authed, flow_path)?;
@@ -2192,10 +2192,17 @@ pub async fn resume_suspended_flow_as_owner(
// Check approval conditions (self-approval, required groups, etc.)
if let Some(ref flow_status_value) = flow.flow_status {
if let Ok(flow_status) = serde_json::from_value::<FlowStatus>(flow_status_value.clone()) {
let trigger_email = flow.email.as_deref().unwrap_or("");
conditionally_require_authed_user(Some(authed.clone()), flow_status, trigger_email)?;
}
let trigger_email = flow.email.as_deref().unwrap_or("");
let ac = serde_json::from_value::<FlowStatus>(flow_status_value.clone())
.ok()
.and_then(|fs| fs.approval_conditions)
.or_else(|| {
// WAC flows store approval_conditions directly in flow_status JSONB
flow_status_value
.get("approval_conditions")
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok())
});
conditionally_require_authed_user(Some(authed.clone()), ac, trigger_email)?;
}
let value = value.unwrap_or(serde_json::Value::Null);
@@ -2211,12 +2218,426 @@ pub async fn resume_suspended_flow_as_owner(
)
.await?;
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
if is_wac {
// WAC: directly decrement suspend counter
if flow.suspend > 0 {
sqlx::query!(
"UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1",
flow.id,
)
.execute(&mut *tx)
.await?;
}
} else {
resume_immediately_if_relevant(flow, job_id, &mut tx).await?;
}
tx.commit().await?;
Ok(StatusCode::CREATED)
}
// --- New approval system endpoints ---
use windmill_common::variables::generate_approval_token;
/// Verify an approval token against the workspace key + job_id.
async fn validate_approval_token(
db: &DB,
token: &str,
job_id: Uuid,
workspace_id: &str,
) -> error::Result<()> {
let expected = generate_approval_token(workspace_id, job_id, db).await?;
if token != expected {
return Err(Error::NotAuthorized("Invalid approval token".to_string()));
}
Ok(())
}
#[derive(Deserialize)]
struct ResumeSuspendedBody {
payload: Option<serde_json::Value>,
approval_token: Option<String>,
approved: Option<bool>,
}
async fn resume_suspended(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Json(body): Json<ResumeSuspendedBody>,
) -> error::Result<StatusCode> {
let approved = body.approved.unwrap_or(true);
let value = body.payload.unwrap_or(serde_json::Value::Null);
// Determine if we have a valid authed user or token
let has_token = if let Some(ref token) = body.approval_token {
validate_approval_token(&db, token, job_id, &w_id)
.await
.is_ok()
} else {
false
};
if opt_authed.is_none() && !has_token {
return Err(Error::NotAuthorized(
"Must be logged in or provide a valid approval token".to_string(),
));
}
let mut tx = db.begin().await?;
// Resolve the suspended flow (works for both WAC and classic flows)
let (flow, resume_job_id, is_wac) = get_suspended_flow_info(job_id, &mut tx).await?;
// Verify the job belongs to this workspace
let job_workspace: Option<String> =
sqlx::query_scalar("SELECT workspace_id FROM v2_job WHERE id = $1")
.bind(&flow.id)
.fetch_optional(&mut *tx)
.await?;
if job_workspace.as_deref() != Some(w_id.as_str()) {
return Err(Error::NotFound(
"Job not found in this workspace".to_string(),
));
}
// Check approval conditions
let approval_conditions = if is_wac {
flow.flow_status
.as_ref()
.and_then(|v| v.get("approval_conditions"))
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok())
} else {
flow.flow_status
.as_ref()
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
.and_then(|fs| fs.approval_conditions)
};
if let Some(ref ac) = approval_conditions {
if ac.user_auth_required && opt_authed.is_none() {
return Err(Error::NotAuthorized(
"This approval requires a logged-in user. Please sign in.".to_string(),
));
}
}
// If logged in, check authorization rules
if let Some(ref authed) = opt_authed {
let is_admin = authed.is_admin;
let is_owner = flow
.script_path
.as_deref()
.map(|p| require_owner_of_path(authed, p).is_ok())
.unwrap_or(false);
if !is_admin && !is_owner {
let trigger_email = flow.email.as_deref().unwrap_or("");
conditionally_require_authed_user(
Some(authed.clone()),
approval_conditions.clone(),
trigger_email,
)?;
}
} else if !has_token {
return Err(Error::NotAuthorized(
"Must be logged in or provide a valid approval token".to_string(),
));
}
// Generate a unique resume_id
let resume_id: u32 = rand::random();
// Check for duplicate
let exists: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM resume_job WHERE id = $1)")
.bind(Uuid::from_u128(resume_job_id.as_u128() ^ resume_id as u128))
.fetch_one(&mut *tx)
.await?;
if exists {
return Err(Error::BadRequest("Resume request already sent".to_string()));
}
let approver_value = opt_authed.as_ref().map(|a| a.username.clone());
insert_resume_job(
resume_id,
resume_job_id,
&flow,
value,
approver_value.clone(),
approved,
&mut tx,
)
.await?;
if !approved {
sqlx::query("UPDATE v2_job_queue SET suspend = 0 WHERE id = $1")
.bind(&flow.id)
.execute(&mut *tx)
.await?;
} else if is_wac {
if flow.suspend > 0 {
sqlx::query("UPDATE v2_job_queue SET suspend = GREATEST(suspend - 1, 0) WHERE id = $1")
.bind(&flow.id)
.execute(&mut *tx)
.await?;
}
} else {
resume_immediately_if_relevant(flow, resume_job_id, &mut tx).await?;
}
let approver = approver_value.unwrap_or_else(|| "anonymous".to_string());
let audit_author = if let Some(ref authed) = opt_authed {
AuditAuthor::from(authed)
} else {
AuditAuthor {
email: approver.clone(),
username: approver.clone(),
username_override: None,
token_prefix: None,
}
};
audit_log(
&mut *tx,
&audit_author,
"jobs.suspend_resume",
ActionKind::Update,
&w_id,
Some(
&serde_json::json!({
"approved": approved,
"job_id": job_id,
"details": if approved {
format!("Approved by {}", &approver)
} else {
format!("Cancelled by {}", &approver)
}
})
.to_string(),
),
None,
)
.await?;
tx.commit().await?;
Ok(StatusCode::CREATED)
}
#[derive(Deserialize)]
struct ApprovalInfoQuery {
token: Option<String>,
}
#[derive(Serialize)]
struct ApprovalInfo {
flow_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
form_schema: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
approval_conditions: Option<ApprovalConditions>,
can_approve: bool,
user_auth_required: bool,
#[serde(skip_serializing_if = "Option::is_none")]
hide_cancel: Option<bool>,
approvers: Vec<Approval>,
}
async fn get_approval_info(
OptAuthed(opt_authed): OptAuthed,
Extension(db): Extension<DB>,
Path((w_id, job_id)): Path<(String, Uuid)>,
Query(query): Query<ApprovalInfoQuery>,
) -> error::Result<Json<ApprovalInfo>> {
// Validate access: either logged in or valid token
let has_token = if let Some(ref token) = query.token {
validate_approval_token(&db, token, job_id, &w_id)
.await
.is_ok()
} else {
false
};
if opt_authed.is_none() && !has_token {
return Err(Error::NotAuthorized(
"Must be logged in or provide a valid approval token".to_string(),
));
}
// Fetch job info
#[derive(sqlx::FromRow)]
struct ApprovalJobRow {
id: Uuid,
script_path: Option<String>,
email: String,
flow_status: Option<serde_json::Value>,
workflow_as_code_status: Option<serde_json::Value>,
}
let row = sqlx::query_as::<_, ApprovalJobRow>(
"SELECT j.id, j.runnable_path as script_path, j.permissioned_as_email as email,
s.flow_status, s.workflow_as_code_status
FROM v2_job j
LEFT JOIN v2_job_status s ON s.id = j.id
WHERE j.id = $1 AND j.workspace_id = $2",
)
.bind(&job_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.ok_or_else(|| Error::NotFound(format!("Job {job_id} not found")))?;
let is_wac = row.workflow_as_code_status.is_some();
// Extract approval info based on WAC vs classic flow
let (form_schema, description, approval_conditions, hide_cancel) = if is_wac {
let approval_meta = row
.workflow_as_code_status
.as_ref()
.and_then(|v| v.get("_approval"));
let form = approval_meta.and_then(|m| m.get("form").cloned());
let ac = row
.flow_status
.as_ref()
.and_then(|v| v.get("approval_conditions"))
.and_then(|v| serde_json::from_value::<ApprovalConditions>(v.clone()).ok());
(form, None, ac, None)
} else {
let fs = row
.flow_status
.as_ref()
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok());
let ac = fs.as_ref().and_then(|s| s.approval_conditions.clone());
// For classic flows, form/description come from the flow definition and step result
let approval_step = fs.as_ref().map(|s| (s.step as usize).saturating_sub(1));
// Fetch flow definition to get suspend settings (form schema, hide_cancel).
// Try raw_flow on the job first, fall back to flow_version for deployed flows.
let raw_flow: Option<FlowValue> = {
let from_job: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT raw_flow FROM v2_job WHERE id = $1 AND workspace_id = $2",
)
.bind(&job_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.flatten();
if let Some(v) = from_job {
serde_json::from_value(v).ok()
} else {
// Deployed flow: fetch from flow_version using runnable_id
let from_version: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT fv.value FROM v2_job j JOIN flow_version fv ON fv.id = j.runnable_id \
WHERE j.id = $1 AND j.workspace_id = $2",
)
.bind(&job_id)
.bind(&w_id)
.fetch_optional(&db)
.await?
.flatten();
from_version.and_then(|v| serde_json::from_value(v).ok())
}
};
let suspend_module = raw_flow
.as_ref()
.and_then(|rf| approval_step.and_then(|s| rf.modules.get(s)));
let suspend_settings = suspend_module.and_then(|m| m.suspend.as_ref());
let form = suspend_settings
.and_then(|s| s.resume_form.as_ref())
.map(|rf| serde_json::json!(rf));
let hc = suspend_settings.map(|s| s.hide_cancel.unwrap_or(false));
// Fetch description and default_args from the step's completed job result
let step_job_id = fs
.as_ref()
.and_then(|s| approval_step.and_then(|step| s.modules.get(step)))
.and_then(|m| m.job());
let (desc, _default_args) = if let Some(sjid) = step_job_id {
let result: Option<serde_json::Value> = sqlx::query_scalar(
"SELECT result FROM v2_job_completed WHERE id = $1 AND workspace_id = $2",
)
.bind(sjid)
.bind(&w_id)
.fetch_optional(&db)
.await?
.flatten();
let desc = result.as_ref().and_then(|r| r.get("description").cloned());
let da = result.as_ref().and_then(|r| r.get("default_args").cloned());
(desc, da)
} else {
(None, None)
};
(form, desc, ac, hc)
};
let user_auth_required = approval_conditions
.as_ref()
.map(|ac| ac.user_auth_required)
.unwrap_or(false);
// Determine if current user can approve
let can_approve = if let Some(ref authed) = opt_authed {
if authed.is_admin {
true
} else {
let is_owner = row
.script_path
.as_deref()
.map(|p| require_owner_of_path(authed, p).is_ok())
.unwrap_or(false);
if is_owner {
true
} else {
let trigger_email = row.email.as_str();
conditionally_require_authed_user(
Some(authed.clone()),
approval_conditions.clone(),
trigger_email,
)
.is_ok()
}
}
} else {
// Not logged in — can approve only if no auth required
!user_auth_required
};
// Get existing approvers
let approvers: Vec<Approval> = sqlx::query_as::<_, (i32, Option<String>)>(
"SELECT resume_id, approver FROM resume_job WHERE flow = $1",
)
.bind(&job_id)
.fetch_all(&db)
.await?
.into_iter()
.map(|(rid, approver)| Approval {
resume_id: rid as u16,
approver: approver.unwrap_or_else(|| "anonymous".to_string()),
})
.collect();
Ok(Json(ApprovalInfo {
flow_id: row.id,
form_schema,
description,
approval_conditions,
can_approve,
user_auth_required,
hide_cancel,
approvers,
}))
}
// --- End new approval system endpoints ---
pub async fn resume_suspended_job(
authed: Option<ApiAuthed>,
opt_tokened: OptTokened,
@@ -2258,26 +2679,8 @@ async fn resume_suspended_job_internal(
// Get flow info - works for step-level, flow-level, and WAC approval
let (flow_info, is_flow_level, is_wac) = get_flow_info_for_resume(job_id, &db).await?;
// For step-level resumes, verify user auth and flow status
// For flow-level resumes (pre-approvals), the flow might not be at a suspended step yet
// For WAC approvals, skip flow status checks (there is no flow)
if !is_flow_level && !is_wac {
let parent_flow = GetQuery::new()
.without_logs()
.without_code()
.without_flow()
.fetch(&db, &flow_info.id, &w_id)
.await?;
let flow_status = parent_flow
.flow_status()
.ok_or_else(|| anyhow::anyhow!("unable to find the flow status in the flow job"))?;
let trigger_email = match &parent_flow {
Job::CompletedJob(job) => &job.email,
Job::QueuedJob(job) => &job.email,
};
conditionally_require_authed_user(authed.clone(), flow_status, trigger_email)?;
}
// HMAC secret = full capability. Skip approval_conditions checks.
// Authorization rules are enforced by the new resume_suspended endpoint instead.
let exists = sqlx::query_scalar!(
r#"
@@ -2543,7 +2946,7 @@ async fn get_flow_info_for_resume(job_id: Uuid, db: &DB) -> error::Result<(FlowI
async fn get_suspended_flow_info<'c>(
job_id: Uuid,
tx: &mut Transaction<'c, Postgres>,
) -> error::Result<(FlowInfo, Uuid)> {
) -> error::Result<(FlowInfo, Uuid, bool)> {
let flow = sqlx::query_as!(
FlowInfo,
r#"
@@ -2556,7 +2959,9 @@ async fn get_suspended_flow_info<'c>(
.fetch_optional(&mut **tx)
.await?
.ok_or_else(|| anyhow::anyhow!("parent flow job not found"))?;
let job_id = flow
// Try to extract step job_id from FlowStatus modules (classic flow path)
let step_job_id = flow
.flow_status
.as_ref()
.and_then(|v| serde_json::from_value::<FlowStatus>(v.clone()).ok())
@@ -2565,8 +2970,31 @@ async fn get_suspended_flow_info<'c>(
_ => None,
});
if let Some(job_id) = job_id {
Ok((flow, job_id))
if let Some(step_job_id) = step_job_id {
// Classic flow
Ok((flow, step_job_id, false))
} else if flow.suspend > 0 {
// WAC approval: no FlowStatus modules, but the job is suspended
// The flow_status here comes from COALESCE(flow_status, workflow_as_code_status),
// so for WAC it may contain approval_conditions from flow_status column
// or the WAC checkpoint from workflow_as_code_status column.
// We need the approval_conditions which are in flow_status column.
// Re-fetch just flow_status (without COALESCE fallback) for the auth check.
let flow_status_only: Option<serde_json::Value> =
sqlx::query_scalar("SELECT flow_status FROM v2_job_status WHERE id = $1")
.bind(&job_id)
.fetch_optional(&mut **tx)
.await?
.flatten();
let flow = FlowInfo {
id: flow.id,
flow_status: flow_status_only,
suspend: flow.suspend,
script_path: flow.script_path,
email: flow.email,
};
Ok((flow, job_id, true))
} else {
Err(anyhow::anyhow!("the flow is not in a suspended state anymore").into())
}
@@ -2643,7 +3071,11 @@ pub async fn get_suspended_job_flow(
Job::CompletedJob(job) => &job.email,
Job::QueuedJob(job) => &job.email,
};
conditionally_require_authed_user(authed.clone(), flow_status.clone(), trigger_email)?;
conditionally_require_authed_user(
authed.clone(),
flow_status.approval_conditions.clone(),
trigger_email,
)?;
let approvers_from_status = match flow_module_status {
FlowStatusModule::Success { approvers, .. } => approvers.to_owned(),
@@ -2684,16 +3116,25 @@ pub async fn get_suspended_job_flow(
fn conditionally_require_authed_user(
_authed: Option<ApiAuthed>,
flow_status: FlowStatus,
approval_conditions_opt: Option<ApprovalConditions>,
_trigger_email: &str,
) -> error::Result<()> {
let approval_conditions_opt = flow_status.approval_conditions;
if approval_conditions_opt.is_none() {
return Ok(());
}
let approval_conditions = approval_conditions_opt.unwrap();
// Check self-approval independently of user_auth_required
if approval_conditions.self_approval_disabled {
if let Some(ref authed) = _authed {
if !authed.is_admin && authed.email.eq(_trigger_email) {
return Err(Error::PermissionDenied(
"Self-approval is disabled for this flow step".to_string(),
));
}
}
}
if approval_conditions.user_auth_required {
{
#[cfg(not(feature = "enterprise"))]
@@ -2711,13 +3152,6 @@ fn conditionally_require_authed_user(
let authed = _authed.unwrap();
if !authed.is_admin {
if approval_conditions.self_approval_disabled && authed.email.eq(_trigger_email)
{
return Err(Error::PermissionDenied(
"Self-approval is disabled for this flow step".to_string(),
));
}
if !approval_conditions.user_groups_required.is_empty() {
#[cfg(feature = "enterprise")]
{
@@ -2863,11 +3297,18 @@ pub async fn get_resume_urls_internal(
.map(|x| format!("?approver={}", encode(x)))
.unwrap_or_else(String::new);
// Generate approval token for the new approval page URL.
// The token targets the parent flow/WAC job for proper resolution.
let approval_target_id = get_flow_id_for_job(&db, job_id)
.await
.unwrap_or(target_job_id);
let approval_token = generate_approval_token(&w_id, approval_target_id, &db).await?;
let base_url_str = BASE_URL.read().await.clone();
let base_url = base_url_str.as_str();
let res = ResumeUrls {
approvalPage: format!(
"{base_url}/approve/{w_id}/{target_job_id}/{resume_id}/{signature}{approver_query}"
"{base_url}/approve/{w_id}/{approval_target_id}?token={approval_token}"
),
cancel: build_resume_url(
"cancel",

View File

@@ -45,8 +45,8 @@ use windmill_common::global_settings::EMAIL_DOMAIN_SETTING;
use windmill_common::worker::HUB_CACHE_DIR;
use std::fs::DirBuilder;
use std::sync::Arc;
use std::time::Duration;
use std::{net::SocketAddr, sync::Arc};
use tokio::sync::RwLock;
use tower::ServiceBuilder;
use tower_cookies::CookieManagerLayer;
@@ -165,6 +165,7 @@ pub mod teams_ee;
mod teams_oss;
mod token;
mod tracing_init;
mod trash;
pub mod triggers;
mod users;
#[cfg(feature = "private")]
@@ -325,7 +326,7 @@ pub async fn run_server(
db: DB,
job_index_reader: Option<IndexReader>,
log_index_reader: Option<ServiceLogIndexReader>,
addr: SocketAddr,
listener: tokio::net::TcpListener,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
port_tx: tokio::sync::oneshot::Sender<String>,
server_mode: bool,
@@ -411,6 +412,9 @@ pub async fn run_server(
auth_cache: auth_cache.clone(),
base_internal_url: _base_internal_url.clone(),
});
let addr = listener
.local_addr()
.unwrap_or_else(|_| std::net::SocketAddr::from(([127, 0, 0, 1], 0)));
if let Err(err) = smtp_server.start_listener_thread(addr).await {
tracing::error!("Error starting SMTP server: {err:#}");
}
@@ -451,9 +455,6 @@ pub async fn run_server(
health::start_health_check_loop(db.clone(), killpill_rx.resubscribe());
}
let listener = tokio::net::TcpListener::bind(addr)
.await
.context("binding main windmill server")?;
let port = listener.local_addr().map(|x| x.port()).unwrap_or(8000);
let ip = listener
.local_addr()
@@ -597,6 +598,7 @@ pub async fn run_server(
.nest("/resources", resources::workspaced_service())
.nest("/schedules", windmill_api_schedule::workspaced_service())
.nest("/scripts", scripts::workspaced_service())
.nest("/trash", trash::workspaced_service())
.nest(
"/users",
users::workspaced_service().layer(Extension(argon2.clone())),

View File

@@ -0,0 +1,511 @@
use axum::{
extract::{Extension, Json, Path, Query},
routing::{delete, get, post},
Router,
};
use serde::Deserialize;
use windmill_audit::{audit_oss::audit_log, ActionKind};
use windmill_common::{
db::UserDB,
error::{Error, Result},
trashbin::{self, TrashItem, TrashItemWithData},
utils::require_admin,
};
use crate::db::{ApiAuthed, DB};
pub fn workspaced_service() -> Router {
Router::new()
.route("/list", get(list_trash))
.route("/get/:id", get(get_trash_item))
.route("/restore/:id", post(restore_trash_item))
.route("/delete/:id", delete(permanently_delete_item))
.route("/empty", post(empty_trash))
}
#[derive(Deserialize)]
struct ListTrashQuery {
item_kind: Option<String>,
page: Option<i64>,
per_page: Option<i64>,
}
async fn list_trash(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
Query(query): Query<ListTrashQuery>,
) -> Result<Json<Vec<TrashItem>>> {
require_admin(authed.is_admin, &authed.username)?;
let items = trashbin::list_trash(
&db,
&w_id,
query.item_kind.as_deref(),
query.page,
query.per_page,
)
.await?;
Ok(Json(items))
}
async fn get_trash_item(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path((w_id, id)): Path<(String, i64)>,
) -> Result<Json<TrashItemWithData>> {
require_admin(authed.is_admin, &authed.username)?;
let item = trashbin::get_trash_item(&db, &w_id, id).await?;
Ok(Json(item))
}
async fn restore_trash_item(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, i64)>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let item = trashbin::get_trash_item(&db, &w_id, id).await?;
let mut tx = user_db.begin(&authed).await?;
match item.item_kind.as_str() {
"script" => restore_script(&mut tx, &item).await?,
"flow" => restore_flow(&mut tx, &item).await?,
"app" => restore_app(&mut tx, &item).await?,
"schedule" => restore_schedule(&mut tx, &item).await?,
"variable" => restore_variable(&mut tx, &item).await?,
"resource" => restore_resource(&mut tx, &item).await?,
kind if kind.ends_with("_trigger") => restore_trigger(&mut tx, &item).await?,
_ => {
return Err(Error::BadRequest(format!(
"Unknown item kind: {}",
item.item_kind
)))
}
}
sqlx::query!("DELETE FROM trashbin WHERE id = $1", item.id)
.execute(&mut *tx)
.await?;
audit_log(
&mut *tx,
&authed,
"trash.restore",
ActionKind::Create,
&w_id,
Some(&item.item_path),
None,
)
.await?;
tx.commit().await?;
Ok(format!("{} '{}' restored", item.item_kind, item.item_path))
}
async fn permanently_delete_item(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path((w_id, id)): Path<(String, i64)>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let item = trashbin::get_trash_item(&db, &w_id, id).await?;
let mut tx = user_db.begin(&authed).await?;
trashbin::permanently_delete_item(&mut *tx, &w_id, id).await?;
audit_log(
&mut *tx,
&authed,
"trashbin.permanently_delete",
ActionKind::Delete,
&w_id,
Some(&item.item_path),
None,
)
.await?;
tx.commit().await?;
Ok("permanently deleted".to_string())
}
async fn empty_trash(
authed: ApiAuthed,
Extension(_db): Extension<DB>,
Extension(user_db): Extension<UserDB>,
Path(w_id): Path<String>,
) -> Result<String> {
require_admin(authed.is_admin, &authed.username)?;
let mut tx = user_db.begin(&authed).await?;
let count = trashbin::empty_trash(&mut *tx, &w_id).await?;
audit_log(
&mut *tx,
&authed,
"trashbin.empty",
ActionKind::Delete,
&w_id,
None,
None,
)
.await?;
tx.commit().await?;
Ok(format!("{} items permanently deleted", count))
}
// --- Restore functions per item kind ---
async fn restore_script(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
let data = &item.item_data;
// Check for path conflict
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2)",
&item.item_path,
&item.workspace_id,
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if exists {
return Err(Error::BadRequest(format!(
"A script already exists at path '{}'",
item.item_path
)));
}
// Scripts are stored as an array (all versions for the path)
let scripts = data
.get("scripts")
.and_then(|v| v.as_array())
.ok_or_else(|| Error::internal_err("Invalid trash data for script"))?;
for script in scripts {
sqlx::query("INSERT INTO script SELECT * FROM jsonb_populate_record(null::script, $1)")
.bind(script)
.execute(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("restoring script: {e:#}")))?;
}
// Restore drafts if present
if let Some(drafts) = data.get("drafts").and_then(|v| v.as_array()) {
for draft in drafts {
sqlx::query(
"INSERT INTO draft SELECT * FROM jsonb_populate_record(null::draft, $1)
ON CONFLICT DO NOTHING",
)
.bind(draft)
.execute(&mut *tx)
.await?;
}
}
Ok(())
}
async fn restore_flow(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
let data = &item.item_data;
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM flow WHERE path = $1 AND workspace_id = $2)",
&item.item_path,
&item.workspace_id,
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if exists {
return Err(Error::BadRequest(format!(
"A flow already exists at path '{}'",
item.item_path
)));
}
let row = data
.get("row")
.ok_or_else(|| Error::internal_err("Invalid trash data for flow"))?;
sqlx::query("INSERT INTO flow SELECT * FROM jsonb_populate_record(null::flow, $1)")
.bind(row)
.execute(&mut *tx)
.await?;
// Restore flow_versions
if let Some(versions) = data.get("flow_versions").and_then(|v| v.as_array()) {
for version in versions {
sqlx::query(
"INSERT INTO flow_version SELECT * FROM jsonb_populate_record(null::flow_version, $1)
ON CONFLICT DO NOTHING",
)
.bind(version)
.execute(&mut *tx)
.await?;
}
}
// Restore flow_nodes
if let Some(nodes) = data.get("flow_nodes").and_then(|v| v.as_array()) {
for node in nodes {
sqlx::query(
"INSERT INTO flow_node SELECT * FROM jsonb_populate_record(null::flow_node, $1)
ON CONFLICT DO NOTHING",
)
.bind(node)
.execute(&mut *tx)
.await?;
}
}
// Restore drafts
if let Some(drafts) = data.get("drafts").and_then(|v| v.as_array()) {
for draft in drafts {
sqlx::query(
"INSERT INTO draft SELECT * FROM jsonb_populate_record(null::draft, $1)
ON CONFLICT DO NOTHING",
)
.bind(draft)
.execute(&mut *tx)
.await?;
}
}
Ok(())
}
async fn restore_app(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
let data = &item.item_data;
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM app WHERE path = $1 AND workspace_id = $2)",
&item.item_path,
&item.workspace_id,
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if exists {
return Err(Error::BadRequest(format!(
"An app already exists at path '{}'",
item.item_path
)));
}
let row = data
.get("row")
.ok_or_else(|| Error::internal_err("Invalid trash data for app"))?;
// Insert app first (app_version has FK to app.id)
sqlx::query("INSERT INTO app SELECT * FROM jsonb_populate_record(null::app, $1)")
.bind(row)
.execute(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("restoring app row: {e:#}")))?;
// Then restore app_versions
if let Some(versions) = data.get("app_versions").and_then(|v| v.as_array()) {
for version in versions {
sqlx::query(
"INSERT INTO app_version SELECT * FROM jsonb_populate_record(null::app_version, $1)
ON CONFLICT DO NOTHING",
)
.bind(version)
.execute(&mut *tx)
.await
.map_err(|e| Error::internal_err(format!("restoring app_version: {e:#}")))?;
}
}
// Restore drafts
if let Some(drafts) = data.get("drafts").and_then(|v| v.as_array()) {
for draft in drafts {
sqlx::query(
"INSERT INTO draft SELECT * FROM jsonb_populate_record(null::draft, $1)
ON CONFLICT DO NOTHING",
)
.bind(draft)
.execute(&mut *tx)
.await?;
}
}
Ok(())
}
async fn restore_schedule(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
let data = &item.item_data;
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM schedule WHERE path = $1 AND workspace_id = $2)",
&item.item_path,
&item.workspace_id,
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if exists {
return Err(Error::BadRequest(format!(
"A schedule already exists at path '{}'",
item.item_path
)));
}
let row = data
.get("row")
.ok_or_else(|| Error::internal_err("Invalid trash data for schedule"))?;
sqlx::query("INSERT INTO schedule SELECT * FROM jsonb_populate_record(null::schedule, $1)")
.bind(row)
.execute(&mut *tx)
.await?;
Ok(())
}
async fn restore_variable(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
let data = &item.item_data;
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM variable WHERE path = $1 AND workspace_id = $2)",
&item.item_path,
&item.workspace_id,
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if exists {
return Err(Error::BadRequest(format!(
"A variable already exists at path '{}'",
item.item_path
)));
}
let row = data
.get("row")
.ok_or_else(|| Error::internal_err("Invalid trash data for variable"))?;
sqlx::query("INSERT INTO variable SELECT * FROM jsonb_populate_record(null::variable, $1)")
.bind(row)
.execute(&mut *tx)
.await?;
// Restore linked resource if present
if let Some(linked_resource) = data.get("linked_resource") {
if !linked_resource.is_null() {
sqlx::query(
"INSERT INTO resource SELECT * FROM jsonb_populate_record(null::resource, $1)
ON CONFLICT DO NOTHING",
)
.bind(linked_resource)
.execute(&mut *tx)
.await?;
}
}
Ok(())
}
async fn restore_resource(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
let data = &item.item_data;
let exists = sqlx::query_scalar!(
"SELECT EXISTS(SELECT 1 FROM resource WHERE path = $1 AND workspace_id = $2)",
&item.item_path,
&item.workspace_id,
)
.fetch_one(&mut *tx)
.await?
.unwrap_or(false);
if exists {
return Err(Error::BadRequest(format!(
"A resource already exists at path '{}'",
item.item_path
)));
}
let row = data
.get("row")
.ok_or_else(|| Error::internal_err("Invalid trash data for resource"))?;
sqlx::query("INSERT INTO resource SELECT * FROM jsonb_populate_record(null::resource, $1)")
.bind(row)
.execute(&mut *tx)
.await?;
// Restore linked variables if present
if let Some(linked_vars) = data.get("linked_variables").and_then(|v| v.as_array()) {
for var in linked_vars {
sqlx::query(
"INSERT INTO variable SELECT * FROM jsonb_populate_record(null::variable, $1)
ON CONFLICT DO NOTHING",
)
.bind(var)
.execute(&mut *tx)
.await?;
}
}
Ok(())
}
async fn restore_trigger(tx: &mut sqlx::PgConnection, item: &TrashItemWithData) -> Result<()> {
let data = &item.item_data;
let table_name = data
.get("table_name")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::internal_err("Invalid trash data for trigger: missing table_name"))?;
// Validate table name to prevent SQL injection
let valid_tables = [
"http_trigger",
"websocket_trigger",
"kafka_trigger",
"nats_trigger",
"postgres_trigger",
"mqtt_trigger",
"sqs_trigger",
"gcp_trigger",
"email_trigger",
];
if !valid_tables.contains(&table_name) {
return Err(Error::BadRequest(format!(
"Invalid trigger table: {}",
table_name
)));
}
let exists: bool = sqlx::query_scalar(&format!(
"SELECT EXISTS(SELECT 1 FROM {} WHERE path = $1 AND workspace_id = $2)",
table_name
))
.bind(&item.item_path)
.bind(&item.workspace_id)
.fetch_one(&mut *tx)
.await?;
if exists {
return Err(Error::BadRequest(format!(
"A trigger already exists at path '{}'",
item.item_path
)));
}
let row = data
.get("row")
.ok_or_else(|| Error::internal_err("Invalid trash data for trigger"))?;
sqlx::query(&format!(
"INSERT INTO {} SELECT * FROM jsonb_populate_record(null::{}, $1)",
table_name, table_name
))
.bind(row)
.execute(&mut *tx)
.await?;
Ok(())
}

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