Merge branch 'main' into free-token-limit

This commit is contained in:
Diego Imbert
2026-07-15 11:14:17 +02:00
committed by GitHub
65 changed files with 2657 additions and 469 deletions
+15
View File
@@ -31,6 +31,21 @@ The `up.sql` usually defines:
- trigger-specific fields
- Indexes on foreign keys + any frequently-filtered columns
- Foreign key to `workspace`
- The RLS policies (`see_own`, `see_member`, `see_folder_extra_perms_user_*`, `see_extra_perms_user_*`, `see_extra_perms_groups_*`), copied from an existing trigger table
**RLS: wrap every session GUC read in a scalar sub-select.** Write the session
reads as `(select current_setting('session.user'))`,
`= any((select regexp_split_to_array(current_setting('session.groups'), ','))::text[])`,
`?| (select regexp_split_to_array(current_setting('session.pgroups'), ','))::text[]`,
`? (select concat('u/', current_setting('session.user')))`, etc. — not the bare
`current_setting(...)`. The GUCs are set with `SET LOCAL`, so the sub-select
hoists them to a one-time InitPlan instead of re-evaluating per scanned row.
Put the `::text[]` cast **outside** the sub-select for the array cases: in an
`= any (...)` context, casting inside — `= any((select ...::text[]))` — makes
Postgres parse the operand as a row-returning subquery and fails at CREATE with
`operator does not exist: text = text[]`. The outside cast keeps it in
array-operand form. See migration `20260714230440_wrap_session_gucs_in_rls_policies`
for the canonical wrapped forms.
Down migration drops the table and any enum types.
+23 -1
View File
@@ -239,13 +239,35 @@ jobs:
- name: cargo test
timeout-minutes: 30
env:
# setup-rust-toolchain exports RUSTFLAGS=-D warnings, and the RUSTFLAGS env
# var fully REPLACES (never merges with) target.*.rustflags in
# backend/.cargo/config.toml. That silently drops the config's
# `-C link-arg=-fuse-ld=mold`, so CI links the many large integration-test
# binaries (v8 + duckdb + every language runtime, statically linked) with the
# default bfd linker. Its peak memory across ~12 parallel links OOM-kills the
# runner mid-link (SIGTERM => exit 143, before any test runs). Re-add the mold
# link arg here so CI links with mold like local dev, keeping -D warnings.
# (config.toml's `linker = "clang"` still applies; env only overrides rustflags.)
RUSTFLAGS: "-D warnings -C link-arg=-fuse-ld=mold"
SQLX_OFFLINE: true
DATABASE_URL: postgres://postgres:changeme@localhost:5432/windmill
DISABLE_EMBEDDING: true
RUST_LOG: "off"
RUST_LOG_STYLE: never
CARGO_NET_GIT_FETCH_WITH_CLI: true
CARGO_BUILD_JOBS: 12
# Cap parallel rustc/link jobs below the 16 available cores. The tail of
# the build links ~128 full-graph test binaries (one per tests/*.rs file
# across the workspace); at high parallelism enough heavy codegen+link
# units (rustc ~2.6GB, mold ~1GB each) overlap to exhaust the 64GB
# runner. Matches backend-test-windows.yml, which already uses 8.
CARGO_BUILD_JOBS: 8
# Incremental compilation is per-run dead weight in CI: rust-cache
# (cache-workspaces above) restores compiled dependency artifacts but
# never persists target/**/incremental, so there is no prior state to
# reuse in a one-shot `cargo test`. It only adds per-crate memory
# overhead and extra disk. Off here (kept on for local dev via
# .cargo/config.toml). Matches backend-test-windows.yml.
CARGO_INCREMENTAL: "0"
# backend/Cargo.toml leaves profile.dev at the default debug = 2 for
# the (large) windmill workspace crates; that debug info is emitted
# into every object file and embedded in each test binary. Across the
@@ -5,6 +5,12 @@ env:
name: Build caddy-l4
on:
workflow_dispatch:
push:
branches:
- main
paths:
- docker/DockerfileCaddyL4
- .github/workflows/build-caddy-l4-image.yml
permissions: write-all
+1 -1
View File
@@ -27,7 +27,7 @@ jobs:
go build
- name: Pushes to another repository
id: push_directory
uses: cpina/github-action-push-to-another-repository@devel
uses: cpina/github-action-push-to-another-repository@55306faa4ed53b815ae49e564af8cfb359d32ae2 # v1.7.3
env:
API_TOKEN_GITHUB: ${{ secrets.DENO_PAT }}
with:
+49
View File
@@ -1,5 +1,54 @@
# Changelog
## [1.759.0](https://github.com/windmill-labs/windmill/compare/v1.758.0...v1.759.0) (2026-07-15)
### Features
* **nativets:** add Web Crypto support via deno_crypto ([#10109](https://github.com/windmill-labs/windmill/issues/10109)) ([ba23254](https://github.com/windmill-labs/windmill/commit/ba232544e7608731560defc0ed103c3e746e46ea))
* **nativets:** expose the standard web-platform globals deno_web provides ([#10112](https://github.com/windmill-labs/windmill/issues/10112)) ([6d1e12d](https://github.com/windmill-labs/windmill/commit/6d1e12d5e95a6e3fb74c142e33a141b5ce0856a9))
### Bug Fixes
* **jseval:** raise QuickJS eval memory cap to 128MB with clear OOM error ([#10116](https://github.com/windmill-labs/windmill/issues/10116)) ([95d9ff0](https://github.com/windmill-labs/windmill/commit/95d9ff02ee8a92162c06857bac7102c92c708c51))
* **mcp:** advertise flow input variables in MCP tools ([#10117](https://github.com/windmill-labs/windmill/issues/10117)) ([a6191e2](https://github.com/windmill-labs/windmill/commit/a6191e2a855e03d0b03847067787a9de26e9d54a))
* **mcp:** let MCP tokens call preview run tools (jobs:run scope) — Fixes GIT-920 ([#10107](https://github.com/windmill-labs/windmill/issues/10107)) ([4917f79](https://github.com/windmill-labs/windmill/commit/4917f79935acd4ecf9fb5a21d3131dec9ae9da44))
* **nativets:** apply parameter defaults for missing args instead of null ([#10111](https://github.com/windmill-labs/windmill/issues/10111)) ([ba7f9c0](https://github.com/windmill-labs/windmill/commit/ba7f9c065f78641487c2765370be98227dcf1c6a))
* **self-host:** resolve caddy-l4 "unrecognized global option: layer4" error ([#10106](https://github.com/windmill-labs/windmill/issues/10106)) ([770ac2b](https://github.com/windmill-labs/windmill/commit/770ac2be9ed3ff8f647aa3948e1f42d62c97865b))
* **tree-view:** align file indentation with sibling folders ([#10115](https://github.com/windmill-labs/windmill/issues/10115)) ([88030d0](https://github.com/windmill-labs/windmill/commit/88030d0f557a834f2dae80e2a31033261d8b1d1d))
### Performance Improvements
* **rls:** wrap session GUC reads in RLS policies for per-statement InitPlan (GIT-919) ([#10110](https://github.com/windmill-labs/windmill/issues/10110)) ([e9fd4e7](https://github.com/windmill-labs/windmill/commit/e9fd4e7554f624a7e6c3923b9c6b604bee9ebd1f))
## [1.758.0](https://github.com/windmill-labs/windmill/compare/v1.757.0...v1.758.0) (2026-07-14)
### Features
* **ai-agent:** give tools a real description instead of the tool name ([#10083](https://github.com/windmill-labs/windmill/issues/10083)) ([7ebfad3](https://github.com/windmill-labs/windmill/commit/7ebfad382a2649a325444fcb745aca415871fe77))
* **ai-chat:** port flow-group and sticky-note instructions to global chat ([#10090](https://github.com/windmill-labs/windmill/issues/10090)) ([32f32d9](https://github.com/windmill-labs/windmill/commit/32f32d9a29bb214d6aae58c501b8892ceb1c6453))
* **cli:** add --tag override to script and flow run/preview ([#10079](https://github.com/windmill-labs/windmill/issues/10079)) ([98e6cca](https://github.com/windmill-labs/windmill/commit/98e6cca75d4dbc7417c7dd64289e0e8e56b84b0e))
* **sessions:** support many pending sessions persisted in IndexedDB ([#10076](https://github.com/windmill-labs/windmill/issues/10076)) ([bfcec7e](https://github.com/windmill-labs/windmill/commit/bfcec7e8ac71e86ab16d7db789556b5fc7cfd3c7))
### Bug Fixes
* **ai-chat:** size AI-created flow notes to fit their text ([#10091](https://github.com/windmill-labs/windmill/issues/10091)) ([af3e3fe](https://github.com/windmill-labs/windmill/commit/af3e3fe6674de21a03b5c157a649452320c1ed0e))
* **apps:** allow setting sandbox isolation and public access before first deploy ([#10085](https://github.com/windmill-labs/windmill/issues/10085)) ([cfc3f29](https://github.com/windmill-labs/windmill/commit/cfc3f292ad2fdc6067c558e42ef0754eca9469a9))
* **apps:** load themes when selecting the Resources → Theme tab ([#10086](https://github.com/windmill-labs/windmill/issues/10086)) ([f2869d8](https://github.com/windmill-labs/windmill/commit/f2869d8c1a6f84837168d59724a496b39080bcce))
* **frontend:** stop spurious raw-app reload that 404s on "Start without AI" ([#10099](https://github.com/windmill-labs/windmill/issues/10099)) ([2c702ef](https://github.com/windmill-labs/windmill/commit/2c702efec026bebdd9c1b8cdca4808e394b2fd09))
* **mcp:** align script auto_kind filter with scripts list API ([#10098](https://github.com/windmill-labs/windmill/issues/10098)) ([4edffeb](https://github.com/windmill-labs/windmill/commit/4edffeb84b5d691884dc3c274dfd8aa4e9441295))
* **sessions:** pending-draft debounce follow-ups (delete-cancel, keystroke de-transient, teardown count) ([#10087](https://github.com/windmill-labs/windmill/issues/10087)) ([f3cd5d9](https://github.com/windmill-labs/windmill/commit/f3cd5d9f7f370ba0aa27c452e8434454a10e07fe))
* **sessions:** reopen script test panel when preview goes full screen ([#10082](https://github.com/windmill-labs/windmill/issues/10082)) ([eff9076](https://github.com/windmill-labs/windmill/commit/eff9076e9127eaa9b6a47897d5664932c720990b))
### Performance Improvements
* **runs:** index-bound batch re-run selection with a lossless completed_at bound (WIN-2168) ([#10074](https://github.com/windmill-labs/windmill/issues/10074)) ([15391f6](https://github.com/windmill-labs/windmill/commit/15391f6399eef5b85dac116b3ae04e71f70263a1))
## [1.757.0](https://github.com/windmill-labs/windmill/compare/v1.756.1...v1.757.0) (2026-07-14)
+8 -3
View File
@@ -1,15 +1,20 @@
{
layer4 {
:25 {
proxy {
to windmill_server:2525
route {
proxy {
upstream windmill_server:2525
}
}
}
}
}
{$BASE_URL} {
bind {$ADDRESS}
# Default to all interfaces (IPv4 + IPv6) when ADDRESS is unset. A bare
# `bind {$ADDRESS}` with an empty value makes Caddy >= 2.9 drop this whole
# site, silently disabling the HTTP proxy while the :25 layer4 listener stays up.
bind {$ADDRESS:0.0.0.0 ::}
# Extra services: LSP, Multiplayer, Debugger (windmill_extra gateway)
reverse_proxy /ws/* /ws_mp/* /ws_debug/* http://windmill_extra:3000
+1
View File
@@ -162,6 +162,7 @@ ENV PATH /usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
RUN apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends netbase tzdata ca-certificates wget curl jq unzip build-essential unixodbc xmlsec1 tini gnupg libargon2-1 \
&& if echo "$features" | grep -q "ee"; then apt-get install -y --no-install-recommends libsasl2-modules-gssapi-mit krb5-user; fi \
&& apt-get clean \
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT description FROM script WHERE hash = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "description",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Int8",
"Text"
]
},
"nullable": [
false
]
},
"hash": "d8ce7c6f3f82fb806db1c1d3781107233d887b63b950a6f457245a1c3fdbd61e"
}
+346 -194
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.757.0"
version = "1.759.0"
authors.workspace = true
edition.workspace = true
@@ -87,7 +87,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.757.0"
version = "1.759.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
@@ -490,6 +490,10 @@ deno_console = "0.209.0"
deno_url = "0.209.0"
deno_webidl = "0.209.0"
deno_web = "0.240.0"
# Sibling release of the pinned deno_core 0.352 / deno_web 0.240 stack
# (its deps are deno_core ^0.352, deno_web ^0.240, deno_error =0.6.1). A newer
# deno_crypto would force bumping the whole deno stack.
deno_crypto = "0.223.0"
deno_io = "0.119.0"
deno_fs = "0.119.0"
deno_net = "0.201.0"
@@ -0,0 +1,68 @@
-- Inverse of the up migration: strip the scalar sub-select wrapping from the
-- session GUC reads in RLS predicates, restoring the bare per-row forms.
--
-- Postgres re-deparses the wrapped sub-selects into a canonical shape
-- ( SELECT <expr> AS <alias>), and drops the redundant ::text[] cast in the
-- jsonb `?|` context while keeping it in the `= ANY (...)` context, so the
-- unwrap tolerates the alias and the optional trailing cast.
CREATE FUNCTION pg_temp.unwrap_session_gucs(expr text) RETURNS text AS $fn$
DECLARE e text := expr;
BEGIN
IF e IS NULL THEN
RETURN NULL;
END IF;
-- ( SELECT regexp_split_to_array(current_setting('session.<g>'::text), ','::text) AS regexp_split_to_array)[::text[]]
e := regexp_replace(e,
'\( SELECT (regexp_split_to_array\(current_setting\(''session\.(?:pgroups|groups|folders_read|folders_write)''::text\), '',''::text\)) AS regexp_split_to_array\)(?:::text\[\])?',
'\1', 'g');
-- ( SELECT concat('u/', current_setting('session.user'::text)) AS concat)
e := regexp_replace(e,
'\( SELECT (concat\(''u/'', current_setting\(''session\.user''::text\)\)) AS concat\)',
'\1', 'g');
-- ( SELECT current_setting('session.user'::text) AS current_setting)
e := regexp_replace(e,
'\( SELECT (current_setting\(''session\.user''::text\)) AS current_setting\)',
'\1', 'g');
RETURN e;
END;
$fn$ LANGUAGE plpgsql IMMUTABLE;
DO $do$
DECLARE
r record;
new_qual text;
new_check text;
stmt text;
BEGIN
FOR r IN
SELECT schemaname, tablename, policyname, permissive, cmd, roles, qual, with_check
FROM pg_policies
WHERE schemaname = 'public'
AND (coalesce(qual, '') LIKE '%current_setting(''session.%'
OR coalesce(with_check, '') LIKE '%current_setting(''session.%')
LOOP
new_qual := pg_temp.unwrap_session_gucs(r.qual);
new_check := pg_temp.unwrap_session_gucs(r.with_check);
EXECUTE format('DROP POLICY %I ON %I.%I', r.policyname, r.schemaname, r.tablename);
stmt := format(
'CREATE POLICY %I ON %I.%I AS %s FOR %s TO %s',
r.policyname, r.schemaname, r.tablename,
r.permissive, r.cmd,
(SELECT string_agg(quote_ident(role_name), ', ') FROM unnest(r.roles) AS role_name)
);
IF new_qual IS NOT NULL THEN
stmt := stmt || format(' USING (%s)', new_qual);
END IF;
IF new_check IS NOT NULL THEN
stmt := stmt || format(' WITH CHECK (%s)', new_check);
END IF;
EXECUTE stmt;
END LOOP;
END;
$do$;
DROP FUNCTION pg_temp.unwrap_session_gucs(text);
@@ -0,0 +1,80 @@
-- Wrap per-row session GUC reads in RLS predicates in a scalar sub-select so
-- Postgres hoists them to a one-time InitPlan instead of re-evaluating
-- current_setting('session.*') once per scanned row.
--
-- The session GUCs are set with SET LOCAL (set_config(..., true)) in
-- set_session_context(), so they are constant for the duration of a statement.
-- Wrapping the session-derived subexpression in (select ...) is therefore
-- value-preserving: same rows in, same rows out, N per-row GUC lookups collapse
-- to 1. Array-producing subexpressions keep an explicit ::text[] cast on the
-- sub-select so `= ANY (...)` / `?|` stay in their array-operand form rather
-- than being reparsed as a row-returning subquery.
--
-- This rewrites every existing policy (recorded across ~30 prior migrations)
-- whose USING / WITH CHECK predicate reads a session GUC, by deparsing the
-- current predicate and substituting the wrapped forms. See the .down.sql for
-- the inverse.
CREATE FUNCTION pg_temp.wrap_session_gucs(expr text) RETURNS text AS $fn$
DECLARE e text := expr;
BEGIN
IF e IS NULL THEN
RETURN NULL;
END IF;
-- Protect the maximal session-derived subexpressions with placeholders first,
-- so the bare-scalar pass below only touches genuinely-bare session.user reads.
e := replace(e, 'regexp_split_to_array(current_setting(''session.pgroups''::text), '',''::text)', '@@WM_P0@@');
e := replace(e, 'regexp_split_to_array(current_setting(''session.groups''::text), '',''::text)', '@@WM_P1@@');
e := replace(e, 'regexp_split_to_array(current_setting(''session.folders_read''::text), '',''::text)', '@@WM_P2@@');
e := replace(e, 'regexp_split_to_array(current_setting(''session.folders_write''::text), '',''::text)', '@@WM_P3@@');
e := replace(e, 'concat(''u/'', current_setting(''session.user''::text))', '@@WM_P4@@');
-- Wrap any remaining bare scalar session.user read.
e := replace(e, 'current_setting(''session.user''::text)', '(select current_setting(''session.user''::text))');
-- Restore the protected subexpressions, now wrapped in a scalar sub-select.
e := replace(e, '@@WM_P0@@', '(select regexp_split_to_array(current_setting(''session.pgroups''::text), '',''::text))::text[]');
e := replace(e, '@@WM_P1@@', '(select regexp_split_to_array(current_setting(''session.groups''::text), '',''::text))::text[]');
e := replace(e, '@@WM_P2@@', '(select regexp_split_to_array(current_setting(''session.folders_read''::text), '',''::text))::text[]');
e := replace(e, '@@WM_P3@@', '(select regexp_split_to_array(current_setting(''session.folders_write''::text), '',''::text))::text[]');
e := replace(e, '@@WM_P4@@', '(select concat(''u/'', current_setting(''session.user''::text)))');
RETURN e;
END;
$fn$ LANGUAGE plpgsql IMMUTABLE;
DO $do$
DECLARE
r record;
new_qual text;
new_check text;
stmt text;
BEGIN
FOR r IN
SELECT schemaname, tablename, policyname, permissive, cmd, roles, qual, with_check
FROM pg_policies
WHERE schemaname = 'public'
AND (coalesce(qual, '') LIKE '%current_setting(''session.%'
OR coalesce(with_check, '') LIKE '%current_setting(''session.%')
LOOP
new_qual := pg_temp.wrap_session_gucs(r.qual);
new_check := pg_temp.wrap_session_gucs(r.with_check);
EXECUTE format('DROP POLICY %I ON %I.%I', r.policyname, r.schemaname, r.tablename);
stmt := format(
'CREATE POLICY %I ON %I.%I AS %s FOR %s TO %s',
r.policyname, r.schemaname, r.tablename,
r.permissive, r.cmd,
(SELECT string_agg(quote_ident(role_name), ', ') FROM unnest(r.roles) AS role_name)
);
IF new_qual IS NOT NULL THEN
stmt := stmt || format(' USING (%s)', new_qual);
END IF;
IF new_check IS NOT NULL THEN
stmt := stmt || format(' WITH CHECK (%s)', new_check);
END IF;
EXECUTE stmt;
END LOOP;
END;
$do$;
DROP FUNCTION pg_temp.wrap_session_gucs(text);
+24 -24
View File
@@ -6191,7 +6191,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"aho-corasick",
"anyhow",
@@ -6272,7 +6272,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6284,7 +6284,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"convert_case",
"serde",
@@ -6293,7 +6293,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6305,7 +6305,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6317,7 +6317,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6329,7 +6329,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6341,7 +6341,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6353,7 +6353,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6364,7 +6364,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6375,7 +6375,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6387,7 +6387,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6398,7 +6398,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6420,7 +6420,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6432,7 +6432,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6446,7 +6446,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6463,7 +6463,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6476,7 +6476,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"serde",
@@ -6488,7 +6488,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6506,7 +6506,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6522,7 +6522,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"rustpython-ast",
@@ -6538,7 +6538,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6570,7 +6570,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"serde",
@@ -6581,7 +6581,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.757.0"
version = "1.759.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.757.0"
version = "1.759.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+47
View File
@@ -596,6 +596,17 @@ fn map_http_method_to_action(method: &str, route_path: &str) -> ScopeAction {
/// Returns `"flows"` or `"scripts"` based on the match, or `None` if no match is found.
fn determine_kind_from_route(route_path: &str) -> Option<String> {
if route_path.starts_with("jobs") {
// Preview/bundle runs execute arbitrary code with no deployed path, so
// their handlers require the broad `jobs:run` scope: they must carry no
// kind, else the derived scope is narrower than the handler demands.
// Anchor to the endpoint segment so by-path runs of a deployed runnable
// whose path contains "preview" (e.g. `run/p/f/team/preview_report`) are
// still classified by their kind.
if route_path.starts_with("jobs/run/preview")
|| route_path.starts_with("jobs/run_wait_result/preview")
{
return None;
}
if FLOW_JOBS.iter().any(|path| route_path.starts_with(path)) {
return Some("flows".to_string());
} else if SCRIPT_JOBS.iter().any(|path| route_path.starts_with(path)) {
@@ -1296,6 +1307,42 @@ mod tests {
Some("jobs:run:flows")
);
// Preview/bundle runs have no deployed path and their handlers require the
// broad `jobs:run` scope, so the derived scope must not carry a kind.
for path in [
"/api/w/ws/jobs/run/preview",
"/api/w/ws/jobs/run/preview_bundle",
"/api/w/ws/jobs/run/preview_flow",
"/api/w/ws/jobs/run_wait_result/preview",
"/api/w/ws/jobs/run_wait_result/preview_flow",
] {
assert_eq!(
scope_for_route("POST", path).as_deref(),
Some("jobs:run"),
"preview route {path} must derive the broad jobs:run scope"
);
}
// By-path runs of a deployed runnable whose path contains "preview" must
// still derive their kind (not be swept into the broad jobs:run above),
// otherwise a `jobs:run:scripts:*`/`jobs:run:flows:*` token is denied.
assert_eq!(
scope_for_route("POST", "/api/w/ws/jobs/run/p/u/alice/preview_report").as_deref(),
Some("jobs:run:scripts")
);
assert_eq!(
scope_for_route(
"POST",
"/api/w/ws/jobs/run_wait_result/p/f/team/preview_report"
)
.as_deref(),
Some("jobs:run:scripts")
);
assert_eq!(
scope_for_route("POST", "/api/w/ws/jobs/run/f/f/team/preview_report").as_deref(),
Some("jobs:run:flows")
);
// The minted scope actually satisfies the route check it targets.
let s = scope_for_route("POST", "/api/w/ws/variables/create").unwrap();
assert!(check_route_access(&[s], "/api/w/ws/variables/create", "POST").is_ok());
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.757.0
version: 1.759.0
title: Windmill API
contact:
+4 -1
View File
@@ -149,7 +149,10 @@ pub async fn get_items<T: for<'a> sqlx::FromRow<'a, sqlx::postgres::PgRow> + Sen
.and_where("o.archived = false");
if item_type == "script" {
sqlb.and_where("o.auto_kind IS NULL");
// only exclude library scripts (no main function); pipeline, test, WAC,
// and any future `auto_kind` values remain callable. Mirrors the scripts
// list API deny-list.
sqlb.and_where("(o.auto_kind IS NULL OR o.auto_kind <> 'lib')");
}
if let Some(prefix) = path_prefix {
+294 -28
View File
@@ -290,6 +290,7 @@ pub async fn eval_timeout_quickjs(
.collect();
let expr_clone = expr.clone();
let memory_limit = *QUICKJS_MEMORY_LIMIT_BYTES;
// Run the QuickJS evaluation with a timeout.
// Use the current runtime handle rather than creating an independent
@@ -313,6 +314,7 @@ pub async fn eval_timeout_quickjs(
by_id_clone,
ctx,
context_keys,
memory_limit,
)
.await
})
@@ -326,8 +328,97 @@ pub async fn eval_timeout_quickjs(
})??
}
/// Default memory cap, in bytes, for a single flow step-input transform eval
/// (`eval_timeout_quickjs`). Large enough for transforms that build sizeable
/// arrays; genuinely large payloads raise it via `QUICKJS_MEMORY_LIMIT_MB`.
///
/// Sizing constraint: evals are authenticated and, within a worker process, run
/// one at a time (`transform_input` awaits each transform sequentially and each
/// eval drops its runtime before the next), so in the default one-worker-per-
/// process deployment the peak is a single cap. Native / multi-worker-in-process
/// mode runs up to `NUM_WORKERS` evals concurrently in one heap, so the peak is
/// `NUM_WORKERS × cap` — hence a modest default rather than a large one.
#[cfg(feature = "quickjs")]
const QUICKJS_MEMORY_LIMIT: usize = 32 * 1024 * 1024;
const DEFAULT_QUICKJS_MEMORY_LIMIT_BYTES: usize = 128 * 1024 * 1024;
/// Memory cap, in bytes, for `eval_simple_js` (batch-rerun filter and `retry_if`
/// boolean/arg expressions). Kept lower than the flow-transform cap and NOT tied
/// to `QUICKJS_MEMORY_LIMIT_MB`: `eval_simple_js` runs in the API process, which
/// serves concurrent requests with no per-process serialization, so its peak is
/// `concurrent_requests × cap` and unbounded by worker count. These expressions
/// only remap job args / return a boolean, so a small cap is ample.
#[cfg(feature = "quickjs")]
const EVAL_SIMPLE_JS_MEMORY_LIMIT_BYTES: usize = 32 * 1024 * 1024;
#[cfg(feature = "quickjs")]
lazy_static! {
/// Flow-transform eval memory limit in bytes, resolved once at process start.
/// Overridable via the `QUICKJS_MEMORY_LIMIT_MB` env var (a positive integer
/// in MB); falls back to `DEFAULT_QUICKJS_MEMORY_LIMIT_BYTES` otherwise.
static ref QUICKJS_MEMORY_LIMIT_BYTES: usize = std::env::var("QUICKJS_MEMORY_LIMIT_MB")
.ok()
.and_then(|s| s.parse::<usize>().ok())
.filter(|mb| *mb > 0)
.map(|mb| mb.saturating_mul(1024 * 1024))
.unwrap_or(DEFAULT_QUICKJS_MEMORY_LIMIT_BYTES);
}
/// Convert a caught QuickJS error into an `anyhow::Error`, turning heap
/// exhaustion into a clear, actionable message instead of an opaque one.
///
/// QuickJS signals OOM two ways, neither meaningful to users: an `InternalError`
/// whose message is exactly "out of memory", or — when it cannot even allocate
/// that error — a bare `null`/`undefined` throw that rquickjs renders as
/// "Exception generated by quickjs". Both are detected here from the caught value
/// itself (its kind), which is reliable; a post-hoc heap check is not, because
/// QuickJS ref-count frees the offending allocations as the JS stack unwinds.
///
/// The `InternalError` name is required (not just the message) so that a user's
/// own `throw new Error("out of memory")` — a plain `Error` — is left as a normal
/// error. The one irreducible ambiguity is an explicit `throw null` / `throw
/// undefined`: QuickJS's own OOM null-throw is indistinguishable from it, so those
/// rare user throws are intentionally absorbed into the OOM bucket rather than
/// leaking the opaque error for the far more common genuine-OOM case.
///
/// `env_override` names the env var that tunes the cap for this eval path, or is
/// `None` when the cap is fixed (so the message doesn't advise a setting that
/// wouldn't help).
#[cfg(feature = "quickjs")]
fn map_quickjs_error(
err: rquickjs::CaughtError<'_>,
memory_limit: usize,
env_override: Option<&str>,
) -> anyhow::Error {
let is_oom = match &err {
rquickjs::CaughtError::Exception(e) => {
e.as_object()
.get::<_, Option<String>>("name")
.ok()
.flatten()
.as_deref()
== Some("InternalError")
&& e.message().as_deref() == Some("out of memory")
}
rquickjs::CaughtError::Value(v) => v.is_null() || v.is_undefined(),
rquickjs::CaughtError::Error(_) => false,
};
if is_oom {
let remediation = match env_override {
Some(var) => format!(
"Reduce the amount of data handled in the expression, or raise the \
cap via the {var} environment variable."
),
None => "Reduce the amount of data handled in the expression.".to_string(),
};
anyhow::anyhow!(
"The expression evaluation exceeded the memory limit of {} MB. {}",
memory_limit / (1024 * 1024),
remediation
)
} else {
anyhow::anyhow!("QuickJS evaluation error: {}", err)
}
}
#[cfg(feature = "quickjs")]
async fn eval_quickjs_inner(
@@ -339,9 +430,10 @@ async fn eval_quickjs_inner(
by_id: Option<IdContext>,
extra_ctx: Option<Vec<(String, String)>>,
context_keys: Vec<String>,
memory_limit: usize,
) -> anyhow::Result<Box<RawValue>> {
let runtime = AsyncRuntime::new()?;
runtime.set_memory_limit(QUICKJS_MEMORY_LIMIT).await;
runtime.set_memory_limit(memory_limit).await;
let context = AsyncContext::full(&runtime).await?;
// Create shared state for async ops if we have a client
@@ -467,10 +559,10 @@ async fn eval_quickjs_inner(
};
// Evaluate the expression (returns a Promise that resolves to a JSON string)
let promise: rquickjs::Promise = ctx.eval(code).catch(&ctx).map_err(quickjs_error_to_anyhow)?;
let promise: rquickjs::Promise = ctx.eval(code).catch(&ctx).map_err(|e| map_quickjs_error(e, memory_limit, Some("QUICKJS_MEMORY_LIMIT_MB")))?;
// Await the promise
let result: Value = promise.into_future().await.catch(&ctx).map_err(quickjs_error_to_anyhow)?;
let result: Value = promise.into_future().await.catch(&ctx).map_err(|e| map_quickjs_error(e, memory_limit, Some("QUICKJS_MEMORY_LIMIT_MB")))?;
let json_str = String::from_js(&ctx, result)
.unwrap_or_else(|_| "null".to_string());
@@ -856,34 +948,13 @@ pub async fn eval_simple_js(
expr: String,
globals: HashMap<String, serde_json::Value>,
) -> anyhow::Result<Box<RawValue>> {
let memory_limit = EVAL_SIMPLE_JS_MEMORY_LIMIT_BYTES;
let handle = tokio::runtime::Handle::current();
tokio::time::timeout(
std::time::Duration::from_millis(EVAL_TIMEOUT_MS),
tokio::task::spawn_blocking(move || {
handle.block_on(async move {
let runtime = AsyncRuntime::new()?;
runtime.set_memory_limit(QUICKJS_MEMORY_LIMIT).await;
let context = AsyncContext::full(&runtime).await?;
async_with!(context => |ctx| {
let js_globals = ctx.globals();
// Set up each named global
for (name, value) in &globals {
let js_val = json_to_js(&ctx, value)?;
js_globals.set(name.as_str(), js_val)?;
}
// Wrap expression to return JSON string
let code = format!("JSON.stringify(({}) ?? null)", expr);
let result: String = ctx.eval(code)
.catch(&ctx)
.map_err(quickjs_error_to_anyhow)?;
Ok(unsafe_raw(result))
})
.await
})
handle
.block_on(async move { eval_simple_js_inner(&expr, &globals, memory_limit).await })
}),
)
.await
@@ -892,6 +963,37 @@ pub async fn eval_simple_js(
})??
}
#[cfg(feature = "quickjs")]
async fn eval_simple_js_inner(
expr: &str,
globals: &HashMap<String, serde_json::Value>,
memory_limit: usize,
) -> anyhow::Result<Box<RawValue>> {
let runtime = AsyncRuntime::new()?;
runtime.set_memory_limit(memory_limit).await;
let context = AsyncContext::full(&runtime).await?;
async_with!(context => |ctx| {
let js_globals = ctx.globals();
// Set up each named global
for (name, value) in globals {
let js_val = json_to_js(&ctx, value)?;
js_globals.set(name.as_str(), js_val)?;
}
// Wrap expression to return JSON string
let code = format!("JSON.stringify(({}) ?? null)", expr);
// Fixed cap (EVAL_SIMPLE_JS_MEMORY_LIMIT_BYTES): no env override to suggest.
let result: String = ctx.eval(code)
.catch(&ctx)
.map_err(|e| map_quickjs_error(e, memory_limit, None))?;
Ok(unsafe_raw(result))
})
.await
}
// ── Fallback stubs when quickjs is disabled ──────────────────────────
#[cfg(not(feature = "quickjs"))]
@@ -2715,4 +2817,168 @@ mod tests {
let result = eval_simple_js("this is not valid js @#$".to_string(), globals).await;
assert!(result.is_err());
}
// =====================================================================
// MEMORY LIMIT
// =====================================================================
#[test]
fn test_quickjs_memory_limit_default() {
// Absent (or invalid) env var falls back to the compiled default.
if std::env::var("QUICKJS_MEMORY_LIMIT_MB").is_err() {
assert_eq!(
*QUICKJS_MEMORY_LIMIT_BYTES,
DEFAULT_QUICKJS_MEMORY_LIMIT_BYTES
);
}
}
#[tokio::test]
async fn test_eval_oom_error_reports_memory_limit() {
// A million-element array cannot fit in a 4MB heap. Here QuickJS has room
// to build a proper InternalError; it must surface as a clear memory-limit
// message. This is the fixed-cap eval_simple_js path, so it must NOT
// advise the env var (which does not tune this path).
let err = eval_simple_js_inner(
"Array.from({ length: 1000000 }, (_, i) => i)",
&HashMap::new(),
4 * 1024 * 1024,
)
.await
.expect_err("expected OOM to fail")
.to_string();
assert!(err.contains("memory limit"), "unexpected error: {err}");
assert!(
!err.contains("QUICKJS_MEMORY_LIMIT_MB"),
"fixed-cap path must not advise the env var: {err}"
);
}
#[tokio::test]
async fn test_eval_flow_oom_reports_env_override() {
// The flow step-input transform path is env-tunable, so its OOM message
// must point at QUICKJS_MEMORY_LIMIT_MB.
let err = eval_quickjs_inner(
"Array.from({ length: 1000000 }, (_, i) => i)",
HashMap::new(),
None,
None,
None,
None,
None,
Vec::new(),
4 * 1024 * 1024,
)
.await
.expect_err("expected OOM to fail")
.to_string();
assert!(err.contains("memory limit"), "unexpected error: {err}");
assert!(
err.contains("QUICKJS_MEMORY_LIMIT_MB"),
"no env hint: {err}"
);
}
#[tokio::test]
async fn test_eval_opaque_oom_reports_memory_limit() {
// Under a cap too small to hold it, QuickJS runs out of memory while
// building the flattened array and cannot even allocate the Error object,
// so it throws a bare null that rquickjs renders as the opaque "Exception
// generated by quickjs". That must still be recognised as a memory-limit
// failure rather than surfaced opaquely (issue #8073).
let err = eval_simple_js_inner(
"Array.from({ length: 1000000 }, (_, i) => [i, i + 1, i + 2]).flat().length",
&HashMap::new(),
64 * 1024 * 1024,
)
.await
.expect_err("expected OOM to fail")
.to_string();
assert!(err.contains("memory limit"), "opaque OOM leaked: {err}");
assert!(
!err.contains("Exception generated by quickjs"),
"opaque OOM leaked: {err}"
);
}
#[tokio::test]
async fn test_eval_user_throw_not_reported_as_oom() {
// A transform that throws a non-null value must NOT be misattributed to
// the memory limit — only OOM's null/undefined throw is. The Error cases
// guard the InternalError-kind check: a plain `Error`, even one whose
// message is exactly "out of memory", is a user error, not OOM.
for expr in [
"(() => { throw 'boom' })()",
"(() => { throw new Error('boom') })()",
"(() => { throw new Error('out of memory later') })()",
"(() => { throw new Error('out of memory') })()",
] {
let err = eval_simple_js_inner(expr, &HashMap::new(), 64 * 1024 * 1024)
.await
.expect_err("expected user throw to fail")
.to_string();
assert!(
!err.contains("memory limit"),
"misreported as OOM ({expr}): {err}"
);
}
}
#[tokio::test]
async fn test_eval_bare_null_throw_treated_as_oom() {
// Documents the accepted ambiguity: QuickJS reports OOM as a bare null
// throw, indistinguishable from a user `throw null` / `throw undefined`.
// Absorbing these rare user throws into the OOM bucket is the deliberate
// tradeoff for reliably catching the far more common OOM case.
for expr in ["(() => { throw null })()", "(() => { throw undefined })()"] {
let err = eval_simple_js_inner(expr, &HashMap::new(), 64 * 1024 * 1024)
.await
.expect_err("expected throw to fail")
.to_string();
assert!(
err.contains("memory limit"),
"expected OOM bucket ({expr}): {err}"
);
}
}
#[tokio::test]
async fn test_eval_issue_payload_succeeds_with_raised_cap() {
// The exact #8073 payload exceeds the conservative default but succeeds
// once the cap is raised to 256MB (what the env override lets operators
// do), exercising the flow step-input transform path (eval_timeout_quickjs)
// via eval_quickjs_inner.
let result = eval_quickjs_inner(
"Array.from({ length: 1000000 }, (_, i) => [i, i + 1, i + 2]).flat().length",
HashMap::new(),
None,
None,
None,
None,
None,
Vec::new(),
256 * 1024 * 1024,
)
.await
.expect("issue payload should evaluate once the cap is raised");
assert_eq!(result.get(), "3000000");
}
#[tokio::test]
async fn test_eval_moderately_large_array_under_default() {
// A ~48MB array: comfortably above the 32MB range yet under the 128MB
// default, evaluated through the flow step-input transform path.
let result = eval_timeout_quickjs(
"Array.from({ length: 3000000 }, (_, i) => i).length".to_string(),
HashMap::new(),
None,
None,
None,
None,
None,
)
.await
.expect("moderately large array should evaluate under the default limit");
assert_eq!(result.get(), "3000000");
}
}
+35 -3
View File
@@ -376,6 +376,36 @@ mod tests {
use serde_json::json;
use std::collections::HashMap;
fn raw_schema(raw: &str) -> Option<Schema> {
Some(serde_json::from_str::<Schema>(raw).unwrap())
}
#[test]
fn flow_schema_without_required_keeps_properties() {
// Real flow input schema shape (see backend/tests/worker.rs): it omits
// `required` and carries an `order` key instead. This shape must keep its
// properties, not fall back to an empty schema, so MCP flow tools still
// advertise their inputs.
let flow = raw_schema(
r#"{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"world":{"type":"string"}},"type":"object","order":["world"]}"#,
);
let schema_type = convert_schema_to_schema_type(flow);
assert_eq!(schema_type.r#type, "object");
assert!(schema_type.properties.contains_key("world"));
assert!(schema_type.required.is_empty());
}
#[test]
fn script_schema_with_required_keeps_properties() {
// Script schemas always include `required`; this must remain unaffected.
let script = raw_schema(
r#"{"$schema":"https://json-schema.org/draft/2020-12/schema","properties":{"world":{"type":"string"}},"required":["world"],"type":"object"}"#,
);
let schema_type = convert_schema_to_schema_type(script);
assert!(schema_type.properties.contains_key("world"));
assert_eq!(schema_type.required, vec!["world".to_string()]);
}
fn aws_resources() -> (HashMap<String, Vec<ResourceInfo>>, Vec<ResourceType>) {
let mut cache = HashMap::new();
cache.insert(
@@ -965,9 +995,11 @@ mod tests {
make_schema_compatible(&mut schema);
assert_eq!(schema["properties"]["services"]["type"], json!("array"));
assert!(schema["properties"]["services"]["items"]["properties"]["value"]
.get("type")
.is_none());
assert!(
schema["properties"]["services"]["items"]["properties"]["value"]
.get("type")
.is_none()
);
assert_all_types_valid(&schema);
}
}
+12
View File
@@ -53,14 +53,26 @@ pub struct HubScriptInfo {
}
/// Schema type structure for JSON schemas
///
/// The `#[serde(default)]` attributes are load-bearing: flow input schemas
/// legitimately omit `required` (they carry an `order` key instead) and can omit
/// `type`. Without the defaults, `serde_json::from_str::<SchemaType>` errors on
/// those schemas and callers fall back to an empty schema, dropping every input.
#[derive(Serialize, Deserialize, Debug, Clone)]
#[cfg_attr(feature = "server", derive(FromRow))]
pub struct SchemaType {
#[serde(default = "default_schema_type")]
pub r#type: String,
#[serde(default)]
pub properties: HashMap<String, Value>,
#[serde(default)]
pub required: Vec<String>,
}
fn default_schema_type() -> String {
"object".to_string()
}
impl Default for SchemaType {
fn default() -> Self {
Self { r#type: "object".to_string(), properties: HashMap::new(), required: vec![] }
@@ -20,6 +20,7 @@ windmill-parser-ts.workspace = true
deno_fetch.workspace = true
deno_webidl.workspace = true
deno_web.workspace = true
deno_crypto.workspace = true
deno_net.workspace = true
deno_console.workspace = true
deno_url.workspace = true
@@ -55,6 +56,7 @@ rcgen = "0.13.2"
deno_fetch.workspace = true
deno_webidl.workspace = true
deno_web.workspace = true
deno_crypto.workspace = true
deno_net.workspace = true
deno_console.workspace = true
deno_url.workspace = true
+4 -3
View File
@@ -111,9 +111,9 @@ deno_core::extension!(
// `extension_transpiler` callback for `deno_core::snapshot::create_snapshot`.
//
// Specialized to our snapshot's inputs. Of the seven deno_* extensions
// we register via `init()`, six ship pre-built `.js` files
// in their `esm` lists (webidl/url/console/web/fetch/net) — only
// Specialized to our snapshot's inputs. Of the eight deno_* extensions
// we register via `init()`, seven ship pre-built `.js` files
// in their `esm` lists (webidl/url/console/web/crypto/fetch/net) — only
// `deno_telemetry`'s `extension!` macro lists `.ts` files
// (`telemetry.ts`, `util.ts`), so the TypeScript branch is needed
// solely for that crate. Our local `fetch` extension contributes
@@ -187,6 +187,7 @@ fn main() {
deno_url::deno_url::init(),
deno_console::deno_console::init(),
deno_web::deno_web::init::<PermissionsContainer>(Arc::new(BlobStore::default()), None),
deno_crypto::deno_crypto::init(None),
deno_fetch::deno_fetch::init::<PermissionsContainer>(Default::default()),
deno_net::deno_net::init::<PermissionsContainer>(None, None),
fetch::init(),
+14 -1
View File
@@ -572,6 +572,9 @@ pub(crate) fn create_nativets_runtime(
deno_url::deno_url::init(),
deno_console::deno_console::init(),
deno_web::deno_web::init::<PermissionsContainer>(Arc::new(BlobStore::default()), None),
// Registered after deno_web to keep the snapshot (build.rs) a prefix of
// the runtime extension list; deno_crypto declares deps = [deno_webidl, deno_web].
deno_crypto::deno_crypto::init(None),
deno_fetch::deno_fetch::init::<PermissionsContainer>(fetch_options),
deno_net::deno_net::init::<PermissionsContainer>(None, None),
fetch::init(),
@@ -621,6 +624,13 @@ pub(crate) fn create_nativets_runtime(
op_state.put(LogString { s: log_sender });
}
// Per-isolate JS init that can't run in the snapshot (runtime.js executes at
// snapshot-build time): currently seeds performance.timeOrigin via
// setTimeOrigin(), which must read this isolate's wall clock.
js_runtime
.execute_script("<wm_init>", "globalThis.__wmInitPerIsolate()")
.map_err(windmill_common::error::to_anyhow)?;
Ok(CreatedRuntime { js_runtime, log_receiver, memory_limit_rx })
}
@@ -1010,7 +1020,10 @@ function processStreamIterative(res) {{
{otel_context_inject}
let args = Deno.core.ops.op_get_static_args().map(JSON.parse)
// A slot is `null` only when the arg was not provided: pass `undefined` so the
// parameter default applies (JS defaults ignore `null`), matching the bun runner.
// A provided JSON `null` arrives as the string "null" and stays `null`.
let args = Deno.core.ops.op_get_static_args().map((arg) => arg === null ? undefined : JSON.parse(arg))
import("file:///eval.ts").then((module) => module.{main_fn}(...args))
.then(res => {{
if (isAsyncIterable(res)) {{
@@ -1,4 +1,5 @@
import * as abortSignal from "ext:deno_web/03_abort_signal.js";
import * as domException from "ext:deno_web/01_dom_exception.js";
import * as base64 from "ext:deno_web/05_base64.js";
import * as console from "ext:deno_console/01_console.js";
import * as encoding from "ext:deno_web/08_text_encoding.js";
@@ -15,13 +16,17 @@ import * as net from "ext:deno_net/01_net.js";
import * as tls from "ext:deno_net/02_tls.js";
import * as urlPattern from "ext:deno_url/01_urlpattern.js";
import * as webidl from "ext:deno_webidl/00_webidl.js";
import * as crypto from "ext:deno_crypto/00_crypto.js";
import * as response from "ext:deno_fetch/23_response.js";
import * as request from "ext:deno_fetch/23_request.js";
import "ext:deno_web/02_structured_clone.js";
import "ext:deno_web/04_global_interfaces.js";
import "ext:deno_web/13_message_port.js";
import "ext:deno_web/14_compression.js";
import "ext:deno_web/15_performance.js";
import * as globalInterfaces from "ext:deno_web/04_global_interfaces.js";
// Namespace imports (not side-effect-only) so their constructors are reachable
// for the globalThis wiring below. The module bodies still execute on
// evaluation, so their side effects apply.
import * as messagePort from "ext:deno_web/13_message_port.js";
import * as compression from "ext:deno_web/14_compression.js";
import * as performance from "ext:deno_web/15_performance.js";
import "ext:deno_web/16_image_data.js";
import "ext:deno_fetch/27_eventsource.js";
@@ -41,6 +46,10 @@ globalThis.console = new console.Console((msg, level) =>
);
globalThis.AbortController = abortSignal.AbortController;
globalThis.AbortSignal = abortSignal.AbortSignal;
globalThis.crypto = crypto.crypto;
globalThis.Crypto = crypto.Crypto;
globalThis.CryptoKey = crypto.CryptoKey;
globalThis.SubtleCrypto = crypto.SubtleCrypto;
Object.assign(globalThis, {
clearInterval: timers.clearInterval,
@@ -49,6 +58,93 @@ Object.assign(globalThis, {
setTimeout: timers.setTimeout,
});
// Standard web-platform globals from the deno_web / deno_url extensions,
// exposed to match the bun runner's global surface. Every name below is present
// in bun; names bun lacks (EventSource, ImageData) are deliberately excluded.
Object.assign(globalThis, {
// DOMException. Beyond bun parity, deno_web modules reference it as a global:
// AbortController.abort() with no reason constructs `new DOMException(...)`, so
// without this the already-wired AbortController/AbortSignal throw on abort.
DOMException: domException.DOMException,
// Text encoding + encoding streams.
TextEncoder: encoding.TextEncoder,
TextDecoder: encoding.TextDecoder,
TextEncoderStream: encoding.TextEncoderStream,
TextDecoderStream: encoding.TextDecoderStream,
// File (Blob is already wired above).
File: file.File,
// Events (AbortSignal, already wired, extends EventTarget). MessageEvent is
// the companion to MessagePort/MessageChannel below. Only the event types
// bun exposes are wired (ProgressEvent / PromiseRejectionEvent are not).
// reportError works because __wmInitPerIsolate makes globalThis an EventTarget.
Event: event.Event,
EventTarget: event.EventTarget,
CustomEvent: event.CustomEvent,
MessageEvent: event.MessageEvent,
CloseEvent: event.CloseEvent,
ErrorEvent: event.ErrorEvent,
reportError: event.reportError,
// Streams + queuing strategies + the reader/controller constructors bun also
// exposes as globals (used for `x instanceof ReadableStreamDefaultReader` etc.;
// the controllers throw on direct construction, matching the spec).
ReadableStream: streams.ReadableStream,
ReadableStreamDefaultReader: streams.ReadableStreamDefaultReader,
ReadableStreamBYOBReader: streams.ReadableStreamBYOBReader,
ReadableStreamDefaultController: streams.ReadableStreamDefaultController,
ReadableByteStreamController: streams.ReadableByteStreamController,
ReadableStreamBYOBRequest: streams.ReadableStreamBYOBRequest,
WritableStream: streams.WritableStream,
WritableStreamDefaultWriter: streams.WritableStreamDefaultWriter,
WritableStreamDefaultController: streams.WritableStreamDefaultController,
TransformStream: streams.TransformStream,
TransformStreamDefaultController: streams.TransformStreamDefaultController,
ByteLengthQueuingStrategy: streams.ByteLengthQueuingStrategy,
CountQueuingStrategy: streams.CountQueuingStrategy,
// URL pattern matching.
URLPattern: urlPattern.URLPattern,
// Compression streams.
CompressionStream: compression.CompressionStream,
DecompressionStream: compression.DecompressionStream,
// Message channel / port.
MessageChannel: messagePort.MessageChannel,
MessagePort: messagePort.MessagePort,
// High-resolution timing: the `performance` singleton and its constructor
// globals (bun exposes all of these; PerformanceObserver is not in deno_web).
performance: performance.performance,
Performance: performance.Performance,
PerformanceEntry: performance.PerformanceEntry,
PerformanceMark: performance.PerformanceMark,
PerformanceMeasure: performance.PerformanceMeasure,
// Spec structuredClone (validates args + honors the options bag), from the
// message-port module rather than the single-arg internal helper in
// 02_structured_clone.js.
structuredClone: messagePort.structuredClone,
});
// Per-isolate init, invoked from Rust after the snapshot is restored (this
// module body runs at snapshot-build time, not per isolate).
globalThis.__wmInitPerIsolate = () => {
// setTimeOrigin() seeds performance.timeOrigin from the isolate's wall clock;
// without it timeOrigin is undefined and `timeOrigin + performance.now()` is NaN.
performance.setTimeOrigin();
// Make globalThis a functional EventTarget, as Deno's bootstrap does. deno_web
// routes uncaught EventTarget-listener errors and reportError through
// reportException, which dispatches an error event on the saved global
// reference; reportError also requires its receiver to equal that reference.
// Both need the reference to be globalThis and globalThis to be an EventTarget,
// otherwise dispatch throws a masking error and globalThis.reportError() throws
// "Illegal invocation". Set up per isolate so the reference is the live global.
// The prototype + brand are what webidl.assertBranded checks in the methods.
Object.setPrototypeOf(
globalThis,
globalInterfaces.DedicatedWorkerGlobalScope.prototype,
);
event.setEventTargetData(globalThis);
globalThis[webidl.brand] = webidl.brand;
event.saveGlobalThisReference(globalThis);
};
// Expose bootstrapOtel globally so it can be called from Rust after runtime creation.
// We use dynamic import so deno_telemetry isn't loaded during snapshot creation.
// Config: [tracingEnabled, metricsEnabled, consoleConfig, deterministic]
@@ -55,6 +55,34 @@ export async function main(x: number): Promise<number> {
assert_eq!(unwrap_value(&r), serde_json::json!(42));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_missing_optional_arg_uses_default() {
// A missing optional arg must arrive as `undefined`, not `null`, so the
// parameter default applies. With `null`, slice(0, null) -> [] -> length 0.
let ts = r#"
export async function main(limit = 50): Promise<number> {
return [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].slice(0, limit).length;
}
"#;
let r = run_ts(ts, &["limit"], serde_json::json!({})).await;
assert_eq!(unwrap_value(&r), serde_json::json!(10));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_explicit_null_arg_is_preserved() {
// An explicitly-provided JSON null must stay null (distinct from a missing
// arg), so the default does NOT apply.
let ts = r#"
export async function main(x: number | null = 7): Promise<string> {
return x === null ? "null" : String(x);
}
"#;
let r = run_ts(ts, &["x"], serde_json::json!({ "x": null })).await;
assert_eq!(unwrap_value(&r), serde_json::json!("null"));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_transpile_enum_and_union() {
@@ -120,9 +148,8 @@ export async function main(): Promise<{ host: string; pairs: [string, string][]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_web_blob_btoa_atob() {
// deno_web surface: Blob, atob/btoa. `structuredClone` is *not* wired
// into the nativets global (the deno_web binding doesn't expose it
// here) — if that's ever changed, extend this test to cover it.
// deno_web surface: Blob, atob/btoa. (`structuredClone` and the rest of
// the wired web globals are covered by `smoke_web_globals_are_wired`.)
let ts = r#"
export async function main(): Promise<{ b64: string; round_trip: string; size: number }> {
const blob = new Blob(["hello"], { type: "text/plain" });
@@ -230,6 +257,540 @@ export async function main(i: number): Promise<number> {
assert_eq!(got, expected);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_text_encoder_decoder() {
// TextEncoder/TextDecoder are wired from deno_web's 08_text_encoding.
// The "€" (U+20AC) is a 3-byte UTF-8 sequence, so this asserts the
// multi-byte encode → decode round-trip (not just ASCII) survives the
// deno_core op boundary.
let ts = r#"
export async function main(): Promise<{ bytes: number[]; round_trip: string }> {
const enc = new TextEncoder();
const dec = new TextDecoder();
const bytes = enc.encode("a€b");
return { bytes: Array.from(bytes), round_trip: dec.decode(bytes) };
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
assert_eq!(
unwrap_value(&r),
serde_json::json!({
// "a" = 0x61, "€" = 0xE2 0x82 0xAC, "b" = 0x62
"bytes": [0x61, 0xE2, 0x82, 0xAC, 0x62],
"round_trip": "a€b",
}),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_web_globals_are_wired() {
// Guard against a wrong export name silently leaving a global unwired:
// assert each web-platform global we assign in runtime.js is actually
// defined. If a deno_web/deno_url export is renamed on a future bump,
// the corresponding `globalThis.X = mod.X` becomes `undefined` and this
// test flips it to false.
let ts = r#"
export async function main(): Promise<Record<string, boolean>> {
const names = [
"DOMException",
"TextEncoder", "TextDecoder", "TextEncoderStream", "TextDecoderStream",
"File",
"Event", "EventTarget", "CustomEvent",
"MessageEvent", "CloseEvent", "ErrorEvent", "reportError",
"ReadableStream", "WritableStream", "TransformStream",
"ReadableStreamDefaultReader", "ReadableStreamBYOBReader",
"ReadableStreamDefaultController", "ReadableByteStreamController",
"ReadableStreamBYOBRequest", "WritableStreamDefaultWriter",
"WritableStreamDefaultController", "TransformStreamDefaultController",
"ByteLengthQueuingStrategy", "CountQueuingStrategy",
"Performance", "PerformanceEntry", "PerformanceMark", "PerformanceMeasure",
"URLPattern",
"CompressionStream", "DecompressionStream",
"MessageChannel", "MessagePort",
"structuredClone", "performance",
];
const out: Record<string, boolean> = {};
for (const n of names) out[n] = typeof (globalThis as any)[n] !== "undefined";
return out;
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
let v = unwrap_value(&r);
let obj = v.as_object().expect("expected an object result");
let unwired: Vec<&String> = obj
.iter()
.filter(|(_, defined)| defined.as_bool() != Some(true))
.map(|(name, _)| name)
.collect();
assert!(
unwired.is_empty(),
"these globals were not wired: {unwired:?}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_structured_clone_and_performance() {
// structuredClone is the spec function (from 13_message_port.js): it
// deep-clones and accepts an options bag. performance.now() must be finite,
// and performance.timeOrigin must be seeded per isolate (via the Rust-side
// __wmInitPerIsolate call) so that `timeOrigin + now()` tracks wall-clock
// time rather than being NaN.
let ts = r#"
export async function main(): Promise<{ deep_equal: boolean; source_unchanged: boolean; now_ok: boolean; origin_ok: boolean }> {
const src = { a: 1, nested: { b: [2, 3] } };
const copy = structuredClone(src);
copy.nested.b.push(4);
const deep_equal = JSON.stringify(copy) === JSON.stringify({ a: 1, nested: { b: [2, 3, 4] } });
// Mutating the clone must not touch the source (proves a real deep clone).
const source_unchanged = src.nested.b.length === 2;
const now_ok = Number.isFinite(performance.now());
// timeOrigin must be a finite epoch-ms value, and timeOrigin + now() must
// land within a few seconds of Date.now() (guards the per-isolate seeding).
const origin = performance.timeOrigin;
const origin_ok = Number.isFinite(origin) && Math.abs(origin + performance.now() - Date.now()) < 5000;
return { deep_equal, source_unchanged, now_ok, origin_ok };
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
assert_eq!(
unwrap_value(&r),
serde_json::json!({ "deep_equal": true, "source_unchanged": true, "now_ok": true, "origin_ok": true }),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_web_globals_construct() {
// `smoke_web_globals_are_wired` only checks the constructors are defined.
// Several are backed by deno_web ops (compression, message-port, URL
// pattern parsing) that a future bump could move out from under the
// still-defined constructor — it would pass the presence check but throw
// at `new`. Actually construct those here so that regression surfaces.
let ts = r#"
export async function main(): Promise<{ pathname: boolean; channel: boolean; gzip_ok: boolean }> {
const pat = new URLPattern({ pathname: "/books/:id" });
const pathname = pat.test("https://example.com/books/42");
const chan = new MessageChannel();
const channel = chan.port1 instanceof MessagePort && chan.port2 instanceof MessagePort;
// Round-trip "hi" through gzip compression then decompression.
const compressed = new Blob(["hi"]).stream().pipeThrough(new CompressionStream("gzip"));
const restored = compressed.pipeThrough(new DecompressionStream("gzip"));
const bytes = new Uint8Array(await new Response(restored).arrayBuffer());
const gzip_ok = new TextDecoder().decode(bytes) === "hi";
return { pathname, channel, gzip_ok };
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
assert_eq!(
unwrap_value(&r),
serde_json::json!({ "pathname": true, "channel": true, "gzip_ok": true }),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_readable_stream_roundtrip() {
// ReadableStream + TextEncoderStream/TextDecoderStream: pipe an encode
// stream through and read chunks back. Exercises the streams surface
// (06_streams.js) that `res.body instanceof ReadableStream` relies on.
let ts = r#"
export async function main(): Promise<{ is_readable: boolean; text: string }> {
const rs = new ReadableStream<string>({
start(controller) {
controller.enqueue("hello ");
controller.enqueue("stream");
controller.close();
},
});
const is_readable = rs instanceof ReadableStream;
const decoded = rs
.pipeThrough(new TextEncoderStream())
.pipeThrough(new TextDecoderStream());
let text = "";
for await (const chunk of decoded) text += chunk;
return { is_readable, text };
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
assert_eq!(
unwrap_value(&r),
serde_json::json!({ "is_readable": true, "text": "hello stream" }),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_web_crypto() {
// deno_crypto surface: the Web Crypto globals (`crypto`, `crypto.subtle`)
// that bring nativets to parity with the bun runner. Covers all three
// op families: getRandomValues (sync fill), randomUUID (RNG + formatting),
// and subtle.digest (async op returning an ArrayBuffer). The SHA-256 of
// "abc" is a fixed NIST vector, so a broken digest op fails the assert
// rather than silently returning garbage.
let ts = r#"
export async function main(): Promise<{ uuid: string; nonzero: boolean; sha256: string }> {
const uuid = crypto.randomUUID();
const buf = new Uint8Array(16);
crypto.getRandomValues(buf);
// A 16-byte CSPRNG fill returning all zeros is astronomically unlikely;
// a no-op/stub getRandomValues would leave the array zeroed.
const nonzero = buf.some((b) => b !== 0);
// "abc" as raw bytes — this test targets deno_crypto, so it avoids
// depending on deno_web's text encoding.
const data = new Uint8Array([0x61, 0x62, 0x63]);
const digest = await crypto.subtle.digest("SHA-256", data);
const sha256 = Array.from(new Uint8Array(digest))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
return { uuid, nonzero, sha256 };
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
let v = unwrap_value(&r);
// UUIDv4 shape: 8-4-4-4-12 hex, version nibble 4, variant nibble 8/9/a/b.
let uuid = v.get("uuid").and_then(|x| x.as_str()).unwrap_or("");
let re =
regex::Regex::new(r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
.unwrap();
assert!(
re.is_match(uuid),
"randomUUID did not match UUIDv4 shape: {uuid:?}"
);
assert_eq!(
v.get("nonzero"),
Some(&serde_json::json!(true)),
"getRandomValues left the buffer all zeros",
);
// Known SHA-256("abc") vector.
assert_eq!(
v.get("sha256").and_then(|x| x.as_str()),
Some("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_web_globals_edge_cases() {
// Broad functional sweep of every wired global — not just "defined" but
// "actually works". A constructor can be present yet throw at `new`/on call
// (an op moved by a deno bump, or a global dependency like DOMException not
// wired), which a presence check misses. Each check returns a bool; anything
// that isn't "ok" is reported in the failure message.
let ts = r#"
export async function main(): Promise<Record<string, string>> {
const out: Record<string, string> = {};
async function check(name: string, fn: () => any) {
try { out[name] = (await fn()) ? "ok" : "FAIL"; }
catch (e: any) { out[name] = "ERR: " + (e && e.message ? e.message : String(e)); }
}
// --- DOMException + AbortController/AbortSignal (needs the DOMException global) ---
await check("DOMException.construct", () => {
const e = new DOMException("nope", "AbortError");
return e.name === "AbortError" && e.message === "nope" && e instanceof DOMException;
});
await check("AbortController.abort_default_reason", () => {
const ac = new AbortController();
let fired = false;
ac.signal.addEventListener("abort", () => { fired = true; });
ac.abort(); // constructs a default DOMException("...", "AbortError")
return fired && ac.signal.aborted === true && ac.signal.reason?.name === "AbortError";
});
await check("AbortSignal.timeout", async () => {
const sig = AbortSignal.timeout(5);
await new Promise((r) => setTimeout(r, 30));
return sig.aborted === true && sig.reason?.name === "TimeoutError";
});
// --- TextEncoder / TextDecoder ---
await check("TextEncoder.encodeInto", () => {
const buf = new Uint8Array(3);
const { read, written } = new TextEncoder().encodeInto("abc", buf);
return read === 3 && written === 3 && buf[0] === 0x61;
});
await check("TextDecoder.utf16le", () =>
new TextDecoder("utf-16le").decode(new Uint8Array([0x41, 0x00])) === "A");
await check("TextDecoder.fatal_throws", () => {
try { new TextDecoder("utf-8", { fatal: true }).decode(new Uint8Array([0xff])); return false; }
catch { return true; }
});
// --- File ---
await check("File.props_and_read", async () => {
const f = new File(["hi"], "a.txt", { type: "text/plain", lastModified: 123 });
return f.name === "a.txt" && f.type === "text/plain" && f.lastModified === 123
&& f.size === 2 && (await f.text()) === "hi" && f instanceof Blob;
});
// --- Events ---
await check("EventTarget.dispatch_fires", () => {
const et = new EventTarget();
let got = "";
et.addEventListener("ping", (e: any) => { got = e.detail; });
et.dispatchEvent(new CustomEvent("ping", { detail: "pong" }));
return got === "pong";
});
// (A throwing EventTarget listener and reportError both surface the error
// asynchronously as an unhandled exception — matching bun, which exits
// non-zero — so they fail the script rather than a `check`; the dedicated
// smoke_eventtarget_throwing_listener_reports_original test covers that the
// ORIGINAL error is surfaced, not a masking one.)
await check("MessageEvent.data", () => new MessageEvent("m", { data: 42 }).data === 42);
await check("CloseEvent.code_reason", () => {
const e = new CloseEvent("close", { code: 1000, reason: "bye" });
return e.code === 1000 && e.reason === "bye";
});
await check("ErrorEvent.message", () => new ErrorEvent("error", { message: "boom" }).message === "boom");
// --- Streams ---
await check("ReadableStream.tee", async () => {
const rs = new ReadableStream<number>({ start(c) { c.enqueue(1); c.enqueue(2); c.close(); } });
const [a, b] = rs.tee();
const ra: number[] = []; for await (const x of a) ra.push(x);
const rb: number[] = []; for await (const x of b) rb.push(x);
return JSON.stringify(ra) === "[1,2]" && JSON.stringify(rb) === "[1,2]";
});
await check("ReadableStream.reader_cancel", async () => {
const rs = new ReadableStream<string>({ start(c) { c.enqueue("x"); } });
const rd = rs.getReader();
const { value } = await rd.read();
await rd.cancel();
return value === "x";
});
await check("WritableStream.write_close", async () => {
const chunks: string[] = [];
const ws = new WritableStream<string>({ write(c) { chunks.push(c); } });
const w = ws.getWriter();
await w.write("a"); await w.write("b"); await w.close();
return JSON.stringify(chunks) === '["a","b"]';
});
await check("TransformStream.identity", async () => {
const t = new TransformStream<string, string>();
const w = t.writable.getWriter(); w.write("hello"); w.close();
let o = ""; for await (const c of t.readable) o += c;
return o === "hello";
});
await check("Stream reader/controller instanceof globals", async () => {
// The reader/writer/controller constructors are exposed as globals for
// instanceof checks (bun parity). Obtain real instances and verify.
let ctrlOk = false;
const rs = new ReadableStream({
start(c: any) { ctrlOk = c instanceof ReadableStreamDefaultController; c.close(); },
});
const reader = rs.getReader();
const readerOk = reader instanceof ReadableStreamDefaultReader;
const ws = new WritableStream();
const writerOk = ws.getWriter() instanceof WritableStreamDefaultWriter;
return ctrlOk && readerOk && writerOk;
});
await check("ByteLengthQueuingStrategy", () => {
const s = new ByteLengthQueuingStrategy({ highWaterMark: 16 });
return s.highWaterMark === 16 && typeof s.size === "function";
});
await check("CountQueuingStrategy.in_stream", async () => {
const s = new CountQueuingStrategy({ highWaterMark: 1 });
const rs = new ReadableStream<number>({ start(c) { c.enqueue(7); c.close(); } }, s);
const rd = rs.getReader();
return (await rd.read()).value === 7 && s.highWaterMark === 1;
});
// --- URLPattern ---
await check("URLPattern.exec_groups", () => {
const p = new URLPattern({ pathname: "/users/:id" });
const m = p.exec("https://x.com/users/42");
return p.test("https://x.com/users/42") && m?.pathname.groups.id === "42";
});
// --- Compression (all three formats) ---
async function roundtrip(fmt: string): Promise<string> {
const comp = new Blob(["hello compression"]).stream().pipeThrough(new CompressionStream(fmt as any));
const decomp = comp.pipeThrough(new DecompressionStream(fmt as any));
return new TextDecoder().decode(new Uint8Array(await new Response(decomp).arrayBuffer()));
}
await check("Compression.gzip", async () => (await roundtrip("gzip")) === "hello compression");
await check("Compression.deflate", async () => (await roundtrip("deflate")) === "hello compression");
await check("Compression.deflate_raw", async () => (await roundtrip("deflate-raw")) === "hello compression");
// --- structuredClone edge cases ---
await check("structuredClone.Map", () => {
const c = structuredClone(new Map([["k", 1]]));
return c instanceof Map && c.get("k") === 1;
});
await check("structuredClone.Set", () => {
const c = structuredClone(new Set([1, 2]));
return c instanceof Set && c.has(2);
});
await check("structuredClone.Date", () => {
const c = structuredClone(new Date(0));
return c instanceof Date && c.getTime() === 0;
});
await check("structuredClone.TypedArray", () => {
const c = structuredClone(new Uint8Array([1, 2, 3]));
return c instanceof Uint8Array && c[1] === 2;
});
await check("structuredClone.circular", () => {
const o: any = {}; o.self = o;
const c = structuredClone(o);
return c.self === c;
});
await check("structuredClone.rejects_function", () => {
try { structuredClone(() => {}); return false; } catch { return true; }
});
await check("structuredClone.options_bag", () => {
const c = structuredClone({ a: 1 }, { transfer: [] });
return c.a === 1;
});
// --- performance ---
await check("performance.now_monotonic", () => {
const a = performance.now(); const b = performance.now();
return Number.isFinite(a) && b >= a;
});
await check("performance.mark_measure", () => {
performance.mark("m1");
performance.measure("meas", "m1");
return performance.getEntriesByType("measure").some((e: any) => e.name === "meas");
});
await check("performance.constructor_globals", () => {
// The Performance/PerformanceEntry/Mark/Measure constructors are exposed
// as globals (bun parity) for instanceof checks against real entries.
const mark = performance.mark("m2");
const meas = performance.measure("meas2", "m2");
return performance instanceof Performance
&& mark instanceof PerformanceMark && mark instanceof PerformanceEntry
&& meas instanceof PerformanceMeasure && meas instanceof PerformanceEntry;
});
await check("performance.toJSON_origin", () => {
const j: any = performance.toJSON();
return Number.isFinite(j.timeOrigin);
});
// --- MessageChannel / MessagePort (async delivery, guarded by timeout) ---
await check("MessagePort.postMessage", async () => {
const mc = new MessageChannel();
const got = await Promise.race([
new Promise<string>((res) => {
mc.port2.onmessage = (e: any) => res(JSON.stringify(e.data));
mc.port1.postMessage({ hello: "world" });
}),
new Promise<string>((res) => setTimeout(() => res("TIMEOUT"), 3000)),
]);
return got === '{"hello":"world"}';
});
// --- Web Crypto works alongside the other wired globals ---
await check("crypto.getRandomValues", () => {
const buf = new Uint8Array(16);
crypto.getRandomValues(buf);
return buf.some((b) => b !== 0);
});
await check("crypto.subtle.digest", async () => {
const d = await crypto.subtle.digest("SHA-256", new Uint8Array([0x61, 0x62, 0x63]));
return new Uint8Array(d)[0] === 0xba; // SHA-256("abc") starts with 0xba
});
return out;
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
let v = unwrap_value(&r);
let obj = v.as_object().expect("expected an object result");
let failures: Vec<String> = obj
.iter()
.filter(|(_, val)| val.as_str() != Some("ok"))
.map(|(name, val)| format!("{name} => {}", val.as_str().unwrap_or("?")))
.collect();
assert!(
failures.is_empty(),
"edge-case checks that did not pass ({} of {}):\n{}",
failures.len(),
obj.len(),
failures.join("\n"),
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_eventtarget_throwing_listener_reports_original() {
// Invariant: globalThis is a functional EventTarget, so deno_web's
// reportException has a valid saved global dispatch target. An uncaught
// EventTarget-listener error is therefore surfaced with its original message
// as an async unhandled exception (matching bun, which exits non-zero), not
// replaced by a masking "Illegal invocation"/undefined-reference error.
let ts = r#"
export async function main(): Promise<void> {
const et = new EventTarget();
et.addEventListener("boom", () => { throw new Error("wm_listener_marker"); });
et.dispatchEvent(new Event("boom"));
// Give the async unhandled-exception report a tick to fire.
await new Promise((r) => setTimeout(r, 20));
}
"#;
let r = run_ts(ts, &[], serde_json::json!({})).await;
let err = r
.result
.expect_err("a throwing listener should surface an error");
assert!(
err.contains("wm_listener_marker"),
"the original listener error was lost: {err}",
);
assert!(
!err.contains("Illegal invocation") && !err.to_lowercase().contains("undefined"),
"dispatchEvent surfaced a masking error instead of the original: {err}",
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "deno_core upgrade smoke; run with --ignored"]
async fn smoke_report_error_both_call_forms() {
// reportError must work both unqualified and as a property of globalThis.
// The property form goes through a receiver check (`this === globalThis_`),
// which only passes because globalThis is the saved EventTarget reference;
// otherwise it throws "Illegal invocation". Both forms report the error as an
// async unhandled exception (matching bun), so the script errors with the
// original message rather than a masking one.
for (label, call) in [
("bare", "reportError(new Error(\"wm_report_marker\"));"),
(
"property",
"globalThis.reportError(new Error(\"wm_report_marker\"));",
),
] {
let ts = format!(
r#"
export async function main(): Promise<void> {{
{call}
await new Promise((r) => setTimeout(r, 20));
}}
"#
);
let r = run_ts(&ts, &[], serde_json::json!({})).await;
let err = r
.result
.expect_err(&format!("{label} reportError should surface an error"));
assert!(
err.contains("wm_report_marker"),
"{label} reportError lost the original error: {err}",
);
assert!(
!err.contains("Illegal invocation"),
"{label} reportError threw Illegal invocation: {err}",
);
}
}
// -----------------------------------------------------------------------------
// Network — actually exercise deno_fetch end-to-end. Skip in air-gapped CI
// with `--skip smoke_net_`.
+107 -10
View File
@@ -2431,8 +2431,13 @@ fn is_private_or_reserved_ip(ip: &IpAddr) -> bool {
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64)
}
IpAddr::V6(v6) => {
let seg = v6.segments();
v6.is_loopback()
|| v6.is_unspecified()
// fc00::/7 (unique local address) — std has no stable is_unique_local()
|| (seg[0] & 0xfe00) == 0xfc00
// fe80::/10 (link-local) — std has no stable is_unicast_link_local()
|| (seg[0] & 0xffc0) == 0xfe80
// IPv4-mapped IPv6 (::ffff:x.x.x.x) — check the inner v4
|| v6.to_ipv4_mapped().map_or(false, |v4| {
is_private_or_reserved_ip(&IpAddr::V4(v4))
@@ -2447,10 +2452,16 @@ fn is_private_or_reserved_ip(ip: &IpAddr) -> bool {
/// SCP-style (`user@host:path`).
fn extract_host_from_git_url(url: &str) -> Option<String> {
if let Some(after_scheme) = url.split("://").nth(1) {
// Standard URL with scheme
let host_part = match after_scheme.find('@') {
Some(pos) => &after_scheme[pos + 1..],
None => after_scheme,
// The authority ends at the first '/', '?', or '#'; the credentials '@'
// must be searched only within it, else a '@' in the path/query/fragment
// mis-scopes the host (SSRF bypass, GHSA-p5cj-8cfh-mjv6).
let authority_end = after_scheme
.find(|c| c == '/' || c == '?' || c == '#')
.unwrap_or(after_scheme.len());
let authority = &after_scheme[..authority_end];
let host_part = match authority.rfind('@') {
Some(pos) => &authority[pos + 1..],
None => authority,
};
// Handle IPv6 in brackets: [::1]
if host_part.starts_with('[') {
@@ -2462,18 +2473,22 @@ fn extract_host_from_git_url(url: &str) -> Option<String> {
Some(host.to_lowercase())
};
}
let host_port = host_part.split('/').next()?;
let host = host_port.rsplit_once(':').map_or(host_port, |(h, _)| h);
let host = host_part.rsplit_once(':').map_or(host_part, |(h, _)| h);
if host.is_empty() {
return None;
}
return Some(host.to_lowercase());
}
// SCP-style: user@host:path
if let Some(at_pos) = url.find('@') {
let after_at = &url[at_pos + 1..];
let host = after_at.split(':').next()?;
// SCP-style: user@host:path. The host is bounded by the first ':' (which
// begins the path); credentials are taken from the last '@' within that
// authority, mirroring the scheme path so a planted '@' cannot mis-scope it.
if url.contains('@') {
let authority = url.split(':').next().unwrap_or(url);
let host = match authority.rfind('@') {
Some(pos) => &authority[pos + 1..],
None => authority,
};
if host.is_empty() {
return None;
}
@@ -2499,6 +2514,15 @@ async fn validate_git_url(url: &str) -> Result<()> {
"Git URL contains invalid characters".to_string(),
));
}
// Reject query/fragment components. git remote URLs never need them, and
// allowing them lets the URL's true authority (what git actually dials)
// diverge from the host we validate, e.g.
// `http://127.0.0.1/repo.git#@github.com/...` (SSRF, GHSA-p5cj-8cfh-mjv6).
if url.contains('?') || url.contains('#') {
return Err(Error::BadRequest(
"Git URL cannot contain '?' or '#' characters".to_string(),
));
}
let lower = url.to_lowercase();
@@ -3016,6 +3040,32 @@ mod tests {
extract_host_from_git_url("http://[::1]:8080/repo.git"),
Some("::1".to_string())
);
// Fragment/query must not leak into the authority (GHSA-p5cj-8cfh-mjv6):
// the host is the real authority, not the '@' planted in the fragment/query.
assert_eq!(
extract_host_from_git_url(
"http://127.0.0.1:40173/repo.git#@github.com/windmill-labs/windmill.git"
),
Some("127.0.0.1".to_string())
);
assert_eq!(
extract_host_from_git_url(
"http://127.0.0.1:40173/repo.git?@github.com/windmill-labs/windmill.git"
),
Some("127.0.0.1".to_string())
);
// Path-less authority terminated by the fragment (exercises the '#' branch
// of the authority boundary directly).
assert_eq!(
extract_host_from_git_url("http://127.0.0.1#@github.com"),
Some("127.0.0.1".to_string())
);
// SCP-style with a planted extra '@' must resolve to the real host, not the
// credential segment.
assert_eq!(
extract_host_from_git_url("a@b@127.0.0.1:user/repo.git"),
Some("127.0.0.1".to_string())
);
// No host extractable
assert_eq!(extract_host_from_git_url("/local/path"), None);
assert_eq!(
@@ -3058,6 +3108,17 @@ mod tests {
));
// IPv6 loopback
assert!(is_private_or_reserved_ip(&"::1".parse::<IpAddr>().unwrap()));
// IPv6 unique local address (fc00::/7)
assert!(is_private_or_reserved_ip(
&"fd00::1".parse::<IpAddr>().unwrap()
));
assert!(is_private_or_reserved_ip(
&"fc00::1".parse::<IpAddr>().unwrap()
));
// IPv6 link-local (fe80::/10)
assert!(is_private_or_reserved_ip(
&"fe80::1".parse::<IpAddr>().unwrap()
));
// IPv4-mapped IPv6
assert!(is_private_or_reserved_ip(
&"::ffff:127.0.0.1".parse::<IpAddr>().unwrap()
@@ -3069,6 +3130,12 @@ mod tests {
assert!(!is_private_or_reserved_ip(
&"140.82.121.4".parse::<IpAddr>().unwrap()
));
// Public IPv6 should pass
assert!(!is_private_or_reserved_ip(
&"2606:2800:220:1:248:1893:25c8:1946"
.parse::<IpAddr>()
.unwrap()
));
}
#[tokio::test]
@@ -3092,6 +3159,10 @@ mod tests {
.await
.is_err());
assert!(validate_git_url("git://0.0.0.0/repo.git").await.is_err());
// IPv6 loopback, unique-local, and link-local literals
assert!(validate_git_url("git://[::1]/repo.git").await.is_err());
assert!(validate_git_url("git://[fd00::1]/repo.git").await.is_err());
assert!(validate_git_url("git://[fe80::1]/repo.git").await.is_err());
}
#[tokio::test]
@@ -3128,4 +3199,30 @@ mod tests {
assert!(validate_git_url("-evil").await.is_err());
assert!(validate_git_url("--upload-pack=evil").await.is_err());
}
#[tokio::test]
async fn test_validate_git_url_blocks_fragment_query_ssrf() {
// GHSA-p5cj-8cfh-mjv6: a loopback authority must stay blocked, and the
// fragment/query `@public-host` bypasses of #8600 must be rejected so the
// host git dials can never diverge from the validated host.
assert!(validate_git_url("http://127.0.0.1:40173/repo.git")
.await
.is_err());
assert!(validate_git_url(
"http://127.0.0.1:40173/repo.git#@github.com/windmill-labs/windmill.git"
)
.await
.is_err());
assert!(validate_git_url(
"http://127.0.0.1:40173/repo.git?@github.com/windmill-labs/windmill.git"
)
.await
.is_err());
// A legitimate public repo URL still validates.
assert!(
validate_git_url("https://github.com/windmill-labs/windmill.git")
.await
.is_ok()
);
}
}
+6
View File
@@ -804,6 +804,10 @@ pub struct AgentTool {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
/// Free-text description given to the AI to decide when and how to call this tool.
/// Overrides the description auto-derived from the underlying runnable.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub value: ToolValue,
}
@@ -816,6 +820,8 @@ impl From<FlowModule> for AgentTool {
AgentTool {
id: flow_module.id,
summary: flow_module.summary,
// FlowModule has no dedicated tool description; it is carried on AgentTool only.
description: None,
value: ToolValue::FlowModule(module_value),
}
}
+107 -5
View File
@@ -135,6 +135,43 @@ fn find_ai_agent_tool_module_in_parent_agent(
Ok(None)
}
/// Resolve the `description` sent to the model for an AI agent tool, in priority order:
/// an explicit per-tool description, then one auto-derived from the underlying runnable,
/// then the tool name as the historical last-resort fallback. Blank/whitespace-only values
/// at each level are skipped so a lower-priority source can still apply.
fn resolve_tool_description(
user_description: Option<String>,
derived_description: Option<String>,
tool_name: &str,
) -> String {
fn non_empty(value: Option<String>) -> Option<String> {
value
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
non_empty(user_description)
.or_else(|| non_empty(derived_description))
.unwrap_or_else(|| tool_name.to_string())
}
/// Fetch a workspace script's stored description by hash, used to auto-derive an AI agent
/// tool's description when the user did not provide an explicit one. Returns `None` when the
/// script has no description or on any lookup error, so the caller falls back to the tool name.
async fn fetch_script_description(db: &DB, w_id: &str, hash: i64) -> Option<String> {
sqlx::query_scalar!(
"SELECT description FROM script WHERE hash = $1 AND workspace_id = $2",
hash,
w_id,
)
.fetch_optional(db)
.await
.ok()
.flatten()
.map(|d| d.trim().to_string())
.filter(|d| !d.is_empty())
}
pub async fn handle_ai_agent_job(
// connection
conn: &Connection,
@@ -259,6 +296,9 @@ pub async fn handle_ai_agent_job(
// Separate Windmill tools from MCP tools, websearch, and extract MCP resource configs
let mut windmill_modules: Vec<FlowModule> = Vec::new();
// Explicit per-tool descriptions keyed by tool id. When set, these override the
// description auto-derived from the underlying runnable when building tool definitions.
let mut tool_descriptions: HashMap<String, String> = HashMap::new();
#[allow(unused_mut)]
let mut mcp_configs: Vec<crate::ai::utils::McpResourceConfig> = Vec::new();
let mut has_websearch = false;
@@ -291,6 +331,14 @@ pub async fn handle_ai_agent_job(
ToolValue::FlowModule(_) => {
// Regular Windmill flow module (script, flow, etc.) - convert to FlowModule
tracing::debug!("Windmill module: {:?}", tool.id);
if let Some(description) = tool
.description
.as_ref()
.map(|d| d.trim())
.filter(|d| !d.is_empty())
{
tool_descriptions.insert(tool.id.clone(), description.to_string());
}
if let Some(flow_module) = Option::<FlowModule>::from(&tool) {
windmill_modules.push(flow_module);
}
@@ -308,6 +356,7 @@ pub async fn handle_ai_agent_job(
let conn = conn;
let db = db;
let job = job;
let user_description = tool_descriptions.get(&t.id).cloned();
async move {
let Some(summary) = t.summary.as_ref().filter(|s| TOOL_NAME_REGEX.is_match(s)) else {
return Err(Error::internal_err(format!(
@@ -316,9 +365,9 @@ pub async fn handle_ai_agent_job(
)));
};
// Extract schema and input_transforms from the module value
// Extract schema, input_transforms, and an auto-derived description from the module value
let module_value = t.get_value()?;
let (schema, input_transforms) = match &module_value {
let (schema, input_transforms, derived_description) = match &module_value {
FlowModuleValue::Script {
hash,
path,
@@ -327,9 +376,12 @@ pub async fn handle_ai_agent_job(
is_trigger,
pass_flow_input_directly,
} => {
let derived_description: Option<String>;
let schema = match hash {
Some(hash) => {
let (_, metadata) = cache::script::fetch(conn, hash.clone()).await?;
derived_description =
fetch_script_description(db, &job.workspace_id, hash.0).await;
Ok::<_, Error>(
metadata
.schema
@@ -346,6 +398,12 @@ pub async fn handle_ai_agent_job(
None,
)
.await?;
// Hub scripts carry their free-text description in `summary`.
derived_description = hub_script
.summary
.as_ref()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Ok(Some(hub_script.schema))
} else {
let hash = get_latest_hash_for_path(
@@ -365,6 +423,8 @@ pub async fn handle_ai_agent_job(
is_trigger: *is_trigger,
pass_flow_input_directly: *pass_flow_input_directly,
});
derived_description =
fetch_script_description(db, &job.workspace_id, hash.0).await;
let (_, metadata) = cache::script::fetch(conn, hash).await?;
Ok(metadata
.schema
@@ -374,11 +434,11 @@ pub async fn handle_ai_agent_job(
}
}
}?;
(schema, input_transforms)
(schema, input_transforms, derived_description)
}
FlowModuleValue::RawScript { content, language, input_transforms, .. } => {
let schema = Some(parse_raw_script_schema(&content, &language)?);
(schema, input_transforms)
(schema, input_transforms, None)
}
FlowModuleValue::AIAgent { input_transforms, .. } => {
// By convention for AIAgent tools, only user_message is expected to be AI-filled.
@@ -388,6 +448,7 @@ pub async fn handle_ai_agent_job(
.expect("AI_AGENT_TOOL_SCHEMA should always be valid JSON"),
),
input_transforms,
None,
)
}
_ => {
@@ -405,12 +466,15 @@ pub async fn handle_ai_agent_job(
None
};
let description =
resolve_tool_description(user_description, derived_description, summary);
Ok(Tool {
def: ToolDef {
r#type: "function".to_string(),
function: ToolDefFunction {
name: summary.clone(),
description: Some(summary.clone()),
description: Some(description),
parameters: schema.unwrap_or_else(|| {
to_raw_value(&serde_json::json!({
"type": "object",
@@ -1368,6 +1432,44 @@ mod tests {
}
}
#[test]
fn tool_description_prefers_explicit_over_derived_and_name() {
assert_eq!(
resolve_tool_description(
Some(" Use to look up a user by id ".to_string()),
Some("derived from script".to_string()),
"get_user"
),
"Use to look up a user by id"
);
}
#[test]
fn tool_description_falls_back_to_derived_when_no_explicit() {
assert_eq!(
resolve_tool_description(None, Some("Sync resources".to_string()), "sync_tool"),
"Sync resources"
);
// A blank explicit description must not shadow a usable derived one.
assert_eq!(
resolve_tool_description(
Some(" ".to_string()),
Some("Sync resources".to_string()),
"sync_tool"
),
"Sync resources"
);
}
#[test]
fn tool_description_falls_back_to_name_when_nothing_usable() {
assert_eq!(resolve_tool_description(None, None, "my_tool"), "my_tool");
assert_eq!(
resolve_tool_description(Some(" ".to_string()), Some("".to_string()), "my_tool"),
"my_tool"
);
}
#[test]
fn auto_memory_request_preserves_messages_within_context_window() {
let loaded_messages = vec![
+1 -1
View File
@@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts";
import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts";
import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts";
export const VERSION = "v1.757.0";
export const VERSION = "v1.759.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+1 -1
View File
@@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork";
// (e.g. utils.ts) can read it without importing main.ts and creating a circular
// dependency (main → workspace → utils → main) that triggers a TDZ.
// Re-exported from main.ts for backwards compatibility.
export const VERSION = "1.757.0";
export const VERSION = "1.759.0";
File diff suppressed because one or more lines are too long
+7 -5
View File
@@ -1,10 +1,12 @@
FROM caddy:2.8.4-builder-alpine AS builder
FROM caddy:2.11.4-builder-alpine AS builder
# caddy-l4 natively registers the `layer4` global option used by the Caddyfile.
# Do NOT also add RussellLuo/caddy-ext/layer4: it registers the same global
# option, and building both panics with "global option 'layer4' already registered".
RUN xcaddy build \
--with github.com/mholt/caddy-l4@145ec36251a44286f05a10d231d8bfb3a8192e09 \
--with github.com/RussellLuo/caddy-ext/layer4@ab1e18cfe426012af351a68463937ae2e934a2a1
--with github.com/mholt/caddy-l4@bd96009ea7373869bb07d61055554f966b1f5088
FROM caddy:2.8.4-alpine
FROM caddy:2.11.4-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
+1
View File
@@ -39,6 +39,7 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
# Install system dependencies
RUN apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30t64 libgcrypt20 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
+1
View File
@@ -39,6 +39,7 @@ ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
# Install system dependencies
RUN apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends ca-certificates wget curl git jq unzip unixodbc xmlsec1 gnupg lsb-release libgnutls30t64 libgcrypt20 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
+65
View File
@@ -0,0 +1,65 @@
# Docker base-OS security patching
The runtime images are built on `debian:trixie-slim` (Debian stable). The base
runtime stages run `apt-get update && apt-get upgrade -y && apt-get install …`
so that base-OS packages pick up Debian security and point-release fixes at
build time, instead of staying frozen at whatever versions the base tag shipped.
## Where the upgrade lives
`apt-get upgrade -y` is applied in the first apt block of the three stages that
establish a runtime Debian layer:
- `Dockerfile` (the primary `windmill` / `windmill-ee` image)
- `docker/DockerfileSlim` (`windmill-slim`)
- `docker/DockerfileSlimEe` (`windmill-ee-slim`)
Every other runtime image inherits its base OS from one of these transitively,
so patching here is sufficient:
- `DockerfileFull`, `DockerfileFullEe`, `DockerfileCuda` build `FROM` the primary
`windmill` / `windmill-ee` image.
- `DockerfileExtra` builds `FROM windmill-ee-slim`.
The nsjail *builder* stages are throwaway (only the compiled `nsjail` binary is
copied out), so they are intentionally not upgraded. `DockerfileMultiplayer`
(`node:slim`), the CLI, Caddy-L4, CUDA-only, and RHEL/dnf images are out of scope
for this apt-based patching.
## Why `apt-get upgrade` and not `unattended-upgrades` / pinning
Debian stable's archive only receives security updates and ABI-stable point
releases (e.g. `openssl 3.0.x → 3.0.x+deb12u2`, same soname). It does not ship
feature/major bumps, so a build-time `apt-get upgrade` cannot silently break a
pinned runtime dependency the way it might on a rolling distro. The default
`debian.sources` already includes the `*-security` suite, so a plain upgrade
picks up security fixes without extra machinery. `unattended-upgrades` adds a
package and config for no benefit in a build context (it does not run at build
time), and a pinned base digest would freeze the CVEs in place.
Note the runtime apt installs were already unpinned (only the throwaway nsjail
builder pins versions), so these images were never byte-for-byte reproducible in
this dimension; `upgrade` moves the same already-floating packages to their
patched versions rather than changing the reproducibility posture.
## Caching and freshness
`apt-get upgrade` sits in the same `RUN` as `apt-get update && install`. Docker
keys that layer on the command string plus the parent layer, not on package
contents, so an unchanged build is a cache hit and the upgrade does not re-run.
The layer is invalidated — and fresh patches are pulled — when the parent layer
changes, primarily when the mutable `debian:trixie-slim` base digest moves on a
Debian point release. That self-aligns: the cache refreshes when there is
something new to pick up.
Because of apt-cache staleness, a security fix that lands between base-digest
bumps will not be picked up by a cached build until the next bump. To close that
gap, rebuild and republish the `latest` / patch tags:
- on each Debian point release (base digest bump), and
- on a periodic cadence (e.g. per Windmill release), rebuilding the base stages
with `--no-cache` if you need to force a fresh `apt-get upgrade` regardless of
the base digest.
Scan the published images (e.g. Trivy / Defender) after rebuilds to confirm the
base-OS finding count stays low.
+1 -2
View File
@@ -354,8 +354,7 @@
(pkgs.writeScriptBin "wm-caddy" ''
cd ./frontend
xcaddy build "$@" \
--with github.com/mholt/caddy-l4@145ec36251a44286f05a10d231d8bfb3a8192e09 \
--with github.com/RussellLuo/caddy-ext/layer4@ab1e18cfe426012af351a68463937ae2e934a2a1
--with github.com/mholt/caddy-l4@bd96009ea7373869bb07d61055554f966b1f5088
'')
(pkgs.writeScriptBin "wm-setup" ''
sqlx database create
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.757.0",
"version": "1.759.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.757.0",
"version": "1.759.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.757.0",
"version": "1.759.0",
"scripts": {
"dev": "vite dev",
"dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev",
+9 -3
View File
@@ -9,8 +9,12 @@ type TextArea = HTMLTextAreaElement
* the textarea stops growing and scrolls internally (overflow-y: auto) instead.
* Accepts a number (px) or a CSS-ish string ending in `vh`/`px` (e.g. `'40vh'`).
* When omitted the textarea grows without bound (the historical behaviour).
*
* `minHeight` (px) sets the shortest the textarea may collapse to. Defaults to 30px;
* pass a smaller value (e.g. `0`) for a compact field that hugs a single line of
* content instead of reserving the default floor.
*/
export type AutosizeParams = { maxHeight?: number | string } | undefined
export type AutosizeParams = { maxHeight?: number | string; minHeight?: number } | undefined
/** Resolve a `maxHeight` param to a pixel value, or null when uncapped/invalid. */
function resolveMaxHeight(maxHeight: number | string | undefined): number | null {
@@ -28,11 +32,12 @@ export const autosize = (node: TextArea, params?: AutosizeParams) => {
* Constants
* ---------------------------------------------------------------- */
const UPDATE_EVENT = new Event('update')
const MIN_HEIGHT = 30 // px
const DEFAULT_MIN_HEIGHT = 30 // px
const EXTRA = 2 // px added to scrollHeight
let width = 0
let maxHeight = params?.maxHeight
let minHeight = params?.minHeight ?? DEFAULT_MIN_HEIGHT
let capped = maxHeight != null
/* ------------------------------------------------------------------
@@ -40,7 +45,7 @@ export const autosize = (node: TextArea, params?: AutosizeParams) => {
* ---------------------------------------------------------------- */
const resize = () => {
node.style.height = 'auto'
let height = Math.max(node.scrollHeight, MIN_HEIGHT) + EXTRA
let height = Math.max(node.scrollHeight, minHeight) + EXTRA
const maxPx = resolveMaxHeight(maxHeight)
if (maxPx != null) {
@@ -128,6 +133,7 @@ export const autosize = (node: TextArea, params?: AutosizeParams) => {
return {
update(newParams?: AutosizeParams) {
maxHeight = newParams?.maxHeight
minHeight = newParams?.minHeight ?? DEFAULT_MIN_HEIGHT
const nowCapped = maxHeight != null
if (nowCapped && !capped) {
window.addEventListener('resize', resize)
@@ -10,7 +10,11 @@
import { base } from '$lib/base'
import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy'
import { devBadgeText, devLabelKey, devLabelNoun } from '$lib/utils/devWorkspaceLabel'
import { loadProtectionRules } from '$lib/workspaceProtectionRules.svelte'
import {
loadProtectionRules,
fetchProtectionRulesForWorkspace,
isRuleUnconditionallyActiveInRulesets
} from '$lib/workspaceProtectionRules.svelte'
import { GitFork, ExternalLink } from 'lucide-svelte'
import { resource } from 'runed'
@@ -55,6 +59,48 @@
let busy = $state(false)
let labelBusy = $state(false)
// If this workspace already blocks direct deploy / forking through an existing protection rule, keep
// the matching lock toggle on but locked: attaching only manages its own reserved dev-workspace rule,
// so turning it "off" here couldn't lift a separately-defined block. Fetched only while the attach form
// is on screen; a failed fetch falls back to the editable default-on toggle (real rules still enforce).
const rootProtectionRules = resource(
() => (!parentId && !pairedDev ? $workspaceStore : undefined),
async (ws, _prev, { signal }) => {
if (!ws) return undefined
const rules = await fetchProtectionRulesForWorkspace(ws)
// The generated client can't take an abort signal, so drop a superseded response here: a late
// result for a previously selected workspace must not overwrite the current one's rules.
if (signal.aborted) throw new DOMException('superseded', 'AbortError')
return { ws, rules }
}
)
// Only trust a result that belongs to the current workspace (guards the in-flight window and any
// out-of-order response); undefined means "not known yet" and is treated as locked below.
let rootRules = $derived.by(() => {
const current = rootProtectionRules.current
return current && current.ws === $workspaceStore ? current.rules : undefined
})
// Only a rule with no bypass users/groups matches the empty-bypass reserved lock we would create; a
// bypassable rule stays editable, otherwise forcing the lock on would revoke the bypassed users'
// direct-deploy / forking access.
let alreadyBlocksDeploy = $derived(
isRuleUnconditionallyActiveInRulesets(rootRules ?? [], 'DisableDirectDeployment')
)
let alreadyBlocksForking = $derived(
isRuleUnconditionallyActiveInRulesets(rootRules ?? [], 'DisableWorkspaceForking')
)
// Until the fetch resolves for the current workspace its rules are unknown. Treat each lock as
// engaged during that window so the toggle is locked on and the effective value stays true:
// otherwise a user could turn a lock off and attach before an existing rule is detected, sending
// false and omitting the reserved rule — leaving prod unprotected if that rule is later removed.
let rulesUnknown = $derived(rootProtectionRules.loading || rootRules === undefined)
let deployLocked = $derived(alreadyBlocksDeploy || rulesUnknown)
let forkingLocked = $derived(alreadyBlocksForking || rulesUnknown)
// Sent to the backend: a locked restriction (enforced or not-yet-known) stays on regardless of the
// toggle's raw state, keeping the request consistent with what the locked toggle shows.
let effectiveLockProdDeploy = $derived(deployLocked || lockProdDeploy)
let effectiveLockProdForking = $derived(forkingLocked || lockProdForking)
// A standalone root workspace, or an existing fork of this prod (same family), can be attached.
// A fork parented to a different workspace can't (the backend rejects a parent that isn't this
// prod), so it's excluded here.
@@ -92,8 +138,8 @@
workspace: $workspaceStore,
requestBody: {
dev_workspace_id: selectedDevId,
lock_prod_deploy: lockProdDeploy,
lock_prod_forking: lockProdForking,
lock_prod_deploy: effectiveLockProdDeploy,
lock_prod_forking: effectiveLockProdForking,
dev_workspace_label: attachLabel
}
})
@@ -217,13 +263,44 @@
Change to {attachLabel === 'staging' ? 'dev' : 'staging'}
</button>
</div>
<Toggle
bind:checked={lockProdDeploy}
options={{
right: 'Block direct edits in this workspace (deploy via the dev workspace)'
}}
/>
<Toggle bind:checked={lockProdForking} options={{ right: 'Prevent forking this workspace' }} />
{#if deployLocked}
<div class="flex flex-col gap-0.5">
<Toggle
checked
disabled
options={{
right: 'Block direct edits in this workspace (deploy via the dev workspace)'
}}
/>
{#if alreadyBlocksDeploy}
<span class="text-2xs text-secondary ml-11"
>Already enforced by an existing protection rule</span
>
{/if}
</div>
{:else}
<Toggle
bind:checked={lockProdDeploy}
options={{
right: 'Block direct edits in this workspace (deploy via the dev workspace)'
}}
/>
{/if}
{#if forkingLocked}
<div class="flex flex-col gap-0.5">
<Toggle checked disabled options={{ right: 'Prevent forking this workspace' }} />
{#if alreadyBlocksForking}
<span class="text-2xs text-secondary ml-11"
>Already enforced by an existing protection rule</span
>
{/if}
</div>
{:else}
<Toggle
bind:checked={lockProdForking}
options={{ right: 'Prevent forking this workspace' }}
/>
{/if}
<div class="flex gap-2">
<Button variant="accent" disabled={busy || !selectedDevId} onclick={attach}>
Attach dev workspace
@@ -152,6 +152,10 @@
}}
></div>
{/if}
<!-- Tree-view alignment: a folder header's icon sits at px-4 (16px) + its inner
padding-left of depth*16, i.e. (depth+1)*16. This row's inline padding-left
overrides px-4, so it must carry the full (depth+1)*16 for a file to line up
with its sibling folder at the same depth. -->
<div
bind:this={rowEl}
class={twMerge(
@@ -161,7 +165,7 @@
clickToSelect ? 'cursor-pointer select-none' : '',
selected ? 'bg-surface-accent-selected' : keyboardSelected ? 'bg-gray-200 dark:bg-gray-700' : ''
)}
style={depth > 0 ? `padding-left: ${depth * 32}px;` : ''}
style={depth > 0 ? `padding-left: ${(depth + 1) * 16}px;` : ''}
role={clickToSelect ? 'button' : undefined}
tabindex={clickToSelect ? 0 : undefined}
onclick={handleRowClick}
@@ -118,6 +118,7 @@ Generate a tool name for the script below:
class?: string
onChange?: (content: string) => void
siblingToolNames?: string[]
hideError?: boolean
}
let {
@@ -132,7 +133,8 @@ Generate a tool name for the script below:
elementProps = {},
class: clazz = '',
onChange = undefined,
siblingToolNames = undefined
siblingToolNames = undefined,
hideError = false
}: Props = $props()
let toolNameError = $derived(
@@ -364,7 +366,7 @@ Generate a tool name for the script below:
onfocus={() => (focused = true)}
onblur={() => (focused = false)}
/>
{#if toolNameError}
{#if toolNameError && !hideError}
<p class="text-3xs text-red-400 leading-tight mt-0.5">
{toolNameError}
</p>
@@ -453,6 +453,21 @@ describe('validateFlowNotes', () => {
expect(note.size!.height).toBeGreaterThan(0)
})
it('sizes a geometry-less free note tall enough for its text so it does not overflow', () => {
const [short] = validateFlowNotes([{ id: 's', text: 'hi' }])!
const longText = Array.from({ length: 20 }, (_, i) => `Line ${i} of note content`).join('\n')
const [long] = validateFlowNotes([{ id: 'l', text: longText }])!
expect(long.size!.height).toBeGreaterThan(short.size!.height)
})
it('clamps a short free note to the minimum height and a huge one to the max', () => {
const [short] = validateFlowNotes([{ id: 's', text: 'hi' }])!
expect(short.size!.height).toBe(60) // MIN_NOTE_HEIGHT
const hugeText = Array.from({ length: 500 }, (_, i) => `Line ${i}`).join('\n')
const [huge] = validateFlowNotes([{ id: 'h', text: hugeText }])!
expect(huge.size!.height).toBe(600) // MAX_HEIGHT
})
it('staggers the default y position of multiple geometry-less free notes', () => {
const notes = validateFlowNotes([
{ id: 'a', text: 't' },
@@ -461,6 +476,42 @@ describe('validateFlowNotes', () => {
expect(notes[0].position!.y).not.toEqual(notes[1].position!.y)
})
it('stacks auto-placed notes by their real heights so tall notes do not overlap', () => {
const longText = Array.from({ length: 20 }, (_, i) => `Line ${i} of note content`).join('\n')
const notes = validateFlowNotes([
{ id: 'a', text: longText },
{ id: 'b', text: 'short' }
])!
// Second note must start at or below the bottom of the first (tall) note.
expect(notes[1].position!.y).toBeGreaterThanOrEqual(
notes[0].position!.y + notes[0].size!.height
)
})
it('does not drop an auto-placed note on top of a preserved note in the same column', () => {
// A round-tripped note keeps its existing auto-column geometry {-375, 0};
// a freshly added geometry-less note must stack below it, not overlap.
const notes = validateFlowNotes([
{
id: 'existing',
text: 'kept',
position: { x: -375, y: 0 },
size: { width: 275, height: 120 }
},
{ id: 'new', text: 'added' }
])!
expect(notes[1].position!.y).toBeGreaterThanOrEqual(
notes[0].position!.y + notes[0].size!.height
)
})
it('grows a note whose single source line wraps across many display lines', () => {
const short = validateFlowNotes([{ id: 's', text: 'hi' }])![0]
const oneLongLine = 'word '.repeat(200).trim() // no newlines, wraps many times
const wrapped = validateFlowNotes([{ id: 'w', text: oneLongLine }])![0]
expect(wrapped.size!.height).toBeGreaterThan(short.size!.height)
})
it('does not override a free note that already has geometry', () => {
const [note] = validateFlowNotes([
{ id: 'n', text: 't', position: { x: 5, y: 6 }, size: { width: 400, height: 90 } }
@@ -15,6 +15,33 @@ import type { InlineScriptSession } from './inlineScriptsUtils'
* break the color picker UI at worst. */
const ALLOWED_NOTE_COLORS = new Set<string>(Object.values(NoteColor))
// Free notes render at a fixed height and never grow to fit their content (the
// renderer's text div overflows the node box), so an AI-created note that omits
// `size` must be seeded tall enough for its text. Constants below mirror the
// renderer (text-xs 12px, line-height 1.4, p-4 padding).
function estimateFreeNoteSize(text: string): { width: number; height: number } {
const width = MIN_NOTE_WIDTH
const HORIZONTAL_PADDING = 32 // p-4 left + right
const VERTICAL_PADDING = 32 // p-4 top + bottom
const LINE_HEIGHT = 17 // 12px * 1.4
const AVG_CHAR_WIDTH = 6.2 // approx width of a char at 12px
const MAX_HEIGHT = 600
const charsPerLine = Math.max(1, Math.floor((width - HORIZONTAL_PADDING) / AVG_CHAR_WIDTH))
const sourceLines = (text ?? '').split('\n')
const wrappedLineCount = sourceLines.reduce(
(sum, line) => sum + Math.max(1, Math.ceil(line.length / charsPerLine)),
0
)
const height = Math.min(
MAX_HEIGHT,
Math.max(MIN_NOTE_HEIGHT, Math.ceil(wrappedLineCount * LINE_HEIGHT) + VERTICAL_PADDING)
)
return { width, height }
}
type FlowLike = Pick<OpenFlow, 'value'> & {
schema?: Record<string, any>
}
@@ -126,6 +153,13 @@ export function validateFlowNotes(rawNotes: unknown, moduleIds?: Set<string>): F
}
const seenIds = new Set<string>()
// Column and running y-cursor for auto-placed free notes so consecutive ones
// stack below each other by their actual heights instead of overlapping. A
// preserved note (explicit geometry) sitting in this column also advances the
// cursor, so a later auto-placed note doesn't land on top of it.
const AUTO_STACK_X = -(MIN_NOTE_WIDTH + 100)
const AUTO_STACK_GAP = 24
let autoStackY = 0
return rawNotes.map((note, index) => {
if (!note || typeof note !== 'object' || Array.isArray(note)) {
throw new Error(`Invalid note at index ${index}: must be an object`)
@@ -204,19 +238,27 @@ export function validateFlowNotes(rawNotes: unknown, moduleIds?: Set<string>): F
color: typeof n.color === 'string' ? n.color : DEFAULT_NOTE_COLOR
} as FlowNote
// Free notes need explicit geometry to be draggable/resizable. Place
// missing ones to the left of the flow column, staggered by index so
// several new notes don't land exactly on top of each other. Group notes
// Free notes need explicit geometry to be draggable/resizable. Size first
// (from text, so tall notes get a tall box), then place any note missing a
// position to the left of the flow column, stacking auto-placed notes by
// their real heights so several generated notes don't overlap. Group notes
// derive their layout from contained nodes, so they are left alone.
if (type === 'free') {
if (normalized.position == null) {
normalized.position = {
x: -(MIN_NOTE_WIDTH + 100),
y: index * (MIN_NOTE_HEIGHT + 24)
}
}
if (normalized.size == null) {
normalized.size = { width: MIN_NOTE_WIDTH, height: MIN_NOTE_HEIGHT }
normalized.size = estimateFreeNoteSize(normalized.text)
}
const height = normalized.size?.height ?? MIN_NOTE_HEIGHT
const width = normalized.size?.width ?? MIN_NOTE_WIDTH
if (normalized.position == null) {
normalized.position = { x: AUTO_STACK_X, y: autoStackY }
autoStackY += height + AUTO_STACK_GAP
} else if (
// A preserved note overlapping the auto-stack column pushes the cursor
// below it so a later auto-placed note isn't dropped on top of it.
normalized.position.x < AUTO_STACK_X + MIN_NOTE_WIDTH &&
normalized.position.x + width > AUTO_STACK_X
) {
autoStackY = Math.max(autoStackY, normalized.position.y + height + AUTO_STACK_GAP)
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -5,6 +5,7 @@
interface Props {
title?: string | undefined
summary?: string | undefined
description?: string | undefined
noEditor: boolean
noHeader?: boolean
flowModuleValue?: FlowModuleValue | undefined
@@ -18,6 +19,7 @@
let {
title = undefined,
summary = $bindable(undefined),
description = $bindable(undefined),
noEditor,
noHeader = false,
flowModuleValue = undefined,
@@ -37,6 +39,7 @@
on:reload
{title}
bind:summary
bind:description
{flowModuleValue}
{action}
{isAgentTool}
@@ -21,11 +21,13 @@
import { twMerge } from 'tailwind-merge'
import { getToolNameError } from '$lib/components/graph/renderers/nodes/AIToolNode.svelte'
import { DEFAULT_HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION } from '$lib/hub'
import autosize from '$lib/autosize'
interface Props {
flowModuleValue?: FlowModuleValue | undefined
title?: string | undefined
summary?: string | undefined
description?: string | undefined
children?: import('svelte').Snippet
action?: import('svelte').Snippet
isAgentTool?: boolean
@@ -36,6 +38,7 @@
flowModuleValue = undefined,
title = undefined,
summary = $bindable(undefined),
description = $bindable(undefined),
children,
action,
isAgentTool = false,
@@ -93,120 +96,136 @@
})
</script>
<div
class="overflow-x-auto scrollbar-hidden flex items-center justify-between px-4 py-2 flex-nowrap"
>
{#if flowModuleValue}
<span class="text-sm w-full mr-4">
<div class="flex items-center space-x-2">
{#if flowModuleValue.type === 'identity'}
<span class="font-bold text-xs">Identity (input copied to output)</span>
{:else if flowModuleValue.type === 'rawscript'}
<div class="mx-0.5">
<LanguageIcon lang={flowModuleValue.language} width={20} height={20} />
</div>
<MetadataGen
bind:content={summary}
promptConfigName={isAgentTool ? 'agentToolFunctionName' : 'summary'}
code={flowModuleValue.content}
class="w-full"
elementProps={{
placeholder: isAgentTool ? 'Tool name' : 'Summary'
}}
{siblingToolNames}
/>
{:else if flowModuleValue.type === 'script' && 'path' in flowModuleValue && flowModuleValue.path}
<IconedPath path={flowModuleValue.path} hash={flowModuleValue.hash} class="grow" />
{#if hubVersionId}
<Button
title="Report an issue with this hub script"
unifiedSize="sm"
variant="subtle"
on:click={() => {
const targetHubBaseUrl =
Number(hubVersionId) < PRIVATE_HUB_MIN_VERSION
? DEFAULT_HUB_BASE_URL
: $hubBaseUrlStore
window.open(
`${targetHubBaseUrl}/from_version/${hubVersionId}?report_issue=${hubVersionId}`,
'_blank'
)
}}
>
<Flag size={12} />Report issue
</Button>
{/if}
{#if flowModuleValue.hash}
{#if latestHash != flowModuleValue.hash}
<Button
size="xs"
variant="default"
on:click={() => {
if (flowModuleValue.type == 'script') {
dispatch('setHash', latestHash)
}
dispatch('reload')
}}>Update to latest hash</Button
>
{/if}
<Button
title="Unlock hash to always use latest deployed version at that path"
size="xs"
btnClasses="text-primary inline-flex gap-1 items-center"
color="light"
on:click={() => {
if (flowModuleValue.type == 'script') {
dispatch('setHash', undefined)
}
}}><Unlock size={12} />hash</Button
>
{:else if latestHash}
<div class="flex gap-2">
<Button
title="Lock hash to always use this specific version"
unifiedSize="sm"
variant="default"
on:click={() => {
if (flowModuleValue.type == 'script') {
dispatch('setHash', latestHash)
}
}}><Lock size={12} />hash</Button
>
<Button
title="Reload latest hash"
unifiedSize="sm"
variant="default"
on:click={() => dispatch('reload')}
startIcon={{ icon: RefreshCw }}
iconOnly
/>
<div class="flex flex-col gap-1 px-4 py-2">
<div
class="overflow-x-auto scrollbar-hidden flex items-center justify-between flex-nowrap w-full"
>
{#if flowModuleValue}
<span class="text-sm w-full mr-4">
<div class="flex items-center space-x-2">
{#if flowModuleValue.type === 'identity'}
<span class="font-bold text-xs">Identity (input copied to output)</span>
{:else if flowModuleValue.type === 'rawscript'}
<div class="mx-0.5">
<LanguageIcon lang={flowModuleValue.language} width={20} height={20} />
</div>
{/if}
<div class="flex flex-col w-full grow">
<input
bind:value={summary}
placeholder={isAgentTool ? 'Tool name' : 'Summary'}
class={twMerge('w-full grow', toolNameError && '!border-red-400')}
<MetadataGen
bind:content={summary}
promptConfigName={isAgentTool ? 'agentToolFunctionName' : 'summary'}
code={flowModuleValue.content}
class="w-full"
elementProps={{
placeholder: isAgentTool ? 'Tool name' : 'Summary'
}}
hideError={isAgentTool}
{siblingToolNames}
/>
{#if toolNameError}
<p class="text-3xs text-red-400 leading-tight mt-0.5">{toolNameError}</p>
{:else if flowModuleValue.type === 'script' && 'path' in flowModuleValue && flowModuleValue.path}
<IconedPath path={flowModuleValue.path} hash={flowModuleValue.hash} class="grow" />
{#if hubVersionId}
<Button
title="Report an issue with this hub script"
unifiedSize="sm"
variant="subtle"
on:click={() => {
const targetHubBaseUrl =
Number(hubVersionId) < PRIVATE_HUB_MIN_VERSION
? DEFAULT_HUB_BASE_URL
: $hubBaseUrlStore
window.open(
`${targetHubBaseUrl}/from_version/${hubVersionId}?report_issue=${hubVersionId}`,
'_blank'
)
}}
>
<Flag size={12} />Report issue
</Button>
{/if}
</div>
{:else if flowModuleValue.type === 'flow'}
<Badge color="indigo" capitalize>flow</Badge>
<input bind:value={summary} placeholder="Summary" class="w-full grow" />
{:else if flowModuleValue.type === 'aiagent'}
<Badge color="indigo">AI Agent</Badge>
<input bind:value={summary} placeholder="Summary" class="w-full grow" />
{/if}
</div>
</span>
{#if flowModuleValue.hash}
{#if latestHash != flowModuleValue.hash}
<Button
size="xs"
variant="default"
on:click={() => {
if (flowModuleValue.type == 'script') {
dispatch('setHash', latestHash)
}
dispatch('reload')
}}>Update to latest hash</Button
>
{/if}
<Button
title="Unlock hash to always use latest deployed version at that path"
size="xs"
btnClasses="text-primary inline-flex gap-1 items-center"
color="light"
on:click={() => {
if (flowModuleValue.type == 'script') {
dispatch('setHash', undefined)
}
}}><Unlock size={12} />hash</Button
>
{:else if latestHash}
<div class="flex gap-2">
<Button
title="Lock hash to always use this specific version"
unifiedSize="sm"
variant="default"
on:click={() => {
if (flowModuleValue.type == 'script') {
dispatch('setHash', latestHash)
}
}}><Lock size={12} />hash</Button
>
<Button
title="Reload latest hash"
unifiedSize="sm"
variant="default"
on:click={() => dispatch('reload')}
startIcon={{ icon: RefreshCw }}
iconOnly
/>
</div>
{/if}
<div class="flex flex-col w-full grow">
<input
bind:value={summary}
placeholder={isAgentTool ? 'Tool name' : 'Summary'}
class={twMerge('w-full grow', toolNameError && '!border-red-400')}
/>
{#if toolNameError && !isAgentTool}
<p class="text-3xs text-red-400 leading-tight mt-0.5">{toolNameError}</p>
{/if}
</div>
{:else if flowModuleValue.type === 'flow'}
<Badge color="indigo" capitalize>flow</Badge>
<input bind:value={summary} placeholder="Summary" class="w-full grow" />
{:else if flowModuleValue.type === 'aiagent'}
<Badge color="indigo">AI Agent</Badge>
<input bind:value={summary} placeholder="Summary" class="w-full grow" />
{/if}
</div>
</span>
{/if}
{#if title}
<div class="text-sm font-bold text-primary pr-2">{title}</div>
{/if}
{@render children?.()}
{@render action?.()}
</div>
{#if isAgentTool}
{#if toolNameError}
<p class="text-3xs text-red-400 leading-tight w-full">{toolNameError}</p>
{/if}
<textarea
rows="1"
use:autosize={{ minHeight: 0 }}
bind:value={description}
maxlength={3000}
placeholder="Tool description (optional): tells the AI when and how to use this tool"
class="w-full text-xs resize-none"
></textarea>
{/if}
{#if title}
<div class="text-sm font-bold text-primary pr-2">{title}</div>
{/if}
{@render children?.()}
{@render action?.()}
</div>
@@ -45,6 +45,7 @@
forceTestTab={forceTestTab?.[tool.id]}
highlightArg={highlightArg?.[tool.id]}
isAgentTool={true}
bind:toolDescription={tool.description}
{siblingToolNames}
/>
{:else if isMcpTool(tool)}
@@ -115,6 +115,7 @@
forceTestTab?: boolean
highlightArg?: string
isAgentTool?: boolean
toolDescription?: string | undefined
siblingToolNames?: string[]
}
@@ -132,6 +133,7 @@
forceTestTab = false,
highlightArg = undefined,
isAgentTool = false,
toolDescription = $bindable(undefined),
siblingToolNames = undefined
}: Props = $props()
@@ -733,6 +735,7 @@
}
}}
bind:summary={flowModule.summary}
bind:description={toolDescription}
{isAgentTool}
{siblingToolNames}
>
@@ -306,6 +306,10 @@ export function setSessionDraftPrompt(sessionId: string, text: string): void {
// mount-time onDraftChange('') as a non-touch (draftPrompt is undefined),
// so merely opening an untouched draft never persists it.
if ((s.draftPrompt ?? '') === text) return
// Keep `transient` (means "in-memory only") set until the flush persists the
// draft, so hydrateSessions preserves it across a reconcile inside this window;
// isReusableBlank, not `transient`, is what stops createSession reusing a typed
// draft. Only the IndexedDB write is debounced.
s.draftPrompt = text
clearTimeout(draftPromptFlushHandles.get(sessionId))
draftPromptFlushHandles.set(
@@ -541,15 +545,18 @@ export async function reconcileAfterWorkspaceChange(): Promise<void> {
await reconcileSessionsLifecycle()
}
// Count non-transient sessions committed to a given workspace — used to warn the
// user, before archiving/deleting a workspace, how many AI sessions go with it.
// Matches on `workspace_id ?? pending_workspace_id` so persisted unsent drafts
// count too; reconcileSessionsLifecycle tears them down with committed sessions on
// archive/delete, so this pre-teardown confirmation count must match.
export async function countSessionsForWorkspace(workspaceId: string): Promise<number> {
if (!BROWSER) return 0
const db = await sessionsDb.whenReady()
if (!db) return 0
try {
const all = await db.getAll('sessions')
return all.filter((s) => s.workspace_id === workspaceId && !s.transient).length
return all.filter(
(s) => (s.workspace_id ?? s.pending_workspace_id) === workspaceId && !s.transient
).length
} catch {
return 0
}
@@ -645,15 +652,22 @@ export function requestComposerFocus(): void {
composerFocusRequest.nonce++
}
// An untouched in-memory blank that `+` may reuse/discard. `draftPrompt ===
// undefined` (never edited), not falsiness: a draft typed then erased to '' still
// has a pending flush and is a real session, so it must survive both. Every other
// touch clears `transient` synchronously, so only the draft prompt needs checking.
function isReusableBlank(s: Session): boolean {
return !!s.transient && s.draftPrompt === undefined
}
export function createSession(): Session {
// Reuse an existing untouched draft from the active family rather than pile a
// blank entry on every `+`. "Untouched" is exactly `transient`: a pending
// session leaves the in-memory-only state the moment the user touches it
// (types a prompt, picks a workspace, opens the panel, renames), at which
// point it persists and is its own session — so several pending sessions can
// still be built up in parallel, one touch at a time. A cross-family leftover
// draft is dropped instead of reused (reusing it would act on that family).
const reusable = sessionState.sessions.find((s) => s.transient && sessionInCurrentFamily(s))
// blank entry on every `+`, so several pending sessions can still be built up
// in parallel, one touch at a time. A cross-family leftover blank is dropped
// instead of reused (reusing it would act on that family).
const reusable = sessionState.sessions.find(
(s) => isReusableBlank(s) && sessionInCurrentFamily(s)
)
if (reusable) {
sessionState.currentSessionId = reusable.id
// Reusing an already-active draft doesn't change currentSessionId, so ask
@@ -661,7 +675,7 @@ export function createSession(): Session {
requestComposerFocus()
return reusable
}
sessionState.sessions = sessionState.sessions.filter((s) => !s.transient)
sessionState.sessions = sessionState.sessions.filter((s) => !isReusableBlank(s))
const existingNumbers = sessionState.sessions
.map((s) => /^session-(\d+)$/.exec(s.name)?.[1])
.map((n) => (n ? parseInt(n, 10) : 0))
@@ -960,6 +974,11 @@ export function setSessionArchived(id: string, archived: boolean) {
export function deleteSession(id: string) {
const s = sessionState.sessions.find((x) => x.id === id)
if (!s) return
// Cancel any pending draft-prompt flush: left running, its persistTouched
// would write the record back to IndexedDB after we delete it, resurrecting
// a draft deleted inside the debounce window.
clearTimeout(draftPromptFlushHandles.get(id))
draftPromptFlushHandles.delete(id)
sessionState.sessions = sessionState.sessions.filter((x) => x.id !== id)
if (sessionState.currentSessionId === id) {
sessionState.currentSessionId = sessionState.sessions[0]?.id
@@ -8,6 +8,7 @@ import {
renameSession,
sessionInCurrentFamily,
setGeneratedSessionSummary,
setSessionDraftPrompt,
sessionState,
type Session
} from './sessionState.svelte'
@@ -414,4 +415,76 @@ describe('createSession — reuses an untouched draft, family-scoped', () => {
restore()
}
})
it('stops reusing a draft the instant it is typed into, before the debounce flush', () => {
// The real transition (not a hand-built flag): a keystroke sets draftPrompt
// while the write is debounced. The draft stays transient (survives hydration)
// but is no longer a reusable blank, so `+` within the window spawns a second
// session and must not discard the typed draft.
vi.useFakeTimers()
const restore = withTwoFamilies('rootA')
const prevCurrent = sessionState.currentSessionId
const draft = session({
id: 'typed-within-window',
name: 'session-904',
pending_workspace_id: 'rootA',
transient: true
})
sessionState.sessions.push(draft)
let createdId: string | undefined
try {
setSessionDraftPrompt('typed-within-window', 'h')
// Still transient (persistence deferred), but now carries typed text.
expect(draft.transient).toBe(true)
expect(draft.draftPrompt).toBe('h')
const created = createSession()
createdId = created.id
expect(created.id).not.toBe('typed-within-window')
expect(created.transient).toBe(true)
// The typed draft survives the non-reuse drop as its own entry.
expect(sessionState.sessions.some((s) => s.id === 'typed-within-window')).toBe(true)
} finally {
vi.clearAllTimers()
vi.useRealTimers()
sessionState.sessions = sessionState.sessions.filter(
(s) => s.id !== 'typed-within-window' && s.id !== createdId
)
sessionState.currentSessionId = prevCurrent
restore()
}
})
it('does not reuse a draft typed into then erased back to empty (flush still pending)', () => {
// draftPrompt is '' here, not undefined: the user edited it (a flush is pending),
// so it must stay a real session — reusing/dropping it would be inconsistent
// across the 400ms boundary and could resurrect it via the pending timer.
vi.useFakeTimers()
const restore = withTwoFamilies('rootA')
const prevCurrent = sessionState.currentSessionId
const draft = session({
id: 'typed-then-erased',
name: 'session-905',
pending_workspace_id: 'rootA',
transient: true
})
sessionState.sessions.push(draft)
let createdId: string | undefined
try {
setSessionDraftPrompt('typed-then-erased', 'h')
setSessionDraftPrompt('typed-then-erased', '')
expect(draft.draftPrompt).toBe('')
const created = createSession()
createdId = created.id
expect(created.id).not.toBe('typed-then-erased')
expect(sessionState.sessions.some((s) => s.id === 'typed-then-erased')).toBe(true)
} finally {
vi.clearAllTimers()
vi.useRealTimers()
sessionState.sessions = sessionState.sessions.filter(
(s) => s.id !== 'typed-then-erased' && s.id !== createdId
)
sessionState.currentSessionId = prevCurrent
restore()
}
})
})
@@ -37,6 +37,7 @@ import {
deleteSessionRecord,
archiveSessionsForWorkspace,
deleteSessionsForWorkspace,
countSessionsForWorkspace,
materializeTransient,
getSessionDraftPrompt,
setSessionDraftPrompt,
@@ -219,6 +220,24 @@ describe('sessionState IndexedDB persistence', () => {
expect(sessionState.sessions).toEqual([])
})
it('deleting a draft inside the debounce window does not resurrect it', async () => {
const user = freshUser()
userStore.set(user)
await flush()
const s = session({ id: 't4b', transient: true, pending_workspace_id: 'wsA' })
sessionState.sessions = [s]
// Schedule a flush, then delete before the 400ms timer fires. The cancelled
// timer must not write the record back to IndexedDB.
setSessionDraftPrompt('t4b', 'typed then deleted')
deleteSession('t4b')
await new Promise((r) => setTimeout(r, 500))
await rehydrate(user)
await flush()
expect(sessionState.sessions).toEqual([])
})
it('removes a session record', async () => {
const user = freshUser()
userStore.set(user)
@@ -492,6 +511,20 @@ describe('sessionState IndexedDB persistence', () => {
expect(rec).toBeUndefined()
})
it('counts persisted pending drafts alongside committed sessions for a workspace', async () => {
const user = freshUser()
userStore.set(user)
await flush()
// One committed session and one still-unsent draft, both bound to wsCount —
// reconcile removes both on teardown, so the confirmation count must see both.
await putSession(session({ id: 'committed', createdAt: 1, workspace_id: 'wsCount' }))
await putSession(session({ id: 'pending', createdAt: 2, pending_workspace_id: 'wsCount' }))
// A committed session in a different workspace must not be counted.
await putSession(session({ id: 'other', createdAt: 3, workspace_id: 'wsOther' }))
expect(await countSessionsForWorkspace('wsCount')).toBe(2)
})
it('archives a persisted pending draft (tagged) when its pending workspace is archived', async () => {
const user = freshUser()
usersWorkspaceStore.set({
@@ -514,6 +547,38 @@ describe('sessionState IndexedDB persistence', () => {
expect(rec.archivedByWorkspace).toBe(true)
})
it('keeps a just-typed draft in memory when reconcile hydrates before its flush fires', async () => {
const user = freshUser()
usersWorkspaceStore.set({
email: user.email,
workspaces: [{ id: 'wsRec', name: 'rec', disabled: false }] as never
})
userStore.set(user)
await flush()
// A committed session gives reconcile a workspace to query, so it proceeds to
// hydrateSessions (which rebuilds the list as in-memory-transients + DB rows).
await putSession(session({ id: 'committed', createdAt: 1, workspace_id: 'wsRec' }))
// A fresh draft the user just started typing into: transient (not yet written)
// with a debounced flush pending. hydrate must preserve it — clearing transient
// early would leave it in neither bucket and drop it, dangling currentSessionId.
sessionState.sessions.push(
session({ id: 'draftRec', pending_workspace_id: 'wsRec', transient: true })
)
sessionState.currentSessionId = 'draftRec'
setSessionDraftPrompt('draftRec', 'typing')
vi.mocked(WorkspaceService.getSessionWorkspaceStatus).mockResolvedValueOnce({
wsRec: 'active'
} as never)
await reconcileSessionsLifecycle()
expect(sessionState.sessions.some((s) => s.id === 'draftRec')).toBe(true)
expect(sessionState.currentSessionId).toBe('draftRec')
// Cancel the still-pending flush so it can't write to a torn-down DB later.
deleteSession('draftRec')
})
it('clears the in-memory list on logout', async () => {
const user = freshUser()
userStore.set(user)
@@ -21,6 +21,10 @@
findWorkspaceDescendants
} from '$lib/utils/workspaceHierarchy'
import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte'
import {
fetchProtectionRulesForWorkspace,
isRuleUnconditionallyActiveInRulesets
} from '$lib/workspaceProtectionRules.svelte'
import { resource } from 'runed'
import { Badge, Button } from '$lib/components/common'
import { devBadgeText } from '$lib/utils/devWorkspaceLabel'
@@ -143,6 +147,48 @@
baseWorkspaceEntry?.name ?? baseWorkspaceId ?? 'the root workspace'
)
// If the root already blocks direct deploy / forking through an existing protection rule, keep the
// matching lock toggle on but locked: this flow only manages its own reserved dev-workspace rule, so
// turning it "off" here couldn't lift a separately-defined block. On a failed fetch we fall back to the
// editable default-on toggle, which can't drop protection (any real rule still enforces server-side).
const rootProtectionRules = resource(
() => (canDesignateDevWorkspace && createAsDevWorkspace ? baseWorkspaceId : undefined),
async (ws, _prev, { signal }) => {
if (!ws) return undefined
const rules = await fetchProtectionRulesForWorkspace(ws)
// The generated client can't take an abort signal, so drop a superseded response here: a late
// result for a previous base must not overwrite the newly selected base's rules.
if (signal.aborted) throw new DOMException('superseded', 'AbortError')
return { ws, rules }
}
)
// Only trust a result that belongs to the currently selected base (guards the in-flight window and
// any out-of-order response); undefined means "not known yet" and is treated as locked below.
let rootRules = $derived.by(() => {
const current = rootProtectionRules.current
return current && current.ws === baseWorkspaceId ? current.rules : undefined
})
// Only a rule with no bypass users/groups matches the empty-bypass reserved lock we would create; a
// bypassable rule stays editable, otherwise forcing the lock on would revoke the bypassed users'
// direct-deploy / forking access.
let rootAlreadyBlocksDeploy = $derived(
isRuleUnconditionallyActiveInRulesets(rootRules ?? [], 'DisableDirectDeployment')
)
let rootAlreadyBlocksForking = $derived(
isRuleUnconditionallyActiveInRulesets(rootRules ?? [], 'DisableWorkspaceForking')
)
// Until the fetch resolves for the selected base its rules are unknown. Treat each lock as engaged
// during that window so the toggle is locked on and the effective value stays true: otherwise a user
// could turn a lock off and submit before an existing rule is detected, sending false and omitting
// the reserved rule — leaving prod unprotected if that existing rule is later removed.
let rootRulesUnknown = $derived(rootProtectionRules.loading || rootRules === undefined)
let deployLocked = $derived(rootAlreadyBlocksDeploy || rootRulesUnknown)
let forkingLocked = $derived(rootAlreadyBlocksForking || rootRulesUnknown)
// Sent to the backend: a locked restriction (enforced or not-yet-known) stays on regardless of the
// toggle's raw state, keeping the request consistent with what the locked toggle shows.
let effectiveLockProdDeploy = $derived(deployLocked || lockProdDeploy)
let effectiveLockProdForking = $derived(forkingLocked || lockProdForking)
let id = $state('')
let name = $state('')
let username = $state('')
@@ -306,8 +352,8 @@
dev_workspace_label: createAsDevWorkspace ? devWorkspaceLabel : undefined,
// Send the lock intent in this first phase too so the backend can reject a non-admin's
// locked-dev request before any branch is created (avoids dangling branches).
lock_prod_deploy: createAsDevWorkspace && lockProdDeploy,
lock_prod_forking: createAsDevWorkspace && lockProdForking,
lock_prod_deploy: createAsDevWorkspace && effectiveLockProdDeploy,
lock_prod_forking: createAsDevWorkspace && effectiveLockProdForking,
copy_members: copyMembers
}
})
@@ -374,8 +420,8 @@
shared_ducklakes: forkDucklakeSection?.getSharedDucklakes() ?? [],
is_dev_workspace: createAsDevWorkspace,
dev_workspace_label: createAsDevWorkspace ? devWorkspaceLabel : undefined,
lock_prod_deploy: createAsDevWorkspace && lockProdDeploy,
lock_prod_forking: createAsDevWorkspace && lockProdForking,
lock_prod_deploy: createAsDevWorkspace && effectiveLockProdDeploy,
lock_prod_forking: createAsDevWorkspace && effectiveLockProdForking,
copy_members: copyMembers
}
})
@@ -769,11 +815,37 @@
dev workspace and promoted here.
</span>
</div>
<Toggle
bind:checked={lockProdDeploy}
options={{ right: 'Block direct edits (deploy via the dev workspace)' }}
/>
<Toggle bind:checked={lockProdForking} options={{ right: 'Prevent forking' }} />
{#if deployLocked}
<div class="flex flex-col gap-0.5">
<Toggle
checked
disabled
options={{ right: 'Block direct edits (deploy via the dev workspace)' }}
/>
{#if rootAlreadyBlocksDeploy}
<span class="text-2xs text-secondary ml-11"
>Already enforced by an existing protection rule</span
>
{/if}
</div>
{:else}
<Toggle
bind:checked={lockProdDeploy}
options={{ right: 'Block direct edits (deploy via the dev workspace)' }}
/>
{/if}
{#if forkingLocked}
<div class="flex flex-col gap-0.5">
<Toggle checked disabled options={{ right: 'Prevent forking' }} />
{#if rootAlreadyBlocksForking}
<span class="text-2xs text-secondary ml-11"
>Already enforced by an existing protection rule</span
>
{/if}
</div>
{:else}
<Toggle bind:checked={lockProdForking} options={{ right: 'Prevent forking' }} />
{/if}
</div>
{/if}
</div>
@@ -182,6 +182,23 @@ export function isRuleActiveInRulesets(
return rulesets.some((ruleset) => ruleset.rules.includes(ruleKind))
}
/**
* Whether a rule kind is enforced with no bypass users/groups in at least one ruleset, the only case
* that matches the empty-bypass reserved dev-workspace lock. A bypassable rule does not, since adding
* the unconditional lock would revoke those users' access; callers keep such a toggle editable.
*/
export function isRuleUnconditionallyActiveInRulesets(
rulesets: ProtectionRuleset[],
ruleKind: ProtectionRuleKind
): boolean {
return rulesets.some(
(ruleset) =>
ruleset.rules.includes(ruleKind) &&
ruleset.bypass_users.length === 0 &&
ruleset.bypass_groups.length === 0
)
}
/**
* Checks if user can bypass a rule kind in given rulesets (workspace-agnostic version)
* @param rulesets Array of protection rulesets to check
+1 -1
View File
@@ -4,7 +4,7 @@ verify_ssl = true
name = "pypi"
[packages]
wmill = ">=1.757.0"
wmill = ">=1.759.0"
sendgrid = "*"
mysql-connector-python = "*"
pymongo = "*"
+4 -1
View File
@@ -1,7 +1,7 @@
openapi: '3.0.3'
info:
version: 1.757.0
version: 1.759.0
title: OpenFlow Spec
contact:
name: Ruben Fiszel
@@ -935,6 +935,9 @@ components:
summary:
type: string
description: Short description of what this tool does (shown to the AI)
description:
type: string
description: Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script.
value:
$ref: '#/components/schemas/ToolValue'
required:
@@ -12,7 +12,7 @@
RootModule = 'WindmillClient.psm1'
# Version number of this module.
ModuleVersion = '1.757.0'
ModuleVersion = '1.759.0'
# Supported PSEditions
# CompatiblePSEditions = @()
+1 -1
View File
@@ -1,6 +1,6 @@
[tool.poetry]
name = "wmill"
version = "1.757.0"
version = "1.759.0"
description = "A client library for accessing Windmill server wrapping the Windmill client API"
license = "Apache-2.0"
homepage = "https://windmill.dev"
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill/windmill",
"version": "1.757.0",
"version": "1.759.0",
"exports": "./src/index.ts",
"publish": {
"exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts"]
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "windmill-client",
"description": "Windmill SDK client for browsers and Node.js",
"version": "1.757.0",
"version": "1.759.0",
"author": "Ruben Fiszel",
"license": "Apache 2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme",
+1 -1
View File
@@ -1 +1 @@
1.757.0
1.759.0