Compare commits

...
Author SHA1 Message Date
windmill-internal-app[bot] 0f7ee6c4e5 Batch DELETE on legacy audit table to prevent DB overload 2026-05-18 15:23:05 +00:00
centdix fec4008696 fix: preserve ai reasoning content (#9208)
* fix: preserve ai reasoning content

* fix: avoid text-only reasoning replay

* feat: add deepseek ai eval models
2026-05-18 10:40:18 +00:00
centdix bd32c5f951 refactor: move openai-compatible proxy building (#9133)
* refactor: introduce ai proxy request types

* refactor: move openai-compatible proxy building
2026-05-18 10:24:16 +00:00
Ruben Fiszelandrubenfiszel 4e91f83b8f chore(main): release 1.703.3 (#9200)
* chore(main): release 1.703.3

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-18 09:09:50 +00:00
Ruben FiszelandClaude Opus 4.7 bd05bcadde fix: validate entrypoint override to prevent worker code injection (GHSA-wxjq-w5pj-jqhx) (#9204)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 09:01:05 +00:00
Ruben FiszelandClaude Opus 4.7 664edcdfb7 fix: enforce jobs:run scope on job preview and inline endpoints (#9198)
* fix: enforce jobs:run scope on job preview and inline endpoints

Preview/inline endpoints (run/preview, run/preview_bundle, run/preview_flow,
run/dynamic_select inline) execute arbitrary request-supplied code but only
checked folder/namespace read access, which is a no-op when path is null. A
token scoped to a specific script/flow could escape its scope and run any
code. Add a jobs:run scope check, matching other arbitrary-execution
endpoints. Advisory GHSA-vxc5-w28p-m9xw.

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

* fix: scope-check dynamic_select flow branch and inline preview

Address CI review: the dynamic_select Deployed{Flow} branch ran a deployed
flow's dynamic-select code without any scope check (only the Script branch
delegated to a scope-checked handler), and run_inline_preview_script executed
request-supplied code with no in-handler scope check. Add jobs:run:flows:{path}
to the flow branch and jobs:run to inline preview; correct the misleading
comment. Expand regression tests (preview_flow case, assert success for the
broad-token case). Advisory GHSA-vxc5-w28p-m9xw.

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

* test: remove preview scope enforcement test after local validation

The regression test passed locally (3/3) and validated the fix end-to-end;
removed from the PR per maintainer preference. Advisory GHSA-vxc5-w28p-m9xw.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:57:24 +00:00
Ruben FiszelandClaude Opus 4.7 9dbce4a8c4 ci: disable PDB generation in Windows backend tests (#9201)
The dev profile's split-debuginfo = "unpacked" is coerced to "packed" on
windows-msvc, so each test-binary link spawns the shared mspdbsrv.exe PDB
type server. With 12 parallel link jobs this races the type-server cap
(LNK1318 "LIMIT (12)") and exhausts the runner disk (LNK1180), recurringly
failing the Windows release CI. CI needs no debug info, so disable PDB
generation for the dev/test profiles in this job only.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:43:35 +00:00
Ruben Fiszel e1df6b45e9 chore: remove alpha/beta warnings from tested frontend features (#9196) 2026-05-17 14:41:10 +00:00
Ruben FiszelandClaude Opus 4.7 24eedef918 fix: constrain unauthenticated get_public_resource to app_theme resources (#9203)
* fix: constrain unauthenticated get_public_resource to app_theme resources

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

* test: remove get_public_resource regression test

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:36:51 +00:00
Ruben FiszelandClaude Opus 4.7 ab11c7747a fix: enforce folder ACL on flow run-by-version routes (#9202)
* fix: enforce folder ACL on flow run-by-version routes (GHSA-8mv7-hmrg-96xv)

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

* fix: don't echo resolved flow path in version-route NotAuthorized (cubic P2)

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

* chore: remove GHSA-8mv7-hmrg-96xv regression test (verified locally pre-removal)

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:36:24 +00:00
Ruben FiszelandClaude Opus 4.7 8bc2295b94 fix(mcp): validate oauth dynamic client registration redirect_uris (#9197)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 14:00:06 +00:00
Ruben Fiszelandrubenfiszel 20719b4731 chore(main): release 1.703.2 (#9195)
* chore(main): release 1.703.2

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-17 12:59:58 +00:00
Ruben FiszelandClaude Opus 4.7 f8467f38c8 fix: prevent cross-tenant DNS poisoning via writable /etc in nsjail (#9194)
* fix: bind /etc resolver files read-only in nsjail sandboxes

* docs(nsjail): explain why per-file /etc resolver binds are load-bearing

The explicit /etc/hosts, /etc/resolv.conf and /etc/hostname binds look
like removable duplication of the read-only /etc bind above them. They
are not: on Kubernetes those files are separate kubelet bind-mounts on
top of /etc and nsjail's read-only remount is non-recursive, so without
these shadow binds they stay writable and a job can persist cross-tenant
DNS poisoning for the pod lifetime. Comment guards against a future
"dedup cleanup" silently reintroducing the vulnerability.

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

* docs(nsjail): shorten the load-bearing-bind comment to 3 lines

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-17 12:54:38 +00:00
Ruben FiszelandClaude Opus 4.7 25172bdc28 sidebar rendering expanded-but-empty at sub-pixel widths near 768px (#9191)
* fix: sidebar menu rendering expanded-but-empty near 768px width

The desktop sidebar branch is gated by JS (`innerWidth < 768`), but its
width was set only via Tailwind `md:` classes (`@media (min-width:768px)`).
`window.innerWidth` rounds fractional viewport widths, so at e.g. 767.8px
JS rounds to 768 and renders the desktop sidebar, while the CSS media
query does not match and no width class applies — leaving the sidebar
shell expanded with no width/content. Drop the now-redundant `md:`
prefix so width tracks the JS branch decision.

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

* fix: align content offset breakpoint with sidebar JS gate

The sidebar width now follows the JS innerWidth gate, but the main
content left-offset in AiChatLayout still used the `md:` CSS media
query, leaving the two breakpoints out of sync in the same sub-pixel
band. Pass an `isMobile` flag from the layout (mirroring the sidebar's
`innerWidth < 768` condition) and gate the content padding on it with
unprefixed classes so sidebar width and content offset always flip
together.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:13:15 +00:00
Ruben Fiszelandrubenfiszel fa090f3081 chore(main): release 1.703.1 (#9182)
* chore(main): release 1.703.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-16 08:42:33 +00:00
Ruben FiszelandClaude Opus 4.7 dfeed9c5c2 fix: actionable error when a custom_path is taken by an app in another workspace (#9190)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 08:29:53 +00:00
52960ca30a fix: reset parent_hash in auto_parent when all versions at path are archived (#9172)
* fix: reset parent_hash in auto_parent when all versions at path are archived

* test: regression test for auto_parent with all versions archived

---------

Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-05-16 07:43:35 +00:00
Ruben Fiszel 302ce58e98 harden UI builder artifact bootstrap with verified pinned metadata (#9189)
* feat: harden UI builder artifact bootstrap with verified pinned metadata

* fix: emit tab-indented artifact json to match prettier config

* refactor: rewrite artifact json with node instead of python

* refactor: simplify bootstrap to flat script, drop test scaffolding
2026-05-16 07:40:29 +00:00
Ruben FiszelandClaude Opus 4.7 4e25954722 fix: don't show ALLOW_PRIVATE_AI_BASE_URLS hint for malformed AI base URLs (#9188)
* fix: don't show ALLOW_PRIVATE_AI_BASE_URLS hint for malformed AI base URLs

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

* fix: impl std::error::Error for SsrfValidationError for anyhow callers

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 07:39:47 +00:00
Ruben FiszelandClaude Opus 4.7 81b5736106 fix: atomic bundle cache writes to prevent parallel cold-load race (#9186)
* fix: atomic bundle cache writes to prevent parallel cold-load race

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

* fix: trust-but-replace in atomic_publish_dir to never trust a stale partial cache dir

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

* fix: simplify atomic_publish_dir and add content-addressed rename-failure fallback

Revert the destroy-then-recreate dir swap (introduced concurrent-publisher
edge cases: spurious Err under a real herd, EACCES masking a stale partial)
back to a single atomic rename. Add the content-addressed exists-fallback to
atomic_write_file_bytes/atomic_copy_file so the loser of a publish race (and
Windows, where rename cannot replace an open/existing destination) treats the
already-published identical cache as success instead of failing.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 21:04:16 +00:00
Ruben FiszelandClaude Opus 4.7 6a334e9a07 fix: detect S3 assets passed as SDK object arg in ts parser (#9181)
windmill-parser-ts-asset only recognized writeS3File/loadS3File when
the first arg was a bare 's3://...' string literal. The actual SDK
signature takes an S3Object ({ s3, storage? }) or 's3://bucket/key'
string, which every real script uses, so object-form writes/reads were
never detected as assets. Resolve the S3Object arg the same way the
runtime parseS3Object does, mapping { s3, storage } to s3://<storage>/
<key> and feeding it through parse_asset_syntax so the path matches the
// on s3:///… trigger form. Adds regression tests.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:19:55 +00:00
Blossom 85555542bf replace sync deprecation placeholder link (#9180) 2026-05-15 13:20:36 +00:00
Ruben Fiszelandrubenfiszel e3a3dbb89c chore(main): release 1.703.0 (#9170)
* chore(main): release 1.703.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-15 13:15:28 +00:00
Ruben Fiszel 8f95402850 use OPENAI_API_KEY for codex workflow 2026-05-15 13:15:02 +00:00
69b3141e03 fix: apply pip_local_dependencies filtering to deployed scripts with populated lockfiles (#9178)
* fix: apply pip_local_dependencies filtering to deployed scripts with populated lockfiles

* refactor: share pip_local_dependencies filtering helper, log ignored deps

* test: split pure filter core out for unit testing, cover #-preservation

---------

Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-05-15 12:46:37 +00:00
Ruben Fiszel b7bc9b44b4 chore(frontend): update vite to 8.0.13 (#9179) 2026-05-15 12:45:00 +00:00
e1819313e1 fix: aggregate wait time should target the true root job, not flow_innermost_root_job (#9177)
* fix: aggregate wait time should target the true root job, not flow_innermost_root_job

* refactor: reuse get_root_job_id helper for wait-time aggregation

Instead of duplicating the root_job → flow_innermost_root_job →
parent_job fallback chain inline, call the existing get_root_job_id()
helper (the same one used by push_next_flow_job) and filter out the
self-id case so standalone scripts still skip aggregate insertion.
Behaviorally identical, single source of truth.

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

---------

Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 12:24:29 +00:00
Ruben Fiszel d48d61cc79 feat(otel-tracing-proxy): configurable tracing MITM NO_PROXY hosts (#9169)
* feat(otel-tracing-proxy): configurable NO_PROXY hosts

* refactor(otel-tracing-proxy): NO_PROXY only governs job-side bypass

* fix(otel-tracing-proxy): restore empty NO_PROXY default

* test(otel-tracing-proxy): unit tests for NO_PROXY normalization

* fix(otel-tracing-proxy): gate normalize_no_proxy_hosts to EE features
2026-05-14 14:03:47 +00:00
Ruben Fiszel 1c05604e4c add .claude worktrees 2026-05-14 13:37:50 +00:00
Ruben Fiszel f414ffc484 fix: never mark failure/trigger/approval scripts as auto_kind=lib (#9168) 2026-05-14 13:29:56 +00:00
Ruben Fiszelandrubenfiszel 7f589a8c7d chore(main): release 1.702.1 (#9166)
* chore(main): release 1.702.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-14 10:54:35 +00:00
Ruben Fiszel 6637e00375 test(nativets): cover deno_telemetry init contract + http trace export E2E (#9165) 2026-05-14 10:54:19 +00:00
bf99283c33 fix(nativets): pass tracing-enabled OtelConfig to deno_telemetry::init (#9163)
* [ee] fix(nativets): pass tracing-enabled OtelConfig to deno_telemetry::init

* chore: update ee-repo-ref to 19a76a09ffb43649ee19e62d07e8b8a42d78757b

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

Previous ee-repo-ref: daf7ce31f192d64ca6b5602af626ab6453d2aff4

New ee-repo-ref: 19a76a09ffb43649ee19e62d07e8b8a42d78757b

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-14 10:46:43 +00:00
Ruben Fiszelandrubenfiszel 90f494975d chore(main): release 1.702.0 (#9160)
* chore(main): release 1.702.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-14 09:18:42 +00:00
Ruben Fiszel 5e909b2b4f feat(git-sync): sync extra_perms for flows/scripts/apps (#9162)
* feat(git-sync): sync extra_perms for flows/scripts/apps

* fix(git-sync): hash extra_perms, fix script no-op + up-to-date checks

* refactor(git-sync): route extra_perms through /acls/* instead of update endpoints

* fix(git-sync): dispatch raw_app perm changes via DeployedObject::RawApp

* fix(git-sync): wire applyExtraPermsDiff into pushRawApp + per-change logs

* feat(git-sync): opt-in tarball perms, no-op when yaml omits, audit logs, validation

* fix(git-sync): replace remaining bool literal in EE-only trigger export call

* fix(git-sync): raw_app /acls/* hits app table; refetch after create

* fix(git-sync): drop unnecessary post-deploy refetch (folder perms not merged)

* fix(git-sync): raw_app /acls/get; reject malformed local extra_perms

* audit: distinct raw_apps prefix so dashboards can split raw_app ACL events
2026-05-14 08:16:24 +00:00
Ruben Fiszel d870edc959 fix(mcp): sanitize and enrich nested resource schemas (#9158)
* fix(mcp): sanitize and enrich nested resource schemas

* fix(mcp): strip Windmill-internal keys inline during resource enrichment

* fix(mcp): strip resourceType unconditionally in schema sanitizer
2026-05-14 06:54:32 +00:00
Ruben FiszelandClaude Opus 4.7 e5286f4607 feat: include service accounts in instance settings users list (#9157)
* feat: include service accounts in instance settings users list

Service accounts (workspace-scoped, no password row) now appear in the
superadmin users list with a Bot icon, workspace badge, and a link to
manage them in the workspace settings. Role is locked to Operator.

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

* chore: update sqlx offline cache

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

* fix: use composite key for users each block

Service accounts can share emails across workspaces, so key by email + workspace_id to avoid Svelte each_key_duplicate.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 06:46:48 +00:00
Ruben Fiszelandrubenfiszel 4f3a1e3109 chore(main): release 1.701.0 (#9131)
* chore(main): release 1.701.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-13 16:19:55 +00:00
Ruben Fiszel 33bf01b627 fix(python): preserve strings containing Infinity/NaN in result JSON (#9149)
* fix(python): preserve strings containing Infinity/NaN in result JSON

* test(python): add sanity checks for Infinity/NaN in results

* refactor(python): use string-aware regex callback for single-pass cleanup

* refactor(python): compact regex + handle backslash-escape parity

* perf(python): short-circuit cleanup when no Infinity/NaN/NUL in result
2026-05-13 15:49:36 +00:00
d666e8431c feat: read-only flag on API tokens (#9144)
* feat: read-only flag on API tokens, orthogonal to scopes

Add a per-token `read_only` boolean set at creation time. When true, the
token can only call HTTP methods classified as Read (GET/HEAD/OPTIONS).
Mutating methods and job-run actions are rejected with 403, regardless of
which scopes are attached. Surfaced as a prominent toggle in the standard
token-creation flow and a discreet `2xs` toggle in MCP mode (where users
often want write access, so we don't bias them toward enabling it).

MCP enforcement: read-only tokens hide all script/flow/hub tools from
`list_tools` and only see endpoint tools whose method is GET, and the
runner rejects `call_tool` on anything mutating.

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

* fix: review fixes for read-only token flag

- Exempt /api/mcp/* and /mcp/* paths from the read-only middleware check.
  MCP transport runs over POST (streamable HTTP / SSE), so otherwise the
  middleware would 403 every MCP request before the runner could enforce
  read-only at the tool-call level.
- Tighten is_endpoint_read_only to GET only, matching the read_only_hint
  that create_endpoint_annotations actually emits.
- Add unit test for check_read_only_for_route covering GET/HEAD/OPTIONS,
  mutating methods, and run paths.

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

* chore: bump ee-repo-ref to read-only-trigger-toggle

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

* fix(frontend): make read-only toggle discreet in both modes

Match the MCP-mode treatment in standard mode: text-tertiary, 2xs, shared
"Read-only" label. The tooltip switches per mode so the explanation still
fits the context.

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

* fix(frontend): gate read-only toggle behind Limit token permissions

The read-only toggle now only shows when the user has limited the token's
scopes (standard mode) or in MCP mode (which always picks an MCP scope).
Turning the limit off also resets read-only so it doesn't silently stick.

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

* feat(frontend): hide incompatible MCP tools when read-only is on

When the read-only toggle is on in MCP mode:
- Endpoint badges and the custom-mode endpoint MultiSelect filter to GET.
- Already-selected non-GET endpoints are pruned from the scope.
- The scripts/flows preview is replaced with a note explaining they're
  hidden (the runner already rejects script/flow runs for read-only).

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

* fix(frontend): place read-only toggle at top of limited scope area

The previous gate required at least one scope to be picked before the
read-only toggle appeared, which made it look missing while the user was
still building their scope list. Move the toggle inside ScopesPicker:
- Standard mode: sits directly under the "Limit token permissions" toggle
  whenever Limit is on, before the scope selector.
- MCP mode: sits at the top of the MCP scope block.

readOnly is now $bindable on ScopesPicker so CreateToken still owns the
value. The auto-reset on un-limit moves into ScopesPicker too.

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

* fix(frontend): nest read-only toggle inside the scope list card

Place the read-only toggle at the top of the scope list (between the
Selected Scopes summary and the bordered domain list) via a new optional
topSlot snippet on ScopeSelector. Keeps ScopeSelector decoupled from
read-only specifics; ScopesPicker fills the slot.

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

* chore: update ee-repo-ref to 9bc8160be50b3e57a60daf4e1b71c389a6e02b8a

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

Previous ee-repo-ref: f53d26e6685dfd60bfa67686fbd7358169cfd130

New ee-repo-ref: 9bc8160be50b3e57a60daf4e1b71c389a6e02b8a

Automated by sync-ee-ref workflow.

* fix: address CI review for read-only token flag

- P1 (Codex): narrow the MCP middleware exemption from "any /api/mcp/*"
  to just the streamable HTTP transport endpoints
  (/api/mcp/gateway, /api/mcp/w/{ws}/{mcp,sse,list_tools}). Without this,
  a read-only token could POST /api/mcp/gateway/oauth/server/approve and
  mint a follow-on non-read-only MCP token via the OAuth code/token
  exchange.
- P2 (Claude/cubic): fix test comment/assertion mismatch — the run-path
  assertion now exercises GET (which is what the RUN_PATH_ACTIONS
  elevation comment describes) in addition to POST. Add a regression
  assertion for /api/mcp/gateway/oauth/server/approve.
- P2 (cubic): short-circuit script/flow/hub-script/resource fetches in
  MCP list_tools when read_only is on — they would only be discarded
  below, so skipping the DB and resource fan-out is pure win.
- P2 (cubic): when scopes are pre-supplied via the CreateToken prop, the
  ScopesPicker isn't rendered, which previously hid the read-only
  toggle entirely. Render it next to the pre-supplied scopes display.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-13 15:26:21 +00:00
windmill-internal-app[bot]andwindmill-internal-app[bot] 110bef0a6e fix: Allow devops role to use all_workspaces runs filter in admins workspace (#9153)
Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com>
2026-05-13 15:12:11 +00:00
Ruben Fiszel c5092069cb fix: align script path existence check with deploy logic; hide Delete for non-admin (#9152)
- exists_script_by_path now filters archived = false, matching the
  conflict check in create_script_internal. Previously the frontend
  blocked creating a new script at a path occupied only by archived
  scripts, even though renaming to that same path was allowed.
- Hide the Delete entry in the script details "..." menu unless the
  user is admin. The backend delete_script_by_hash already requires
  admin, so non-admins would always see an error after clicking.
2026-05-13 15:05:25 +00:00
Ruben FiszelandClaude Opus 4.7 dd19e52a84 perf(dynselect): only retrigger when helper args actually change (#9148)
* perf(dynselect): only retrigger when helper-script args actually change

Parse the inline helper's signature with the existing WASM parser and
restrict the form-arg diff to keys the helper actually consumes. Typing
into unrelated fields no longer queues a dynselect job every second.
Falls back to the previous full-args comparison when the helper is
deployed or parsing fails.

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

* fix(dynselect): avoid double helper-script fetch on mount

usePromise defaults to loadInit=true, so refresh() ran before the
JobLoader child was bound (firing a no-op pending promise) and the
$effect then fired a second refresh once the bind:this resolved.
Disable loadInit so the effect owns the single first call.

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

* refactor(dynselect): use parser directly instead of inferArgs

inferArgs mutates a Schema object we never use and goes through a
shared cache; when fed an empty schema for non-main entrypoints the
caller cannot reliably read back the resulting properties. Add
parseEntrypointArgs that just runs the parser and returns the
parameter name Set (or undefined when unknown / unsupported / has
rest args / function not found). DynamicInput uses that and keeps
the previous params in flight while the next parse is computing.

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

* feat(dynselect): support deployed helpers in smart retrigger

Add getHelperEntrypointArgs which dispatches on HelperScript.source:
inline parses immediately; deployed fetches the script (or the flow's
inline dyn-select code) once and caches per (workspace, kind, path,
entrypoint). Without this the /scripts/get/* run view fell back to
the full-args comparison and still retriggered on unrelated fields.

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

* fix(dynselect): zero-arg helpers report empty deps, not unknown

Codex review flagged that a valid zero-parameter entrypoint was being
treated as "couldn't determine signature" and falling back to the
full-args comparison. Distinguish "function found with no params" from
"function not found" via the parser's auto_kind field — only the
latter sets it, so empty args + auto_kind=null means a real zero-arg
helper and we return an empty Set (no retrigger on unrelated fields).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 14:58:35 +00:00
Ruben FiszelandClaude Opus 4.7 4d0f2c26a1 fix(bun): pass --preserve-symlinks on unbundled execution (#9147)
* fix(bun): pass --preserve-symlinks on unbundled execution

Bun 1.2/1.3 moved its global package cache to a content-addressed
layout and the installer now creates a single directory symlink from
node_modules/<pkg> to the cache entry. Without --preserve-symlinks,
Bun resolves modules from each file's realpath, so any require/import
inside an installed package walks up from cache_nomount/bun/... and
never finds the sibling deps living under <job_dir>/node_modules/.

This manifested as e.g.
  ENOENT while resolving package 'zod/v3' from
  '/tmp/windmill/cache_nomount/bun/@langchain/core@1.1.44@@@1/dist/...'
on //nobundling scripts that pull @langchain/core, even though zod is
correctly installed alongside it in node_modules.

The bundled execution path already had --preserve-symlinks since #4132
(needed because we symlink the cached bundle file into the job dir).
The unbundled path didn't, because at the time Bun installed via per-
file hardlinks and the realpath of node_modules entries was the job
dir itself. The Bun installer's layout change made the flag necessary
on the unbundled path as well.

Add the flag to all three unbundled `bun run` invocations:
- nsjail unbundled path
- non-nsjail unbundled path
- dedicated worker (always unbundled)

This also fixes a latent bug on the first run of any bun script that
imports a package whose internals reference siblings (the build_cache
path runs unbundled this round while it builds the bundle for next
time).

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

* test(bun): regression test for nobundling + transitive require resolution

Adds an integration test that mirrors the original failure: a //nobundling
script importing @langchain/core, which (in its CJS internals) does
require('zod/v3'). Before --preserve-symlinks was added to the unbundled
bun run invocations, this failed with:

  ENOENT while resolving package 'zod/v3' from
  '.../cache_nomount/bun/@langchain/core@<ver>@@@1/dist/runnables/base.js'

The test covers the non-nsjail unbundled path. Reproducibility of the
pre-fix failure depends on Bun's installer choosing the directory-symlink
layout for the node_modules entry (the default on Bun 1.2/1.3+ with the
new content-addressed global cache that produced the user's error).

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 13:04:31 +00:00
centdix d243e0cde8 align global flow tool arguments (#9146) 2026-05-13 12:38:49 +00:00
818cb31fbc fix: send flow push-loop ping outside transaction so zombie monitor sees it (#9136)
* fix: send flow push-loop ping outside transaction so zombie monitor sees it

* fix: keep flow push-loop ping using now() with reusable sqlx cache

---------

Co-authored-by: windmill-internal-app[bot] <1429786+windmill-internal-app[bot]@users.noreply.github.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2026-05-13 12:32:35 +00:00
hugocasaandwindmill-internal-app[bot] 2ec1863340 fix: scope promotion-mode debounce key per repo (#9145)
* test(git-sync): regression tests for secondary promotion repos

Adds two integration tests that reproduce the bug where a second
promotion-mode repo's deployment callback was silently dropped via
debounce-key collision, plus the EE ref bump that includes the fix.

Updates the two existing promotion-mode debounce-key tests to expect
the new repo-namespaced key shape.

* test(git-sync): drop redundant distinct-debounce-keys test

The behavior test (`test_two_promotion_repos_both_enqueue_callback`)
already covers the same regression one layer up: if the debounce keys
collide, one callback gets marked skipped, which the behavior test
catches.

* chore: update ee-repo-ref to 7a32388adaa37eb1dd1820b40e140ff1877110f2

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

Previous ee-repo-ref: dbf26f5e4c01c0de536f606679be46eb316aaf31

New ee-repo-ref: 7a32388adaa37eb1dd1820b40e140ff1877110f2

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-13 11:35:30 +00:00
centdix 7a7d246a6e test: add global ai eval mode (#9129)
* feat: add global ai eval mode

* fix: improve global eval validation feedback
2026-05-13 08:20:30 +00:00
Ruben Fiszel b348119ab9 publish CLI skills + AGENTS.md to windmill-cli-docs for context7 (#9143)
* feat: publish CLI skills + AGENTS.md to windmill-cli-docs for context7

Auto-generates a public docs snapshot (AGENTS.md, full CLI reference,
all rendered skills) and pushes it to windmill-labs/windmill-cli-docs on
every release tag, so context7 can index Windmill CLI docs.

- generate.py: new --context7-dir flag rendering fully-resolved skills
  + AGENTS.md (extracted from cli/src/guidance/core.ts to avoid drift)
  + cli-commands.md + README.md + manifest.json into a docs-repo checkout.
  Preserves .git, .github, LICENSE, context7.json across regenerations.
- publish-cli-docs.yml: GitHub Action on v* tag and workflow_dispatch
  that regenerates the docs repo and pushes via the CLI_DOCS_DEPLOY_KEY
  SSH deploy key.

* fix: skip tag mirror on workflow_dispatch from non-tag ref

* docs: turn windmill-cli-docs README into a CLI quickstart

* fix: address PR review (target safety, regex anchor, concurrency, tag mirror)

- Refuse to wipe --context7-dir unless empty, has a context7 marker, or
  points at the windmill-cli-docs remote (P1, prevents typo blast).
- Anchor AGENTS.md template regex on `generateAgentsMdContent` so adding
  other template-returning functions to core.ts can't silently retarget it.
- Decode TS escapes in one pass to avoid order-sensitive mangling.
- Include Windmill version (from version.txt) in manifest.json so each
  snapshot is self-describing.
- Add concurrency group on the publish workflow.
- Always mirror version tag on tag pushes, even when content is unchanged,
  so the docs repo has a tag for every Windmill release.
- Expand preserve list with .gitignore, .gitattributes, CODEOWNERS.

* fix: validate manifest.json content, not just presence, before wipe
2026-05-13 07:11:24 +00:00
Ruben Fiszel 79c5b7b8b7 fix(cli): prevent !inline-corruption in flow push/pull (#9142)
* fix(cli): hard-fail flow push on missing inline files; guard extractor

* fix(cli): gate !inline extractor guard with opt-in flag

* test(cli): fix createFlowFixture !inline path to be relative to flow folder
2026-05-13 07:09:45 +00:00
Ruben Fiszel 7ebb08133c fix(operator): refresh IAM RDS / Entra ID tokens in operator process (#9141)
* fix(operator): refresh IAM RDS / Entra ID tokens in operator process

* fix: gate DEFAULT_MAX_CONNECTIONS_OPERATOR on operator feature
2026-05-13 06:38:57 +00:00
Guilhem d0f23cc523 feat(frontend): unified EditorHeader with file picker for flow/script/app editors (#9047)
* feat: add EditableInput component

* feat: add EditorHeader for flow editor with file picker entry point

* feat: WorkspaceItemPicker for editor header navigation

* feat: clickable breadcrumb in EditorHeader, scoped picker keyboard nav

* fix: reload flow on URL change and reset highlight in search mode

* feat: editor header layout polish and trigger removal

* feat: apply EditorHeader to script, app, and raw app editors

* fix: show generated initial path for new apps in EditorHeader

* fix: align EditorHeader new-app path with draft drawer's Path component

* fix: read page.params.path in loadApp to reload on URL change

* fix: remount AppEditor when navigating between apps

* fix: clear app/files on URL change so editor remounts with fresh data

* fix: route picker selections to /apps/edit or /apps_raw/edit based on raw_app

* fix: drop Save button from path popover; defer rename to deploy

* refactor: drop pathPopoverContent snippet, bind newEditedPath directly

* refactor: dedupe editor header plumbing (editPathFor, userPathPrefix, breadcrumb snippet)

* fix: freeze breadcrumb during path edit so popover doesn't drift

* fix: drop spinner from path dependency-check; render nothing when no usages

* fix: swallow 404 in checkFlowOnBehalfOf so renaming a flow doesn't toast

* Revert "fix: swallow 404 in checkFlowOnBehalfOf so renaming a flow doesn't toast"

This reverts commit 82dec462ae.

* refactor: drop moveRenameManager dep from EditorHeader; pass onBehalfOfEmail as prop

* refactor: replace breadcrumb-snapshot effect with open/close setter

* refactor: drop unused dirtyPath state from EditorHeader

* fix: surface Path validation error in pen popover

* fix: decouple Path validation error from hideFullPath toggle

* refactor: use InputError for path validation message (slide transition)

* fix: re-derive Path meta from external path changes (sibling sync)

* docs: note Path's meta could be replaced with function-form bindings

* fix: 'Exit & see details' uses deployed path, not live store

* fix: undo/redo shortcut uses shiftKey instead of fragile case-match

* fix: type errors and keep edit pen visible while popover is open

* chore: remove unused meltComponents/Accordion wrapper

* fix: focus search input on picker open via popover openFocus selector

* fix: refocus picker search input on every popover open

* fix: pre-seed picker loaded state from cache so accordion opens at the right place

* fix: breadcrumb and picker track savedPath, not draft-renamed live path

* fix: inject current draft item into picker so breadcrumb scope isn't empty

* docs: add component-level and prop-level docs to EditableInput

* feat: warn that a deploy is needed when path is edited on a saved item

* feat: show same path-change-needs-deploy message in flow/script settings

* fix(flows): persist draft-renamed path through reload and dirty check

* fix: include path in unsaved-changes diff so renames trigger the modal

* feat: nested folders in picker tree and breadcrumb

* fix: per-segment popover state so switching breadcrumbs closes the previous one

* refactor: replace accordion picker with drill-through picker

* refactor(picker): review fixes, drill polish, and breadcrumb collapse

* fix(picker): review fixes — banned bindable, drop sibling-sync, load races, mouse highlight

* fix(picker): smooth-scroll highlighted row into view on open

* fix(picker): second-pass review fixes — load races, cache invalidation, breadcrumb/picker a11y, raw_app routing

* fix(RowIcon): apply size prop to resource_type and fallback divs

* feat(picker): add cross-kind 'All' root; deeper breadcrumb segments open there

* fix(picker,editor): third-pass review — search loading state, pen autofocus, allow empty summary, a11y

* fix(picker,editor): fourth-pass review — own check, flowbuilder arrows, editableinput double-save, customui.path gate, invalidate races

* fix(editor,picker): codex review — fresh URL state per load, granular whitelabel topBar gates

* fix(flows): clear localStorage in auto-reload to break URL-state loop
2026-05-12 14:33:21 +00:00
centdix 17cf538a2d refactor: move ai providers to windmill-ai (#9120) 2026-05-12 14:09:31 +00:00
centdix a305a74e73 refactor: require ai evals proxy backend (#9119) 2026-05-12 13:44:00 +00:00
Ruben Fiszelandrubenfiszel c6346aabe0 chore(main): release 1.700.2 (#9117)
* chore(main): release 1.700.2

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-12 12:32:07 +00:00
Ruben FiszelandClaude Opus 4.7 cab0000f3a fix: preserve explicit nulls for typed fields in bulk instance config (#9123)
Setting a typed instance setting (e.g. `object_store_cache_config`) to
`null` via `PUT /api/settings/instance_config` was silently ignored:
`Json<InstanceConfig>` deserialized null to `None`, and `to_settings_map`
then dropped it via `skip_serializing_if = "Option::is_none"`, so
`diff_global_settings` (Merge mode) never saw the deletion.

Add `#[serde(remote = "Self")]` to `GlobalSettings` plus a manual
`Deserialize` impl that captures top-level explicit nulls into `extra`,
which already round-trips correctly via flatten. The handler stays
fully typed.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 12:07:45 +00:00
Ruben Fiszel 0bb77a9bd6 route result and args downloads through downloadViaClient (#9122)
* fix(frontend): route result and args downloads through downloadViaClient

* refactor(frontend): reuse argsApiPath in WINDMILL_TOO_BIG branch
2026-05-12 11:48:42 +00:00
Ruben FiszelandClaude Opus 4.7 07a4cb6872 branch download UI on shouldDownloadViaClient instead of onclick interception (#9118)
* fix(frontend): branch download UI on shouldDownloadViaClient instead of intercepting in onclick

When OpenAPI.TOKEN is set, several download links rendered an `<a href>`
to the API and relied on an `onclick` handler to call `e.preventDefault()`
and route the request through `downloadViaClient`. This is fragile in
embedded contexts (e.g. the whitelabel React SDK) where Svelte's hydrated
event listener may not intercept the click in time, so the browser
follows the unauthenticated `href` straight to the API.

Mirror the drawer pattern already used in `LogViewer` and
`FlowStatusViewerInner`: render a `<button>` calling `downloadViaClient`
when `shouldDownloadViaClient()` is true, and fall back to the plain
`<a href download>` otherwise. Affects the LogViewer top bar, the large-
result download in DisplayResult, the inline S3 link in ObjectViewer,
the CSV link in ParqetCsvTableRenderer, and FileDownload.

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

* fix(frontend): treat custom HEADERS / basic auth as token equivalents in shouldDownloadViaClient

Customers wiring the SDK with cookie-bypass auth via custom request
headers (e.g. `OpenAPI.HEADERS = getAuthHeaders()` returning a Bearer
header) had `shouldDownloadViaClient()` return false because it only
checked `OpenAPI.TOKEN`. The plain `<a href download>` branch then
followed the link without those headers, so authenticated downloads
silently degraded to the cookie path (or failed when there is no cookie).

Widen the check to any non-cookie auth: TOKEN, HEADERS, or USERNAME
(basic auth). Route `downloadViaClient` through the generated client's
`getHeaders` so all configured auth schemes are applied consistently
instead of hand-building an Authorization header for TOKEN only.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:42:05 +00:00
centdixandClaude Opus 4.5 110384580e refactor: add global ai chat mode with workspace-item draft tools (#9056)
* docs: add global ai mode plan

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

* feat: add global ai draft mode

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

* fix: scope global ai mode to scripts and flows

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

* refactor: simplify global ai workspace item shape

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

* refactor: split global ai write tool into per-type tools

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

* feat: add global ai schedule and trigger workspace item tools

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

* feat: add dev-only /global_drafts route to inspect ai draft store

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

* feat: add edit_script and patch_flow_json global ai tools

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

* feat: add deploy_workspace_item global ai tool with confirmation

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

* feat: emit open-resource action card after deploy_workspace_item

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

* feat: add delete_workspace_item global ai tool with confirmation

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

* chore(system_prompts): emit RESOURCES_BASE and resource/variable zod schemas

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

* feat: add global ai resource and variable workspace item tools

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

* fix: search_resource_types uses listResourceType to avoid embedding feature dep

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

* Revert "fix: search_resource_types uses listResourceType to avoid embedding feature dep"

This reverts commit 6d1d19514a.

* feat: emit open-resource action card for variable and resource deploys

* feat: add global ai raw app workspace item tools

* feat: split raw-app prompt into chat-only authoring and cli prefix

* feat: add init_app global ai tool to scaffold raw apps from templates

* fix: pass write_flow value as JSON string for gemini compat

* refactor: hoist countExactMatches and applyExactReplace to chat/shared

* refactor: extract editableFlowJson module shared with global mode

* fix(global): preserve flow schema and groups across draft and deploy

* feat: extract inline scripts from flow reads and patches in global mode

* refactor: add findAndReplace helper for match-validated text patches

* refactor: extract getInlineRunnableContent helper for app file tools

* refactor: extract assertNotGeneratedAppFile guard for /wmill.d.ts

* feat: gate global ai mode behind localStorage flag for dev rollout

* chore: bump svelte to ^5.55.5 in raw app template (sync with main)

* fix: isolate global ai draft rollout

* fix: preserve global ai deploy metadata

* fix: harden global ai draft tools

* chore: remove global ai plan doc

* fix: align raw app prompt guidance

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-12 09:42:07 +00:00
Ruben FiszelandClaude Opus 4.7 01e21c7f91 fix: preserve negative integers in Bedrock tool schema conversion (#9116)
json_to_document cast any i64 to u64 via `as` and wrapped in PosInt.
Negative integers in a tool's JSON schema (e.g. "default": -1,
"minimum": -1) became huge unsigned values like 18446744073709551615,
which Bedrock rejected with `tools.N.custom.input_schema: int too big
to convert`.

Try as_u64 first to preserve full u64 range, fall back to as_i64 for
negatives via NegInt, then as_f64.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 07:00:58 +00:00
Ruben Fiszelandrubenfiszel 411ca47ffd chore(main): release 1.700.1 (#9114)
* chore(main): release 1.700.1

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-11 23:23:08 +00:00
cd65de4928 fix: CE build broken by enterprise-gated compute_instance_hash (#9113)
* [ee] fix(license): bump ee-repo-ref to fix CE build

#9089 introduced a call from `windmill-api-settings/src/ee.rs::validate_license_key`
(license-gated) to `windmill_common::ee::compute_instance_hash` (enterprise-gated).
CE builds compile the caller (private + license) but not the callee, breaking the
docker image build on main with E0425 since #9089 landed.

Companion EE PR drops the enterprise gate from `compute_instance_hash`. The
function only depends on common state (BASE_URL, get_instance_uid,
canonical_base_url) and the parent `ee` module is already gated by `private`.

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

* chore: update ee-repo-ref to f9494c6320bb5fd07c1e9e09734b7fd5fbe7aa38

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

Previous ee-repo-ref: 0f495da1ef984cad22342c90691c0dc8b0a20faa

New ee-repo-ref: f9494c6320bb5fd07c1e9e09734b7fd5fbe7aa38

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-11 23:14:20 +00:00
Ruben Fiszelandrubenfiszel b972fabab4 chore(main): release 1.700.0 (#9092)
* chore(main): release 1.700.0

* Apply automatic changes

---------

Co-authored-by: rubenfiszel <275584+rubenfiszel@users.noreply.github.com>
2026-05-11 22:37:58 +00:00
Ruben Fiszel 9da13d0180 update prompts 2026-05-11 22:34:09 +00:00
Ruben Fiszel ac3c155541 fix: mask oauth client secret in instance settings (#9112)
* feat: mask oauth client secret in instance settings

* fix: address ci review - migrate nextcloud + use Password small prop

* fix: associate client secret labels with input via for/id
2026-05-11 22:30:15 +00:00
Ruben Fiszel 07d3ffbf34 system prompts refresh 2026-05-11 22:13:58 +00:00
9c6cd8c852 offline (URL-bound) license keys (#9089)
* [ee] feat(license): offline (URL-bound) license keys

Offline keys are a 4-segment variant for air-gapped customers — no
phone-home, embedded seat/CU caps, locked to the instance's base_url.
Existing 3-segment online keys are unchanged.

Companion PRs:
- windmill-labs/windmill-ee-private (full design + EE impl)
- windmill-labs/windmill-customer-service (issuance + portal)
- windmill-labs/windmill-cf-worker-keygen (signing)

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

* [ee] refactor(license): bind offline keys via instance hash; simpler CU enforcement

- /settings/license_status now surfaces an `instance_hash` superadmins share
  with support when requesting an offline key
- OfflineMetadata: `hash` replaces `base_url`; OfflineCapStatus reports
  `current_cu` (last 2min) and drops the grace-period fields
- verify_license_key now takes a db so EE can recheck the hash
- InstanceSetting.svelte: hash copy-block + simpler status panel
- Bump ee-repo-ref

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

* [ee] chore(license): bump ee-repo-ref

Pulls in the current_cu clamp + prod public key restoration.

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

* [ee] refactor(license): split instance_hash endpoint; minimal cap UI; restore workers expiry toast

- `instance_hash` is no longer part of /settings/license_status responses; it
  lives at GET /settings/instance_hash (super-admin only) so it isn't re-emitted
  on every status poll. The UI doesn't show it — admins fetch it explicitly when
  requesting a key from support.
- InstanceSetting offline cap UI is now two compact green/red status lines
  (Seats X.X/Y and CUs X.X/Y) placed above the action buttons, matching the
  existing "Latest key renewal" badge style. The block-panel is gone.
- "Latest key renewal" line and the "Renew key" button are now hidden when an
  offline key is loaded (renewal is server-disabled for offline keys).
- Restore parseLicenseKey + checkLicenseExpiration toast on /workers
  (works for both 3- and 4-segment keys).

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

* [ee] chore(license): bump ee-repo-ref

Pulls in the plain-SHA256 instance hash + stats_ee revert.

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

* [ee] chore(license): bump ee-repo-ref

Picks up the alert wording change.

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

* [ee] chore(license): bump ee-repo-ref

Picks up the instance_uid cache so the periodic verify_license_key cycle
no longer hits global_settings.

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

* [ee] refactor(license): rename /settings/license_status → /offline_license_status

The endpoint was only used by the offline-license UI; the other fields it
returned (license_key_id, license_key_valid, kind, offline metadata) were
unused. Rename to clarify scope and flatten the response — it now returns
just the OfflineCapStatus (or null when no offline license is loaded).

Frontend uses `offlineCapStatus != null` as the "is offline" check.

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

* [ee] fix(ci): regenerate sqlx cache for the inline worker_ping query

After reverting unused stats_ee helpers (fetch_worker_pings*), the
inline `sqlx::query_as!(WorkerPingRecord, ...)` in get_stats_payload
lost its cache entry — CI's check_ee_full + cargo_test were failing
under SQLX_OFFLINE=true with E0282 type-inference errors.

Re-running update_sqlx.sh regenerates the cache file under its
current hash and prunes a couple of stale entries.

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

* [ee] fix(license): address cubic-bot review

- get_offline_license_status: propagate enforce_offline_caps errors as 500
  instead of swallowing into a "no offline license" (Option::None) response
- canonical_base_url: rewrite the doc to match the actual fallback behavior
  (lowercase + trailing-slash strip on URL parse failure); the original
  cross-service contract is gone since the customer-service no longer
  canonicalizes (treats the instance hash as opaque)
- check_seat_cap_for_new_user: take an email and short-circuit when the
  email is already in `usr ∪ workspace_invite` so net-zero invite upserts
  and invite→user transitions aren't spuriously blocked at cap. Mirrors
  the dedup rule the count itself uses.
- Bump ee-repo-ref to pull in the EE-side change

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

* [ee] chore(license): bump ee-repo-ref

Picks up the exact-delta seat-cap check (replaces the simple existence
short-circuit). Regenerates the new sqlx cache for the bool_and query.

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

* [ee] fix(license): propagate get_instance_hash errors; bump ee-repo-ref

- get_instance_hash: replace `.ok().flatten()` with map_err+? so DB errors
  during instance_uid lookup surface as 500 instead of silently returning
  `{"instance_hash": null}` (same pattern get_offline_license_status already uses)
- Bump ee-repo-ref to pull in the enforce_offline_caps cached-state preservation

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

* chore: update ee-repo-ref to c6cd1afe2d9e04809b30751cd1687b28a65e62b1

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

Previous ee-repo-ref: a6d91016ae0d43c46604313aecae3aa9c778c8e0

New ee-repo-ref: c6cd1afe2d9e04809b30751cd1687b28a65e62b1

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-11 22:09:21 +00:00
Samuel Wilk 1abfe9de39 Add max-iterations to OpenAPI spec for AI Agent (#9103) 2026-05-11 22:03:04 +00:00
Ruben Fiszelandwindmill-internal-app[bot] f8ba0840d7 feat(vault): configurable JWT auth mount path and setup-doc fixes (#9100)
* chore: narrow secret-file Read deny rule to dotfiles/extensions

* feat(vault): configurable JWT auth mount path and fix setup docs

* chore: bump ee-repo-ref for vault jwt mount path

* chore: bump ee-repo-ref after rebase onto EE main

* chore: update ee-repo-ref to a1cd60b54e8595b4e5ce6b654e675e4bbe2253b2

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

Previous ee-repo-ref: c274f233a0ebb54afa296c3db15ff330e1baebcf

New ee-repo-ref: a1cd60b54e8595b4e5ce6b654e675e4bbe2253b2

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-11 21:54:40 +00:00
Ruben Fiszelandwindmill-internal-app[bot] 0a5f8dcd48 deps: pin tokio-postgres to forked branch with query_typed_raw deadlock fix (#9106)
* deps: pin tokio-postgres to MaterializeInc fork to fix query_typed_raw deadlock

`pg_executor`'s `Client::query_typed_raw` (and `Client::prepare` on the
streaming path) deadlock when the result schema contains a column whose
Oid the client doesn't know yet — citext, custom enums, custom domains,
postgis types. Easy to reproduce against any partitioned table with a
citext column: ~100+ rows is enough on localhost, less on slower links.
`psql` works fine for the same query because the simple-query protocol
doesn't trigger the typeinfo lookup path.

## Root cause (unchanged tokio-postgres bug for years)

`query::query_typed` calls `get_type(client, oid).await` synchronously
while still holding the original query's `Responses` stream. The
original query's `DataRow`s back up in the per-request
`mpsc::channel(1)`, `Connection::poll_read` stops draining the wire,
and the typeinfo sub-query response (queued on the same socket behind
those DataRows) never arrives. Classic head-of-line blocking.

## Fix

Pin `tokio-postgres` / `postgres-types` / `postgres-protocol` (via
[patch.crates-io]) and the workspace `rust-postgres` /
`rust-postgres-native-tls` aliases to the
[MaterializeInc rust-postgres fork at `78c1222577`](https://github.com/MaterializeInc/rust-postgres/tree/master).
MI's [PR #33 "bigger-channels"](https://github.com/MaterializeInc/rust-postgres/pull/33)
(merged 2025-12-11) resized the per-request response channel from
`mpsc::channel(1)` → `mpsc::channel(1024)`. That gives the connection
task 1024 batches of headroom while a streaming consumer is paused
mid-stream — orders of magnitude more than realistic typeinfo
deferral needs (≈3 batches).

## Why MaterializeInc and not a windmill-labs fork

`windmill-trigger-postgres` already depended on the imor fork for the
`postgres-replication` crate (logical replication: `CopyBothDuplex`,
`LogicalReplicationStream`, `TupleData` decoding including binary
tuples). That crate has never been on upstream rust-postgres — petrosagg's
[PR #752](https://github.com/rust-postgres/rust-postgres/pull/752) was
closed in 2021 in favour of a smaller split,
[PR #778](https://github.com/rust-postgres/rust-postgres/pull/778) is
still open today after five years. petrosagg keeps the replication work
alive on the MaterializeInc fork.

MaterializeInc is a strict superset of what we previously got from imor:
- imor's binary-tuple commit (sha `20265ef38e`) was merged into MI master.
- petrosagg has added perf + correctness fixes on top (allocation reuse,
  proper decoding fixes).
- The deadlock mitigation (`channel(1024)`) was added three weeks before
  this issue surfaced.

MI tracks upstream rust-postgres with a periodic catch-up merge (12-18 mo
cadence; last on 2025-12-03, ~100 commits picked up). Not an abandoned
fork.

## Why this works now (didn't on earlier attempt)

A previous attempt at this PR (`248ccb5a97`) hit CI failure because the
MI fork's `postgres-types 0.2.11` requires `serde_core ^1.0.221`, but
Windmill's workspace pinned `serde = "=1.0.220"` for swc_common 0.37.5's
`pub use serde::__private as serde;` hack. Bumping serde above 1.0.220
broke the swc_ecma_ast `Deserialize` derive under the
`enterprise,deno_core,…` feature set.

The earlier blocker is now resolved by #9111 which bumped the deno +
swc pin set to a "goldilocks" combination where `swc_common 14.0.4`
drops the `__private` hack, freeing the workspace serde pin to `^1`.
serde now resolves to 1.0.228, which satisfies MI's `serde_core ^1.0.221`
requirement transitively — no extra workspace pin needed.

## Diff shape

Two files only:
- `backend/Cargo.toml` (+34/-1): three new `[patch.crates-io]` entries
  (`tokio-postgres`, `postgres-types`, `postgres-protocol` → MI fork)
  plus comment block, plus the two workspace deps (`rust-postgres` /
  `rust-postgres-native-tls`) repointed from imor's fork to MI's.
- `backend/Cargo.lock` — auto-regenerated. Replaces all `imor/rust-postgres`
  references with `MaterializeInc/rust-postgres`, bumps the affected
  crate versions to MI's set (tokio-postgres 0.7.11 → 0.7.15,
  postgres-types 0.2.7 → 0.2.11, postgres-protocol 0.6.7 → 0.6.9,
  postgres-native-tls 0.5.0 → 0.5.2). No source code changes.

## Verification

- `cargo check --features quickjs` → clean.
- `cargo check -p windmill-worker --features quickjs` → clean (pg_executor
  builds).
- Repro tested earlier in the thread that produced this PR: the
  partitioned-citext-table query on Neon goes from "hangs indefinitely"
  (server idle on `wait_event=ClientRead` while client awaits typeinfo
  behind undrained DataRows) to "completes in ~1.0s, 100 rows" with the
  MI fork's `bounded(1024)` response channel.

## Caveats

- **`bounded(1024)` is a mitigation, not a closure.** Theoretical failure
  mode remains at >~64 MB single-query results with a custom-Oid column
  (typeinfo defers for >1024 batches of ~64 KB each). The strict-correct
  fix is `mpsc::unbounded()` — proposed as a follow-up PR to MI. For
  realistic Windmill workloads, 1024 batches of headroom is well past
  the ~3-batch typeinfo deferral that's actually needed.
- **`postgres-replication` is now upstream-of-fork's only home.** No
  realistic path to upstream rust-postgres merging it. The MI pin is
  intended to stay in place until either upstream changes course
  (unlikely) or MI publishes to crates.io (also unlikely — they don't
  publish releases of the fork).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

* chore: update ee-repo-ref to 8fe0d290fb0b71c24184eb5ad99bbdc7c813697c

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

Previous ee-repo-ref: 4d01d171228196f28ddabc1150242bfab623d5cf

New ee-repo-ref: 8fe0d290fb0b71c24184eb5ad99bbdc7c813697c

Automated by sync-ee-ref workflow.

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
2026-05-11 21:43:18 +00:00
Ruben Fiszel e3a914fd48 download files via openapi client when token is set (#9102) 2026-05-11 21:39:41 +00:00
Ruben Fiszel 3cd0eac8c1 deps: bump deno_core / deno_ast / swc to the goldilocks pin set; drop serde ceiling (#9111)
* deps: bump deno_core / deno_ast / swc to the goldilocks pin set; drop serde ceiling

Bumps every deno_* and swc_* workspace dep to a hand-picked "goldilocks"
combination that drops the serde =1.0.220 ceiling without crashing into
the rustls / aws-sdk resolver wall that the obvious deno v2.6.0 target
hits.

## What's the goldilocks set

| crate            | old      | new       | source                                |
|------------------|----------|-----------|---------------------------------------|
| deno_core        | 0.336.0  | 0.352.0   | deno v2.4.0                           |
| deno_fetch       | 0.214.0  | 0.233.0   | deno v2.4.0                           |
| deno_tls         | 0.177.0  | 0.196.0   | deno v2.4.0 (last permissive-rustls)  |
| deno_console     | 0.190.0  | 0.209.0   | deno v2.4.0                           |
| deno_url         | 0.190.0  | 0.209.0   | deno v2.4.0                           |
| deno_webidl      | 0.190.0  | 0.209.0   | deno v2.4.0                           |
| deno_web         | 0.221.0  | 0.240.0   | deno v2.4.0                           |
| deno_io          | 0.100.0  | 0.119.0   | deno v2.4.0                           |
| deno_net         | 0.182.0  | 0.201.0   | deno v2.4.0                           |
| deno_permissions | 0.49.0   | 0.68.0    | deno v2.4.0                           |
| deno_telemetry   | 0.12.0   | 0.31.0    | deno v2.4.0                           |
| deno_error       | =0.5.5   | =0.6.1    | deno v2.4.0                           |
| deno_ast         | =0.44.0  | =0.51.0   | **override** — see "load-bearing" below |
| deno_fs          | (new)    | 0.119.0   | new workspace dep — FetchPermissions exposes deno_fs::CheckedPath / GetPath as public API |
| v8               | =130.0.7 | =137.1.0  | deno_core 0.352 transitive            |
| swc_common       | =0.37.5  | =14.0.4   | **the load-bearing pin**              |
| swc_ecma_ast     | =0.118.2 | =15.0.0   | matched set with swc_common 14.0.4    |
| swc_ecma_parser  | =0.149.1 | =24.0.3   | matched set                           |
| swc_ecma_visit   | =0.104.8 | =15.0.0   | matched set                           |
| serde            | =1.0.220 | ^1        | **freed** (resolves to 1.0.228+)      |

## Why this combination and not v2.6.0

The obvious target was deno v2.6.0 (with deno_ast 0.52 → swc_common 17,
well past the `__private` ceiling). That hits three resolver collisions:

1. libsqlite3-sys: deno_cache → rusqlite 0.37 → libsqlite3-sys 0.35
   vs sqlx → libsqlite3-sys 0.30. **Already killed by PR #9110** —
   we dropped deno_runtime, which is what pulled in deno_cache.
2. fqdn 0.4.6/0.4.7 yanked, required by deno_permissions 0.81.0. Solvable
   by injecting the yanked entry into Cargo.lock manually but ugly.
3. rustls: deno_tls 0.198+ hard-pins `=0.23.28`, but aws-sdk-bedrockruntime
   1.122.0 → aws-smithy-http-client 1.1.5 wants `^0.23.31`. Within-major
   conflict, no resolver path. The unbeatable wall.

Goldilocks-set choice sidesteps (2) and (3) entirely:

- `deno_tls 0.196.0` was the last version before deno tightened
  `rustls ^0.23.11` (range, accepts 0.23.31) to exact `=0.23.28`. With
  ^0.23.11, the resolver picks rustls 0.23.35 (latest 0.23 patch) which
  satisfies both deno_tls's `>=0.23.11` and aws-sdk's `>=0.23.31`. Verified
  empirically: lockfile has rustls 0.23.35 after this bump.
- `deno_permissions 0.68.0` (v2.4.0's pin) doesn't depend on fqdn at all.
  The fqdn dep was added in a later deno_permissions release.

## Why deno_ast =0.51.0 specifically (not 0.48.0 from v2.4.0)

`swc_common 14.0.4` is the first patch that **drops the
`pub use serde::__private as serde;` line** in `src/private/mod.rs`. Older
14.0.x and all 0.37.5–13.x revisions still have it, and that line is
what was capping `serde = "=1.0.220"` (the workspace pin's "stuck because
of swc" comment). Empirically verified by inspecting the tarballs of
14.0.0 / 14.0.1 / 14.0.2 / 14.0.3 / 14.0.4:

    14.0.0: has hack
    14.0.1: has hack
    14.0.2: has hack
    14.0.3: has hack
    14.0.4: NO HACK    ← inflection point

`deno_ast 0.51.0` pins `swc_common =14.0.4` exactly — older deno_ast
versions pin earlier swc_common patches that still have the hack.
Notably, deno v2.4.0 itself pins `deno_ast =0.48.0` (swc_common 9.2.0,
still has hack) — we deliberately deviate from v2.4.0's deno_ast pin
to escape the swc serde wall, while keeping the rest of v2.4.0's pin
set for resolver compatibility with aws-sdk. deno_ast 0.51 was never
shipped in any deno release (v2.4.5 used 0.49, v2.5.0 jumped to 0.50,
v2.6.0 to 0.52), but it's published on crates.io and compatible with
v2.4.0's deno_core 0.352.

## What this unblocks

- PR #9106's `serde = "=1.0.224"` bump variant can rebase onto this
  and resolve cleanly (MaterializeInc/rust-postgres' `postgres-types`
  needs `serde_core ^1.0.221`, which is satisfied now that we're on
  serde 1.0.228).
- Future deno_* / swc_* bumps no longer need to argue about the serde
  ceiling — it's gone.

## What changes in source code

This commit is Cargo.toml + Cargo.lock only. Source changes that the
new deno_core / deno_fetch API requires live in the follow-up commits:

- `parsers/windmill-parser-{ts,ts-asset,wac}`: swc 0.37 → 14
  (`code.into()` ambiguity fix at 5 sites)
- `windmill-runtime-nativets/build.rs` + `src/lib.rs`: deno_core 0.336
  → 0.352 API moves (`init_ops_and_esm()` → `init()`,
  `FetchPermissions` / `NetPermissions` trait signature updates,
  `deno_tls::Proxy` enum shape change)

A companion change in windmill-ee-private adjusts
`otel_tracing_proxy_ee.rs:521` for `deno_telemetry::init`'s second arg
becoming by-value (was `&OtelConfig`).

* fix(parsers): adapt to swc_common 14 BytesStr ambiguity

swc 0.37.5 → 14.0.4 changed `SourceMap::new_source_file`'s `src` argument
from `String` to `impl Into<BytesStr>`. With `BytesStr` available, the
existing call sites' `code.into()` on a `&str` becomes ambiguous between
`Into<Bytes>` (from the bytes crate) and `Into<BytesStr>` (from
bytes_str). Switch to `code.to_string()` to produce an owned `String`
that satisfies `From<String> for BytesStr` unambiguously.

Five call sites across three crates:
- windmill-parser-ts/src/lib.rs (3 sites)
- windmill-parser-ts-asset/src/lib.rs (1 site)
- windmill-parser-wac/src/typescript.rs (1 site)

* fix(nativets): adapt to deno_core 0.352 / deno_fetch 0.233 API changes

The goldilocks deno bump (deno_core 0.336 → 0.352, deno_fetch 0.214 →
0.233, etc.) ripples through nativets' build.rs and src/lib.rs.
Source-level changes required:

## 1. `extension!` macro: `init_ops_and_esm()` and `init_ops()` removed

deno_core 0.352's `extension!` macro now generates a single `init()`
function on the extension struct (full: ops + esm), plus `lazy_init()`
(ops only, with `needs_lazy_init = true` and a contract that the
caller invokes `JsRuntime::lazy_init_extensions` after construction).

- `build.rs` (snapshot creation, wants both ops and esm baked in):
  `X::init_ops_and_esm(...)` → `X::init(...)`.
- `src/lib.rs:create_nativets_runtime` (runtime, was using `init_ops()`
  because the snapshot already provides esm): also → `X::init(...)`.
  deno_core's snapshot path skips esm re-execution when the snapshot
  provides them, so the esm registration is a no-op at runtime. This
  is how deno's own v2.4.0 runtime works.

Avoided `lazy_init` because it requires plumbing
`JsRuntime::lazy_init_extensions(ext_args_vec)` correctly across the
codebase, which is invasive for no behavioural benefit.

## 2. Local `fetch` extension now declared in both build.rs and lib.rs

deno_core 0.352 validates extension order between snapshot and runtime.
Our snapshot's last extension is the local `fetch` ext (which provides
ext:fetch/src/runtime.js). To avoid a runtime panic:

    "Extensions from snapshot loaded in wrong order: expected fetch but got windmill"

…the runtime extension list now ends with `fetch::init()` matching the
snapshot order. The macro requires the same `esm` argument to type-check,
even though the ESM is not re-executed at runtime (it's in the snapshot).

## 3. `FetchPermissions` and `NetPermissions` trait shape

`deno_fetch::FetchPermissions` (deno_fetch 0.233.0) added new methods
and changed signatures:

- `check_read` / `check_write`: now take `path: Cow<'a, Path>` plus
  a new `get_path: &'a dyn deno_fs::GetPath` parameter, and return
  `Result<deno_fs::CheckedPath<'a>, FsError>` instead of `Result<Cow<Path>, FsError>`.
- New `check_write` (didn't exist) and `check_net_vsock` methods.

`deno_net::NetPermissions` (deno_net 0.201.0) gained `check_vsock`
and `check_write_path` now takes `Cow<'_, Path>`.

For `build.rs`'s `PermissionsContainer` (used only during snapshot
creation, where permissions are never actually checked): all methods
`unreachable!("snapshotting")`.

For `src/lib.rs`'s `PermissionsContainer` (used at runtime — the
nativets policy is "allow everything"): `check_read` / `check_write`
return `Ok(CheckedPath::Unresolved(path))`, `check_*_vsock` return
`Ok(())`. Smoke tests confirm fetch/net/url/web/blob/timers/structuredClone
behaviour is intact end-to-end.

## 4. `deno_tls::Proxy` is now an enum

`deno_tls::Proxy` was a struct, is now an enum with `Http`, `Https`,
`Socks5` variants. Our call site uses HTTP proxies — switched the
struct literal `deno_tls::Proxy { url, basic_auth }` to
`deno_tls::Proxy::Http { url, basic_auth }`.

## 5. New `deno_fs` direct workspace dep

`FetchPermissions` exposes `deno_fs::CheckedPath` and `deno_fs::GetPath`
as part of its public API. We can't avoid naming `deno_fs` directly any
more. Pinned to 0.119.0 (v2.4.0's matched version, transitively present
already through deno_fetch). Added to workspace `[dependencies]` plus
nativets's `[dependencies]` and `[build-dependencies]`.

## Validation

`cargo check --features enterprise,deno_core,duckdb,license,python,rust,scoped_cache,parquet,private,private_registry_test,csharp,php,ruby,mysql,quickjs,mcp,run_inline`
→ clean.

`cargo test -p windmill-runtime-nativets smoke -- --ignored --skip smoke_net_`
→ 8 passed; 0 failed (the full local smoke suite covering fetch,
setTimeout/Promise.all, URL/SearchParams, Blob/btoa/atob, large payload
roundtrip, error propagation, concurrent isolates, TS enum/union
transpile).

Network smoke tests (`smoke_net_fetch_example_com`,
`smoke_net_fetch_json_and_headers`) not run as part of the validation
gate but expected to pass — the change preserves deno_fetch behaviour
through the trait surface.

* chore: update ee-repo-ref to pick up deno_telemetry::init by-value fix

Points at windmill-ee-private branch deps/bump-deno-and-swc-goldilocks
which contains the companion otel_tracing_proxy_ee.rs adjustment for
deno_telemetry 0.12 → 0.31 (second arg of `init` is now by-value).
EE-only file, doesn't affect OSS build.

* chore(nix): bump rusty_v8 in flake.nix to 137.1.0 to match Cargo.toml

Cargo.toml's v8 pin moved from =130.0.7 to =137.1.0 as part of the
deno_core 0.336 → 0.352 bump, but I missed the comment directly above
the version pin:

    # Exact version NOTE: Do not forget to update version and hash in flake.nix

flake.nix provides the prebuilt librusty_v8 binary that the v8 crate
links against. A version mismatch would either fail to fetch (if the
137.1.0 release didn't exist) or cause link-time symbol mismatches.
Nix is used by rust-client-check.yml and rust_on_release.yml in CI,
plus the dev shell — stale flake pin breaks all of those.

Updates x86_64-linux's sha256 to match the actual hash of
librusty_v8_release_x86_64-unknown-linux-gnu.a.gz at the 137.1.0 tag.
Other targets (aarch64-linux, x86_64-darwin, aarch64-darwin) remain
as lib.fakeHash — they were already placeholders in the previous
pin, so we don't regress on them.

Caught by both cubic and Pi reviewers on PR #9111.

* docs(nativets): clarify snapshot-prefix rule in extension-order comment

Claude reviewer caught that the doc comment claimed the runtime
extension list matches the snapshot's order — implying an exact match.
The truth is more permissive: deno_core 0.352 requires the snapshot's
extension list to be a *prefix* of the runtime's, not an exact match.
Runtime is allowed to append extra extensions (which we do — the
windmill `ext` carrying our ops is the last entry at runtime but absent
from the snapshot).

The code is correct as-is; only the comment wording was misleading.

Also fixes the same wording in PR description.
2026-05-11 21:13:24 +00:00
Ruben Fiszel 36b316d9e8 deps(nativets): inline maybe_transpile_source, drop deno_runtime (#9110)
windmill-runtime-nativets was the workspace's only consumer of the
deno_runtime crate, and its only use of it was one call site in
build.rs:

    deno_runtime::transpile::maybe_transpile_source(specifier, source)

That function (`deno_runtime-0.198.0/transpile.rs`, ~80 lines) is a pure
deno_ast + deno_core + deno_error wrapper — it doesn't touch any
deno_runtime state. Inline it verbatim into our build.rs and drop the
entire deno_runtime dep.

Why this matters now: deno_runtime transitively pulls in deno_cache →
rusqlite → libsqlite3-sys. From deno_cache 0.128.0 (Feb-Mar 2025)
onwards, rusqlite was bumped to ^0.34, which means libsqlite3-sys ^0.35.
sqlx 0.8 transitively requires libsqlite3-sys ^0.30. Cargo's `links =
"sqlite3"` rule allows only one libsqlite3-sys in a build graph, so the
two crates collide on any deno release ≥ v2.5. Inlining the transpile
helper sidesteps the collision entirely — sqlx-sqlite stays the sole
libsqlite3-sys consumer at 0.30.1.

All other appearances of "deno_runtime" in the source tree are for a
Windmill-internal function named `setup_deno_runtime`, not the crate.

Build artifacts validated:
- `cargo check --features quickjs` → green.
- `cargo test -p windmill-runtime-nativets smoke -- --ignored --skip smoke_net_`
  → 8 passed (the in-process V8 runtime + deno_fetch + deno_web + swc
  transpilation surface still works end-to-end through the inlined
  function).
- `cargo tree --invert deno_cache` → "did not match any packages"
  (gone from the graph).
- Single `libsqlite3-sys` entry in Cargo.lock at 0.30.1 (sqlx's).
2026-05-11 20:21:46 +00:00
brone1323 9f79a86a68 fix: add Input, Result, Trigger to reserved flow step IDs (#9109)
Renaming a flow step to 'Input', 'Result', or 'Trigger' would silently
corrupt the UI — the flow editor panel would switch to rendering the
special Input/Result/Trigger node instead of the step's config panel,
making the step inaccessible without editing YAML directly.

These virtual node IDs were already handled as reserved by
multiSelectUtils.ts but were missing from the forbiddenIds list that
drives the IdEditorInput validation, so users got no warning.

Fixes #7139
2026-05-11 19:51:49 +00:00
Ruben Fiszel 1e89aff2d6 test(nativets): add #[ignore]'d smoke suite for deno_core / deno_ast / swc bumps (#9108)
Adds ten `#[ignore]`'d integration tests in
backend/windmill-runtime-nativets/src/smoke_tests.rs exercising the JS
API surface that the existing nativets tests in tests/worker.rs don't
reach. Run them when bumping the deno_core / deno_ast / deno_runtime /
swc_* pins in backend/Cargo.toml, not on every CI:

    cargo test -p windmill-runtime-nativets smoke -- --ignored
    # skip network-dependent ones with `--skip smoke_net_`

Why here and not in tests/worker.rs:
windmill-runtime-nativets is the only consumer of the deno_core crate
in the workspace — ScriptLang::Deno spawns the external `deno` binary
via deno_executor.rs, while ScriptLang::Nativets is the only path that
loads the in-process V8 runtime. So a deno_core / deno_ast bump can
only break things downstream of this crate. Co-locating the smoke
tests with the runtime they exercise means they hit the right surface
directly, skip the entire job-queue / worker / API-server stack, and
run in <1s end-to-end (vs. ~30-60s per test for the worker-level
nativets tests).

The tests use the existing `PrewarmedIsolate::spawn` API (already
public for the dedicated-worker path), which gives a clean
"compile-TS → load module → execute main(args) → return JSON" entry
point with no DB or queue plumbing required.

Coverage:
- smoke_basic_value_passing — args binding + return marshaling
- smoke_transpile_enum_and_union — TS-specific syntax (enums,
  discriminated unions, casts) through swc_ecma_parser / swc_ecma_ast
- smoke_set_timeout_and_promise_all — deno_web timer ops + V8
  microtask drain order
- smoke_url_and_searchparams — deno_url surface
- smoke_web_blob_btoa_atob — deno_web Blob + base64 ops
- smoke_large_payload_roundtrip — 512 KB string in/out through the
  op-table boundary
- smoke_error_propagation_with_message — thrown Error must surface
  in PrewarmedResult::Err with original message
- smoke_concurrent_isolates — 8 isolates spawned in parallel from
  the same tokio runtime; catches V8 isolate-setup races
- smoke_net_fetch_example_com — deno_fetch end-to-end against
  example.com
- smoke_net_fetch_json_and_headers — deno_fetch with custom request
  headers + Response.json() against httpbin.org/anything

`structuredClone` is not currently wired into the nativets global —
documented in the smoke_web_blob_btoa_atob test in case that ever
changes.

All ten tests pass locally against the current pinned versions
(deno_core 0.336.0 / deno_ast =0.44.0 / swc_common =0.37.5).
2026-05-11 18:43:54 +00:00
Diego ImbertandClaude Opus 4.7 20ecd904e7 feat(operators): allow operators to access assets page (#9095)
* feat(operators): allow operators to access assets page

Adds the "assets" key to workspace operator_settings (defaulting to true
for existing and new workspaces) and toggles the frontend default so the
assets page is visible to operators by default.

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

* nit: remove settings btn when not available

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 18:36:34 +00:00
Ruben Fiszel 03e8bc8c14 perf: lazy-load script editor history and hit partial index (#9107) 2026-05-11 17:20:46 +00:00
321 changed files with 18134 additions and 6822 deletions
+4 -1
View File
@@ -55,7 +55,10 @@
"Read(**/*.pem)",
"Read(**/*.key)",
"Read(**/credentials.json)",
"Read(**/*secret*)",
"Read(**/.secret*)",
"Read(**/.secrets*)",
"Read(**/*.secret)",
"Read(**/*.secrets)",
"Edit(.env)",
"Edit(.env.*)",
"Edit(**/.env)",
@@ -145,6 +145,14 @@ jobs:
RUST_LOG_STYLE: never
CARGO_NET_GIT_FETCH_WITH_CLI: true
CARGO_BUILD_JOBS: 12
# backend/Cargo.toml sets split-debuginfo = "unpacked", which on
# windows-msvc is coerced to "packed": every test-binary link spawns
# the mspdbsrv.exe PDB type server and writes a large .pdb. With 12
# parallel link jobs this races the type-server cap (LNK1318 "LIMIT
# (12)") and exhausts the runner disk (LNK1180). CI needs no debug
# info, so disable PDB generation for the dev/test profiles here.
CARGO_PROFILE_DEV_SPLIT_DEBUGINFO: "off"
CARGO_PROFILE_TEST_SPLIT_DEBUGINFO: "off"
# Tests' poll-time stack frames (deep nested async fn chains in
# debug builds) reach ~1.8MB. 4MB gives ~2x headroom against flaky
# overflows under parallel-test contention.
+18 -6
View File
@@ -20,6 +20,8 @@ on:
type: string
default: ''
secrets:
OPENAI_API_KEY:
required: false
CODEX_AUTH_JSON:
required: false
WINDMILL_EE_PRIVATE_ACCESS:
@@ -60,13 +62,18 @@ jobs:
- name: Check Codex configuration
id: codex_config
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
run: |
if [ -n "$CODEX_AUTH_JSON" ]; then
if [ -n "$OPENAI_API_KEY" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
echo "auth_mode=api_key" >> "$GITHUB_OUTPUT"
elif [ -n "$CODEX_AUTH_JSON" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
echo "auth_mode=oauth_json" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
echo "CODEX_AUTH_JSON is not configured; skipping Codex review."
echo "Codex auth is not configured; set OPENAI_API_KEY or CODEX_AUTH_JSON to enable Codex review."
fi
- name: Resolve PR metadata
@@ -169,9 +176,10 @@ jobs:
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
run: npm install --global @openai/codex@0.128.0
- name: Configure file-backed Codex auth
- name: Configure Codex auth
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
run: |
CODEX_HOME="$HOME/.codex"
@@ -181,9 +189,13 @@ jobs:
cat > "$CODEX_HOME/config.toml" <<'EOF'
cli_auth_credentials_store = "file"
EOF
printf '%s' "$CODEX_AUTH_JSON" > "$CODEX_HOME/auth.json"
chmod 600 "$CODEX_HOME/auth.json"
node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$CODEX_HOME/auth.json"
if [ -n "$OPENAI_API_KEY" ]; then
printf '%s' "$OPENAI_API_KEY" | codex login --with-api-key
else
printf '%s' "$CODEX_AUTH_JSON" > "$CODEX_HOME/auth.json"
chmod 600 "$CODEX_HOME/auth.json"
node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$CODEX_HOME/auth.json"
fi
- name: Pre-fetch base and head refs for the PR
if: steps.codex_config.outputs.enabled == 'true' && steps.pr.outputs.skip != 'true'
+1
View File
@@ -100,6 +100,7 @@ jobs:
extra_prompt: ${{ needs.parse.outputs.extra_prompt }}
triggered_by: ${{ github.event.comment.user.login }}
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
CODEX_AUTH_JSON: ${{ secrets.CODEX_AUTH_JSON }}
WINDMILL_EE_PRIVATE_ACCESS: ${{ secrets.WINDMILL_EE_PRIVATE_ACCESS }}
+84
View File
@@ -0,0 +1,84 @@
name: Publish CLI docs repo
# Regenerates the windmill-cli-docs repo (consumed by context7) from the
# canonical sources in this repo on every Windmill release.
#
# Required secret:
# CLI_DOCS_DEPLOY_KEY — ed25519 private key whose public half is registered
# as a write-access deploy key on
# windmill-labs/windmill-cli-docs.
on:
push:
tags:
- "v*"
workflow_dispatch:
# Serialize pushes to windmill-cli-docs so two release tags landing close
# together (e.g. a release-please bump + a hotfix) can't race to force-push
# the docs repo.
concurrency:
group: publish-cli-docs
cancel-in-progress: false
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Checkout windmill (source of truth)
uses: actions/checkout@v4
with:
path: windmill
- name: Checkout windmill-cli-docs (publish target)
uses: actions/checkout@v4
with:
repository: windmill-labs/windmill-cli-docs
path: windmill-cli-docs
ssh-key: ${{ secrets.CLI_DOCS_DEPLOY_KEY }}
fetch-depth: 0
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install pyyaml
- name: Regenerate docs
run: |
python3 windmill/system_prompts/generate.py \
--context7-dir "$GITHUB_WORKSPACE/windmill-cli-docs"
- name: Commit and push if changed
working-directory: windmill-cli-docs
env:
REF_NAME: ${{ github.ref_name }}
REF_TYPE: ${{ github.ref_type }}
run: |
git config user.name "windmill-bot"
git config user.email "bot@windmill.dev"
git add -A
if git diff --cached --quiet; then
echo "No doc changes for ${REF_NAME}."
committed=false
else
committed=true
if [ "${REF_TYPE}" = "tag" ]; then
git commit -m "chore: sync from windmill ${REF_NAME}"
else
git commit -m "chore: sync from windmill (manual dispatch from ${REF_NAME})"
fi
git push origin HEAD
fi
# Always mirror the version tag on tag pushes, even when content
# didn't change — downstream consumers tie snapshots to releases by
# tag, and skipping it would leave the docs repo without a tag for
# the new Windmill release.
# workflow_dispatch from a non-tag ref skips this so we don't
# create a junk tag named after a branch.
if [ "${REF_TYPE}" = "tag" ]; then
git tag -f "${REF_NAME}"
git push origin "${REF_NAME}" --force
echo "Mirrored tag ${REF_NAME} to windmill-cli-docs (content changed: ${committed})."
fi
+1
View File
@@ -20,6 +20,7 @@ rust-client/Cargo.toml
# Worktree-specific Claude Code settings (generated by scripts/worktree-env)
.claude/settings.local.json
.claude/worktrees/
# Symlinked cache directories (for git worktrees)
backend/target
+126
View File
@@ -1,5 +1,131 @@
# Changelog
## [1.703.3](https://github.com/windmill-labs/windmill/compare/v1.703.2...v1.703.3) (2026-05-18)
### Bug Fixes
* constrain unauthenticated get_public_resource to app_theme resources ([#9203](https://github.com/windmill-labs/windmill/issues/9203)) ([24eedef](https://github.com/windmill-labs/windmill/commit/24eedef918376d9d401335b6fada577916f8cc0e))
* enforce folder ACL on flow run-by-version routes ([#9202](https://github.com/windmill-labs/windmill/issues/9202)) ([ab11c77](https://github.com/windmill-labs/windmill/commit/ab11c7747a9076e8121fcea6eafb8e88079ac987))
* enforce jobs:run scope on job preview and inline endpoints ([#9198](https://github.com/windmill-labs/windmill/issues/9198)) ([664edcd](https://github.com/windmill-labs/windmill/commit/664edcdfb746f6c8513e2b487383b5d9ab9f5434))
* **mcp:** validate oauth dynamic client registration redirect_uris ([#9197](https://github.com/windmill-labs/windmill/issues/9197)) ([8bc2295](https://github.com/windmill-labs/windmill/commit/8bc2295b94df159a7c8630cdbe02953b8b7c13a1))
* validate entrypoint override to prevent worker code injection (GHSA-wxjq-w5pj-jqhx) ([#9204](https://github.com/windmill-labs/windmill/issues/9204)) ([bd05bca](https://github.com/windmill-labs/windmill/commit/bd05bcadde06b65fc4b732f576d89aae908b5a3f))
## [1.703.2](https://github.com/windmill-labs/windmill/compare/v1.703.1...v1.703.2) (2026-05-17)
### Bug Fixes
* prevent cross-tenant DNS poisoning via writable /etc in nsjail ([#9194](https://github.com/windmill-labs/windmill/issues/9194)) ([f8467f3](https://github.com/windmill-labs/windmill/commit/f8467f38c8a053117ce62f96684cfb15ef792f08))
## [1.703.1](https://github.com/windmill-labs/windmill/compare/v1.703.0...v1.703.1) (2026-05-16)
### Bug Fixes
* actionable error when a custom_path is taken by an app in another workspace ([#9190](https://github.com/windmill-labs/windmill/issues/9190)) ([dfeed9c](https://github.com/windmill-labs/windmill/commit/dfeed9c5c2e39bf3e10eea4f69ea140ee9e7832f))
* atomic bundle cache writes to prevent parallel cold-load race ([#9186](https://github.com/windmill-labs/windmill/issues/9186)) ([81b5736](https://github.com/windmill-labs/windmill/commit/81b573610692b386e4861ef989fa7698b53fc861))
* detect S3 assets passed as SDK object arg in ts parser ([#9181](https://github.com/windmill-labs/windmill/issues/9181)) ([6a334e9](https://github.com/windmill-labs/windmill/commit/6a334e9a07a7d0cffabde48be75263b0844d586c))
* don't show ALLOW_PRIVATE_AI_BASE_URLS hint for malformed AI base URLs ([#9188](https://github.com/windmill-labs/windmill/issues/9188)) ([4e25954](https://github.com/windmill-labs/windmill/commit/4e259547225e13e5b51a166a84cdbbbfa35c3264))
* reset parent_hash in auto_parent when all versions at path are archived ([#9172](https://github.com/windmill-labs/windmill/issues/9172)) ([52960ca](https://github.com/windmill-labs/windmill/commit/52960ca30ab9c019186a28b3ab054a1dfe72f451))
## [1.703.0](https://github.com/windmill-labs/windmill/compare/v1.702.1...v1.703.0) (2026-05-15)
### Features
* **otel-tracing-proxy:** configurable tracing MITM NO_PROXY hosts ([#9169](https://github.com/windmill-labs/windmill/issues/9169)) ([d48d61c](https://github.com/windmill-labs/windmill/commit/d48d61cc79114f0b36736306d4015789be10c1f4))
### Bug Fixes
* aggregate wait time should target the true root job, not flow_innermost_root_job ([#9177](https://github.com/windmill-labs/windmill/issues/9177)) ([e181931](https://github.com/windmill-labs/windmill/commit/e1819313e15766007c959497a84fae5f5c78a46b))
* apply pip_local_dependencies filtering to deployed scripts with populated lockfiles ([#9178](https://github.com/windmill-labs/windmill/issues/9178)) ([69b3141](https://github.com/windmill-labs/windmill/commit/69b3141e0370b95f2e13987503480d341608dbdf))
* never mark failure/trigger/approval scripts as auto_kind=lib ([#9168](https://github.com/windmill-labs/windmill/issues/9168)) ([f414ffc](https://github.com/windmill-labs/windmill/commit/f414ffc4849cf4b92fcd5ca9611ecd246e59a7bd))
## [1.702.1](https://github.com/windmill-labs/windmill/compare/v1.702.0...v1.702.1) (2026-05-14)
### Bug Fixes
* **nativets:** pass tracing-enabled OtelConfig to deno_telemetry::init ([#9163](https://github.com/windmill-labs/windmill/issues/9163)) ([bf99283](https://github.com/windmill-labs/windmill/commit/bf99283c3333bcdbc7679f4aea04ba29e41a48a5))
## [1.702.0](https://github.com/windmill-labs/windmill/compare/v1.701.0...v1.702.0) (2026-05-14)
### Features
* **git-sync:** sync extra_perms for flows/scripts/apps ([#9162](https://github.com/windmill-labs/windmill/issues/9162)) ([5e909b2](https://github.com/windmill-labs/windmill/commit/5e909b2b4f2819f19deaf06d9e78e6458b324683))
* include service accounts in instance settings users list ([#9157](https://github.com/windmill-labs/windmill/issues/9157)) ([e5286f4](https://github.com/windmill-labs/windmill/commit/e5286f46074cf2893e6ccd26175f929f16011c8f))
### Bug Fixes
* **mcp:** sanitize and enrich nested resource schemas ([#9158](https://github.com/windmill-labs/windmill/issues/9158)) ([d870edc](https://github.com/windmill-labs/windmill/commit/d870edc959481a06c894b4eda5e2be1a0269d7d0))
## [1.701.0](https://github.com/windmill-labs/windmill/compare/v1.700.2...v1.701.0) (2026-05-13)
### Features
* **frontend:** unified EditorHeader with file picker for flow/script/app editors ([#9047](https://github.com/windmill-labs/windmill/issues/9047)) ([d0f23cc](https://github.com/windmill-labs/windmill/commit/d0f23cc5238b025208c61e983701894de28536d5))
* read-only flag on API tokens ([#9144](https://github.com/windmill-labs/windmill/issues/9144)) ([d666e84](https://github.com/windmill-labs/windmill/commit/d666e8431cdbf14d9373d9ef625b5aafc50ac50a))
### Bug Fixes
* align script path existence check with deploy logic; hide Delete for non-admin ([#9152](https://github.com/windmill-labs/windmill/issues/9152)) ([c509206](https://github.com/windmill-labs/windmill/commit/c5092069cbeda2c4c18bea80dd629c7c087b30bf))
* Allow devops role to use all_workspaces runs filter in admins workspace ([#9153](https://github.com/windmill-labs/windmill/issues/9153)) ([110bef0](https://github.com/windmill-labs/windmill/commit/110bef0a6e76615c7b371c5c0f5bc1f4e7a73a64))
* **bun:** pass --preserve-symlinks on unbundled execution ([#9147](https://github.com/windmill-labs/windmill/issues/9147)) ([4d0f2c2](https://github.com/windmill-labs/windmill/commit/4d0f2c26a116a0f8a89a64231dc824eabda0a8c3))
* **cli:** prevent !inline-corruption in flow push/pull ([#9142](https://github.com/windmill-labs/windmill/issues/9142)) ([79c5b7b](https://github.com/windmill-labs/windmill/commit/79c5b7b8b7676b0a06fa6480dd04b7105d39d250))
* **operator:** refresh IAM RDS / Entra ID tokens in operator process ([#9141](https://github.com/windmill-labs/windmill/issues/9141)) ([7ebb081](https://github.com/windmill-labs/windmill/commit/7ebb08133cd4027bc00bacc4a0fc5865cd5709ec))
* **python:** preserve strings containing Infinity/NaN in result JSON ([#9149](https://github.com/windmill-labs/windmill/issues/9149)) ([33bf01b](https://github.com/windmill-labs/windmill/commit/33bf01b627c8ea430c03dfc27a97a8f2d770582f))
* scope promotion-mode debounce key per repo ([#9145](https://github.com/windmill-labs/windmill/issues/9145)) ([2ec1863](https://github.com/windmill-labs/windmill/commit/2ec1863340e759bba3408dbc4f41b16912b959ea))
* send flow push-loop ping outside transaction so zombie monitor sees it ([#9136](https://github.com/windmill-labs/windmill/issues/9136)) ([818cb31](https://github.com/windmill-labs/windmill/commit/818cb31fbc731fa5c70ddf5942bb37bc4bc56e4d))
### Performance Improvements
* **dynselect:** only retrigger when helper args actually change ([#9148](https://github.com/windmill-labs/windmill/issues/9148)) ([dd19e52](https://github.com/windmill-labs/windmill/commit/dd19e52a84fb9a9f48e3ad061b084841c2ee7464))
## [1.700.2](https://github.com/windmill-labs/windmill/compare/v1.700.1...v1.700.2) (2026-05-12)
### Bug Fixes
* preserve explicit nulls for typed fields in bulk instance config ([#9123](https://github.com/windmill-labs/windmill/issues/9123)) ([cab0000](https://github.com/windmill-labs/windmill/commit/cab0000f3a5e9a0b201a85da1a01b1f82df8a316))
* preserve negative integers in Bedrock tool schema conversion ([#9116](https://github.com/windmill-labs/windmill/issues/9116)) ([01e21c7](https://github.com/windmill-labs/windmill/commit/01e21c7f913eaf7dffc3d6a31501418ff2104c8b))
## [1.700.1](https://github.com/windmill-labs/windmill/compare/v1.700.0...v1.700.1) (2026-05-11)
### Bug Fixes
* CE build broken by enterprise-gated compute_instance_hash ([#9113](https://github.com/windmill-labs/windmill/issues/9113)) ([cd65de4](https://github.com/windmill-labs/windmill/commit/cd65de49285ff60abdd94c883180ded65609f382))
## [1.700.0](https://github.com/windmill-labs/windmill/compare/v1.699.0...v1.700.0) (2026-05-11)
### Features
* **cli:** auto-infer args for `wmill app push` ([#9091](https://github.com/windmill-labs/windmill/issues/9091)) ([43b1800](https://github.com/windmill-labs/windmill/commit/43b18006f32fd5db54bbf8ae7ff0e0b314a517e5))
* **forks:** prompt to delete forked children when deleting a fork ([#9097](https://github.com/windmill-labs/windmill/issues/9097)) ([e43a958](https://github.com/windmill-labs/windmill/commit/e43a958c5c6ae01a1fbecf3db63c6541a245be62))
* **operators:** allow operators to access assets page ([#9095](https://github.com/windmill-labs/windmill/issues/9095)) ([20ecd90](https://github.com/windmill-labs/windmill/commit/20ecd904e7060c3cf90f2605740bb349b2a3e6ed))
* **vault:** configurable JWT auth mount path and setup-doc fixes ([#9100](https://github.com/windmill-labs/windmill/issues/9100)) ([f8ba084](https://github.com/windmill-labs/windmill/commit/f8ba0840d74572c880cf458938365b3ec808c6fb))
### Bug Fixes
* add Input, Result, Trigger to reserved flow step IDs ([#9109](https://github.com/windmill-labs/windmill/issues/9109)) ([9f79a86](https://github.com/windmill-labs/windmill/commit/9f79a86a686708f66ccc512d4f132cb9a00397a7)), closes [#7139](https://github.com/windmill-labs/windmill/issues/7139)
* **frontend:** mark Path dirty when folder picker changes selection ([#9096](https://github.com/windmill-labs/windmill/issues/9096)) ([23bb1b5](https://github.com/windmill-labs/windmill/commit/23bb1b541e78846d5978153fd8d9bb4f01cec72b))
* mask oauth client secret in instance settings ([#9112](https://github.com/windmill-labs/windmill/issues/9112)) ([ac3c155](https://github.com/windmill-labs/windmill/commit/ac3c155541eb5ca20d65c38ad13dca6c10a572c9))
* populate raw_code for flowscript and appscript runs ([#9104](https://github.com/windmill-labs/windmill/issues/9104)) ([05172ac](https://github.com/windmill-labs/windmill/commit/05172ac3bdfc3472da5e9d8a825cdd479ba9e375))
### Performance Improvements
* lazy-load script editor history and hit partial index ([#9107](https://github.com/windmill-labs/windmill/issues/9107)) ([03e8bc8](https://github.com/windmill-labs/windmill/commit/03e8bc8c14258355d7d695333c1588807fbf8cd6))
## [1.699.0](https://github.com/windmill-labs/windmill/compare/v1.698.0...v1.699.0) (2026-05-08)
+11
View File
@@ -6,6 +6,7 @@ This folder contains black-box benchmark cases for:
- `app`
- `script`
- `cli`
- `global`
The goal is to test the current production prompts and guidance with realistic user requests, not to test one exact implementation shape.
@@ -75,6 +76,16 @@ Still, avoid benchmark phrasing. The prompt should read like a repo task, not a
When relevant, ask the assistant to tell the user which `wmill` commands to run next. That is part of the benchmarked behavior.
## Global-specific rules
Global prompts should exercise workspace-level drafting behavior:
- inspecting existing scripts, flows, apps, schedules, triggers, resources, and variables when relevant
- writing AI drafts rather than saving or deploying by default
- producing coherent multi-artifact changes when the request crosses artifact boundaries
Keep deterministic validation focused on the draft contract: required draft type/path, required content snippets, forbidden draft paths, and forbidden mutating tools such as deploy/delete unless the case explicitly asks for them.
## Deterministic validation
Use deterministic validation only for hard failures such as:
+21 -13
View File
@@ -1,11 +1,12 @@
# AI Evals
Small benchmark runner for the four Windmill AI generation modes:
Small benchmark runner for the Windmill AI generation modes:
- `cli`
- `flow`
- `script`
- `app`
- `global`
The benchmark always tests the current production prompts, tools, and guidance in this checkout.
@@ -55,8 +56,9 @@ bun run cli -- run flow flow-test4-order-processing-loop --model opus
bun run cli -- run flow flow-test0-sum-two-numbers --models haiku,opus,4o
bun run cli -- run flow flow-test0-sum-two-numbers --runs 3 --verbose
bun run cli -- run flow --record
GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro --transport proxy
GEMINI_API_KEY=... bun run cli -- run app app-test1-counter-create --model gemini-pro
WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000 bun run cli -- run flow --backend-validation preview
bun run cli -- run global global-test1-script-create
bun run cli -- run cli bun-hello-script
```
@@ -72,7 +74,6 @@ Public CLI surface:
- `--output <path>`: custom result JSON path
- `--model <alias>`: choose the model under test
- `--models <a,b,c>`: run the same cases sequentially against several model aliases
- `--transport <mode>`: frontend request transport (`direct` by default, `proxy` to exercise `/api/w/{workspace}/ai/proxy`)
- `--verbose`: stream assistant output for frontend runs
- `--record`: append a compact tracked summary line to `ai_evals/history/<mode>.jsonl` for full-suite runs only
- `--backend-validation <mode>`: optional backend smoke validation (`off` or `preview`) for `script` and `flow` evals
@@ -95,7 +96,7 @@ Today:
Notes:
- the command also prints accepted alias spellings such as `gpt-4o`, `claude-opus-4.6`, and `claude-haiku-4.5`
- frontend modes (`flow`, `script`, `app`) can use Anthropic, OpenAI, and Gemini-backed aliases
- frontend modes (`flow`, `script`, `app`, `global`) can use Anthropic, OpenAI, and Gemini-backed aliases
- `cli` mode always uses the Anthropic agent SDK, so only Anthropic aliases are valid there
- the judge model is separate and currently defaults to `claude-sonnet-4-6`
@@ -134,6 +135,13 @@ For `app` mode, `validate` can express narrow hard requirements such as:
- minimum datatable / datatable-table counts
- specific required datatable tables
For `global` mode, `validate` can express draft-level requirements such as:
- required draft type/path/language
- required or forbidden snippets in draft values
- required or forbidden draft counts
- forbidden draft paths
App fixtures can also include an optional `datatables.json` file at the fixture root.
For `flow` mode, an `initial` fixture can also include a benchmark workspace catalog of
@@ -145,26 +153,23 @@ If `--backend-validation preview` is enabled:
- `script` evals run a real backend script preview in an isolated temp workspace
- `flow` evals run a real backend flow preview only for cases that define `runtime.backendPreview`
- `flow` cases with `initial.workspace` fixtures seed those scripts and flows into the preview workspace before preview
- when `WMILL_AI_EVAL_BACKEND_WORKSPACE` is set, `ai_evals` treats that workspace as a dedicated test workspace, clears managed eval assets under `f/evals/*` before each preview run, and then reseeds the current case fixtures
- when `WMILL_AI_EVAL_BACKEND_WORKSPACE` is set, `ai_evals` creates or reuses that workspace as a dedicated test workspace, clears managed eval assets under `f/evals/*` before each preview run, and then reseeds the current case fixtures
Supported backend validation env vars:
Supported backend env vars:
- `WMILL_AI_EVAL_BACKEND_VALIDATION=preview`
- `WMILL_AI_EVAL_BACKEND_URL=http://127.0.0.1:8000`
- `WMILL_AI_EVAL_BACKEND_EMAIL=admin@windmill.dev`
- `WMILL_AI_EVAL_BACKEND_PASSWORD=changeme`
- `WMILL_AI_EVAL_BACKEND_WORKSPACE=integration-tests` to reuse an existing workspace on CE installs with low workspace limits
- `WMILL_AI_EVAL_KEEP_WORKSPACES=1`
- `WMILL_AI_EVAL_WORKSPACE_PREFIX=ai-evals`
Frontend proxy transport uses the same backend auth/workspace env vars.
Frontend modes require a reachable Windmill backend and send model requests through the workspace AI proxy at `/api/w/{workspace}/ai/proxy`. At startup, `ai_evals` checks the resolved backend URL and fails early with setup guidance if the backend cannot be reached or login fails.
When `--transport proxy` is set:
For frontend modes:
- `ai_evals` creates or reuses a backend workspace
- `ai_evals` creates a temporary backend workspace, or creates/reuses `WMILL_AI_EVAL_BACKEND_WORKSPACE` when it is set
- it upserts a provider resource under `f/evals/ai/<provider>`
- frontend requests go through `/api/w/{workspace}/ai/proxy`
- result JSON and history records include `transport` so direct vs proxy runs stay distinguishable
## Results And Artifacts
@@ -178,11 +183,12 @@ If `--record` is used, the CLI also appends one compact JSON line to:
- `ai_evals/history/flow.jsonl`
- `ai_evals/history/script.jsonl`
- `ai_evals/history/app.jsonl`
- `ai_evals/history/global.jsonl`
- `ai_evals/history/cli.jsonl`
Each recorded line contains:
- run metadata (`createdAt`, `gitSha`, `mode`, `runModel`, `transport`, `judgeModel`)
- run metadata (`createdAt`, `gitSha`, `mode`, `runModel`, `judgeModel`)
- suite totals (`caseCount`, `attemptCount`, `passedAttempts`, `passRate`, `averageDurationMs`, `averageJudgeScore`)
- average token usage (`averageTokenUsagePerAttempt`)
- per-case metrics under `cases[]` (`averageDurationMs`, `averageJudgeScore`, `averageTokenUsagePerAttempt`, pass rate)
@@ -198,6 +204,7 @@ Typical artifacts by mode:
- `flow`: `flow.json`
- `script`: `script.json` plus the generated script file
- `app`: `app.json` plus frontend/backend files
- `global`: `global-drafts.json`
- `cli`: `assistant-output.txt`, `trace.json`, `wmill-invocations.jsonl`, plus generated workspace files
- backend-validated attempts also include `backend-preview.json`
@@ -213,6 +220,7 @@ Typical artifacts by mode:
## Notes
- Frontend modes reuse the production frontend chat code through the Vitest bridge.
- Global mode evaluates the production global AI tools and validates the resulting AI draft store.
- CLI mode creates an isolated workspace, writes the current checkout guidance into it, and benchmarks the real skills / `AGENTS.md` flow.
- CLI mode now also records a structured trace of invoked skills, tool calls, proposed `wmill` commands, and any attempted `wmill` executions.
- Frontend progress streams live while the benchmark is running.
@@ -210,8 +210,6 @@ function buildSettings(
baseUrl: 'http://backend.test/default',
email: 'admin@windmill.dev',
password: 'changeme',
keepWorkspaces: true,
workspacePrefix: 'ai-evals',
pollIntervalMs: 1,
maxWaitMs: 50,
...overrides
+5 -4
View File
@@ -24,6 +24,7 @@ export interface CompletedPreviewJob {
const tokenCache = new Map<string, Promise<string>>()
const sharedWorkspaceQueue = new Map<string, Promise<void>>()
const managedSharedWorkspacePrefixes = ['f/evals/']
const DEFAULT_WORKSPACE_PREFIX = 'ai-evals'
export class BackendPreviewClient {
constructor(private readonly settings: BackendValidationSettings) {}
@@ -35,7 +36,7 @@ export class BackendPreviewClient {
): Promise<T> {
const workspaceId =
this.settings.workspaceOverride ??
buildWorkspaceId(this.settings.workspacePrefix, caseId, attempt)
buildWorkspaceId(caseId, attempt)
const run = async () => {
await this.ensureWorkspace(workspaceId)
@@ -46,7 +47,7 @@ export class BackendPreviewClient {
try {
return await body(workspaceId)
} finally {
if (!this.settings.keepWorkspaces && !this.settings.workspaceOverride) {
if (!this.settings.workspaceOverride) {
await this.deleteWorkspace(workspaceId).catch(() => undefined)
}
}
@@ -440,14 +441,14 @@ async function withSharedWorkspaceLock<T>(workspaceId: string, body: () => Promi
}
}
function buildWorkspaceId(prefix: string, caseId: string, attempt: number): string {
function buildWorkspaceId(caseId: string, attempt: number): string {
const caseSlug = caseId
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 30)
const suffix = randomUUID().slice(0, 8)
return `${prefix}-${caseSlug || 'case'}-a${attempt}-${suffix}`
return `${DEFAULT_WORKSPACE_PREFIX}-${caseSlug || 'case'}-a${attempt}-${suffix}`
}
function extractFolderName(path: string): string | null {
+12 -13
View File
@@ -1,6 +1,5 @@
import { loadSelectedCases } from "../../core/cases";
import { resolveBackendValidationSettings } from "../../core/backendValidation";
import { resolveFrontendEvalTransportSettings } from "../../core/frontendTransport";
import {
formatRunModelLabel,
getFrontendEvalModel,
@@ -9,13 +8,15 @@ import {
import { buildRunResult } from "../../core/results";
import { runSuite } from "../../core/runSuite";
import type { BenchmarkRunResult, ModeRunner } from "../../core/types";
import { resolveWindmillBackendSettings } from "../../core/windmillBackendSettings";
import { emitFrontendBenchmarkProgress } from "./progress";
import { createAppModeRunner } from "../../modes/app";
import { createFlowModeRunner } from "../../modes/flow";
import { createGlobalModeRunner } from "../../modes/global";
import { createScriptModeRunner } from "../../modes/script";
import { DEFAULT_JUDGE_MODEL } from "../../core/judge";
export type FrontendBenchmarkMode = "flow" | "app" | "script";
export type FrontendBenchmarkMode = "flow" | "app" | "script" | "global";
export async function runFrontendBenchmarkFromEnv(): Promise<BenchmarkRunResult> {
const mode = parseMode(process.env.WMILL_FRONTEND_AI_EVAL_MODE);
@@ -36,17 +37,14 @@ export async function runFrontendBenchmarkFromEnv(): Promise<BenchmarkRunResult>
evalMode: mode,
requestedMode: process.env.WMILL_FRONTEND_AI_EVAL_BACKEND_VALIDATION,
});
const transportSettings = resolveFrontendEvalTransportSettings({
evalMode: mode,
requestedTransport: process.env.WMILL_FRONTEND_AI_EVAL_TRANSPORT,
});
const backendSettings = resolveWindmillBackendSettings();
const selectedCases = await loadSelectedCases(mode, caseIds);
const modeRunner = getModeRunner(
mode,
getFrontendEvalModel(model),
backendValidation,
transportSettings,
backendSettings,
);
const runModel = formatRunModelLabel(mode, model);
const caseResults = await runSuite({
@@ -66,7 +64,6 @@ export async function runFrontendBenchmarkFromEnv(): Promise<BenchmarkRunResult>
mode,
runs,
runModel,
transport: transportSettings.transport,
judgeModel: DEFAULT_JUDGE_MODEL,
caseResults,
});
@@ -76,24 +73,26 @@ function getModeRunner(
mode: FrontendBenchmarkMode,
model: ReturnType<typeof getFrontendEvalModel>,
backendValidation: ReturnType<typeof resolveBackendValidationSettings>,
transportSettings: ReturnType<typeof resolveFrontendEvalTransportSettings>,
backendSettings: ReturnType<typeof resolveWindmillBackendSettings>,
): ModeRunner<any, any, any> {
switch (mode) {
case "flow":
return createFlowModeRunner(model, backendValidation, transportSettings);
return createFlowModeRunner(model, backendValidation, backendSettings);
case "app":
return createAppModeRunner(model, transportSettings);
return createAppModeRunner(model, backendSettings);
case "script":
return createScriptModeRunner(
model,
backendValidation,
transportSettings,
backendSettings,
);
case "global":
return createGlobalModeRunner(model, backendSettings);
}
}
function parseMode(value: string | undefined): FrontendBenchmarkMode {
if (value === "flow" || value === "app" || value === "script") {
if (value === "flow" || value === "app" || value === "script" || value === "global") {
return value;
}
throw new Error(`Unsupported frontend benchmark mode: ${String(value)}`);
@@ -12,7 +12,7 @@ import {
prepareAppUserMessage,
} from "../../../../../frontend/src/lib/components/copilot/chat/app/core";
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
import { createAppFileHelpers } from "./fileHelpers";
import { createAppFileHelpers, type AppEvalChatHelpers } from "./fileHelpers";
import { runEval } from "../shared";
import type { AIProvider } from "$lib/gen/types.gen";
import type {
@@ -22,7 +22,6 @@ import type {
} from "../../../../core/types";
import type { TokenUsage } from "../shared/types";
import type { AppFilesState } from "../../../../core/validators";
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
import {
createAppBackendRunnableContextElement,
@@ -49,8 +48,7 @@ export interface AppEvalOptions {
model?: string;
maxIterations?: number;
provider?: AIProvider;
transport?: FrontendEvalTransport;
backend?: WindmillBackendSettings;
backend: WindmillBackendSettings;
workspaceRoot?: string;
runContext?: ModeRunContext;
}
@@ -58,7 +56,7 @@ export interface AppEvalOptions {
export async function runAppEval(
userPrompt: string,
apiKey: string,
options?: AppEvalOptions,
options: AppEvalOptions,
): Promise<AppEvalResult> {
const workspaceRoot =
options?.workspaceRoot ??
@@ -101,10 +99,9 @@ export async function runAppEval(
model,
workspace: workspaceRoot,
provider: options?.provider,
transport: options?.transport,
backend: options?.backend,
proxyCaseId: options?.runContext?.caseId,
proxyAttempt: options?.runContext?.attempt,
backend: options.backend,
caseId: options?.runContext?.caseId,
attempt: options?.runContext?.attempt,
},
});
@@ -124,7 +121,7 @@ export async function runAppEval(
async function buildAdditionalContext(
appContext: EvalCaseRuntimeAppContextSpec | undefined,
helpers: AppAIChatHelpers,
helpers: AppEvalChatHelpers,
): Promise<ContextElement[]> {
const entries = appContext?.additional ?? [];
if (entries.length === 0) {
@@ -2,6 +2,7 @@ import { mkdir, rm, writeFile } from 'fs/promises'
import { dirname, join } from 'path'
import type {
AppAIChatHelpers,
AppDatatableMetadata,
AppFiles,
BackendRunnable,
DataTableSchema,
@@ -10,6 +11,10 @@ import type {
} from '../../../../../frontend/src/lib/components/copilot/chat/app/core'
import { buildAppWmillTypes, collectAppDiagnostics } from '../../../../core/appDiagnostics'
export interface AppEvalChatHelpers extends AppAIChatHelpers {
getDatatables: () => Promise<DataTableSchema[]>
}
async function writeFrontendFile(
workspaceRoot: string | undefined,
path: string,
@@ -92,7 +97,7 @@ export async function createAppFileHelpers(
initialDatatables: DataTableSchema[] = [],
workspaceRoot?: string
): Promise<{
helpers: AppAIChatHelpers
helpers: AppEvalChatHelpers
getFiles: () => AppFiles
getEvalState: () => {
frontend: Record<string, string>
@@ -137,7 +142,7 @@ export async function createAppFileHelpers(
}
await persistDatatables(workspaceRoot, datatables)
const helpers: AppAIChatHelpers = {
const helpers: AppEvalChatHelpers = {
listFrontendFiles: () => [
...Object.keys(frontend).filter((path) => path !== '/wmill.d.ts'),
'/wmill.d.ts'
@@ -211,6 +216,34 @@ export async function createAppFileHelpers(
},
lint,
getDatatables: async () => structuredClone(datatables),
listDatatableTables: async () =>
datatables.map(
(datatable): AppDatatableMetadata => {
const schemas = Object.fromEntries(
Object.entries(datatable.schemas).map(([schemaName, tables]) => [
schemaName,
Object.keys(tables)
])
)
return {
datatable_name: datatable.datatable_name,
schemas,
tableCount: Object.values(schemas).reduce(
(sum, tableNames) => sum + tableNames.length,
0
),
error: datatable.error
}
}
),
getDatatableTableSchema: async (
datatableName: string,
schemaName: string,
tableName: string
) => {
const datatable = datatables.find((entry) => entry.datatable_name === datatableName)
return structuredClone(datatable?.schemas?.[schemaName]?.[tableName] ?? {})
},
getAvailableDatatableNames: () => datatables.map((datatable) => datatable.datatable_name),
execDatatableSql: async (
datatableName: string,
@@ -18,7 +18,6 @@ import {
import { runEval } from "../shared";
import type { ModeRunContext } from "../../../../core/types";
import type { TokenUsage, ToolCallDetail } from "../shared/types";
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
export interface FlowFixture {
@@ -48,8 +47,7 @@ export interface FlowEvalOptions {
model?: string;
maxIterations?: number;
provider?: AIProvider;
transport?: FrontendEvalTransport;
backend?: WindmillBackendSettings;
backend: WindmillBackendSettings;
workspaceRoot?: string;
runContext?: ModeRunContext;
}
@@ -57,7 +55,7 @@ export interface FlowEvalOptions {
export async function runFlowEval(
userPrompt: string,
apiKey: string,
options?: FlowEvalOptions,
options: FlowEvalOptions,
): Promise<FlowEvalResult> {
const workspaceRoot =
options?.workspaceRoot ??
@@ -100,10 +98,9 @@ export async function runFlowEval(
model,
workspace: workspaceRoot,
provider: options?.provider,
transport: options?.transport,
backend: options?.backend,
proxyCaseId: options?.runContext?.caseId,
proxyAttempt: options?.runContext?.attempt,
backend: options.backend,
caseId: options?.runContext?.caseId,
attempt: options?.runContext?.attempt,
},
});
@@ -0,0 +1,127 @@
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import type { AIProvider } from "$lib/gen/types.gen";
import {
globalTools,
prepareGlobalSystemMessage,
prepareGlobalUserMessage,
} from "../../../../../frontend/src/lib/components/copilot/chat/global/core";
import { globalDraftStore } from "../../../../../frontend/src/lib/components/copilot/chat/global/draftStore.svelte";
import type { Tool as ProductionTool } from "../../../../../frontend/src/lib/components/copilot/chat/shared";
import type { ModeRunContext } from "../../../../core/types";
import type { GlobalDraftState } from "../../../../core/validators";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
import {
registerBenchmarkWorkspaceRunnables,
unregisterBenchmarkWorkspaceRunnables,
type BenchmarkWorkspaceRunnables,
} from "../../mockBackend";
import { runEval } from "../shared";
import type { TokenUsage, ToolCallDetail } from "../shared/types";
const MUTATING_GLOBAL_TOOLS = new Set([
"deploy_workspace_item",
"delete_workspace_item",
]);
export interface GlobalEvalResult {
success: boolean;
state: GlobalDraftState;
error?: string;
assistantMessageCount: number;
toolCallCount: number;
toolsUsed: string[];
toolCallDetails: ToolCallDetail[];
tokenUsage: TokenUsage;
}
export interface GlobalEvalOptions {
workspaceFixtures?: BenchmarkWorkspaceRunnables;
model?: string;
maxIterations?: number;
provider?: AIProvider;
backend: WindmillBackendSettings;
workspaceRoot?: string;
runContext?: ModeRunContext;
}
export async function runGlobalEval(
userPrompt: string,
apiKey: string,
options: GlobalEvalOptions,
): Promise<GlobalEvalResult> {
const workspaceRoot =
options.workspaceRoot ??
(await mkdtemp(join(tmpdir(), "wmill-frontend-global-benchmark-")));
globalDraftStore.clearDrafts(workspaceRoot);
registerBenchmarkWorkspaceRunnables(workspaceRoot, options.workspaceFixtures ?? {});
try {
const model = options.model ?? "claude-haiku-4-5-20251001";
const rawResult = await runEval({
userPrompt,
systemMessage: prepareGlobalSystemMessage(),
userMessage: prepareGlobalUserMessage(userPrompt),
tools: getGlobalEvalTools(),
helpers: {},
apiKey,
getOutput: () => ({ drafts: globalDraftStore.listDrafts(workspaceRoot) }),
onAssistantMessageStart: options.runContext?.onAssistantMessageStart,
onAssistantToken: options.runContext?.onAssistantChunk,
onAssistantMessageEnd: options.runContext?.onAssistantMessageEnd,
onToolCall: options.runContext?.onToolCall,
options: {
maxIterations: options.maxIterations,
model,
workspace: workspaceRoot,
provider: options.provider,
backend: options.backend,
caseId: options.runContext?.caseId,
attempt: options.runContext?.attempt,
},
});
return {
state: rawResult.output,
success: rawResult.success,
error: rawResult.error,
assistantMessageCount: rawResult.iterations,
toolCallCount: rawResult.toolCallsCount,
toolsUsed: rawResult.toolsCalled,
toolCallDetails: rawResult.toolCallDetails,
tokenUsage: rawResult.tokenUsage,
};
} finally {
globalDraftStore.clearDrafts(workspaceRoot);
unregisterBenchmarkWorkspaceRunnables(workspaceRoot);
if (!options.workspaceRoot) {
await rm(workspaceRoot, { recursive: true, force: true });
}
}
}
function getGlobalEvalTools(): ProductionTool<{}>[] {
return (globalTools as ProductionTool<{}>[]).map((tool) => {
if (!MUTATING_GLOBAL_TOOLS.has(tool.def.function.name)) {
return tool;
}
return {
...tool,
requiresConfirmation: false,
validateBeforeConfirmation: undefined,
fn: async () =>
JSON.stringify(
{
success: false,
error:
"This mutating workspace tool is disabled during ai_evals global mode.",
},
null,
2,
),
};
});
}
@@ -14,7 +14,6 @@ import { createScriptFileHelpers, type ScriptEvalState } from "./fileHelpers";
import { runEval } from "../shared";
import type { ModeRunContext } from "../../../../core/types";
import type { TokenUsage, ToolCallDetail } from "../shared/types";
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
export interface ScriptEvalResult {
@@ -33,8 +32,7 @@ export interface ScriptEvalOptions {
model?: string;
maxIterations?: number;
provider?: AIProvider;
transport?: FrontendEvalTransport;
backend?: WindmillBackendSettings;
backend: WindmillBackendSettings;
workspaceRoot?: string;
runContext?: ModeRunContext;
}
@@ -98,10 +96,9 @@ export async function runScriptEval(
model,
workspace: workspaceRoot,
provider: modelProvider.provider,
transport: options.transport,
backend: options.backend,
proxyCaseId: options.runContext?.caseId,
proxyAttempt: options.runContext?.attempt,
caseId: options.runContext?.caseId,
attempt: options.runContext?.attempt,
},
});
@@ -40,8 +40,8 @@ export interface RunEvalParams<THelpers, TOutput> {
apiKey: string;
/** Function to get the current output state */
getOutput: () => TOutput;
/** Optional configuration */
options?: EvalRunnerOptions;
/** Model and Windmill backend configuration */
options: EvalRunnerOptions;
onAssistantMessageStart?: () => void;
onAssistantToken?: (token: string) => void;
onAssistantMessageEnd?: () => void;
@@ -70,10 +70,10 @@ export async function runEval<THelpers, TOutput>(
} = params;
let shouldEmitMessageStart = true;
const model = options?.model ?? "gpt-4o";
const maxIterations = options?.maxIterations ?? 20;
const workspace = options?.workspace ?? "test-workspace";
const provider = toFrontendEvalProvider(options?.provider);
const model = options.model ?? "gpt-4o";
const maxIterations = options.maxIterations ?? 20;
const workspace = options.workspace ?? "test-workspace";
const provider = toFrontendEvalProvider(options.provider);
const modelProvider = resolveEvalModelProvider(model, provider);
@@ -203,45 +203,31 @@ export async function runEval<THelpers, TOutput>(
}
};
if (options?.transport === "proxy") {
const backendSettings = options.backend;
if (!backendSettings) {
throw new Error("Missing backend settings for proxy transport");
}
const backendClient = new WindmillBackendClient(backendSettings);
return await backendClient.withWorkspace(
options.proxyCaseId ?? "eval",
options.proxyAttempt ?? 1,
async (proxyWorkspaceId) => {
const resourcePath = buildProxyResourcePath(modelProvider.provider);
await backendClient.upsertResource({
workspaceId: proxyWorkspaceId,
path: resourcePath,
resourceType: modelProvider.provider,
value: { api_key: apiKey },
});
const token = await backendClient.getToken();
const clients = createEvalClients({
provider: modelProvider.provider,
apiKey,
transport: "proxy",
proxy: {
baseURL: `${backendSettings.baseUrl}/api/w/${encodeURIComponent(proxyWorkspaceId)}/ai/proxy`,
bearerToken: token,
resourcePath,
},
}) as unknown as ChatClients;
return await executeChatLoop(clients);
},
);
}
const clients = createEvalClients({
provider: modelProvider.provider,
apiKey,
}) as unknown as ChatClients;
return await executeChatLoop(clients);
const backendSettings = options.backend;
const backendClient = new WindmillBackendClient(backendSettings);
return await backendClient.withWorkspace(
options.caseId ?? "eval",
options.attempt ?? 1,
async (proxyWorkspaceId) => {
const resourcePath = buildProxyResourcePath(modelProvider.provider);
await backendClient.upsertResource({
workspaceId: proxyWorkspaceId,
path: resourcePath,
resourceType: modelProvider.provider,
value: { api_key: apiKey },
});
const token = await backendClient.getToken();
const clients = createEvalClients({
provider: modelProvider.provider,
proxy: {
baseURL: `${backendSettings.baseUrl}/api/w/${encodeURIComponent(proxyWorkspaceId)}/ai/proxy`,
bearerToken: token,
resourcePath,
},
}) as unknown as ChatClients;
return await executeChatLoop(clients);
},
);
}
function toFrontendEvalProvider(
@@ -250,7 +236,8 @@ function toFrontendEvalProvider(
if (
provider === "anthropic" ||
provider === "openai" ||
provider === "googleai"
provider === "googleai" ||
provider === "deepseek"
) {
return provider;
}
@@ -2,35 +2,9 @@ import { describe, expect, it } from "bun:test";
import {
buildProxyHeaders,
buildProxyResourcePath,
buildOpenAICompatibleClientOptions,
resolveEvalModelProvider,
} from "./providerConfig";
describe("buildOpenAICompatibleClientOptions", () => {
it("adds Gemini's OpenAI-compatible base URL and client header", () => {
const options = buildOpenAICompatibleClientOptions(
"googleai",
"gemini-test-key",
);
expect(options).toMatchObject({
apiKey: "gemini-test-key",
baseURL: "https://generativelanguage.googleapis.com/v1beta/openai/",
defaultHeaders: {
"x-goog-api-client": "windmill-ai-evals/1.0",
},
});
});
it("keeps the default OpenAI-compatible config for OpenAI", () => {
expect(
buildOpenAICompatibleClientOptions("openai", "openai-test-key"),
).toEqual({
apiKey: "openai-test-key",
});
});
});
describe("proxy helpers", () => {
it("builds provider-scoped proxy resource paths", () => {
expect(buildProxyResourcePath("googleai")).toBe("f/evals/ai/googleai");
@@ -53,6 +27,13 @@ describe("resolveEvalModelProvider", () => {
});
});
it("infers deepseek from DeepSeek model ids", () => {
expect(resolveEvalModelProvider("deepseek-v4-flash")).toEqual({
provider: "deepseek",
model: "deepseek-v4-flash",
});
});
it("preserves an explicit provider", () => {
expect(resolveEvalModelProvider("gemini-2.5-pro", "googleai")).toEqual({
provider: "googleai",
@@ -1,7 +1,6 @@
import Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
import type { FrontendEvalModelConfig } from "../../../../core/models";
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
export type FrontendEvalProvider = FrontendEvalModelConfig["provider"];
@@ -15,15 +14,12 @@ export interface ResolvedEvalModelProvider {
model: string;
}
export interface EvalProxyClientConfig {
export interface WindmillAiProxyClientConfig {
baseURL: string;
bearerToken: string;
resourcePath: string;
}
const GEMINI_OPENAI_BASE_URL =
"https://generativelanguage.googleapis.com/v1beta/openai/";
const GEMINI_GOOG_API_CLIENT = "windmill-ai-evals/1.0";
const EVAL_PROXY_RESOURCE_PREFIX = "f/evals/ai";
export function buildProxyHeaders(
@@ -40,25 +36,8 @@ export function buildProxyResourcePath(provider: FrontendEvalProvider): string {
return `${EVAL_PROXY_RESOURCE_PREFIX}/${provider}`;
}
export function buildOpenAICompatibleClientOptions(
provider: Exclude<FrontendEvalProvider, "anthropic">,
apiKey: string,
): ConstructorParameters<typeof OpenAI>[0] {
if (provider === "googleai") {
return {
apiKey,
baseURL: GEMINI_OPENAI_BASE_URL,
defaultHeaders: {
"x-goog-api-client": GEMINI_GOOG_API_CLIENT,
},
};
}
return { apiKey };
}
function buildProxyOpenAIClientOptions(
proxy: EvalProxyClientConfig,
proxy: WindmillAiProxyClientConfig,
): ConstructorParameters<typeof OpenAI>[0] {
return {
apiKey: "unused",
@@ -69,52 +48,24 @@ function buildProxyOpenAIClientOptions(
export function createEvalClients(input: {
provider: FrontendEvalProvider;
apiKey: string;
transport?: FrontendEvalTransport;
proxy?: EvalProxyClientConfig;
proxy: WindmillAiProxyClientConfig;
}): EvalClients {
const transport = input.transport ?? "direct";
if (input.provider === "anthropic") {
if (transport === "proxy") {
if (!input.proxy) {
throw new Error(
"Missing proxy client configuration for proxy transport",
);
}
return {
openai: new OpenAI({ apiKey: "unused" }),
anthropic: new Anthropic({
apiKey: "unused",
baseURL: input.proxy.baseURL,
defaultHeaders: buildProxyHeaders(
input.proxy.bearerToken,
input.proxy.resourcePath,
),
}),
};
}
return {
openai: new OpenAI({ apiKey: "unused" }),
anthropic: new Anthropic({ apiKey: input.apiKey }),
};
}
if (transport === "proxy") {
if (!input.proxy) {
throw new Error("Missing proxy client configuration for proxy transport");
}
return {
openai: new OpenAI(buildProxyOpenAIClientOptions(input.proxy)),
anthropic: new Anthropic({ apiKey: "unused" }),
anthropic: new Anthropic({
apiKey: "unused",
baseURL: input.proxy.baseURL,
defaultHeaders: buildProxyHeaders(
input.proxy.bearerToken,
input.proxy.resourcePath,
),
}),
};
}
return {
openai: new OpenAI(
buildOpenAICompatibleClientOptions(input.provider, input.apiKey),
),
openai: new OpenAI(buildProxyOpenAIClientOptions(input.proxy)),
anthropic: new Anthropic({ apiKey: "unused" }),
};
}
@@ -132,6 +83,9 @@ export function resolveEvalModelProvider(
if (model.startsWith("gemini")) {
return { provider: "googleai", model };
}
if (model.startsWith("deepseek")) {
return { provider: "deepseek", model };
}
if (model.startsWith("gpt") || model.startsWith("o")) {
return { provider: "openai", model };
}
@@ -1,6 +1,5 @@
import type { ChatCompletionMessageParam } from "openai/resources/chat/completions.mjs";
import type { AIProvider } from "$lib/gen/types.gen";
import type { FrontendEvalTransport } from "../../../../core/frontendTransport";
import type { WindmillBackendSettings } from "../../../../core/windmillBackendSettings";
export interface TokenUsage {
@@ -15,14 +14,13 @@ export interface ToolCallDetail {
}
export interface EvalRunnerOptions {
backend: WindmillBackendSettings;
maxIterations?: number;
model?: string;
workspace?: string;
provider?: AIProvider;
transport?: FrontendEvalTransport;
backend?: WindmillBackendSettings;
proxyCaseId?: string;
proxyAttempt?: number;
caseId?: string;
attempt?: number;
}
export interface RawEvalResult<TOutput> {
+1 -1
View File
@@ -1,4 +1,4 @@
export type FrontendBenchmarkProgressSurface = 'flow' | 'app' | 'script'
export type FrontendBenchmarkProgressSurface = 'flow' | 'app' | 'script' | 'global'
export type FrontendBenchmarkProgressEvent =
| {
+1 -6
View File
@@ -16,14 +16,13 @@ const FRONTEND_BENCHMARK_TEST =
const FRONTEND_BENCHMARK_CONFIG =
"../ai_evals/adapters/frontend/vitest.config.ts";
export type FrontendMode = "flow" | "app" | "script";
export type FrontendMode = "flow" | "app" | "script" | "global";
export async function runFrontendBenchmarkAdapter(input: {
mode: FrontendMode;
caseIds: string[];
runs: number;
model?: string;
transport?: string;
verbose?: boolean;
backendValidation?: string;
}): Promise<BenchmarkRunResult> {
@@ -44,10 +43,6 @@ export async function runFrontendBenchmarkAdapter(input: {
WMILL_FRONTEND_AI_EVAL_BACKEND_VALIDATION: input.backendValidation ?? "",
};
if (input.transport) {
env.WMILL_FRONTEND_AI_EVAL_TRANSPORT = input.transport;
}
try {
await runVitestBenchmark(
path.join(FRONTEND_DIR, "node_modules", ".bin", "vitest"),
@@ -65,6 +65,10 @@ vi.mock('$lib/gen', async () => {
hasBenchmarkWorkspace(data.workspace)
? (listBenchmarkScripts(data.workspace) ?? [])
: actual.ScriptService.listScripts(data),
existsScriptByPath: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace)
? Boolean(getBenchmarkScriptByPath(data.workspace, data.path))
: actual.ScriptService.existsScriptByPath(data),
getScriptByPath: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
const script = getBenchmarkScriptByPath(data.workspace, data.path)
@@ -91,6 +95,10 @@ vi.mock('$lib/gen', async () => {
hasBenchmarkWorkspace(data.workspace)
? (listBenchmarkFlows(data.workspace) ?? [])
: actual.FlowService.listFlows(data),
existsFlowByPath: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace)
? Boolean(getBenchmarkFlowByPath(data.workspace, data.path))
: actual.FlowService.existsFlowByPath(data),
getFlowByPath: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
const flow = getBenchmarkFlowByPath(data.workspace, data.path)
@@ -142,6 +150,16 @@ vi.mock('$lib/gen', async () => {
}
}),
ScheduleService: wrapService(actual.ScheduleService, {
existsSchedule: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.ScheduleService.existsSchedule(data),
listSchedules: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.ScheduleService.listSchedules(data),
getSchedule: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`Schedule "${data.path}" not found in benchmark workspace`)
}
return actual.ScheduleService.getSchedule(data)
},
previewSchedule: async (data: { requestBody?: Record<string, unknown> }) =>
previewBenchmarkSchedule(data),
createSchedule: async (data: { workspace: string; requestBody: Record<string, unknown> }) =>
@@ -149,11 +167,167 @@ vi.mock('$lib/gen', async () => {
? createBenchmarkSchedule(data)
: actual.ScheduleService.createSchedule(data)
}),
ResourceService: wrapService(actual.ResourceService, {
existsResource: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.ResourceService.existsResource(data),
listResource: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.listResource(data),
getResource: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`Resource "${data.path}" not found in benchmark workspace`)
}
return actual.ResourceService.getResource(data)
},
queryResourceTypes: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.queryResourceTypes(data)
}),
VariableService: wrapService(actual.VariableService, {
existsVariable: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.VariableService.existsVariable(data),
listVariable: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.VariableService.listVariable(data),
getVariable: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`Variable "${data.path}" not found in benchmark workspace`)
}
return actual.VariableService.getVariable(data)
}
}),
AppService: wrapService(actual.AppService, {
existsApp: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.AppService.existsApp(data),
listApps: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.AppService.listApps(data),
getAppByPath: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`App "${data.path}" not found in benchmark workspace`)
}
return actual.AppService.getAppByPath(data)
}
}),
HttpTriggerService: wrapService(actual.HttpTriggerService, {
existsHttpTrigger: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.HttpTriggerService.existsHttpTrigger(data),
listHttpTriggers: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.HttpTriggerService.listHttpTriggers(data),
getHttpTrigger: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`HTTP trigger "${data.path}" not found in benchmark workspace`)
}
return actual.HttpTriggerService.getHttpTrigger(data)
},
createHttpTrigger: async (data: { workspace: string; requestBody: Record<string, unknown> }) =>
hasBenchmarkWorkspace(data.workspace)
? createBenchmarkHttpTrigger(data)
: actual.HttpTriggerService.createHttpTrigger(data)
}),
WebsocketTriggerService: wrapService(actual.WebsocketTriggerService, {
existsWebsocketTrigger: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace)
? false
: actual.WebsocketTriggerService.existsWebsocketTrigger(data),
listWebsocketTriggers: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace)
? []
: actual.WebsocketTriggerService.listWebsocketTriggers(data),
getWebsocketTrigger: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`Websocket trigger "${data.path}" not found in benchmark workspace`)
}
return actual.WebsocketTriggerService.getWebsocketTrigger(data)
}
}),
KafkaTriggerService: wrapService(actual.KafkaTriggerService, {
existsKafkaTrigger: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace)
? false
: actual.KafkaTriggerService.existsKafkaTrigger(data),
listKafkaTriggers: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.KafkaTriggerService.listKafkaTriggers(data),
getKafkaTrigger: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`Kafka trigger "${data.path}" not found in benchmark workspace`)
}
return actual.KafkaTriggerService.getKafkaTrigger(data)
}
}),
NatsTriggerService: wrapService(actual.NatsTriggerService, {
existsNatsTrigger: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.NatsTriggerService.existsNatsTrigger(data),
listNatsTriggers: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.NatsTriggerService.listNatsTriggers(data),
getNatsTrigger: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`NATS trigger "${data.path}" not found in benchmark workspace`)
}
return actual.NatsTriggerService.getNatsTrigger(data)
}
}),
PostgresTriggerService: wrapService(actual.PostgresTriggerService, {
existsPostgresTrigger: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace)
? false
: actual.PostgresTriggerService.existsPostgresTrigger(data),
listPostgresTriggers: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace)
? []
: actual.PostgresTriggerService.listPostgresTriggers(data),
getPostgresTrigger: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`Postgres trigger "${data.path}" not found in benchmark workspace`)
}
return actual.PostgresTriggerService.getPostgresTrigger(data)
}
}),
MqttTriggerService: wrapService(actual.MqttTriggerService, {
existsMqttTrigger: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.MqttTriggerService.existsMqttTrigger(data),
listMqttTriggers: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.MqttTriggerService.listMqttTriggers(data),
getMqttTrigger: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`MQTT trigger "${data.path}" not found in benchmark workspace`)
}
return actual.MqttTriggerService.getMqttTrigger(data)
}
}),
SqsTriggerService: wrapService(actual.SqsTriggerService, {
existsSqsTrigger: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.SqsTriggerService.existsSqsTrigger(data),
listSqsTriggers: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.SqsTriggerService.listSqsTriggers(data),
getSqsTrigger: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`SQS trigger "${data.path}" not found in benchmark workspace`)
}
return actual.SqsTriggerService.getSqsTrigger(data)
}
}),
GcpTriggerService: wrapService(actual.GcpTriggerService, {
existsGcpTrigger: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.GcpTriggerService.existsGcpTrigger(data),
listGcpTriggers: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.GcpTriggerService.listGcpTriggers(data),
getGcpTrigger: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`GCP trigger "${data.path}" not found in benchmark workspace`)
}
return actual.GcpTriggerService.getGcpTrigger(data)
}
}),
AzureTriggerService: wrapService(actual.AzureTriggerService, {
existsAzureTrigger: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace)
? false
: actual.AzureTriggerService.existsAzureTrigger(data),
listAzureTriggers: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace) ? [] : actual.AzureTriggerService.listAzureTriggers(data),
getAzureTrigger: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
throw new Error(`Azure trigger "${data.path}" not found in benchmark workspace`)
}
return actual.AzureTriggerService.getAzureTrigger(data)
}
})
}
})
@@ -0,0 +1,104 @@
import { afterEach, describe, expect, it } from "bun:test";
import type { WindmillBackendSettings } from "../../core/windmillBackendSettings";
import {
WindmillBackendClient,
assertWindmillBackendReachable,
} from "./windmillBackend";
const ORIGINAL_FETCH = globalThis.fetch;
afterEach(() => {
globalThis.fetch = ORIGINAL_FETCH;
});
describe("assertWindmillBackendReachable", () => {
it("logs in to verify backend reachability", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
globalThis.fetch = mockFetch(requests, textResponse(200, "token"));
await expect(
assertWindmillBackendReachable(
buildSettings({ baseUrl: "http://backend.test/reachable" }),
),
).resolves.toBeUndefined();
expect(requests.map((entry) => entry.url)).toEqual([
"http://backend.test/reachable/api/auth/login",
]);
});
it("adds setup guidance when the backend cannot be initialized", async () => {
globalThis.fetch = mockFetch(
[],
textResponse(401, "invalid password"),
);
await expect(
assertWindmillBackendReachable(
buildSettings({ baseUrl: "http://backend.test/auth-failure" }),
),
).rejects.toThrow(
"Start a Windmill backend at that URL, or set WMILL_AI_EVAL_BACKEND_URL=<url>.",
);
});
});
describe("WindmillBackendClient", () => {
it("creates or reuses the specified backend workspace without deleting it", async () => {
const requests: Array<{ url: string; init?: RequestInit }> = [];
globalThis.fetch = mockFetch(
requests,
textResponse(200, "token"),
textResponse(200, "false"),
textResponse(200, ""),
);
const client = new WindmillBackendClient(
buildSettings({
baseUrl: "http://backend.test/shared-workspace",
workspaceOverride: "shared-evals",
}),
);
await expect(
client.withWorkspace("case-a", 1, async (workspaceId) => workspaceId),
).resolves.toBe("shared-evals");
expect(requests.map((entry) => entry.url)).toEqual([
"http://backend.test/shared-workspace/api/auth/login",
"http://backend.test/shared-workspace/api/workspaces/exists",
"http://backend.test/shared-workspace/api/workspaces/create",
]);
});
});
function buildSettings(
overrides: Partial<WindmillBackendSettings> = {},
): WindmillBackendSettings {
return {
baseUrl: "http://backend.test/default",
email: "admin@windmill.dev",
password: "changeme",
...overrides,
};
}
function mockFetch(
requests: Array<{ url: string; init?: RequestInit }>,
...responses: Response[]
): typeof fetch {
const queue = [...responses];
return async (input, init) => {
const url = String(input);
requests.push({ url, init });
const next = queue.shift();
if (!next) {
throw new Error(`Unexpected fetch: ${url}`);
}
return next;
};
}
function textResponse(status: number, body: string): Response {
return new Response(body, { status });
}
+23 -8
View File
@@ -3,6 +3,7 @@ import type { WindmillBackendSettings } from "../../core/windmillBackendSettings
const tokenCache = new Map<string, Promise<string>>();
const sharedWorkspaceQueue = new Map<string, Promise<void>>();
const DEFAULT_WORKSPACE_PREFIX = "ai-evals";
export class WindmillBackendClient {
constructor(private readonly settings: WindmillBackendSettings) {}
@@ -14,7 +15,7 @@ export class WindmillBackendClient {
): Promise<T> {
const workspaceId =
this.settings.workspaceOverride ??
buildWorkspaceId(this.settings.workspacePrefix, caseId, attempt);
buildWorkspaceId(caseId, attempt);
const run = async () => {
await this.ensureWorkspace(workspaceId);
@@ -22,7 +23,7 @@ export class WindmillBackendClient {
try {
return await body(workspaceId);
} finally {
if (!this.settings.keepWorkspaces && !this.settings.workspaceOverride) {
if (!this.settings.workspaceOverride) {
await this.deleteWorkspace(workspaceId).catch(() => undefined);
}
}
@@ -136,6 +137,24 @@ export class WindmillBackendClient {
}
}
export async function assertWindmillBackendReachable(
settings: WindmillBackendSettings,
): Promise<void> {
try {
await new WindmillBackendClient(settings).getToken();
} catch (error) {
const details = error instanceof Error ? error.message : String(error);
throw new Error(
[
`Could not initialize the Windmill backend for AI eval proxy at ${settings.baseUrl}.`,
"Start a Windmill backend at that URL, or set WMILL_AI_EVAL_BACKEND_URL=<url>.",
`Using login ${settings.email}; if authentication failed, set WMILL_AI_EVAL_BACKEND_EMAIL and WMILL_AI_EVAL_BACKEND_PASSWORD.`,
`Details: ${details}`,
].join("\n"),
);
}
}
async function withSharedWorkspaceLock<T>(
workspaceId: string,
body: () => Promise<T>,
@@ -160,18 +179,14 @@ async function withSharedWorkspaceLock<T>(
}
}
function buildWorkspaceId(
prefix: string,
caseId: string,
attempt: number,
): string {
function buildWorkspaceId(caseId: string, attempt: number): string {
const caseSlug = caseId
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 30);
const suffix = randomUUID().slice(0, 8);
return `${prefix}-${caseSlug || "case"}-a${attempt}-${suffix}`;
return `${DEFAULT_WORKSPACE_PREFIX}-${caseSlug || "case"}-a${attempt}-${suffix}`;
}
async function expectOk(response: Response, context: string): Promise<void> {
+89
View File
@@ -0,0 +1,89 @@
- id: global-test1-script-create
prompt: |-
Create a draft Bun script at `f/evals/global/greet_user`.
It should take a string `name` input and return `Hello, ${name}!`.
Leave it as an AI draft only; do not deploy or save it.
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
requiredDrafts:
- type: script
path: f/evals/global/greet_user
language: bun
valueIncludes:
- name
- Hello
toolExpect:
requiredToolsUsed:
- write_script
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- creates a Bun script draft at f/evals/global/greet_user
- the script accepts a name input
- the script returns a greeting containing Hello, the provided name, and an exclamation mark
- the result stays as an AI draft and is not deployed or saved to the workspace
- id: global-test2-script-edit-existing
prompt: |-
Update the existing workspace script at `f/evals/global/format_greeting`.
Keep it as a Bun script, but change the greeting so the provided name is uppercased and the returned message ends with an exclamation mark.
Leave the result as an AI draft only; do not deploy or save it.
initial: ai_evals/fixtures/frontend/global/initial/format_greeting_script.json
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
requiredDrafts:
- type: script
path: f/evals/global/format_greeting
language: bun
valueIncludes:
- toUpperCase
- "!"
toolExpect:
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
judgeChecklist:
- creates an AI draft for the existing f/evals/global/format_greeting script
- preserves the script as Bun
- uppercases the provided name in the greeting
- returns a message ending with an exclamation mark
- does not deploy or save the draft to the workspace
- id: global-test3-flow-create
prompt: |-
Create a draft flow at `f/evals/global/sum_numbers`.
It should take two numeric inputs, `a` and `b`, and return their sum.
Leave it as an AI draft only; do not deploy or save it.
runtime:
maxTurns: 8
validate:
draftCountExactly: 1
requiredDrafts:
- type: flow
path: f/evals/global/sum_numbers
valueIncludes:
- modules
- rawscript
- flow_input.a
- flow_input.b
toolExpect:
requiredToolsUsed:
- write_flow
forbiddenToolsUsed:
- deploy_workspace_item
- delete_workspace_item
toolCallArgs:
- tool: write_flow
field: modules
stringStartsWithAnyOf:
- "["
judgeChecklist:
- creates a flow draft at f/evals/global/sum_numbers
- the flow accepts numeric inputs a and b
- the flow returns the sum of a and b
- the result stays as an AI draft and is not deployed or saved to the workspace
+9 -23
View File
@@ -27,11 +27,8 @@ import { EVAL_MODES, type EvalMode } from "../core/types";
import { DEFAULT_JUDGE_MODEL } from "../core/judge";
import { createCliModeRunner } from "../modes/cli";
import { runFrontendBenchmarkAdapter } from "../adapters/frontend/runtime";
import {
FRONTEND_EVAL_TRANSPORTS,
type FrontendEvalTransport,
parseFrontendEvalTransport,
} from "../core/frontendTransport";
import { resolveWindmillBackendSettings } from "../core/windmillBackendSettings";
import { assertWindmillBackendReachable } from "../adapters/frontend/windmillBackend";
async function main() {
const program = new Command()
@@ -56,6 +53,7 @@ async function main() {
" bun run cli -- run flow --record",
" bun run cli -- run flow --backend-validation preview",
" bun run cli -- run flow flow-test5-simple-modification --runs 3",
" bun run cli -- run global global-test1-script-create",
" bun run cli -- run cli bun-hello-script",
"",
"Models:",
@@ -73,7 +71,7 @@ async function main() {
program
.command("cases")
.description("List available cases")
.argument("[mode]", "cli, flow, script, or app", parseOptionalMode)
.argument("[mode]", "cli, flow, script, app, or global", parseOptionalMode)
.action(async (mode?: EvalMode) => {
await handleCases(mode);
});
@@ -81,7 +79,7 @@ async function main() {
program
.command("run")
.description("Run one benchmark mode")
.argument("<mode>", "cli, flow, script, or app", parseMode)
.argument("<mode>", "cli, flow, script, app, or global", parseMode)
.argument("[caseIds...]", "specific case ids to run")
.option(
"--runs <n>",
@@ -98,10 +96,6 @@ async function main() {
"--models <names>",
"comma-separated model aliases to run sequentially",
)
.option(
"--transport <mode>",
`frontend transport (${FRONTEND_EVAL_TRANSPORTS.join(", ")})`,
)
.option("--verbose", "stream assistant output during frontend runs")
.option(
"--record",
@@ -120,7 +114,6 @@ async function main() {
output?: string;
model?: string;
models?: string;
transport?: string;
verbose?: boolean;
record?: boolean;
backendValidation?: string;
@@ -133,9 +126,6 @@ async function main() {
outputPath: options.output,
model: options.model,
models: options.models,
transport: options.transport
? parseFrontendEvalTransport(options.transport)
: undefined,
verbose: options.verbose ?? false,
record: options.record ?? false,
backendValidation: options.backendValidation,
@@ -163,7 +153,7 @@ function handleModels() {
process.stdout.write("Available models\n");
for (const model of EVAL_MODELS) {
const supports = [
...(model.frontend ? ["flow", "script", "app"] : []),
...(model.frontend ? ["flow", "script", "app", "global"] : []),
...(model.cli ? ["cli"] : []),
];
const aliases = [
@@ -184,7 +174,6 @@ async function handleRun(input: {
outputPath?: string;
model?: string;
models?: string;
transport?: FrontendEvalTransport;
verbose: boolean;
record: boolean;
backendValidation?: string;
@@ -197,11 +186,6 @@ async function handleRun(input: {
if (input.model && input.models) {
throw new Error("Use either --model or --models, not both");
}
if (input.mode === "cli" && input.transport === "proxy") {
throw new Error(
"--transport proxy is only supported for flow, script, and app modes",
);
}
const selectedCases = await loadSelectedCases(input.mode, input.caseIds);
const models = resolveRequestedModels(input.mode, input.model, input.models);
@@ -220,6 +204,9 @@ async function handleRun(input: {
"--backend-validation currently supports only flow and script modes",
);
}
if (input.mode !== "cli") {
await assertWindmillBackendReachable(resolveWindmillBackendSettings());
}
const summaries: Array<{
label: string;
@@ -249,7 +236,6 @@ async function handleRun(input: {
caseIds: input.caseIds,
runs: input.runs,
model: model.id,
transport: input.transport,
verbose: input.verbose,
backendValidation,
});
-2
View File
@@ -13,9 +13,7 @@ export interface BackendValidationSettings {
baseUrl: string;
email: string;
password: string;
keepWorkspaces: boolean;
workspaceOverride?: string;
workspacePrefix: string;
pollIntervalMs: number;
maxWaitMs: number;
}
+20
View File
@@ -183,6 +183,26 @@ describe("loadCases", () => {
});
});
it("loads global draft validation and forbidden tool expectations", async () => {
const globalCases = await loadCases("global");
const caseEntry = globalCases.find((entry) => entry.id === "global-test1-script-create");
expect(caseEntry?.validate).toMatchObject({
draftCountExactly: 1,
requiredDrafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
},
],
});
expect(caseEntry?.toolExpect).toMatchObject({
requiredToolsUsed: ["write_script"],
forbiddenToolsUsed: ["deploy_workspace_item", "delete_workspace_item"],
});
});
it("loads tool expectations for workspace mutation cases", async () => {
const scriptCases = await loadCases("script");
const caseEntry = scriptCases.find(
-64
View File
@@ -1,64 +0,0 @@
import { afterEach, describe, expect, it } from "bun:test";
import {
parseFrontendEvalTransport,
resolveFrontendEvalTransportSettings,
} from "./frontendTransport";
const ORIGINAL_ENV = {
WMILL_AI_EVAL_BACKEND_URL: process.env.WMILL_AI_EVAL_BACKEND_URL,
};
afterEach(() => {
if (ORIGINAL_ENV.WMILL_AI_EVAL_BACKEND_URL === undefined) {
delete process.env.WMILL_AI_EVAL_BACKEND_URL;
} else {
process.env.WMILL_AI_EVAL_BACKEND_URL =
ORIGINAL_ENV.WMILL_AI_EVAL_BACKEND_URL;
}
});
describe("parseFrontendEvalTransport", () => {
it("defaults to direct when unset", () => {
expect(parseFrontendEvalTransport(undefined)).toBe("direct");
});
it("accepts proxy explicitly", () => {
expect(parseFrontendEvalTransport("proxy")).toBe("proxy");
});
it("rejects unsupported values", () => {
expect(() => parseFrontendEvalTransport("worker")).toThrow(
"Unsupported frontend eval transport: worker",
);
});
});
describe("resolveFrontendEvalTransportSettings", () => {
it("includes backend settings for proxy transport", () => {
process.env.WMILL_AI_EVAL_BACKEND_URL = "http://127.0.0.1:8000/";
expect(
resolveFrontendEvalTransportSettings({
evalMode: "app",
requestedTransport: "proxy",
}),
).toMatchObject({
transport: "proxy",
backend: {
baseUrl: "http://127.0.0.1:8000",
},
});
});
it("keeps direct transport for cli runs", () => {
expect(
resolveFrontendEvalTransportSettings({
evalMode: "cli",
requestedTransport: "direct",
}),
).toEqual({
transport: "direct",
backend: undefined,
});
});
});
-49
View File
@@ -1,49 +0,0 @@
import type { EvalMode } from "./types";
import type { WindmillBackendSettings } from "./windmillBackendSettings";
import { resolveWindmillBackendSettings } from "./windmillBackendSettings";
export const FRONTEND_EVAL_TRANSPORTS = ["direct", "proxy"] as const;
export type FrontendEvalTransport = (typeof FRONTEND_EVAL_TRANSPORTS)[number];
export interface FrontendEvalTransportSettings {
transport: FrontendEvalTransport;
backend?: WindmillBackendSettings;
}
export function parseFrontendEvalTransport(
value?: string | null,
): FrontendEvalTransport {
const normalized = value?.trim().toLowerCase();
if (!normalized || normalized === "direct") {
return "direct";
}
if (normalized === "proxy") {
return "proxy";
}
throw new Error(
`Unsupported frontend eval transport: ${value}. Use one of: ${FRONTEND_EVAL_TRANSPORTS.join(", ")}`,
);
}
export function resolveFrontendEvalTransportSettings(input: {
evalMode: EvalMode;
requestedTransport?: string | null;
}): FrontendEvalTransportSettings {
const transport = parseFrontendEvalTransport(input.requestedTransport);
if (transport === "proxy" && input.evalMode === "cli") {
throw new Error(
'Frontend eval transport "proxy" is only supported for flow, script, and app evals',
);
}
return {
transport,
backend:
transport === "proxy" ? resolveWindmillBackendSettings() : undefined,
};
}
+20 -5
View File
@@ -11,19 +11,34 @@ describe("resolveEvalModel", () => {
provider: "googleai",
model: "gemini-2.5-pro",
});
expect(resolveEvalModel("script", "gemini-3-flash-preview").frontend).toEqual({
expect(
resolveEvalModel("script", "gemini-3-flash-preview").frontend,
).toEqual({
provider: "googleai",
model: "gemini-3-flash-preview",
});
expect(resolveEvalModel("flow", "gemini-3.1-pro-preview").frontend).toEqual({
provider: "googleai",
model: "gemini-3.1-pro-preview",
expect(resolveEvalModel("flow", "gemini-3.1-pro-preview").frontend).toEqual(
{
provider: "googleai",
model: "gemini-3.1-pro-preview",
},
);
});
it("supports DeepSeek aliases for frontend evals", () => {
expect(resolveEvalModel("flow", "deepseek").frontend).toEqual({
provider: "deepseek",
model: "deepseek-v4-flash",
});
expect(resolveEvalModel("script", "deepseek-v4-pro").frontend).toEqual({
provider: "deepseek",
model: "deepseek-v4-pro",
});
});
it("rejects Gemini aliases for cli evals", () => {
expect(() => resolveEvalModel("cli", "gemini")).toThrow(
"Model gemini-flash is not supported for cli mode"
"Model gemini-flash is not supported for cli mode",
);
});
});
+39 -7
View File
@@ -1,7 +1,7 @@
import type { EvalMode } from "./types";
export interface FrontendEvalModelConfig {
provider: "anthropic" | "openai" | "googleai";
provider: "anthropic" | "openai" | "googleai" | "deepseek";
model: string;
}
@@ -117,15 +117,40 @@ export const EVAL_MODELS: EvalModelSpec[] = [
{
id: "gemini-3.1-pro-preview",
label: "Gemini 3.1 Pro Preview",
aliases: ["gemini-3.1-pro-preview", "gemini-3.1-pro", "gemini-3-pro-preview"],
aliases: [
"gemini-3.1-pro-preview",
"gemini-3.1-pro",
"gemini-3-pro-preview",
],
frontend: {
provider: "googleai",
model: "gemini-3.1-pro-preview",
},
},
{
id: "deepseek-v4-flash",
label: "DeepSeek V4 Flash",
aliases: ["deepseek", "deepseek-v4", "deepseek-v4-flash"],
frontend: {
provider: "deepseek",
model: "deepseek-v4-flash",
},
},
{
id: "deepseek-v4-pro",
label: "DeepSeek V4 Pro",
aliases: ["deepseek-pro", "deepseek-v4-pro"],
frontend: {
provider: "deepseek",
model: "deepseek-v4-pro",
},
},
];
export function resolveEvalModel(mode: EvalMode, alias?: string): EvalModelSpec {
export function resolveEvalModel(
mode: EvalMode,
alias?: string,
): EvalModelSpec {
const spec = alias ? findEvalModel(alias) : getDefaultEvalModel(mode);
if (!spec) {
throw new Error(`Unknown model: ${alias}`);
@@ -145,21 +170,26 @@ export function resolveEvalModel(mode: EvalMode, alias?: string): EvalModelSpec
export function getEvalModelHelpText(): string {
return EVAL_MODELS.map((model) => {
const modes = [
...(model.frontend ? ["flow", "script", "app"] : []),
...(model.frontend ? ["flow", "script", "app", "global"] : []),
...(model.cli ? ["cli"] : []),
];
return ` ${model.id.padEnd(8)} ${model.label} (${modes.join(", ")})`;
}).join("\n");
}
export function formatRunModelLabel(mode: EvalMode, model: EvalModelSpec): string {
export function formatRunModelLabel(
mode: EvalMode,
model: EvalModelSpec,
): string {
if (mode === "cli") {
return `${model.cli!.provider}:${model.cli!.model}`;
}
return `${model.frontend!.provider}:${model.frontend!.model}`;
}
export function getFrontendEvalModel(model: EvalModelSpec): FrontendEvalModelConfig {
export function getFrontendEvalModel(
model: EvalModelSpec,
): FrontendEvalModelConfig {
if (!model.frontend) {
throw new Error(`Model ${model.id} does not support frontend evals`);
}
@@ -180,6 +210,8 @@ function getDefaultEvalModel(mode: EvalMode): EvalModelSpec {
function findEvalModel(alias: string): EvalModelSpec | undefined {
const normalized = alias.trim().toLowerCase();
return EVAL_MODELS.find((model) =>
[model.id, ...model.aliases].some((candidate) => candidate.toLowerCase() === normalized)
[model.id, ...model.aliases].some(
(candidate) => candidate.toLowerCase() === normalized,
),
);
}
-6
View File
@@ -74,7 +74,6 @@ export function buildRunResult(input: {
mode: EvalMode;
runs: number;
runModel: string | null;
transport?: BenchmarkRunResult["transport"];
judgeModel: string | null;
caseResults: BenchmarkCaseResult[];
}): BenchmarkRunResult {
@@ -116,7 +115,6 @@ export function buildRunResult(input: {
gitSha: getGitSha(),
runs: input.runs,
runModel: input.runModel,
transport: input.transport ?? null,
judgeModel: input.judgeModel,
caseCount: input.caseResults.length,
attemptCount,
@@ -142,9 +140,6 @@ export function formatRunSummary(result: BenchmarkRunResult): string {
`Pass rate: ${formatPercent(result.passRate)} (${result.passedAttempts}/${result.attemptCount})`,
`Average duration: ${Math.round(result.averageDurationMs)}ms`,
];
if (result.transport) {
lines.splice(1, 0, `Transport: ${result.transport}`);
}
const failures = collectFailures(result);
if (failures.length > 0) {
@@ -251,7 +246,6 @@ function toHistoryRecord(result: BenchmarkRunResult) {
mode: result.mode,
runs: result.runs,
runModel: result.runModel,
transport: result.transport,
judgeModel: result.judgeModel,
caseCount: result.caseCount,
attemptCount: result.attemptCount,
+24 -4
View File
@@ -1,7 +1,6 @@
export const EVAL_MODES = ["cli", "flow", "script", "app"] as const;
export const EVAL_MODES = ["cli", "flow", "script", "app", "global"] as const;
export type EvalMode = (typeof EVAL_MODES)[number];
export type FrontendEvalTransport = "direct" | "proxy";
export interface EvalCaseRuntimeBackendPreview {
args?: Record<string, unknown>;
@@ -109,6 +108,27 @@ export interface AppValidationSpec {
forbiddenAppContent?: string[];
}
export interface GlobalDraftRequirement {
type: string;
path: string;
triggerKind?: string;
language?: string;
summaryIncludes?: string[];
valueIncludes?: string[];
valueExcludes?: string[];
}
export interface GlobalValidationSpec {
draftCountAtLeast?: number;
draftCountExactly?: number;
requiredDrafts?: GlobalDraftRequirement[];
forbiddenDrafts?: Array<{
type: string;
path: string;
triggerKind?: string;
}>;
}
export interface CliValidationSpec {
requiredSkills?: string[];
forbiddenSkills?: string[];
@@ -137,10 +157,11 @@ export interface ToolCallArgumentRule {
export interface ToolValidationSpec {
requiredToolsUsed?: string[];
forbiddenToolsUsed?: string[];
toolCallArgs?: ToolCallArgumentRule[];
}
export type EvalValidationSpec = FlowValidationSpec | AppValidationSpec;
export type EvalValidationSpec = FlowValidationSpec | AppValidationSpec | GlobalValidationSpec;
export interface EvalCase {
id: string;
@@ -297,7 +318,6 @@ export interface BenchmarkRunResult {
gitSha: string | null;
runs: number;
runModel: string | null;
transport: FrontendEvalTransport | null;
judgeModel: string | null;
caseCount: number;
attemptCount: number;
+225
View File
@@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test";
import {
validateAppState,
validateCliWorkspace,
validateGlobalState,
validateScriptState,
validateToolExpectations,
} from "./validators";
@@ -117,6 +118,230 @@ describe("validateToolExpectations", () => {
details: 'rejected prefixes: schedules/; values: "schedules/greet_user_daily"',
});
});
it("rejects forbidden tool usage", () => {
const checks = validateToolExpectations({
run: {
success: true,
actual: {},
assistantMessageCount: 1,
toolCallCount: 1,
toolsUsed: ["write_script", "deploy_workspace_item"],
skillsInvoked: [],
},
toolExpect: {
forbiddenToolsUsed: ["deploy_workspace_item"],
},
});
expect(checks).toContainEqual({
name: "does not use deploy_workspace_item",
passed: false,
details: "tools used: write_script, deploy_workspace_item",
});
});
});
describe("validateGlobalState", () => {
it("accepts a required script draft", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
validate: {
draftCountExactly: 1,
requiredDrafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
valueIncludes: ["Hello"],
},
],
},
});
expect(checks.every((check) => check.passed)).toBe(true);
});
it("fails when a required draft is missing", () => {
const checks = validateGlobalState({
actual: {
drafts: [],
},
validate: {
requiredDrafts: [
{
type: "script",
path: "f/evals/global/greet_user",
},
],
},
});
expect(checks).toContainEqual({
name: "global includes script draft f/evals/global/greet_user",
passed: false,
details: "drafts: none",
});
});
it("does not require a TypeScript entrypoint for non-TypeScript script drafts", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_python",
language: "python3",
value: "def main(name: str):\n return f'Hello, {name}!'\n",
isDraft: true,
},
],
},
});
expect(checks.some((check) => check.name.includes("exports entrypoint"))).toBe(
false
);
expect(checks.every((check) => check.passed)).toBe(true);
});
it("allows read-only global cases without draft expectations", () => {
const checks = validateGlobalState({
actual: {
drafts: [],
},
});
expect(
checks.some(
(check) => check.name === "global produced at least one draft"
)
).toBe(false);
expect(checks.every((check) => check.passed)).toBe(true);
});
it("matches expected global draft fixtures", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
value:
"export async function main(name: string) {\r\n return `Hello, ${name}!`\r\n}\r\n",
isDraft: true,
},
],
},
expected: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
});
expect(checks).toContainEqual({
name: "global drafts match expected",
passed: true,
});
});
it("fails when expected global draft fixtures differ", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
expected: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
value:
"export async function main(name: string) {\n return `Bonjour, ${name}!`\n}\n",
isDraft: true,
},
],
},
});
const expectedMatchCheck = checks.find(
(check) => check.name === "global drafts match expected"
);
expect(expectedMatchCheck?.passed).toBe(false);
expect(expectedMatchCheck?.details).toContain(
"script:f/evals/global/greet_user value differs"
);
expect(expectedMatchCheck?.details).toContain("Hello");
expect(expectedMatchCheck?.details).toContain("Bonjour");
});
it("explains expected global draft metadata mismatches", () => {
const checks = validateGlobalState({
actual: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "bun",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
expected: {
drafts: [
{
type: "script",
path: "f/evals/global/greet_user",
language: "python3",
value:
"export async function main(name: string) {\n return `Hello, ${name}!`\n}\n",
isDraft: true,
},
],
},
});
const expectedMatchCheck = checks.find(
(check) => check.name === "global drafts match expected"
);
expect(expectedMatchCheck?.passed).toBe(false);
expect(expectedMatchCheck?.details).toContain(
"script:f/evals/global/greet_user language differs"
);
expect(expectedMatchCheck?.details).toContain('actual="bun"');
expect(expectedMatchCheck?.details).toContain('expected="python3"');
});
});
describe("validateAppState", () => {
+376
View File
@@ -6,6 +6,7 @@ import type {
CliTrace,
CliValidationSpec,
FlowValidationSpec,
GlobalValidationSpec,
ModeRunOutput,
ToolValidationSpec,
} from "./types";
@@ -51,6 +52,20 @@ export interface AppDatatableState {
error?: string;
}
export interface GlobalDraftState {
drafts: GlobalDraft[];
}
export interface GlobalDraft {
type: string;
path: string;
triggerKind?: string;
summary?: string;
language?: string;
value?: unknown;
isDraft?: boolean;
}
const TS_LIKE_LANGUAGES = new Set(["bun", "deno", "nativets", "bunnative", "ts", "typescript"]);
const CONTROL_FLOW_MODULE_TYPES = new Set(["branchone", "branchall", "forloopflow", "whileloopflow"]);
@@ -154,6 +169,16 @@ export function validateToolExpectations(input: {
);
}
for (const toolName of expect.forbiddenToolsUsed ?? []) {
checks.push(
check(
`does not use ${toolName}`,
!input.run.toolsUsed.includes(toolName),
`tools used: ${input.run.toolsUsed.join(", ") || "none"}`
)
);
}
for (const rule of expect.toolCallArgs ?? []) {
const calls = toolCallDetails.filter((call) => call.name === rule.tool);
checks.push(
@@ -202,6 +227,161 @@ export function validateToolExpectations(input: {
return checks;
}
export function validateGlobalState(input: {
actual: GlobalDraftState;
expected?: GlobalDraftState;
validate?: GlobalValidationSpec;
}): BenchmarkCheck[] {
const drafts = input.actual.drafts ?? [];
const checks: BenchmarkCheck[] = [];
// Read-only global cases are valid; only enforce draft production when the
// case explicitly asks for draft output.
if (globalValidationExpectsDrafts(input)) {
checks.push(
check(
"global produced at least one draft",
drafts.length > 0,
`drafts=${drafts.length}`
)
);
}
checks.push(
check(
"all global outputs are drafts",
drafts.every((draft) => draft.isDraft === true),
summarizeGlobalDrafts(drafts)
)
);
for (const draft of drafts) {
if (draft.type !== "script" || typeof draft.value !== "string") {
continue;
}
const language = (draft.language ?? "bun").toLowerCase();
const syntaxErrors = getScriptSyntaxErrors(draft.value, language);
if (TS_LIKE_LANGUAGES.has(language)) {
checks.push(
check(
`script draft ${draft.path} exports entrypoint`,
hasSupportedEntrypoint(draft.value)
)
);
}
checks.push(
check(
`script draft ${draft.path} has no syntax errors`,
syntaxErrors.length === 0,
summarizeProblems(syntaxErrors)
)
);
}
if (input.expected) {
checks.push(
check(
"global drafts match expected",
globalDraftStatesEqual(input.actual, input.expected),
describeGlobalDraftStateMismatch(input.actual, input.expected)
)
);
}
const validate = input.validate;
if (!validate) {
return checks;
}
if (validate.draftCountAtLeast !== undefined) {
checks.push(
check(
`global includes at least ${validate.draftCountAtLeast} draft(s)`,
drafts.length >= validate.draftCountAtLeast,
`drafts=${drafts.length}`
)
);
}
if (validate.draftCountExactly !== undefined) {
checks.push(
check(
`global includes exactly ${validate.draftCountExactly} draft(s)`,
drafts.length === validate.draftCountExactly,
`drafts=${drafts.length}`
)
);
}
for (const required of validate.requiredDrafts ?? []) {
const draft = findGlobalDraft(drafts, required.type, required.path, required.triggerKind);
checks.push(
check(
`global includes ${required.type} draft ${required.path}`,
Boolean(draft),
summarizeGlobalDrafts(drafts)
)
);
if (!draft) {
continue;
}
if (required.language !== undefined) {
checks.push(
check(
`${required.type} draft ${required.path} uses ${required.language}`,
draft.language === required.language,
`language=${draft.language ?? "(none)"}`
)
);
}
for (const snippet of required.summaryIncludes ?? []) {
checks.push(
check(
`${required.type} draft ${required.path} summary includes '${snippet}'`,
normalizeText(draft.summary ?? "").includes(normalizeText(snippet)),
`summary=${draft.summary ?? ""}`
)
);
}
const valueText = stringifyGlobalDraftValue(draft.value);
for (const snippet of required.valueIncludes ?? []) {
checks.push(
check(
`${required.type} draft ${required.path} value includes '${snippet}'`,
normalizeText(valueText).includes(normalizeText(snippet)),
truncateForDetails(valueText)
)
);
}
for (const snippet of required.valueExcludes ?? []) {
checks.push(
check(
`${required.type} draft ${required.path} value excludes '${snippet}'`,
!normalizeText(valueText).includes(normalizeText(snippet)),
truncateForDetails(valueText)
)
);
}
}
for (const forbidden of validate.forbiddenDrafts ?? []) {
checks.push(
check(
`global does not include ${forbidden.type} draft ${forbidden.path}`,
!findGlobalDraft(drafts, forbidden.type, forbidden.path, forbidden.triggerKind),
summarizeGlobalDrafts(drafts)
)
);
}
return checks;
}
export function validateAppState(input: {
actual: AppFilesState;
initial?: AppFilesState;
@@ -433,6 +613,202 @@ function summarizeProblems(problems: string[], limit = 5): string | undefined {
return `${problems.slice(0, limit).join("; ")}; ...and ${problems.length - limit} more`;
}
function findGlobalDraft(
drafts: GlobalDraft[],
type: string,
path: string,
triggerKind?: string
): GlobalDraft | undefined {
return drafts.find(
(draft) =>
draft.type === type &&
draft.path === path &&
(triggerKind === undefined || draft.triggerKind === triggerKind)
);
}
function summarizeGlobalDrafts(drafts: GlobalDraft[]): string {
const summary = drafts
.map((draft) => formatGlobalDraftKey(draft))
.join(", ");
return `drafts: ${summary || "none"}`;
}
function formatGlobalDraftKey(draft: GlobalDraft): string {
return `${draft.type}${draft.triggerKind ? `:${draft.triggerKind}` : ""}:${draft.path}`;
}
function globalValidationExpectsDrafts(input: {
expected?: GlobalDraftState;
validate?: GlobalValidationSpec;
}): boolean {
const validate = input.validate;
return (
(input.expected?.drafts?.length ?? 0) > 0 ||
(validate?.requiredDrafts?.length ?? 0) > 0 ||
(validate?.draftCountAtLeast ?? 0) > 0 ||
(validate?.draftCountExactly ?? 0) > 0
);
}
function globalDraftStatesEqual(left: GlobalDraftState, right: GlobalDraftState): boolean {
return (
JSON.stringify(canonicalizeGlobalDrafts(left.drafts ?? [])) ===
JSON.stringify(canonicalizeGlobalDrafts(right.drafts ?? []))
);
}
function describeGlobalDraftStateMismatch(
actual: GlobalDraftState,
expected: GlobalDraftState
): string {
const actualDrafts = actual.drafts ?? [];
const expectedDrafts = expected.drafts ?? [];
const actualByKey = new Map(
actualDrafts.map((draft) => [globalDraftSortKey(draft), draft] as const)
);
const expectedByKey = new Map(
expectedDrafts.map((draft) => [globalDraftSortKey(draft), draft] as const)
);
for (const key of Array.from(expectedByKey.keys()).sort()) {
const expectedDraft = expectedByKey.get(key);
if (expectedDraft && !actualByKey.has(key)) {
return `missing expected draft ${formatGlobalDraftKey(expectedDraft)}; actual=${summarizeGlobalDrafts(actualDrafts)}`;
}
}
for (const key of Array.from(actualByKey.keys()).sort()) {
const actualDraft = actualByKey.get(key);
if (actualDraft && !expectedByKey.has(key)) {
return `unexpected draft ${formatGlobalDraftKey(actualDraft)}; expected=${summarizeGlobalDrafts(expectedDrafts)}`;
}
}
for (const key of Array.from(expectedByKey.keys()).sort()) {
const actualDraft = actualByKey.get(key);
const expectedDraft = expectedByKey.get(key);
if (!actualDraft || !expectedDraft) {
continue;
}
const fieldMismatch = describeGlobalDraftFieldMismatch(
formatGlobalDraftKey(expectedDraft),
actualDraft,
expectedDraft
);
if (fieldMismatch) {
return fieldMismatch;
}
}
return `actual=${summarizeGlobalDrafts(actualDrafts)}; expected=${summarizeGlobalDrafts(expectedDrafts)}`;
}
function describeGlobalDraftFieldMismatch(
key: string,
actual: GlobalDraft,
expected: GlobalDraft
): string | undefined {
const fields: Array<"language" | "summary" | "value" | "isDraft"> = [
"language",
"summary",
"value",
"isDraft",
];
for (const field of fields) {
const actualValue = comparableGlobalDraftFieldValue(actual, field);
const expectedValue = comparableGlobalDraftFieldValue(expected, field);
if (JSON.stringify(actualValue) === JSON.stringify(expectedValue)) {
continue;
}
return `${key} ${field} differs: actual=${formatGlobalDraftFieldValue(
actualValue
)}; expected=${formatGlobalDraftFieldValue(expectedValue)}`;
}
return undefined;
}
function comparableGlobalDraftFieldValue(
draft: GlobalDraft,
field: "language" | "summary" | "value" | "isDraft"
): unknown {
if (field === "summary" && typeof draft.summary === "string") {
return normalizeText(draft.summary);
}
if (field === "value" && typeof draft.value === "string") {
return normalizeText(draft.value);
}
if (field === "value") {
return canonicalizeJsonValue(draft.value);
}
return draft[field];
}
function formatGlobalDraftFieldValue(value: unknown): string {
if (value === undefined) {
return "(missing)";
}
return truncateForDetails(JSON.stringify(value), 300);
}
function canonicalizeGlobalDrafts(drafts: GlobalDraft[]): unknown[] {
return drafts
.slice()
.sort((left, right) => globalDraftSortKey(left).localeCompare(globalDraftSortKey(right)))
.map((draft) =>
canonicalizeJsonValue({
type: draft.type,
path: draft.path,
triggerKind: draft.triggerKind,
language: draft.language,
summary:
typeof draft.summary === "string" ? normalizeText(draft.summary) : draft.summary,
value:
typeof draft.value === "string"
? normalizeText(draft.value)
: canonicalizeJsonValue(draft.value),
isDraft: draft.isDraft,
})
);
}
function globalDraftSortKey(draft: GlobalDraft): string {
return `${draft.type}:${draft.triggerKind ?? ""}:${draft.path}`;
}
function canonicalizeJsonValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(canonicalizeJsonValue);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, nested]) => [key, canonicalizeJsonValue(nested)])
);
}
return value;
}
function stringifyGlobalDraftValue(value: unknown): string {
if (typeof value === "string") {
return value;
}
return JSON.stringify(value ?? null, null, 2);
}
function truncateForDetails(value: string, maxLength = 500): string {
const normalized = value.replace(/\s+/g, " ").trim();
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 3))}...`;
}
function validateCliExpectations(
assistantOutput: string,
trace: CliTrace | undefined,
@@ -0,0 +1,63 @@
import { afterEach, describe, expect, it } from "bun:test";
import { resolveWindmillBackendSettings } from "./windmillBackendSettings";
const ENV_KEYS = [
"WMILL_AI_EVAL_BACKEND_URL",
"WINDMILL_URL",
"WINDMILL_BASE_URL",
"REMOTE",
"WMILL_AI_EVAL_BACKEND_EMAIL",
"WMILL_AI_EVAL_BACKEND_PASSWORD",
"WMILL_AI_EVAL_BACKEND_WORKSPACE",
] as const;
const ORIGINAL_ENV = Object.fromEntries(
ENV_KEYS.map((key) => [key, process.env[key]]),
) as Record<(typeof ENV_KEYS)[number], string | undefined>;
afterEach(() => {
for (const key of ENV_KEYS) {
const value = ORIGINAL_ENV[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
});
describe("resolveWindmillBackendSettings", () => {
it("uses backend URL/auth defaults and the optional explicit workspace", () => {
delete process.env.WMILL_AI_EVAL_BACKEND_URL;
delete process.env.WINDMILL_URL;
delete process.env.WINDMILL_BASE_URL;
delete process.env.REMOTE;
process.env.WMILL_AI_EVAL_BACKEND_WORKSPACE = "shared-evals";
expect(resolveWindmillBackendSettings()).toEqual({
baseUrl: "http://127.0.0.1:8000",
email: "admin@windmill.dev",
password: "changeme",
workspaceOverride: "shared-evals",
});
});
it("does not expose workspace retention knobs", () => {
process.env.WMILL_AI_EVAL_BACKEND_URL = "http://backend.test/";
const settings = resolveWindmillBackendSettings();
expect(settings).toEqual({
baseUrl: "http://backend.test",
email: "admin@windmill.dev",
password: "changeme",
workspaceOverride: undefined,
});
expect(Object.keys(settings).sort()).toEqual([
"baseUrl",
"email",
"password",
"workspaceOverride",
]);
});
});
-22
View File
@@ -2,9 +2,7 @@ export interface WindmillBackendSettings {
baseUrl: string;
email: string;
password: string;
keepWorkspaces: boolean;
workspaceOverride?: string;
workspacePrefix: string;
}
export function resolveWindmillBackendSettings(): WindmillBackendSettings {
@@ -18,13 +16,9 @@ export function resolveWindmillBackendSettings(): WindmillBackendSettings {
),
email: process.env.WMILL_AI_EVAL_BACKEND_EMAIL ?? "admin@windmill.dev",
password: process.env.WMILL_AI_EVAL_BACKEND_PASSWORD ?? "changeme",
keepWorkspaces: isTruthy(process.env.WMILL_AI_EVAL_KEEP_WORKSPACES),
workspaceOverride: sanitizeOptionalWorkspaceId(
process.env.WMILL_AI_EVAL_BACKEND_WORKSPACE,
),
workspacePrefix: sanitizeWorkspacePrefix(
process.env.WMILL_AI_EVAL_WORKSPACE_PREFIX ?? "ai-evals",
),
};
}
@@ -43,25 +37,9 @@ function normalizeBaseUrl(value: string): string {
return value.replace(/\/+$/, "");
}
function sanitizeWorkspacePrefix(value: string): string {
const sanitized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, "-")
.replace(/^-+|-+$/g, "");
return sanitized.length > 0 ? sanitized : "ai-evals";
}
function sanitizeOptionalWorkspaceId(
value: string | undefined,
): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function isTruthy(value: string | undefined): boolean {
if (!value) {
return false;
}
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
@@ -0,0 +1,23 @@
{
"workspace": {
"scripts": [
{
"path": "f/evals/global/format_greeting",
"summary": "Format a greeting for a provided name",
"description": "Returns a plain greeting for the provided name.",
"language": "bun",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {
"name": {
"type": "string"
}
},
"required": ["name"]
},
"content": "export async function main(name: string) {\n return `Hello, ${name}`\n}\n"
}
]
}
}
+5 -9
View File
@@ -5,15 +5,12 @@ import type { FrontendEvalModelConfig } from "../core/models";
import { validateAppState, type AppFilesState } from "../core/validators";
import type { BenchmarkArtifactFile, ModeRunner } from "../core/types";
import { runAppEval } from "../adapters/frontend/core/app/appEvalRunner";
import {
DEFAULT_FRONTEND_EVAL_MODEL,
getFrontendApiKey,
} from "./frontendCommon";
import type { FrontendEvalTransportSettings } from "../core/frontendTransport";
import { getFrontendApiKey } from "./frontendCommon";
import type { WindmillBackendSettings } from "../core/windmillBackendSettings";
export function createAppModeRunner(
modelConfig: FrontendEvalModelConfig = DEFAULT_FRONTEND_EVAL_MODEL,
transportSettings?: FrontendEvalTransportSettings,
modelConfig: FrontendEvalModelConfig,
backendSettings: WindmillBackendSettings,
): ModeRunner<AppFilesState, AppFilesState, AppFilesState> {
return {
mode: "app",
@@ -37,8 +34,7 @@ export function createAppModeRunner(
appContext: context.evalCase?.runtime?.appContext,
provider: modelConfig.provider,
model: modelConfig.model,
transport: transportSettings?.transport,
backend: transportSettings?.backend,
backend: backendSettings,
runContext: context,
},
);
+6 -10
View File
@@ -7,11 +7,8 @@ import type { BenchmarkArtifactFile, ModeRunner } from "../core/types";
import { runFlowEval } from "../adapters/frontend/core/flow/flowEvalRunner";
import type { FlowWorkspaceFixtures } from "../adapters/frontend/core/flow/fileHelpers";
import { BackendPreviewClient } from "../adapters/frontend/backendPreview";
import {
DEFAULT_FRONTEND_EVAL_MODEL,
getFrontendApiKey,
} from "./frontendCommon";
import type { FrontendEvalTransportSettings } from "../core/frontendTransport";
import { getFrontendApiKey } from "./frontendCommon";
import type { WindmillBackendSettings } from "../core/windmillBackendSettings";
import {
normalizeFlowInitialFixture,
normalizeFlowStateFixture,
@@ -19,9 +16,9 @@ import {
} from "./flowFixtures";
export function createFlowModeRunner(
modelConfig: FrontendEvalModelConfig = DEFAULT_FRONTEND_EVAL_MODEL,
backendValidation?: BackendValidationSettings,
transportSettings?: FrontendEvalTransportSettings,
modelConfig: FrontendEvalModelConfig,
backendValidation: BackendValidationSettings | undefined,
backendSettings: WindmillBackendSettings,
): ModeRunner<FlowInitialFixture, FlowState, FlowState> {
return {
mode: "flow",
@@ -49,8 +46,7 @@ export function createFlowModeRunner(
maxIterations: context.evalCase?.runtime?.maxTurns,
provider: modelConfig.provider,
model: modelConfig.model,
transport: transportSettings?.transport,
backend: transportSettings?.backend,
backend: backendSettings,
runContext: context,
},
);
+8 -1
View File
@@ -5,12 +5,14 @@ const ORIGINAL_ENV = {
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
GEMINI_API_KEY: process.env.GEMINI_API_KEY,
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY,
};
afterEach(() => {
process.env.ANTHROPIC_API_KEY = ORIGINAL_ENV.ANTHROPIC_API_KEY;
process.env.OPENAI_API_KEY = ORIGINAL_ENV.OPENAI_API_KEY;
process.env.GEMINI_API_KEY = ORIGINAL_ENV.GEMINI_API_KEY;
process.env.DEEPSEEK_API_KEY = ORIGINAL_ENV.DEEPSEEK_API_KEY;
});
describe("getFrontendApiKey", () => {
@@ -19,10 +21,15 @@ describe("getFrontendApiKey", () => {
expect(getFrontendApiKey("googleai")).toBe("gemini-test-key");
});
it("reads the DeepSeek API key for deepseek models", () => {
process.env.DEEPSEEK_API_KEY = "deepseek-test-key";
expect(getFrontendApiKey("deepseek")).toBe("deepseek-test-key");
});
it("throws a provider-specific error when the key is missing", () => {
delete process.env.GEMINI_API_KEY;
expect(() => getFrontendApiKey("googleai")).toThrow(
"GEMINI_API_KEY is required for frontend evals"
"GEMINI_API_KEY is required for frontend evals",
);
});
});
+7 -11
View File
@@ -1,20 +1,16 @@
import {
getFrontendEvalModel,
resolveEvalModel,
type FrontendEvalModelConfig,
} from "../core/models";
import type { FrontendEvalModelConfig } from "../core/models";
export const DEFAULT_FRONTEND_EVAL_MODEL: FrontendEvalModelConfig = getFrontendEvalModel(
resolveEvalModel("flow")
);
export function getFrontendApiKey(provider: FrontendEvalModelConfig["provider"]): string {
export function getFrontendApiKey(
provider: FrontendEvalModelConfig["provider"],
): string {
const envName =
provider === "anthropic"
? "ANTHROPIC_API_KEY"
: provider === "googleai"
? "GEMINI_API_KEY"
: "OPENAI_API_KEY";
: provider === "deepseek"
? "DEEPSEEK_API_KEY"
: "OPENAI_API_KEY";
const apiKey = process.env[envName];
if (!apiKey) {
throw new Error(`${envName} is required for frontend evals`);
+81
View File
@@ -0,0 +1,81 @@
import { readFile } from "node:fs/promises";
import { runGlobalEval } from "../adapters/frontend/core/global/globalEvalRunner";
import type { BenchmarkWorkspaceRunnables } from "../adapters/frontend/mockBackend";
import type { FrontendEvalModelConfig } from "../core/models";
import type { BenchmarkArtifactFile, GlobalValidationSpec, ModeRunner } from "../core/types";
import { validateGlobalState, type GlobalDraftState } from "../core/validators";
import type { WindmillBackendSettings } from "../core/windmillBackendSettings";
import { getFrontendApiKey } from "./frontendCommon";
export interface GlobalInitialFixture {
workspace?: BenchmarkWorkspaceRunnables;
}
export function createGlobalModeRunner(
modelConfig: FrontendEvalModelConfig,
backendSettings: WindmillBackendSettings,
): ModeRunner<GlobalInitialFixture, GlobalDraftState, GlobalDraftState> {
return {
mode: "global",
concurrency: 3,
judgeThreshold: 80,
async loadInitial(path) {
return path ? await loadGlobalInitialFixture(path) : undefined;
},
async loadExpected(path) {
return path ? await loadGlobalExpectedFixture(path) : undefined;
},
async run(prompt, initial, context) {
const result = await runGlobalEval(
prompt,
getFrontendApiKey(modelConfig.provider),
{
workspaceFixtures: initial?.workspace,
maxIterations: context.evalCase?.runtime?.maxTurns,
provider: modelConfig.provider,
model: modelConfig.model,
backend: backendSettings,
runContext: context,
},
);
return {
success: result.success,
actual: result.state,
error: result.error,
assistantMessageCount: result.assistantMessageCount,
toolCallCount: result.toolCallCount,
toolsUsed: result.toolsUsed,
toolCallDetails: result.toolCallDetails,
skillsInvoked: [],
tokenUsage: result.tokenUsage,
};
},
validate({ evalCase, actual, expected }) {
return validateGlobalState({
actual,
expected,
validate: evalCase.validate as GlobalValidationSpec | undefined,
});
},
buildArtifacts(actual): BenchmarkArtifactFile[] {
return [
{
path: "global-drafts.json",
content: JSON.stringify(actual, null, 2) + "\n",
},
];
},
};
}
async function loadGlobalInitialFixture(path: string): Promise<GlobalInitialFixture> {
const parsed = JSON.parse(await readFile(path, "utf8")) as GlobalInitialFixture;
return {
workspace: parsed.workspace ?? {},
};
}
async function loadGlobalExpectedFixture(path: string): Promise<GlobalDraftState> {
return JSON.parse(await readFile(path, "utf8")) as GlobalDraftState;
}
+6 -10
View File
@@ -6,16 +6,13 @@ import type { BenchmarkArtifactFile, ModeRunner } from "../core/types";
import { BackendPreviewClient } from "../adapters/frontend/backendPreview";
import { runScriptEval } from "../adapters/frontend/core/script/scriptEvalRunner";
import type { ScriptEvalState } from "../adapters/frontend/core/script/fileHelpers";
import {
DEFAULT_FRONTEND_EVAL_MODEL,
getFrontendApiKey,
} from "./frontendCommon";
import type { FrontendEvalTransportSettings } from "../core/frontendTransport";
import { getFrontendApiKey } from "./frontendCommon";
import type { WindmillBackendSettings } from "../core/windmillBackendSettings";
export function createScriptModeRunner(
modelConfig: FrontendEvalModelConfig = DEFAULT_FRONTEND_EVAL_MODEL,
backendValidation?: BackendValidationSettings,
transportSettings?: FrontendEvalTransportSettings,
modelConfig: FrontendEvalModelConfig,
backendValidation: BackendValidationSettings | undefined,
backendSettings: WindmillBackendSettings,
): ModeRunner<ScriptEvalState, ScriptEvalState, ScriptEvalState> {
return {
mode: "script",
@@ -40,8 +37,7 @@ export function createScriptModeRunner(
maxIterations: context.evalCase?.runtime?.maxTurns,
provider: modelConfig.provider,
model: modelConfig.model,
transport: transportSettings?.transport,
backend: transportSettings?.backend,
backend: backendSettings,
runContext: context,
},
);
@@ -0,0 +1,94 @@
{
"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, disabled, NULL::text as workspace_id FROM password WHERE email = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "login_type",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "super_admin",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "devops",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "verified",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "company",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "operator_only",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "first_time_user",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "role_source",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "disabled",
"type_info": "Bool"
},
{
"ordinal": 12,
"name": "workspace_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
null,
false,
false,
false,
true,
true,
true,
null,
false,
false,
false,
null
]
},
"hash": "0142d9dc9c1b57487dd5709a0376794f18d33e5bd6340c0189be7818cda64328"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT flow_version.path FROM flow_version\n INNER JOIN flow\n ON flow.path = flow_version.path AND\n flow.workspace_id = flow_version.workspace_id\n WHERE flow_version.id = $1 AND flow_version.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8",
"Text"
]
},
"nullable": [
false
]
},
"hash": "0476ae2245aa678a50c5fd04cdee32cc151e29b177cc85caa088430b16336373"
}
@@ -46,11 +46,11 @@
]
},
"nullable": [
true,
true,
true,
true,
true,
false,
false,
false,
false,
false,
true,
true
]
@@ -0,0 +1,95 @@
{
"db_name": "PostgreSQL",
"query": "SELECT email as \"email!\", login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, NULL::bool as operator_only, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id FROM password\n UNION ALL\n SELECT email as \"email!\", 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, true as operator_only, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC, \"email!\"\n LIMIT $1 OFFSET $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "login_type",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "verified!",
"type_info": "Bool"
},
{
"ordinal": 3,
"name": "super_admin!",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "devops!",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "company",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "operator_only",
"type_info": "Bool"
},
{
"ordinal": 9,
"name": "first_time_user!",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "role_source!",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "disabled!",
"type_info": "Bool"
},
{
"ordinal": 12,
"name": "workspace_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
"hash": "16b4496c21d0619dab4521dca22e5fe144c59156a8f06d8592291684c49b2f37"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND workspace_id = $2 ORDER BY created_at DESC LIMIT 1)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "2a49e5b5486b650d96f3e9038cba8a5f2e75d3b12ee4718452e82c7318b1bcf4"
}
@@ -0,0 +1,66 @@
{
"db_name": "PostgreSQL",
"query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes, workspace_id, read_only FROM token WHERE email = $1\n ORDER BY created_at DESC LIMIT $2 OFFSET $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "label",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "token_prefix",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "expiration",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "last_used_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "scopes",
"type_info": "TextArray"
},
{
"ordinal": 6,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "read_only",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
]
},
"nullable": [
true,
false,
true,
false,
false,
true,
true,
false
]
},
"hash": "52777947d60d5ddd6d28852a1cf104a5af2fd565706204b7d5d33594ff91e61e"
}
@@ -0,0 +1,31 @@
{
"db_name": "PostgreSQL",
"query": "SELECT workspace_id, path FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) AND NOT (path = $3 AND workspace_id = $4) LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "5347ec9ab6de69a99e8823d2199757b244b280e1262dcbccd2fc5189c7b3d25b"
}
@@ -1,28 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT s.item_kind, s.path\n FROM ws_specific s\n WHERE s.workspace_id = $1\n AND (\n (s.item_kind = 'resource' AND EXISTS (\n SELECT 1 FROM resource r\n WHERE r.workspace_id = s.workspace_id AND r.path = s.path\n ))\n OR (s.item_kind = 'variable' AND EXISTS (\n SELECT 1 FROM variable v\n WHERE v.workspace_id = s.workspace_id AND v.path = s.path\n ))\n )\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "item_kind",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "path",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "8b92a7d04fcdd8e61178d7dab97c31e10f89481908c479b4039af5e94fa0f8ac"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO token\n (token_hash, token_prefix, token, email, label, expiration, super_admin, scopes, workspace_id, read_only)\n SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10\n WHERE $9::varchar IS NULL OR NOT EXISTS(\n SELECT 1 FROM workspace WHERE id = $9 AND deleted = true\n )",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Varchar",
"Timestamptz",
"Bool",
"TextArray",
"Varchar",
"Bool"
]
},
"nullable": []
},
"hash": "9a1bf7c424154172b56bbd29b51ef468969b5452150ee6fffe31d59b52a4f1c2"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "SELECT bool_and(operator) FROM (\n SELECT operator FROM usr WHERE email = $1\n UNION ALL\n SELECT operator FROM workspace_invite WHERE email = $1\n ) t",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "bool_and",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "9c85ba8d41bedbcb5466f44a7d4cf6b4946e1fd337f00d243f518283783833c9"
}
@@ -1,23 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM ws_specific WHERE workspace_id = $1 AND item_kind = 'variable' AND path = $2)",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "b4162468afae99cf31c4668ca6769657fd73742b6ee8289b1e9736e381314cfb"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value from resource WHERE path = $1 AND workspace_id = $2 AND resource_type = 'app_theme'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "b845cbe97b10c6194fd89dbf2510216da2c1b8a4ce7ee1482ff5f347c3de145e"
}
@@ -0,0 +1,95 @@
{
"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 as \"email!\", (email NOT IN (SELECT email FROM authors)) as operator_only, login_type::text, verified as \"verified!\", super_admin as \"super_admin!\", devops as \"devops!\", name, company, username, first_time_user as \"first_time_user!\", role_source as \"role_source!\", disabled as \"disabled!\", NULL::text as workspace_id\n FROM password\n WHERE email IN (SELECT email FROM active_users)\n UNION ALL\n SELECT email as \"email!\", true as operator_only, 'service_account'::text as login_type, true as \"verified!\", false as \"super_admin!\", false as \"devops!\", NULL::text as name, NULL::text as company, username, false as \"first_time_user!\", 'service_account'::text as \"role_source!\", disabled as \"disabled!\", workspace_id\n FROM usr\n WHERE is_service_account IS true\n ORDER BY \"super_admin!\" DESC, \"devops!\" DESC\n LIMIT $1 OFFSET $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "email!",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "operator_only",
"type_info": "Bool"
},
{
"ordinal": 2,
"name": "login_type",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "verified!",
"type_info": "Bool"
},
{
"ordinal": 4,
"name": "super_admin!",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "devops!",
"type_info": "Bool"
},
{
"ordinal": 6,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "company",
"type_info": "Varchar"
},
{
"ordinal": 8,
"name": "username",
"type_info": "Varchar"
},
{
"ordinal": 9,
"name": "first_time_user!",
"type_info": "Bool"
},
{
"ordinal": 10,
"name": "role_source!",
"type_info": "Varchar"
},
{
"ordinal": 11,
"name": "disabled!",
"type_info": "Bool"
},
{
"ordinal": 12,
"name": "workspace_id",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null,
null
]
},
"hash": "c17c39add3f70218dbae38595a909d02cfabe7e0864af577df6968428ac448ef"
}
@@ -1,11 +1,11 @@
{
"db_name": "PostgreSQL",
"query": "SELECT item_kind, path FROM ws_specific WHERE workspace_id = $1",
"query": "SELECT workspace_id, path FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) LIMIT 1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "item_kind",
"name": "workspace_id",
"type_info": "Varchar"
},
{
@@ -16,6 +16,7 @@
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
@@ -24,5 +25,5 @@
false
]
},
"hash": "0c6e8f03a4e9f543cb85582e0aec1ed508d83695ef6d62ca06cfb612fd332b87"
"hash": "debd7470025cf64cd1191e604fed9ae0ab27c8a76302a2570473046e28c91fdd"
}
@@ -0,0 +1,66 @@
{
"db_name": "PostgreSQL",
"query": "SELECT label, token_prefix, expiration, created_at, last_used_at, scopes, workspace_id, read_only FROM token WHERE email = $1 AND (label != 'ephemeral-script' OR label IS NULL)\n ORDER BY created_at DESC LIMIT $2 OFFSET $3",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "label",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "token_prefix",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "expiration",
"type_info": "Timestamptz"
},
{
"ordinal": 3,
"name": "created_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "last_used_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "scopes",
"type_info": "TextArray"
},
{
"ordinal": 6,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 7,
"name": "read_only",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Int8",
"Int8"
]
},
"nullable": [
true,
false,
true,
false,
false,
true,
true,
false
]
},
"hash": "e001fb68c60fa736cecce52be49c9fb0714c9c91a7ea3924811d345bee94c013"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "WITH potential AS (\n SELECT email, operator FROM usr\n UNION\n SELECT email, operator FROM workspace_invite\n ),\n per_user AS (\n SELECT email, bool_and(operator) AS only_operator FROM potential GROUP BY email\n )\n SELECT\n COUNT(*) FILTER (WHERE NOT only_operator) AS \"authors!\",\n COUNT(*) FILTER (WHERE only_operator) AS \"operators!\"\n FROM per_user",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "authors!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "operators!",
"type_info": "Int8"
}
],
"parameters": {
"Left": []
},
"nullable": [
null,
null
]
},
"hash": "e1ada31c1625b453c2ff85edbcd7ad51a4cd5cbdc2fa34038530070d6a579455"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM variable WHERE workspace_id = $1 AND path = $2)",
"query": "SELECT EXISTS(SELECT 1 FROM flow_version WHERE id = $1 AND workspace_id = $2)",
"describe": {
"columns": [
{
@@ -11,7 +11,7 @@
],
"parameters": {
"Left": [
"Text",
"Int8",
"Text"
]
},
@@ -19,5 +19,5 @@
null
]
},
"hash": "6be4bf59c404d2f557d1106c48c320bb3eff65255a44bd66799ae14288312ba4"
"hash": "e70cbc2a48bfc5c7d2018b9367eadc104e87126c57230c9ed8eb3987e54b53a1"
}
@@ -1,11 +1,11 @@
{
"db_name": "PostgreSQL",
"query": "SELECT label, email, scopes, workspace_id, super_admin, owner, expiration FROM token WHERE token_hash = $1",
"query": "UPDATE token SET last_used_at = now() WHERE\n token_hash = $1\n AND (expiration > NOW() OR expiration IS NULL)\n AND (workspace_id IS NULL OR workspace_id = $2)\n RETURNING owner, email, super_admin, scopes, label, read_only",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "label",
"name": "owner",
"type_info": "Varchar"
},
{
@@ -15,44 +15,39 @@
},
{
"ordinal": 2,
"name": "scopes",
"type_info": "TextArray"
},
{
"ordinal": 3,
"name": "workspace_id",
"type_info": "Varchar"
},
{
"ordinal": 4,
"name": "super_admin",
"type_info": "Bool"
},
{
"ordinal": 5,
"name": "owner",
"ordinal": 3,
"name": "scopes",
"type_info": "TextArray"
},
{
"ordinal": 4,
"name": "label",
"type_info": "Varchar"
},
{
"ordinal": 6,
"name": "expiration",
"type_info": "Timestamptz"
"ordinal": 5,
"name": "read_only",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true,
true,
true,
true,
false,
true,
true
true,
false
]
},
"hash": "406bcbf55758b10243c8eaff1c349b8082c0052d626bf67e08317e56ab9ad026"
"hash": "ed82ffc4d806a19519701e39abc43e8e250cc9772cca4a7dae3442be86f4060c"
}
@@ -0,0 +1,32 @@
{
"db_name": "PostgreSQL",
"query": "SELECT memory, worker, native_mode FROM worker_ping WHERE ping_at > now() - interval '2 minutes'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "memory",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "worker",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "native_mode",
"type_info": "Bool"
}
],
"parameters": {
"Left": []
},
"nullable": [
true,
false,
false
]
},
"hash": "f8f756bc498e5f084851f98e1e8d8c74cdc672a903d566baf5ac5ef50a4da1bd"
}
@@ -1,25 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(SELECT 1 FROM app WHERE custom_path = $1 AND ($2::TEXT IS NULL OR workspace_id = $2) AND NOT (path = $3 AND workspace_id = $4))",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Text",
"Text"
]
},
"nullable": [
null
]
},
"hash": "fb1399c1dc171ec6bb24fee3477a1d606d9725e20e9c2d76a0887fadfd87f8df"
}
+764 -3080
View File
File diff suppressed because it is too large Load Diff
+62 -25
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.699.0"
version = "1.703.3"
authors.workspace = true
edition.workspace = true
@@ -87,7 +87,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.699.0"
version = "1.703.3"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -207,6 +207,36 @@ all_sqlx_features = ["all_languages", "enterprise", "enterprise_saml", "embeddin
object_store = { git = "https://github.com/apache/arrow-rs-object-store", rev = "36752c975d4f29e20b57c91f81a10872dcd48ae7" }
# Use tiberius main branch for libgssapi 0.8.1 fix (https://github.com/prisma/tiberius/issues/343)
tiberius = { git = "https://github.com/prisma/tiberius", rev = "59db57960a14b422fb3a1309aa4aa47880896ff8" }
# Pin tokio-postgres / postgres-types / postgres-protocol to the
# MaterializeInc fork. windmill-trigger-postgres already pulled this
# fork in transitively for the postgres-replication crate
# (CopyBothDuplex, LogicalReplicationStream, TupleData with binary
# tuple support) which upstream rust-postgres has declined to merge
# since 2021 (PR #752 → #778, both still unmerged).
#
# MI also carries a mitigation for the
# Client::query_typed_raw / Client::prepare deadlock on result columns
# whose Oid the client doesn't know about yet (citext, custom enums /
# domains, postgis): MI's 2025-12-11 PR #33 resized the per-request
# response channel from mpsc::channel(1) → mpsc::channel(1024).
# bounded(1024) is sufficient for any realistic typeinfo deferral
# (need ~2-3 batches) but leaves a theoretical failure mode at
# >~64 MB results with a custom-Oid column. The strict-correct fix is
# mpsc::unbounded(); a follow-up PR to MI is open proposing that.
#
# The [patch.crates-io] entries below force windmill-worker's
# pg_executor (which imports `tokio_postgres::` directly from
# crates.io) onto the same fork as windmill-trigger-postgres, so the
# deadlock mitigation reaches both consumers.
#
# Upstream deadlock PRs (open, not on the critical path now that MI
# is mitigated):
# https://github.com/rust-postgres/rust-postgres/pull/1348
# https://github.com/rust-postgres/rust-postgres/pull/1349
# Reproducer: https://github.com/rubenfiszel/tokio-postgres-deadlock-repro
tokio-postgres = { git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" }
postgres-types = { git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" }
postgres-protocol = { git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" }
[dependencies]
anyhow.workspace = true
@@ -387,8 +417,7 @@ tokio-stream = { version = "0.1.17" }
tower = "^0"
tower-http = { version = "^0.6", features = ["trace", "cors", "catch-panic"] }
tower-cookies = "^0.11"
#stuck because of swc for now
serde = "=1.0.220"
serde = "^1"
serde_json = { version = "^1", features = ["preserve_order", "raw_value"] }
serde_yml = "0.0.12"
uuid = { version = "^1", features = ["serde", "v4", "js"] }
@@ -443,21 +472,29 @@ aws-sdk-rds = "^1"
async-trait = "0.1.88"
v8 = "=130.0.7" # Exact version NOTE: Do not forget to update version and hash in flake.nix
deno_fetch = "0.214.0"
deno_tls = "0.177.0"
deno_console = "0.190.0"
deno_url = "0.190.0"
deno_webidl = "0.190.0"
deno_web = "0.221.0"
deno_io = "0.100.0"
deno_net = "0.182.0"
deno_core = "0.336.0"
deno_ast = { version = "=0.44.0", features = ["transpiling"] }
deno_permissions = "0.49.0"
deno_runtime = { version = "0.198.0", features = ["transpile"] }
deno_telemetry = "0.12.0"
deno_error = "=0.5.5"
v8 = "=137.1.0" # Exact version NOTE: Do not forget to update version and hash in flake.nix
# deno_* pin set: deno v2.4.0 base, with deno_ast force-overridden to =0.51.0.
# Rationale: deno_ast 0.51.0 is the first version pulling swc_common =14.0.4,
# the first swc_common patch that dropped `pub use serde::__private as serde;`
# (the line that capped our workspace serde pin at =1.0.220). v2.4.0's other
# pins keep deno_tls at 0.196.0 which uses permissive `rustls ^0.23.11`,
# compatible with aws-sdk-bedrockruntime's `^0.23.31` requirement. deno_tls
# 0.198+ tightened that to exact `=0.23.28`, which would have made any
# meaningful deno bump resolver-impossible against aws-sdk.
deno_fetch = "0.233.0"
deno_tls = "0.196.0"
deno_console = "0.209.0"
deno_url = "0.209.0"
deno_webidl = "0.209.0"
deno_web = "0.240.0"
deno_io = "0.119.0"
deno_fs = "0.119.0"
deno_net = "0.201.0"
deno_core = "0.352.0"
deno_ast = { version = "=0.51.0", features = ["transpiling"] }
deno_permissions = "0.68.0"
deno_telemetry = "0.31.0"
deno_error = "=0.6.1"
rustls-pemfile = "2.2.0"
# only used with special deno_core_mac feature to prevent ffi issue on macos, requires libffi to be installed
@@ -470,10 +507,10 @@ google-cloud-googleapis = {version = "0.16.1", features = ["pubsub"]}
winapi = { version = "0.3.9", features = ["sysinfoapi"] }
sysinfo = { version = "0.32.1" }
swc_common = "=0.37.5"
swc_ecma_parser = "=0.149.1"
swc_ecma_ast = "=0.118.2"
swc_ecma_visit = "=0.104.8"
swc_common = "=14.0.4"
swc_ecma_parser = "=24.0.3"
swc_ecma_ast = "=15.0.0"
swc_ecma_visit = "=15.0.0"
async-recursion = "^1"
@@ -517,8 +554,8 @@ wasm-bindgen-test = "^0"
convert_case = "0.6.0"
getrandom = "0.2"
tokio-postgres = {version = "^0.7", features = ["array-impls", "with-serde_json-1", "with-chrono-0_4", "with-uuid-1", "with-bit-vec-0_6"]}
rust-postgres = { package = "tokio-postgres", git = "https://github.com/imor/rust-postgres", rev = "20265ef38e32a06f76b6f9b678e2077fc2211f6b"}
rust-postgres-native-tls = { package = "postgres-native-tls", git = "https://github.com/imor/rust-postgres", features = ["runtime"], rev = "20265ef38e32a06f76b6f9b678e2077fc2211f6b" }
rust-postgres = { package = "tokio-postgres", git = "https://github.com/MaterializeInc/rust-postgres", rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe"}
rust-postgres-native-tls = { package = "postgres-native-tls", git = "https://github.com/MaterializeInc/rust-postgres", features = ["runtime"], rev = "78c1222577bb091d69bc22b1bc7ad01c14675abe" }
bit-vec = "=0.6.3"
mappable-rc = "^0"
mysql_async = { version = "*", default-features = false, features = ["minimal", "default", "native-tls-tls", "rust_decimal"]}
+1 -1
View File
@@ -1 +1 @@
d3bc7fa85195b46b7a38d43c2f806520bf8b5454
19a76a09ffb43649ee19e62d07e8b8a42d78757b
@@ -0,0 +1,19 @@
-- Remove "assets" key from operator_settings
UPDATE workspace_settings
SET operator_settings = operator_settings - 'assets'
WHERE operator_settings IS NOT NULL
AND operator_settings ? 'assets';
-- Revert the column default
ALTER TABLE workspace_settings
ALTER COLUMN operator_settings SET DEFAULT '{
"runs": true,
"groups": true,
"folders": true,
"workers": true,
"triggers": true,
"resources": true,
"schedules": true,
"variables": true,
"audit_logs": true
}';
@@ -0,0 +1,21 @@
-- Add "assets": true to operator_settings for all workspaces that have operator_settings
-- but don't already have an "assets" key
UPDATE workspace_settings
SET operator_settings = operator_settings || '{"assets": true}'::jsonb
WHERE operator_settings IS NOT NULL
AND NOT operator_settings ? 'assets';
-- Update the column default to include assets
ALTER TABLE workspace_settings
ALTER COLUMN operator_settings SET DEFAULT '{
"runs": true,
"groups": true,
"folders": true,
"workers": true,
"triggers": true,
"resources": true,
"schedules": true,
"variables": true,
"audit_logs": true,
"assets": true
}';
@@ -0,0 +1 @@
ALTER TABLE token DROP COLUMN IF EXISTS read_only;
@@ -0,0 +1,4 @@
-- Add a flag to restrict a token to read-only HTTP endpoints.
-- Orthogonal to `scopes`: even if scopes grant write/run, this flag denies
-- mutating methods (POST/PUT/PATCH/DELETE) and Run actions.
ALTER TABLE token ADD COLUMN read_only BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,3 @@
-- No-op: clearing a stray `auto_kind = 'lib'` value on failure/trigger/approval
-- scripts is not reversible (the original NULL/'lib' distinction is lost), and
-- restoring `'lib'` here would re-hide these scripts from their pickers.
@@ -0,0 +1,9 @@
-- Failure, Trigger, and Approval scripts are runnable entrypoints by
-- definition. A prior parser regression occasionally classified them as
-- `auto_kind = 'lib'`, which hid them from the flow error-handler /
-- trigger / approval pickers. Clear those stray values so existing
-- affected scripts re-appear without requiring a redeploy.
UPDATE script
SET auto_kind = NULL
WHERE auto_kind = 'lib'
AND kind IN ('failure', 'trigger', 'approval');
@@ -1,7 +1,7 @@
use std::collections::HashMap;
use swc_common::{sync::Lrc, FileName, SourceMap, Spanned};
use swc_ecma_ast::{CallExpr, Expr, Lit, MemberExpr, MemberProp, Str};
use swc_ecma_ast::{CallExpr, Expr, Lit, MemberExpr, MemberProp, ObjectLit, Prop, PropName, Str};
use swc_ecma_parser::{lexer::Lexer, Parser, StringInput, Syntax, TsSyntax};
use swc_ecma_visit::{Visit, VisitWith};
use windmill_parser::asset_parser::{
@@ -12,7 +12,7 @@ use AssetUsageAccessType::*;
pub fn parse_assets(code: &str) -> anyhow::Result<ParseAssetsOutput> {
let cm: Lrc<SourceMap> = Default::default();
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.into());
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.to_string());
let lexer = Lexer::new(
// We want to parse ecmascript
Syntax::Typescript(TsSyntax::default()),
@@ -309,6 +309,56 @@ impl Visit for AssetsFinder {
}
}
/// Extract a string-literal property value from an object literal.
/// Returns `Some(value)` for `{ name: "value" }`, ignoring computed,
/// shorthand, spread, and non-string-literal properties.
fn object_str_prop(obj: &ObjectLit, name: &str) -> Option<String> {
for prop in &obj.props {
let swc_ecma_ast::PropOrSpread::Prop(p) = prop else {
continue;
};
let Prop::KeyValue(kv) = p.as_ref() else {
continue;
};
let key = match &kv.key {
PropName::Ident(i) => i.sym.as_str(),
PropName::Str(s) => s.value.as_str(),
_ => continue,
};
if key != name {
continue;
}
if let Expr::Lit(Lit::Str(s)) = kv.value.as_ref() {
return Some(s.value.to_string());
}
}
None
}
/// Resolve the SDK `S3Object` argument of `loadS3File`/`loadS3FileStream`/
/// `writeS3File` to a canonical asset path, mirroring the runtime
/// `parseS3Object`: an object `{ s3: "<key>", storage?: "<bucket>" }` maps to
/// the URI `s3://<bucket>/<key>` (empty bucket for default storage, i.e.
/// `s3:///<key>`), and a bare `"s3://bucket/key"` string is passed through.
/// The resulting URI is fed through `parse_asset_syntax` so the stored path
/// matches the `// on s3:///…` trigger form exactly.
fn s3_object_arg_path(arg: &Expr) -> Option<String> {
let uri = match arg {
Expr::Lit(Lit::Str(s)) => s.value.to_string(),
Expr::Object(obj) => {
let key = object_str_prop(obj, "s3")?;
let storage = object_str_prop(obj, "storage").unwrap_or_default();
format!("s3://{storage}/{key}")
}
_ => return None,
};
Some(
parse_asset_syntax(&uri, false)
.map(|(_, p)| p.to_string())
.unwrap_or(uri),
)
}
impl AssetsFinder {
fn visit_call_expr_inner(&mut self, node: &swc_ecma_ast::CallExpr) -> Result<(), ()> {
let ident = match node.callee.as_expr().map(AsRef::as_ref) {
@@ -331,20 +381,20 @@ impl AssetsFinder {
let arg_value = node.args.get(arg_pos);
match arg_value.map(|e| e.expr.as_ref()) {
Some(Expr::Lit(Lit::Str(Str { value, .. }))) => {
let path = parse_asset_syntax(&value, false)
.map(|(_, p)| p)
.unwrap_or(&value);
self.assets.push(ParseAssetsResult {
kind,
path: path.to_string(),
access_type,
columns: None,
});
}
// S3 helpers take an `S3Object` (`{ s3, storage? }`) or an
// `s3://bucket/key` string — the form every real script uses. Other
// helpers take a bare resource-path string literal.
let is_s3_helper = matches!(kind, AssetKind::S3Object);
let path = match arg_value.map(|e| e.expr.as_ref()) {
Some(arg) if is_s3_helper => s3_object_arg_path(arg).ok_or(())?,
Some(Expr::Lit(Lit::Str(Str { value, .. }))) => parse_asset_syntax(&value, false)
.map(|(_, p)| p.to_string())
.unwrap_or_else(|| value.to_string()),
_ => return Err(()),
}
};
self.assets
.push(ParseAssetsResult { kind, path, access_type, columns: None });
Ok(())
}
}
@@ -375,6 +425,136 @@ mod tests {
);
}
#[test]
fn test_ts_asset_parser_write_s3_object_arg() {
// The SDK signature is `writeS3File(s3object: S3Object, ...)` and every
// real script passes the object form with a bare key. It must resolve
// to the same canonical path as a `// on s3:///<key>` trigger.
let input = r#"
import * as wmill from "windmill-client"
export async function main() {
await wmill.writeS3File(
{ s3: "pipelines/km_real/raw_events.json" },
JSON.stringify([]),
undefined,
"application/json"
)
}
"#;
let s = parse_assets(input);
assert_eq!(
s.map(|r| r.assets).map_err(|e| e.to_string()),
Ok(vec![ParseAssetsResult {
kind: AssetKind::S3Object,
path: "/pipelines/km_real/raw_events.json".to_string(),
access_type: Some(W),
columns: None,
},])
);
}
#[test]
fn test_ts_asset_parser_s3_object_with_storage() {
// `{ s3, storage }` maps to `s3://<storage>/<key>`, matching the
// `s3://bucket/key` string form and `parseS3Object`.
let input = r#"
import * as wmill from "windmill-client"
export async function main() {
await wmill.loadS3File({ s3: "dir/in.csv", storage: "mybucket" })
}
"#;
let s = parse_assets(input);
assert_eq!(
s.map(|r| r.assets).map_err(|e| e.to_string()),
Ok(vec![ParseAssetsResult {
kind: AssetKind::S3Object,
path: "mybucket/dir/in.csv".to_string(),
access_type: Some(R),
columns: None,
},])
);
}
#[test]
fn test_ts_asset_parser_multiple_s3_object_writes() {
// Mirrors the f/km/r_seed shape: several direct object-form writes in
// main() — all four outputs must be detected.
let input = r#"
import * as wmill from "windmill-client"
export async function main() {
await wmill.writeS3File({ s3: "pipelines/km_real/raw_events.json" }, "[]")
await wmill.writeS3File({ s3: "pipelines/km_real/enriched.json" }, "[]")
await wmill.writeS3File({ s3: "pipelines/km_real/summary.json" }, "[]")
await wmill.writeS3File({ s3: "pipelines/km_real/report.json" }, "{}")
}
"#;
// merge_assets returns a deterministic (path-sorted) order.
let s = parse_assets(input);
assert_eq!(
s.map(|r| r.assets).map_err(|e| e.to_string()),
Ok(vec![
ParseAssetsResult {
kind: AssetKind::S3Object,
path: "/pipelines/km_real/enriched.json".to_string(),
access_type: Some(W),
columns: None,
},
ParseAssetsResult {
kind: AssetKind::S3Object,
path: "/pipelines/km_real/raw_events.json".to_string(),
access_type: Some(W),
columns: None,
},
ParseAssetsResult {
kind: AssetKind::S3Object,
path: "/pipelines/km_real/report.json".to_string(),
access_type: Some(W),
columns: None,
},
ParseAssetsResult {
kind: AssetKind::S3Object,
path: "/pipelines/km_real/summary.json".to_string(),
access_type: Some(W),
columns: None,
},
])
);
}
#[test]
fn test_ts_asset_parser_s3_object_quoted_key() {
let input = r#"
import * as wmill from "windmill-client"
export async function main() {
await wmill.writeS3File({ "s3": "out.json" }, "{}")
}
"#;
let s = parse_assets(input);
assert_eq!(
s.map(|r| r.assets).map_err(|e| e.to_string()),
Ok(vec![ParseAssetsResult {
kind: AssetKind::S3Object,
path: "/out.json".to_string(),
access_type: Some(W),
columns: None,
},])
);
}
#[test]
fn test_ts_asset_parser_s3_object_dynamic_key_no_false_positive() {
// A computed key can't be resolved statically — must yield nothing
// rather than a bogus path.
let input = r#"
import * as wmill from "windmill-client"
export async function main(name: string) {
await wmill.writeS3File({ s3: `pipelines/${name}.json` }, "{}")
}
"#;
let s = parse_assets(input);
assert_eq!(s.map(|r| r.assets).map_err(|e| e.to_string()), Ok(vec![]));
}
#[test]
fn test_ts_asset_parser_unused_sql() {
let input = r#"
@@ -129,7 +129,7 @@ impl Visit for ImportsFinder {
/// See also: [`parse_relative_imports`] for resolved absolute paths.
pub fn parse_expr_for_imports(code: &str, skip_type_only: bool) -> anyhow::Result<Vec<String>> {
let cm: Lrc<SourceMap> = Default::default();
let fm = cm.new_source_file(FileName::Custom("main.d.ts".into()).into(), code.into());
let fm = cm.new_source_file(FileName::Custom("main.d.ts".into()).into(), code.to_string());
let mut tss = TsSyntax::default();
tss.disallow_ambiguous_jsx_like;
tss.tsx = true;
@@ -263,7 +263,7 @@ impl Visit for OutputFinder {
pub fn parse_expr_for_ids(code: &str) -> anyhow::Result<Vec<(String, String)>> {
let cm: Lrc<SourceMap> = Default::default();
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.into());
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.to_string());
let lexer = Lexer::new(
// We want to parse ecmascript
Syntax::Es(EsSyntax { jsx: false, ..Default::default() }),
@@ -305,7 +305,7 @@ pub fn parse_deno_signature(
entrypoint_override: Option<String>,
) -> anyhow::Result<MainArgSignature> {
let cm: Lrc<SourceMap> = Default::default();
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.into());
let fm = cm.new_source_file(FileName::Custom("main.ts".into()).into(), code.to_string());
let lexer = Lexer::new(
// We want to parse ecmascript
Syntax::Typescript(TsSyntax::default()),
@@ -712,7 +712,7 @@ fn extract_ts_params(params: &[swc_ecma_ast::Param], cm: &Lrc<SourceMap>) -> Vec
pub fn parse_ts_workflow(code: &str) -> Result<WorkflowDag, Vec<CompileError>> {
let cm: Lrc<SourceMap> = Default::default();
let fm = cm.new_source_file(FileName::Custom("workflow.ts".into()).into(), code.into());
let fm = cm.new_source_file(FileName::Custom("workflow.ts".into()).into(), code.to_string());
let lexer = Lexer::new(
Syntax::Typescript(TsSyntax::default()),
Default::default(),
+24 -24
View File
@@ -6183,7 +6183,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6263,7 +6263,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"proc-macro2",
"quote",
@@ -6275,7 +6275,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"convert_case",
"serde",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -6296,7 +6296,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"serde_json",
@@ -6308,7 +6308,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"gosyn",
@@ -6320,7 +6320,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -6332,7 +6332,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"serde_json",
@@ -6344,7 +6344,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"nu-parser",
@@ -6355,7 +6355,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6366,7 +6366,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6378,7 +6378,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6389,7 +6389,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"async-recursion",
@@ -6411,7 +6411,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"serde_json",
@@ -6423,7 +6423,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -6437,7 +6437,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"convert_case",
@@ -6454,7 +6454,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -6467,7 +6467,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"serde",
@@ -6479,7 +6479,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"lazy_static",
@@ -6497,7 +6497,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6513,7 +6513,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6529,7 +6529,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6561,7 +6561,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"serde",
@@ -6572,7 +6572,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.699.0"
version = "1.703.3"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.699.0"
version = "1.703.3"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+81 -54
View File
@@ -6,6 +6,8 @@ use windmill_common::{
pub const DEFAULT_MAX_CONNECTIONS_SERVER: u32 = 50;
pub const DEFAULT_MAX_CONNECTIONS_WORKER: u32 = 5;
pub const DEFAULT_MAX_CONNECTIONS_INDEXER: u32 = 5;
#[cfg(feature = "operator")]
pub const DEFAULT_MAX_CONNECTIONS_OPERATOR: u32 = 2;
pub async fn initial_connection() -> Result<sqlx::Pool<sqlx::Postgres>, error::Error> {
let connect_options = get_database_url().await?.connect_options().await?;
@@ -16,12 +18,35 @@ pub async fn initial_connection() -> Result<sqlx::Pool<sqlx::Postgres>, error::E
.map_err(|err| Error::ConnectingToDatabase(err.to_string()))
}
/// Connect to the database for the Kubernetes operator process.
///
/// Long-running operator pods need IAM RDS / Entra ID token refresh just like the server,
/// otherwise new pool connections start failing once the initial token expires (~15 min).
#[cfg(feature = "operator")]
pub async fn operator_connection(
#[cfg(all(feature = "enterprise", feature = "private"))]
killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> anyhow::Result<sqlx::Pool<sqlx::Postgres>> {
let database_url = get_database_url().await?;
let pool = connect(
database_url.clone(),
DEFAULT_MAX_CONNECTIONS_OPERATOR,
false,
)
.await?;
#[cfg(all(feature = "enterprise", feature = "private"))]
spawn_token_refresh_task(pool.clone(), database_url, killpill_rx);
Ok(pool)
}
pub async fn connect_db(
server_mode: bool,
indexer_mode: bool,
worker_mode: bool,
num_workers: i32,
#[cfg(feature = "private")] mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
#[cfg(feature = "private")] killpill_rx: tokio::sync::broadcast::Receiver<()>,
) -> anyhow::Result<sqlx::Pool<sqlx::Postgres>> {
use anyhow::Context;
@@ -43,70 +68,72 @@ pub async fn connect_db(
let pool = connect(database_url.clone(), max_connections, worker_mode).await?;
#[cfg(all(feature = "enterprise", feature = "private"))]
{
let needs_token_refresh = matches!(
database_url,
DatabaseUrl::IamRds(_) | DatabaseUrl::EntraId(_)
);
let label = match &database_url {
DatabaseUrl::IamRds(_) => "IAM RDS",
DatabaseUrl::EntraId(_) => "Entra ID",
DatabaseUrl::Static(_) => "",
};
if needs_token_refresh {
let pool2 = pool.clone();
let database_url2 = database_url.clone();
tokio::spawn(async move {
loop {
tokio::select! {
_ = killpill_rx.recv() => {
break;
}
_ = tokio::time::sleep(std::time::Duration::from_secs(10)) => {
if !database_url2.needs_refresh().await {
continue;
}
let new_url = tokio::time::timeout(
std::time::Duration::from_secs(10),
get_database_url(),
)
.await;
match new_url {
Ok(Ok(new_url)) => {
match new_url.connect_options().await {
Ok(connect_options) => {
pool2.set_connect_options(connect_options);
tracing::info!("Refreshed {label} URL successfully");
}
Err(e) => {
tracing::error!(
"Error getting {label} connect options, retrying in 10s: {e}"
);
continue;
}
}
}
Ok(Err(e)) => {
tracing::error!(
"Error refreshing {label} URL, trying again in 10s: {e}"
);
continue;
spawn_token_refresh_task(pool.clone(), database_url, killpill_rx);
Ok(pool)
}
/// Spawn a background task that refreshes IAM RDS / Entra ID tokens before they expire
/// and updates the pool's connect options so new connections use the fresh token.
/// No-op for static (password-based) database URLs.
#[cfg(all(feature = "enterprise", feature = "private"))]
pub fn spawn_token_refresh_task(
pool: sqlx::Pool<sqlx::Postgres>,
database_url: DatabaseUrl,
mut killpill_rx: tokio::sync::broadcast::Receiver<()>,
) {
let label = match &database_url {
DatabaseUrl::IamRds(_) => "IAM RDS",
DatabaseUrl::EntraId(_) => "Entra ID",
DatabaseUrl::Static(_) => return,
};
tokio::spawn(async move {
loop {
tokio::select! {
_ = killpill_rx.recv() => {
break;
}
_ = tokio::time::sleep(std::time::Duration::from_secs(10)) => {
if !database_url.needs_refresh().await {
continue;
}
let new_url = tokio::time::timeout(
std::time::Duration::from_secs(10),
get_database_url(),
)
.await;
match new_url {
Ok(Ok(new_url)) => {
match new_url.connect_options().await {
Ok(connect_options) => {
pool.set_connect_options(connect_options);
tracing::info!("Refreshed {label} URL successfully");
}
Err(e) => {
tracing::error!(
"Timeout after 10s refreshing {label} URL, trying again in 10s: {e}"
"Error getting {label} connect options, retrying in 10s: {e}"
);
continue;
}
}
}
Ok(Err(e)) => {
tracing::error!(
"Error refreshing {label} URL, trying again in 10s: {e}"
);
continue;
}
Err(e) => {
tracing::error!(
"Timeout after 10s refreshing {label} URL, trying again in 10s: {e}"
);
continue;
}
}
}
});
}
}
}
Ok(pool)
});
}
pub async fn connect(
+1 -1
View File
@@ -8,6 +8,6 @@ pub async fn set_license_key(_license_key: String, _db: Option<&windmill_common:
}
#[cfg(all(feature = "enterprise", not(feature = "private")))]
pub async fn verify_license_key() -> () {
pub async fn verify_license_key(_db: Option<&windmill_common::db::DB>) -> () {
// Implementation is not open source
}
+19 -2
View File
@@ -670,7 +670,24 @@ async fn windmill_main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
tracing::info!("Starting Windmill Kubernetes operator...");
tracing::info!("Connecting to database...");
let db = crate::db_connect::initial_connection().await?;
#[cfg(all(feature = "enterprise", feature = "private"))]
let (operator_killpill_tx, operator_killpill_rx) =
tokio::sync::broadcast::channel::<()>(2);
let db = crate::db_connect::operator_connection(
#[cfg(all(feature = "enterprise", feature = "private"))]
operator_killpill_rx,
)
.await?;
#[cfg(all(feature = "enterprise", feature = "private"))]
tokio::spawn(async move {
if let Ok(()) = tokio::signal::ctrl_c().await {
let _ = operator_killpill_tx.send(());
}
});
tracing::info!("Database connected. Starting ConfigMap watcher...");
windmill_operator::run(db).await?;
return Ok(());
@@ -1441,7 +1458,7 @@ Windmill Community Edition {GIT_VERSION}
tracing::error!("Failed to reload license key on agent: {e:#}");
}
#[cfg(feature = "enterprise")]
ee_oss::verify_license_key().await;
ee_oss::verify_license_key(conn.as_sql()).await;
}
// update min version explicitly.
+46 -12
View File
@@ -886,11 +886,13 @@ pub async fn reload_otel_tracing_proxy_setting(conn: &Connection) {
let mut current = OTEL_TRACING_PROXY_SETTINGS.write().await;
if current.enabled != new_settings.enabled
|| current.enabled_languages != new_settings.enabled_languages
|| current.no_proxy_hosts != new_settings.no_proxy_hosts
{
tracing::info!(
"OTEL tracing proxy settings changed: enabled={}, languages={:?}",
"OTEL tracing proxy settings changed: enabled={}, languages={:?}, no_proxy_hosts={:?}",
new_settings.enabled,
new_settings.enabled_languages
new_settings.enabled_languages,
new_settings.no_proxy_hosts,
);
*current = new_settings;
}
@@ -1110,15 +1112,29 @@ pub async fn delete_expired_items(db: &DB) -> () {
let audit_retention_days = audit_log_retention_days().await;
let audit_retention_secs: i64 = audit_retention_days * 60 * 60 * 24;
// Clean up old (non-partitioned) audit table — will eventually be empty and dropped
if let Err(e) = sqlx::query_scalar!(
"DELETE FROM audit WHERE timestamp <= now() - ($1::bigint::text || ' s')::interval",
audit_retention_secs,
)
.fetch_all(db)
.await
{
tracing::error!("Error deleting audit log: {:?}", e);
// Clean up old (non-partitioned) audit table — will eventually be empty and dropped.
// Batched to avoid excessive DB load on instances with very large legacy audit tables.
let mut total_deleted_audit: i64 = 0;
loop {
match sqlx::query_scalar!(
"DELETE FROM audit WHERE ctid IN (SELECT ctid FROM audit WHERE timestamp <= now() - ($1::bigint::text || ' s')::interval LIMIT 10000) RETURNING id",
audit_retention_secs,
)
.fetch_all(db)
.await
{
Ok(rows) if rows.is_empty() => break,
Ok(rows) => {
total_deleted_audit += rows.len() as i64;
if total_deleted_audit >= 100000 {
break;
}
}
Err(e) => {
tracing::error!("Error deleting audit log: {:?}", e);
break;
}
}
}
if let Err(e) = sqlx::query_scalar!(
@@ -2373,7 +2389,19 @@ pub async fn monitor_db(
let verify_license_key_f = async {
#[cfg(feature = "enterprise")]
if !initial_load {
verify_license_key().await;
verify_license_key(conn.as_sql()).await;
}
};
let enforce_offline_caps_f = async {
#[cfg(feature = "enterprise")]
if server_mode && !initial_load {
if let Some(db) = conn.as_sql() {
// Cheap: one query for workers active in the last 2 minutes.
if let Err(e) = windmill_common::ee_oss::enforce_offline_caps(db).await {
tracing::error!("Failed to enforce offline license caps: {e:#}");
}
}
}
};
@@ -2522,6 +2550,7 @@ pub async fn monitor_db(
vacuum_queue_f,
expose_queue_metrics_f,
verify_license_key_f,
enforce_offline_caps_f,
worker_groups_alerts_f,
jobs_waiting_alerts_f,
low_disk_alerts_f,
@@ -2853,6 +2882,11 @@ pub async fn reload_base_url_setting(conn: &Connection) -> error::Result<()> {
IS_SECURE.store(is_secure, Ordering::Relaxed);
#[cfg(feature = "enterprise")]
{
crate::ee_oss::verify_license_key(conn.as_sql()).await;
}
Ok(())
}
@@ -0,0 +1,120 @@
//! Regression test for the cross-workspace custom_path conflict.
//!
//! When custom paths are instance-global (CLOUD_HOSTED unset and
//! `app_workspaced_route` off — the default for dedicated instances), a
//! custom_path is a single global route slot. The uniqueness check correctly
//! blocks two apps from claiming it, including the same logical app deployed
//! to two workspaces (staging/prod, git-sync). The bug was that the error
//! ("App with custom path <x> already exists") gave the operator no idea
//! where the conflicting copy lived. This test pins down:
//! - a single-workspace edit keeping its own custom_path still succeeds
//! (the app's own row is excluded),
//! - a real conflict is still rejected, and
//! - the error now names the conflicting app's path and workspace.
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {}", token))
}
fn new_app(path: &str, custom_path: &str) -> serde_json::Value {
json!({
"path": path,
"summary": "Test app",
"value": { "type": "rawapp", "inline_script": null },
"policy": { "execution_mode": "anonymous", "triggerables": {} },
"custom_path": custom_path
})
}
#[sqlx::test(fixtures("app_custom_path_cross_workspace"))]
async fn test_custom_path_cross_workspace_deploy(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let ws_a = format!("http://localhost:{port}/api/w/test-workspace");
let ws_b = format!("http://localhost:{port}/api/w/test-workspace-2");
let app_path = "f/Newsletter/newsletter_composer";
let custom_path = "newsletter";
// 1. Create the app with a custom path in workspace A.
let resp = authed(client().post(format!("{ws_a}/apps/create")), "SECRET_TOKEN")
.json(&new_app(app_path, custom_path))
.send()
.await?;
assert_eq!(
resp.status(),
201,
"create app in ws A should succeed: {}",
resp.text().await?
);
// 2. Editing the app in its own workspace, keeping the same custom path,
// must still succeed — the app's own row is excluded from the check.
// (This is the common single-workspace deploy; it must not regress.)
let resp = authed(
client().post(format!("{ws_a}/apps/update/{app_path}")),
"SECRET_TOKEN",
)
.json(&json!({
"summary": "Test app (edited)",
"value": { "type": "rawapp", "inline_script": null },
"policy": { "execution_mode": "anonymous", "triggerables": {} },
"custom_path": custom_path
}))
.send()
.await?;
assert_eq!(
resp.status(),
200,
"editing an app in its own workspace keeping its custom path must succeed: {}",
resp.text().await?
);
// 3. Deploying the same app (same path) to a second workspace is a real
// conflict in global mode (one global route slot). It must be rejected,
// and the error must name the conflicting workspace + app so the
// operator knows what to resolve.
let resp = authed(client().post(format!("{ws_b}/apps/create")), "SECRET_TOKEN")
.json(&new_app(app_path, custom_path))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(
status, 400,
"same custom path in another workspace is a global conflict: {body}"
);
assert!(
body.contains("test-workspace") && body.contains(app_path),
"error must name the conflicting workspace and app, got: {body}"
);
// 4. A genuinely different app claiming the in-use custom path is still
// rejected, with the same actionable message.
let resp = authed(client().post(format!("{ws_a}/apps/create")), "SECRET_TOKEN")
.json(&new_app("f/Other/other_app", custom_path))
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(
status, 400,
"a different app must not steal an in-use custom path: {body}"
);
assert!(
body.contains(app_path),
"error must name the app already using the custom path, got: {body}"
);
Ok(())
}
+57
View File
@@ -577,6 +577,63 @@ export function main() {
Ok(())
}
/// Regression test: a `//nobundling` script that pulls a package whose CJS
/// internals do bare-specifier `require()` of a sibling dependency.
///
/// Before the `--preserve-symlinks` fix, Bun 1.2/1.3+ would follow the
/// directory symlink in `node_modules/@langchain/core` to its global cache
/// entry, walk parent dirs from the cache realpath, and fail to find
/// `node_modules/zod` — producing:
/// ENOENT while resolving package 'zod/v3' from
/// '.../cache_nomount/bun/@langchain/core@<ver>@@@1/dist/runnables/base.js'
///
/// The fix passes `--preserve-symlinks` so Bun resolves from the
/// symlink path under `<job_dir>/node_modules/`, where `zod` is a sibling.
#[sqlx::test(fixtures("base"))]
async fn test_bun_nobundling_transitive_require(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let content = r#"//nobundling
import { ChatPromptTemplate } from "@langchain/core/prompts";
export async function main() {
const tpl = ChatPromptTemplate.fromMessages([
["system", "you are a {role}"],
["human", "{input}"],
]);
const out = await tpl.formatMessages({ role: "tester", input: "ping" });
return out.length;
}
"#
.to_owned();
let job = JobPayload::Code(RawCode {
hash: None,
content,
path: None,
language: ScriptLang::Bun,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
modules: None,
tag: None,
});
let result = run_job_in_new_worker_until_complete(&db, false, job, port)
.await
.json_result()
.unwrap();
assert_eq!(result, serde_json::json!(2));
Ok(())
}
// ============================================================================
// Native Mode Tests (requires deno_core feature)
// ============================================================================
@@ -0,0 +1,153 @@
-- Fixture for app_custom_path_cross_workspace regression test.
-- Two workspaces sharing the same admin user, so the same logical app
-- (same `path`) can be deployed to both — exercising the instance-global
-- custom_path uniqueness behavior (CLOUD_HOSTED unset and
-- app_workspaced_route off, the default for dedicated instances).
INSERT INTO workspace
(id, name, owner)
VALUES ('test-workspace', 'test-workspace', 'test-user');
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
('test-workspace', 'test@windmill.dev', 'test-user', true, 'Admin');
INSERT INTO workspace_key(workspace_id, kind, key) VALUES
('test-workspace', 'cloud', 'test-key');
INSERT INTO workspace_settings (workspace_id) VALUES
('test-workspace');
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
('test-workspace', 'all', 'All users', '{}');
INSERT INTO password(email, password_hash, login_type, super_admin, verified, name, username)
VALUES ('test@windmill.dev', 'not-a-real-hash', 'password', true, true, 'Test User', 'test-user');
-- Second workspace, same admin user. Lets us deploy the same app path to
-- two workspaces, which is what triggered the custom_path conflict.
INSERT INTO workspace (id, name, owner) VALUES
('test-workspace-2', 'test-workspace-2', 'test-user');
INSERT INTO usr(workspace_id, email, username, is_admin, role) VALUES
('test-workspace-2', 'test@windmill.dev', 'test-user', true, 'Admin');
INSERT INTO workspace_key(workspace_id, kind, key) VALUES
('test-workspace-2', 'cloud', 'test-key-2');
INSERT INTO workspace_settings (workspace_id) VALUES
('test-workspace-2');
INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES
('test-workspace-2', 'all', 'All users', '{}');
-- super_admin token so custom_path edits pass require_admin in both workspaces.
INSERT INTO token(token_hash, token_prefix, token, email, label, super_admin)
VALUES (encode(sha256('SECRET_TOKEN'::bytea), 'hex'), 'SECRET_TOK', 'SECRET_TOKEN', 'test@windmill.dev', 'test token', true);
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_admin;
GRANT ALL PRIVILEGES ON TABLE workspace_key TO windmill_user;
CREATE FUNCTION "notify_insert_on_completed_job" ()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('completed', NEW.id::text);
RETURN NEW;
END;
$$ LANGUAGE PLPGSQL;
CREATE TRIGGER "notify_insert_on_completed_job"
AFTER INSERT ON "v2_job_completed"
FOR EACH ROW
EXECUTE FUNCTION "notify_insert_on_completed_job" ();
CREATE FUNCTION "notify_queue" ()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('queued', NEW.id::text);
RETURN NEW;
END;
$$ LANGUAGE PLPGSQL;
CREATE TRIGGER "notify_queue_after_insert"
AFTER INSERT ON "v2_job_queue"
FOR EACH ROW
EXECUTE FUNCTION "notify_queue" ();
CREATE TRIGGER "notify_queue_after_flow_status_update"
AFTER UPDATE ON "v2_job_status"
FOR EACH ROW
WHEN (NEW.flow_status IS DISTINCT FROM OLD.flow_status)
EXECUTE FUNCTION "notify_queue" ();
-- Apply phase 4:
DROP FUNCTION IF EXISTS v2_job_after_update CASCADE;
DROP FUNCTION IF EXISTS v2_job_completed_before_insert CASCADE;
DROP FUNCTION IF EXISTS v2_job_completed_before_update CASCADE;
DROP FUNCTION IF EXISTS v2_job_queue_after_insert CASCADE;
DROP FUNCTION IF EXISTS v2_job_queue_before_insert CASCADE;
DROP FUNCTION IF EXISTS v2_job_queue_before_update CASCADE;
DROP FUNCTION IF EXISTS v2_job_runtime_before_insert CASCADE;
DROP FUNCTION IF EXISTS v2_job_runtime_before_update CASCADE;
DROP FUNCTION IF EXISTS v2_job_status_before_insert CASCADE;
DROP FUNCTION IF EXISTS v2_job_status_before_update CASCADE;
DROP VIEW IF EXISTS completed_job, completed_job_view, job, queue, queue_view CASCADE;
ALTER TABLE v2_job_queue
DROP COLUMN IF EXISTS __parent_job CASCADE,
DROP COLUMN IF EXISTS __created_by CASCADE,
DROP COLUMN IF EXISTS __script_hash CASCADE,
DROP COLUMN IF EXISTS __script_path CASCADE,
DROP COLUMN IF EXISTS __args CASCADE,
DROP COLUMN IF EXISTS __logs CASCADE,
DROP COLUMN IF EXISTS __raw_code CASCADE,
DROP COLUMN IF EXISTS __canceled CASCADE,
DROP COLUMN IF EXISTS __last_ping CASCADE,
DROP COLUMN IF EXISTS __job_kind CASCADE,
DROP COLUMN IF EXISTS __env_id CASCADE,
DROP COLUMN IF EXISTS __schedule_path CASCADE,
DROP COLUMN IF EXISTS __permissioned_as CASCADE,
DROP COLUMN IF EXISTS __flow_status CASCADE,
DROP COLUMN IF EXISTS __raw_flow CASCADE,
DROP COLUMN IF EXISTS __is_flow_step CASCADE,
DROP COLUMN IF EXISTS __language CASCADE,
DROP COLUMN IF EXISTS __same_worker CASCADE,
DROP COLUMN IF EXISTS __raw_lock CASCADE,
DROP COLUMN IF EXISTS __pre_run_error CASCADE,
DROP COLUMN IF EXISTS __email CASCADE,
DROP COLUMN IF EXISTS __visible_to_owner CASCADE,
DROP COLUMN IF EXISTS __mem_peak CASCADE,
DROP COLUMN IF EXISTS __root_job CASCADE,
DROP COLUMN IF EXISTS __leaf_jobs CASCADE,
DROP COLUMN IF EXISTS __concurrent_limit CASCADE,
DROP COLUMN IF EXISTS __concurrency_time_window_s CASCADE,
DROP COLUMN IF EXISTS __timeout CASCADE,
DROP COLUMN IF EXISTS __flow_step_id CASCADE,
DROP COLUMN IF EXISTS __cache_ttl CASCADE;
LOCK TABLE v2_job_queue IN ACCESS EXCLUSIVE MODE;
ALTER TABLE v2_job_completed
DROP COLUMN IF EXISTS __parent_job CASCADE,
DROP COLUMN IF EXISTS __created_by CASCADE,
DROP COLUMN IF EXISTS __created_at CASCADE,
DROP COLUMN IF EXISTS __success CASCADE,
DROP COLUMN IF EXISTS __script_hash CASCADE,
DROP COLUMN IF EXISTS __script_path CASCADE,
DROP COLUMN IF EXISTS __args CASCADE,
DROP COLUMN IF EXISTS __logs CASCADE,
DROP COLUMN IF EXISTS __raw_code CASCADE,
DROP COLUMN IF EXISTS __canceled CASCADE,
DROP COLUMN IF EXISTS __job_kind CASCADE,
DROP COLUMN IF EXISTS __env_id CASCADE,
DROP COLUMN IF EXISTS __schedule_path CASCADE,
DROP COLUMN IF EXISTS __permissioned_as CASCADE,
DROP COLUMN IF EXISTS __raw_flow CASCADE,
DROP COLUMN IF EXISTS __is_flow_step CASCADE,
DROP COLUMN IF EXISTS __language CASCADE,
DROP COLUMN IF EXISTS __is_skipped CASCADE,
DROP COLUMN IF EXISTS __raw_lock CASCADE,
DROP COLUMN IF EXISTS __email CASCADE,
DROP COLUMN IF EXISTS __visible_to_owner CASCADE,
DROP COLUMN IF EXISTS __tag CASCADE,
DROP COLUMN IF EXISTS __priority CASCADE;
+106
View File
@@ -760,6 +760,112 @@ def main():
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base"))]
async fn test_python_result_preserves_infinity_in_string(db: Pool<Postgres>) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let content = r#"
def main():
return {
"plain": "Infinity",
"embedded": "value=-Infinity end",
"nan_word": "this is NaN inside text",
"nested": [{"k": "Infinity"}],
}
"#
.to_owned();
let job = JobPayload::Code(RawCode {
hash: None,
content,
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
modules: None,
tag: None,
});
let result = run_job_in_new_worker_until_complete(&db, false, job, port)
.await
.json_result()
.unwrap();
assert_eq!(
result,
serde_json::json!({
"plain": "Infinity",
"embedded": "value=-Infinity end",
"nan_word": "this is NaN inside text",
"nested": [{"k": "Infinity"}],
})
);
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base"))]
async fn test_python_result_non_finite_floats_become_null(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let content = r#"
def main():
return {
"inf": float("inf"),
"neg_inf": float("-inf"),
"nan": float("nan"),
"finite": 1.5,
"nested": [float("inf"), {"x": float("nan")}],
}
"#
.to_owned();
let job = JobPayload::Code(RawCode {
hash: None,
content,
path: None,
language: ScriptLang::Python3,
lock: None,
concurrency_settings: windmill_common::runnable_settings::ConcurrencySettings::default()
.into(),
debouncing_settings: windmill_common::runnable_settings::DebouncingSettings::default(),
cache_ttl: None,
cache_ignore_s3_path: None,
dedicated_worker: None,
modules: None,
tag: None,
});
let result = run_job_in_new_worker_until_complete(&db, false, job, port)
.await
.json_result()
.unwrap();
assert_eq!(
result,
serde_json::json!({
"inf": null,
"neg_inf": null,
"nan": null,
"finite": 1.5,
"nested": [null, {"x": null}],
})
);
Ok(())
}
#[cfg(feature = "python")]
#[sqlx::test(fixtures("base"))]
async fn test_python_global_site_packages(db: Pool<Postgres>) -> anyhow::Result<()> {
+122
View File
@@ -0,0 +1,122 @@
use std::collections::HashMap;
use sqlx::{Pool, Postgres};
use windmill_api_client::types::{NewScript, ScriptLang};
use windmill_test_utils::init_client;
fn quick_ns(content: &str, path: &str, kind: Option<&str>) -> NewScript {
NewScript {
content: content.into(),
language: ScriptLang::Bun,
lock: None,
parent_hash: None,
path: path.into(),
concurrent_limit: None,
concurrency_time_window_s: None,
cache_ttl: None,
dedicated_worker: None,
description: "".to_string(),
draft_only: None,
envs: vec![],
is_template: None,
kind: kind.map(|s| s.to_string()),
summary: "".to_string(),
tag: None,
schema: HashMap::new(),
ws_error_handler_muted: Some(false),
priority: None,
delete_after_secs: None,
timeout: None,
restart_unless_cancelled: None,
deployment_message: None,
concurrency_key: None,
visible_to_runner_only: None,
auto_kind: None,
codebase: None,
has_preprocessor: None,
on_behalf_of_email: None,
assets: vec![],
modules: None,
}
}
/// Regression: a `failure`-kind script must never be marked `auto_kind = 'lib'`
/// even if the parser fails to detect a `main` function, because the flow
/// error-handler picker filters out lib scripts and would otherwise hide it.
#[sqlx::test(fixtures("base"))]
async fn failure_kind_script_without_main_is_not_marked_lib(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
let (client, _port, _s) = init_client(db.clone()).await;
// Content with no `main` — TS parser would normally set auto_kind = 'lib'.
client
.create_script(
"test-workspace",
&quick_ns(
"export function notMain() { return 42 }",
"u/test-user/failure_no_main",
Some("failure"),
),
)
.await
.unwrap();
let auto_kind: Option<String> = sqlx::query_scalar(
"SELECT auto_kind FROM script \
WHERE workspace_id = $1 AND path = $2",
)
.bind("test-workspace")
.bind("u/test-user/failure_no_main")
.fetch_one(&db)
.await?;
assert_ne!(
auto_kind.as_deref(),
Some("lib"),
"failure-kind script must not be marked as 'lib' auto_kind, got {:?}",
auto_kind
);
Ok(())
}
/// Sibling: a normal `script` kind WITHOUT main should still be marked `lib`
/// (so it stays hidden from the regular script picker). Guards against an
/// over-broad sanitizer accidentally clearing the value for plain scripts.
#[sqlx::test(fixtures("base"))]
async fn regular_script_without_main_is_still_marked_lib(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
let (client, _port, _s) = init_client(db.clone()).await;
client
.create_script(
"test-workspace",
&quick_ns(
"export function notMain() { return 42 }",
"u/test-user/script_no_main",
Some("script"),
),
)
.await
.unwrap();
let auto_kind: Option<String> = sqlx::query_scalar(
"SELECT auto_kind FROM script \
WHERE workspace_id = $1 AND path = $2",
)
.bind("test-workspace")
.bind("u/test-user/script_no_main")
.fetch_one(&db)
.await?;
assert_eq!(
auto_kind.as_deref(),
Some("lib"),
"regular script without main should be marked 'lib', got {:?}",
auto_kind
);
Ok(())
}
@@ -0,0 +1,148 @@
//! Regression test for `auto_parent` when all versions at a script path are
//! archived (e.g. after a rename).
//!
//! The CLI's `wmill sync push` sends `parent_hash` together with
//! `auto_parent: true`, delegating parent resolution to the backend. When every
//! version at the target path is archived, there is no active head, so the
//! stale `parent_hash` (an archived ancestor) used to leak into the lineage
//! check and produce a spurious
//! `lineage must be linear: no 2 scripts can have the same parent` error
//! whenever that archived hash already had a child from the prior rename.
//!
//! The fix clears `parent_hash` to `None` in that case so the push starts a
//! fresh lineage instead of failing.
use serde_json::json;
use sqlx::{Pool, Postgres};
use windmill_test_utils::*;
fn client() -> reqwest::Client {
reqwest::Client::new()
}
fn authed(builder: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
builder.header("Authorization", format!("Bearer {}", token))
}
fn new_script(path: &str, content: &str) -> serde_json::Value {
json!({
"path": path,
"summary": "",
"description": "",
"content": content,
"language": "deno",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": {},
"required": []
}
})
}
#[sqlx::test(fixtures("base"))]
async fn test_auto_parent_starts_fresh_lineage_when_all_versions_archived(
db: Pool<Postgres>,
) -> anyhow::Result<()> {
initialize_tracing().await;
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let base = format!("http://localhost:{port}/api/w/test-workspace");
let original_path = "u/test-user/script_archived_parent";
let renamed_path = "u/test-user/script_renamed";
// 1. Create the initial version at the original path.
let resp = authed(
client().post(format!("{base}/scripts/create")),
"SECRET_TOKEN",
)
.json(&new_script(
original_path,
"export async function main() { return 1; }",
))
.send()
.await?;
assert_eq!(resp.status(), 201);
let original_hash: String = resp.text().await?;
// 2. Rename the script (new path, parent_hash pointing at v1). This
// archives the original hash and gives the new version a
// `parent_hashes[1]` equal to `original_hash`, so the original path now
// has only archived versions.
let mut rename = new_script(renamed_path, "export async function main() { return 2; }");
rename["parent_hash"] = json!(original_hash);
let resp = authed(
client().post(format!("{base}/scripts/create")),
"SECRET_TOKEN",
)
.json(&rename)
.send()
.await?;
assert_eq!(
resp.status(),
201,
"rename should succeed: {}",
resp.text().await?
);
// Sanity: the original path has no active (non-archived) version.
let active_at_original: bool = sqlx::query_scalar(
"SELECT EXISTS(SELECT 1 FROM script WHERE path = $1 AND archived = false AND workspace_id = $2)",
)
.bind(original_path)
.bind("test-workspace")
.fetch_one(&db)
.await?;
assert!(
!active_at_original,
"all versions at the original path should be archived after rename"
);
// 3. Reproduce `wmill sync push`: push back to the original path with the
// stale archived `parent_hash` AND `auto_parent: true`. Before the fix
// this returned 400 "lineage must be linear" because the archived hash
// already had a child (the renamed version) sharing the same parent.
let mut push = new_script(original_path, "export async function main() { return 3; }");
push["parent_hash"] = json!(original_hash);
push["auto_parent"] = json!(true);
let resp = authed(
client().post(format!("{base}/scripts/create")),
"SECRET_TOKEN",
)
.json(&push)
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
assert_eq!(
status, 201,
"auto_parent push to a path with only archived versions should start a \
fresh lineage, got {status}: {body}"
);
// 4. There is now exactly one active version at the original path and it is
// a fresh lineage with no parent (rather than attaching to the archived
// ancestor).
let active: Vec<Option<Vec<i64>>> = sqlx::query_scalar(
"SELECT parent_hashes FROM script \
WHERE path = $1 AND archived = false AND workspace_id = $2",
)
.bind(original_path)
.bind("test-workspace")
.fetch_all(&db)
.await?;
assert_eq!(
active.len(),
1,
"exactly one active version expected at the original path"
);
assert!(
active[0].is_none(),
"fresh lineage should have no parent_hashes, got {:?}",
active[0]
);
Ok(())
}
@@ -174,6 +174,7 @@ fn make_authed() -> windmill_api_auth::ApiAuthed {
scopes: None,
username_override: None,
token_prefix: None,
read_only: false,
}
}
+1
View File
@@ -24,6 +24,7 @@ base64.workspace = true
bytes.workspace = true
eventsource-stream.workspace = true
futures.workspace = true
http.workspace = true
mime_guess.workspace = true
reqwest.workspace = true
serde.workspace = true
+34 -2
View File
@@ -326,8 +326,10 @@ pub fn json_to_document(value: serde_json::Value) -> aws_smithy_types::Document
}
Value::Array(arr) => Document::Array(arr.into_iter().map(json_to_document).collect()),
Value::Number(num) => {
if let Some(i) = num.as_i64() {
Document::Number(aws_smithy_types::Number::PosInt(i as u64))
if let Some(u) = num.as_u64() {
Document::Number(aws_smithy_types::Number::PosInt(u))
} else if let Some(i) = num.as_i64() {
Document::Number(aws_smithy_types::Number::NegInt(i))
} else if let Some(f) = num.as_f64() {
Document::Number(aws_smithy_types::Number::Float(f))
} else {
@@ -844,6 +846,36 @@ mod tests {
}
}
#[test]
fn json_to_document_preserves_negative_integers() {
let value = serde_json::json!(-1);
let doc = json_to_document(value);
assert!(matches!(
doc,
aws_smithy_types::Document::Number(aws_smithy_types::Number::NegInt(-1))
));
}
#[test]
fn json_to_document_handles_large_u64_above_i64_max() {
let value = serde_json::json!(u64::MAX);
let doc = json_to_document(value);
assert!(matches!(
doc,
aws_smithy_types::Document::Number(aws_smithy_types::Number::PosInt(u)) if u == u64::MAX
));
}
#[test]
fn json_to_document_handles_positive_integers() {
let value = serde_json::json!(42);
let doc = json_to_document(value);
assert!(matches!(
doc,
aws_smithy_types::Document::Number(aws_smithy_types::Number::PosInt(42))
));
}
#[test]
fn openai_messages_to_bedrock_adds_cache_points_when_enabled() {
let messages = vec![
+11 -5
View File
@@ -2,9 +2,9 @@
* This file contains shared AI provider utilities used by both the API and worker.
*/
use serde::{Deserialize, Deserializer, Serialize};
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.
/// Use with `#[serde(default, deserialize_with = "empty_string_as_none")]`
@@ -65,13 +65,19 @@ impl AIProvider {
pub async fn get_base_url(&self, resource_base_url: Option<String>, db: &DB) -> Result<String> {
if let Some(base_url) = resource_base_url {
if !*ALLOW_PRIVATE_AI_BASE_URLS {
use windmill_common::ssrf::SsrfValidationError;
windmill_common::ssrf::validate_url_for_ssrf(&base_url)
.await
.map_err(|e| {
Error::BadRequest(format!(
.map_err(|e| match e {
// The env-var hint is only actionable when the URL is
// well-formed but blocked for targeting a private
// address. For a malformed URL or bad scheme, surface
// the real error so users fix the URL (issue #9171).
e @ SsrfValidationError::Private { .. } => Error::BadRequest(format!(
"{e}. If you need to use private/internal AI endpoints, \
set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable"
))
set the ALLOW_PRIVATE_AI_BASE_URLS=true environment variable"
)),
e => Error::from(e),
})?;
}
return Ok(base_url);
+2
View File
@@ -5,6 +5,8 @@ pub mod ai_google;
pub mod ai_providers;
pub mod ai_types;
pub mod image_handler;
pub mod providers;
pub mod proxy;
pub mod query_builder;
pub mod sse;
pub mod types;
@@ -1,7 +1,4 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use windmill_ai::{
use crate::{
ai_google::parse_data_url,
ai_providers::AIProvider,
image_handler::prepare_messages_for_api,
@@ -10,6 +7,9 @@ use windmill_ai::{
types::*,
utils::{extract_text_content, should_use_structured_output_tool},
};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::value::RawValue;
use windmill_common::{client::AuthedClient, error::Error};
/// Anthropic API version for standard API
@@ -1,4 +1,4 @@
//! AWS Bedrock provider for the AI agent.
//! AWS Bedrock provider for AI requests.
//!
//! Uses shared SDK code from windmill_ai::ai_bedrock for:
//! - BedrockClient (SDK wrapper with auth)
@@ -6,16 +6,16 @@
//! - Stream event parsing
//! - Helper utilities
use std::collections::HashMap;
use windmill_ai::{
use crate::{
image_handler::prepare_messages_for_api,
query_builder::{ParsedResponse, StreamEventSink},
types::{OpenAIMessage, StreamingEvent, TokenUsage, ToolDef},
};
use std::collections::HashMap;
use windmill_common::{client::AuthedClient, error::Error};
// Import shared Bedrock helpers for worker-specific orchestration.
use windmill_ai::ai_bedrock::{
// Import shared Bedrock helpers for provider orchestration.
use crate::ai_bedrock::{
bedrock_model_supports_prompt_caching, bedrock_stream_event_is_block_stop,
bedrock_stream_event_to_text, bedrock_stream_event_to_tool_delta,
bedrock_stream_event_to_tool_start, build_tool_config, create_inference_config,
@@ -24,7 +24,7 @@ use windmill_ai::ai_bedrock::{
};
// ============================================================================
// Query Builder (Worker-specific orchestration)
// Query Builder
// ============================================================================
#[derive(Default)]
@@ -1,5 +1,4 @@
use async_trait::async_trait;
use windmill_ai::{
use crate::{
ai_google::{
openai_messages_to_gemini, openai_tools_to_gemini, GeminiGenerationConfig,
GeminiImageContent, GeminiImageRequest, GeminiImageResponse, GeminiInlineData, GeminiPart,
@@ -10,6 +9,7 @@ use windmill_ai::{
sse::{GeminiSSEParser, SSEParser},
types::*,
};
use async_trait::async_trait;
use windmill_common::{client::AuthedClient, error::Error};
// ============================================================================
+49
View File
@@ -0,0 +1,49 @@
pub mod anthropic;
#[cfg(feature = "bedrock")]
pub mod bedrock;
pub mod google_ai;
pub mod openai;
pub mod openrouter;
pub mod other;
use crate::{
ai_providers::AIProvider, proxy::ProviderCredentials, query_builder::QueryBuilder,
types::ProviderWithResource,
};
use self::{
anthropic::AnthropicQueryBuilder, google_ai::GoogleAIQueryBuilder, openai::OpenAIQueryBuilder,
openrouter::OpenRouterQueryBuilder, other::OtherQueryBuilder,
};
/// Factory function to create the appropriate query builder for a provider.
pub fn create_query_builder(provider: &ProviderWithResource) -> Box<dyn QueryBuilder> {
match provider.kind {
AIProvider::GoogleAI => {
Box::new(GoogleAIQueryBuilder::new(provider.get_platform().clone()))
}
AIProvider::OpenAI => Box::new(OpenAIQueryBuilder::new(provider.kind.clone())),
AIProvider::Anthropic => Box::new(AnthropicQueryBuilder::new(
provider.kind.clone(),
provider.get_platform().clone(),
provider.get_enable_1m_context(),
)),
AIProvider::OpenRouter => Box::new(OpenRouterQueryBuilder::new()),
_ => Box::new(OtherQueryBuilder::new(provider.kind.clone())),
}
}
/// Factory function to create the appropriate query builder from resolved proxy credentials.
pub fn create_proxy_query_builder(credentials: &ProviderCredentials) -> Box<dyn QueryBuilder> {
match credentials.provider {
AIProvider::GoogleAI => Box::new(GoogleAIQueryBuilder::new(credentials.platform.clone())),
AIProvider::OpenAI => Box::new(OpenAIQueryBuilder::new(credentials.provider.clone())),
AIProvider::Anthropic => Box::new(AnthropicQueryBuilder::new(
credentials.provider.clone(),
credentials.platform.clone(),
credentials.enable_1m_context,
)),
AIProvider::OpenRouter => Box::new(OpenRouterQueryBuilder::new()),
_ => Box::new(OtherQueryBuilder::new(credentials.provider.clone())),
}
}

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