Merge remote-tracking branch 'origin/main' into migrate-session-to-fork

This commit is contained in:
Guilhem Lemouel
2026-10-27 01:17:48 -07:00
188 changed files with 5731 additions and 2899 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.849.0"
".": "1.850.0"
}
+1 -1
View File
@@ -66,7 +66,7 @@ Open-source platform for internal tools, workflows, API integrations, background
- **Domain vocabulary**: `CONTEXT.md` — the words this codebase uses for its own concepts (step, step setting, trigger step, …). Name things the way it does.
- **CLI commands**: when adding/modifying/removing a command, subcommand, option, or description in `cli/src/commands/`, run `python system_prompts/generate.py` to refresh `system_prompts/auto-generated/` and `cli/src/guidance/skills.gen.ts`. The CLI docs the agents use to operate `wmill` are derived from the source — stale generated files give agents the wrong flags.
- **AI guidance** (chat prompts and CLI skills): what the AI knows about Windmill itself — how flows, scripts, apps, pipelines and resources work — is written once, in `system_prompts/base/` and `system_prompts/languages/`, and `system_prompts/generate.py` builds both the chat's `$system_prompts` exports and the CLI's skills from it. Add or change such guidance there, never inline in one consumer: a sentence only one side should see goes in a `<!-- cli-only -->` / `<!-- chat-only -->` block, and a new file reaches both through the `TOPICS` table. The chat prompt builders under `frontend/src/lib/components/copilot/chat/` add only tool plumbing and runtime values, and shared markdown names no chat tool, since it reaches every chat mode. Rerun `generate.py` after editing; `system_prompts/README.md` has the details.
- **Windmill MCP server**: `/api/mcp` gives a token with `mcp:all` the AI session's GLOBAL toolset (`backend/windmill-api/src/mcp/session/`), and a token scoped to a selection of scripts and flows the tools server's per-runnable tools, without endpoint tools. The tools server's own URLs, `/api/mcp/w/{workspace}/mcp` and `/api/mcp/gateway`, keep serving existing tokens, endpoint scopes included. Every tool takes a `workspace_id`; the token bounds which (its bound workspace, the `mcp:workspaces:` list, or all of its owner's). What a model reads there (tool names, descriptions, input schemas, instructions, `get_instructions` texts) is generated from the session's TypeScript into `session/gen/`: after changing a GLOBAL tool definition, the global system prompt or `system_prompts/`, run `npm run gen:mcp-session` from `frontend/` (`mcpSessionGen.test.ts` fails otherwise). What a tool *does* is written twice, in `chat/global/core.ts` and in a Rust handler: a behaviour change on one side must be mirrored on the other, and a new session tool is either implemented in Rust or added to `EDITOR_ONLY` (a `cargo test` in `session/mod.rs` fails otherwise).
- **Windmill MCP server**: `/api/mcp` gives a token with `mcp:all` the AI session's GLOBAL toolset (`backend/windmill-api/src/mcp/session/`), and a token scoped to a selection of scripts and flows the tools server's per-runnable tools, without endpoint tools. The tools server's own URLs, `/api/mcp/w/{workspace}/mcp` and `/api/mcp/gateway`, keep serving existing tokens, endpoint scopes included, except that they give the session toolset to an `mcp:all` token carrying `mcp:session` (added by the OAuth approve, kept across scope edits) or `mcp:read_only`/`mcp:read_run`. Every tool takes a `workspace_id`; the token bounds which (its bound workspace, the `mcp:workspaces:` list, or all of its owner's). What a model reads there (tool names, descriptions, input schemas, instructions, `get_instructions` texts) is generated from the session's TypeScript into `session/gen/`: after changing a GLOBAL tool definition, the global system prompt or `system_prompts/`, run `npm run gen:mcp-session` from `frontend/` (`mcpSessionGen.test.ts` fails otherwise). What a tool *does* is written twice, in `chat/global/core.ts` and in a Rust handler: a behaviour change on one side must be mirrored on the other, and a new session tool is either implemented in Rust or added to `EDITOR_ONLY` (a `cargo test` in `session/mod.rs` fails otherwise).
- **Session recorder**: `frontend/src/lib/components/recording/` is also the recorder `wmill app dev --recording` serves, vendored into the CLI as `cli/src/commands/app/devRecorderBundle.gen.ts`. After changing `rawAppSnapshot.ts` or `rawAppRecording.svelte.ts`, run `bun run gen:dev-recorder` from `cli/` (`cli/test/dev_recorder_bundle_unit.test.ts` fails otherwise).
- **Raw-app policy**: `frontend/src/lib/components/raw_apps/rawAppPolicy.ts` also derives the policy the server's raw-app deploy stores, vendored into the bundle job as `backend/windmill-api/src/apps_raw_policy.gen.js`. After changing it or anything it imports, run `bun run gen:app-policy` from `cli/` (`cli/test/app_policy_bundle_unit.test.ts` fails otherwise). It rides in the job rather than being read from the CLI the job runs because the images install `windmill-cli` unpinned, so an image can carry one older than its server.
+24
View File
@@ -1,5 +1,29 @@
# Changelog
## [1.850.0](https://github.com/windmill-labs/windmill/compare/v1.849.0...v1.850.0) (2026-10-27)
### Features
* add a home setting to hide empty folders ([#12011](https://github.com/windmill-labs/windmill/issues/12011)) ([6e40940](https://github.com/windmill-labs/windmill/commit/6e4094009901f0702dc8b102390466d120361156))
* add ai review to fork deploys and deployment requests ([#12029](https://github.com/windmill-labs/windmill/issues/12029)) ([bac1e46](https://github.com/windmill-labs/windmill/commit/bac1e463e5759add52b34e0555d2530d671b2251))
* build MCP OAuth urls from the api base url, narrowable per surface ([#12010](https://github.com/windmill-labs/windmill/issues/12010)) ([e0da560](https://github.com/windmill-labs/windmill/commit/e0da5605163ce5157e9b605667c5e5fc85894db9))
* forward a pinned version through the hub project export proxy ([#12022](https://github.com/windmill-labs/windmill/issues/12022)) ([7b1dce5](https://github.com/windmill-labs/windmill/commit/7b1dce5af05660ffbe1febbe04976af139569a0b))
* name the previewed item in an empty AI session's composer ([#12021](https://github.com/windmill-labs/windmill/issues/12021)) ([6939454](https://github.com/windmill-labs/windmill/commit/6939454edb55a8df39b4fc5460f77333554f48a5))
* show the draft badge on Home's favorite and recent cards ([#12018](https://github.com/windmill-labs/windmill/issues/12018)) ([4a34dc6](https://github.com/windmill-labs/windmill/commit/4a34dc69f544460e7893b04dbfd002f1c67125ae))
* tool approval and a question tool for ai agent steps ([#11826](https://github.com/windmill-labs/windmill/issues/11826)) ([a1bc9ec](https://github.com/windmill-labs/windmill/commit/a1bc9ec0c27ab615460b145e59d7e7b8fdea8406))
### Bug Fixes
* add job_id, step_id and flow_path to native ts step errors ([#12012](https://github.com/windmill-labs/windmill/issues/12012)) ([2b4dfcc](https://github.com/windmill-labs/windmill/commit/2b4dfccb36493365de4e358f4f8c0fb7d7009c4a))
* build hub script urls and cache keys from the parsed id only ([#12028](https://github.com/windmill-labs/windmill/issues/12028)) ([e2e2e7d](https://github.com/windmill-labs/windmill/commit/e2e2e7d7c4924fe29a912c603554f8545184d704))
* compute list row rights when drawn instead of reloading ([#12023](https://github.com/windmill-labs/windmill/issues/12023)) ([b813c1b](https://github.com/windmill-labs/windmill/commit/b813c1b35d20291e0bf2ec23add0f76de7788e27))
* detect attached -c and --no-config in DENO_FLAGS ([#12027](https://github.com/windmill-labs/windmill/issues/12027)) ([a2b91fd](https://github.com/windmill-labs/windmill/commit/a2b91fd17e785f4c707eea092bc0b993419d7d7d))
* serve new MCP OAuth sign-ins the session toolset on the tools server URLs ([#12024](https://github.com/windmill-labs/windmill/issues/12024)) ([ee4150a](https://github.com/windmill-labs/windmill/commit/ee4150a69cca745ce3a909cefa5b9c1627a5f992))
* show the protection-rule bypass toggle inline ([#12034](https://github.com/windmill-labs/windmill/issues/12034)) ([df2e0cd](https://github.com/windmill-labs/windmill/commit/df2e0cdb9d56a9d5cb7f3d64ed6ff2a621d0cca2))
* stop starred items showing a starred removal in the editor diff ([#12017](https://github.com/windmill-labs/windmill/issues/12017)) ([35be14e](https://github.com/windmill-labs/windmill/commit/35be14ea27e70025b46d33fb072ad3767211eb17))
## [1.849.0](https://github.com/windmill-labs/windmill/compare/v1.848.3...v1.849.0) (2026-10-25)
+6 -2
View File
@@ -169,8 +169,12 @@ ENV UV_TOOL_DIR=/usr/local/uv
ENV PATH /usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
RUN apt-get update \
# The build cache keeps the apt layer while the base image digest and this line are unchanged, so
# Debian security updates are not picked up by later builds. Bump to force a fresh apt-get upgrade;
# bump it in docker/DockerfileSlim and docker/DockerfileSlimEe too.
ARG APT_REFRESH=1
RUN echo "apt refresh ${APT_REFRESH}" \
&& 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 \
+102 -102
View File
@@ -2964,9 +2964,9 @@ dependencies = [
[[package]]
name = "crypto-bigint"
version = "0.7.8"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d726facc8b27e604391b232b970cdc19b5468c69f08b466b9734c759216c7415"
checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11"
dependencies = [
"cpubits",
"ctutils",
@@ -3055,9 +3055,9 @@ dependencies = [
[[package]]
name = "ctutils"
version = "0.4.5"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a"
dependencies = [
"cmov",
"subtle",
@@ -4518,9 +4518,9 @@ dependencies = [
[[package]]
name = "der"
version = "0.8.2"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a878c850e9e421b20262e9b41f9c860e4785fa07541c266b62ff9d1ef998a80a"
checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004"
dependencies = [
"const-oid 0.10.2",
"zeroize",
@@ -5070,7 +5070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b805154de2e68f59874ec217ca36790dcffe500cd872c60fe509d28d0814a74d"
dependencies = [
"ed448",
"elliptic-curve 0.14.2",
"elliptic-curve 0.14.3",
"hash2curve",
"rand_core 0.10.1",
"serdect 0.4.3",
@@ -5126,12 +5126,12 @@ dependencies = [
[[package]]
name = "elliptic-curve"
version = "0.14.2"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad4496221fcfc21015fd8c0e26fcfab00504832dc0604f52acdd7bad2a8dc81"
checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c"
dependencies = [
"base16ct 1.0.0",
"crypto-bigint 0.7.8",
"crypto-bigint 0.7.10",
"crypto-common 0.2.2",
"ff 0.14.0",
"group 0.14.0",
@@ -6241,7 +6241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1eaf40612d7d854743e7189228a6d528f0f6e8502cf6a0cb831d28a218b7f3f6"
dependencies = [
"digest 0.11.3",
"elliptic-curve 0.14.2",
"elliptic-curve 0.14.3",
]
[[package]]
@@ -8423,7 +8423,7 @@ dependencies = [
"libc",
"log",
"openssl",
"openssl-probe 0.2.1",
"openssl-probe 0.2.2",
"openssl-sys",
"schannel",
"security-framework 3.7.0",
@@ -9053,9 +9053,9 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-probe"
version = "0.2.1"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1"
[[package]]
name = "openssl-src"
@@ -9300,9 +9300,9 @@ dependencies = [
[[package]]
name = "ordered-float"
version = "5.5.0"
version = "5.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0"
checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab"
dependencies = [
"num-traits",
]
@@ -9870,7 +9870,7 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
dependencies = [
"der 0.8.2",
"der 0.8.3",
"spki 0.8.1",
]
@@ -11102,9 +11102,9 @@ dependencies = [
[[package]]
name = "rmcp-macros"
version = "3.7.0"
version = "3.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "967fa7f7935d7d6217d593df0e8e5c9307bcaf256974a5a9ee64476c97735389"
checksum = "8b0748e054a5f5598b3213cf72099ae6b2745ba4d996c59ccf455150a7dbb839"
dependencies = [
"darling 0.24.1",
"proc-macro2",
@@ -11510,7 +11510,7 @@ version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4"
dependencies = [
"openssl-probe 0.2.1",
"openssl-probe 0.2.2",
"rustls-pki-types",
"schannel",
"security-framework 3.7.0",
@@ -11841,7 +11841,7 @@ checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d"
dependencies = [
"base16ct 1.0.0",
"ctutils",
"der 0.8.2",
"der 0.8.3",
"hybrid-array",
"subtle",
"zeroize",
@@ -12544,7 +12544,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9"
dependencies = [
"base64ct",
"der 0.8.2",
"der 0.8.3",
]
[[package]]
@@ -13626,7 +13626,7 @@ source = "git+https://github.com/windmill-labs/tantivy?rev=ea3b818c7b93db0c2b5db
dependencies = [
"fnv",
"nom",
"ordered-float 5.5.0",
"ordered-float 5.5.1",
"serde",
"serde_json",
]
@@ -15487,7 +15487,7 @@ dependencies = [
[[package]]
name = "windmill"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15577,7 +15577,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"async-stream",
"async-trait",
@@ -15612,7 +15612,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"axum",
"chrono",
@@ -15625,7 +15625,7 @@ dependencies = [
[[package]]
name = "windmill-api"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"argon2",
@@ -15768,7 +15768,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"axum",
"chrono",
@@ -15792,7 +15792,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"axum",
"chrono",
@@ -15809,7 +15809,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"axum",
@@ -15835,7 +15835,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15845,7 +15845,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"axum",
"chrono",
@@ -15862,7 +15862,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"axum",
"base64 0.22.1",
@@ -15884,7 +15884,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"axum",
@@ -15907,7 +15907,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"axum",
"chrono",
@@ -15923,7 +15923,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"axum",
"chrono",
@@ -15945,7 +15945,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"axum",
"chrono",
@@ -15967,7 +15967,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"axum",
"chrono",
@@ -15981,7 +15981,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16017,7 +16017,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"axum",
@@ -16043,7 +16043,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"axum",
"chrono",
@@ -16071,7 +16071,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"axum",
@@ -16093,7 +16093,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"axum",
"chrono",
@@ -16113,7 +16113,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"axum",
@@ -16155,7 +16155,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"axum",
@@ -16185,7 +16185,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"lazy_static",
"serde",
@@ -16197,7 +16197,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"argon2",
"axum",
@@ -16221,7 +16221,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"axum",
"chrono",
@@ -16236,7 +16236,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"axum",
"chrono",
@@ -16275,7 +16275,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"chrono",
"lazy_static",
@@ -16290,7 +16290,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"axum",
@@ -16309,7 +16309,7 @@ dependencies = [
[[package]]
name = "windmill-common"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -16427,7 +16427,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"chrono",
"futures",
@@ -16447,7 +16447,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"regex",
"serde",
@@ -16464,7 +16464,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -16491,7 +16491,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"futures",
@@ -16508,7 +16508,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -16524,7 +16524,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16546,7 +16546,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16577,7 +16577,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -16602,7 +16602,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-stream",
@@ -16638,7 +16638,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"futures",
@@ -16657,7 +16657,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -16666,7 +16666,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16678,7 +16678,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16690,7 +16690,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"gosyn",
@@ -16702,7 +16702,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16714,7 +16714,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16726,7 +16726,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -16737,7 +16737,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16748,7 +16748,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16760,7 +16760,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -16771,7 +16771,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16794,7 +16794,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16806,7 +16806,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16820,7 +16820,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16837,7 +16837,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16850,7 +16850,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"serde",
@@ -16862,7 +16862,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16880,7 +16880,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16896,7 +16896,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -16913,7 +16913,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16927,7 +16927,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16966,7 +16966,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"const_format",
@@ -17004,7 +17004,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -17015,7 +17015,7 @@ dependencies = [
[[package]]
name = "windmill-store"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -17050,7 +17050,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17074,7 +17074,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17107,7 +17107,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17134,7 +17134,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17167,7 +17167,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17187,7 +17187,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17221,7 +17221,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17258,7 +17258,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17281,7 +17281,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17305,7 +17305,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-nats",
@@ -17329,7 +17329,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17364,7 +17364,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17392,7 +17392,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17417,7 +17417,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -17436,7 +17436,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -17554,7 +17554,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"bytes",
"futures",
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "windmill"
version = "1.849.0"
version = "1.850.0"
authors.workspace = true
edition.workspace = true
@@ -88,7 +88,7 @@ members = [
exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"]
[workspace.package]
version = "1.849.0"
version = "1.850.0"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
943c39b56522f7980385a750ba5e9ca5af4df5a8
b27ce20dd33a011f46b5f4227c17776adabc0fe8
@@ -0,0 +1 @@
DROP TABLE IF EXISTS ai_agent_checkpoint;
@@ -0,0 +1,9 @@
-- The conversation of an AI agent step parked on its approval tool, read back when the job is
-- pulled again. No foreign key to v2_job_queue: one would lock that table on every write here.
-- The monitor deletes rows whose job left the queue.
CREATE TABLE IF NOT EXISTS ai_agent_checkpoint (
job_id UUID PRIMARY KEY,
workspace_id VARCHAR(50) NOT NULL,
checkpoint JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+24 -24
View File
@@ -6340,7 +6340,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -6430,7 +6430,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6442,7 +6442,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"convert_case",
"serde",
@@ -6451,7 +6451,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6463,7 +6463,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6475,7 +6475,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6487,7 +6487,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6499,7 +6499,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6511,7 +6511,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6522,7 +6522,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6533,7 +6533,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6545,7 +6545,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -6556,7 +6556,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6579,7 +6579,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6591,7 +6591,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6605,7 +6605,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6622,7 +6622,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6635,7 +6635,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"serde",
@@ -6647,7 +6647,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6665,7 +6665,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6681,7 +6681,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -6698,7 +6698,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6730,7 +6730,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6744,7 +6744,7 @@ dependencies = [
[[package]]
name = "windmill-types"
version = "1.849.0"
version = "1.850.0"
dependencies = [
"anyhow",
"bitflags",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.849.0"
version = "1.850.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+13 -6
View File
@@ -38,8 +38,9 @@ use windmill_ai::ai_cache::bump_instance_ai_config_revision;
use windmill_common::{
agent_workers::AgentConfig,
global_settings::{
AI_CONFIG_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING,
BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING, BUN_INSTALL_MIN_RELEASE_AGE_SETTING,
AI_CONFIG_SETTING, API_BASE_URL_APPLIES_TO_SETTING, API_BASE_URL_SETTING,
APP_WORKSPACED_ROUTE_SETTING, AUDIT_LOG_RETENTION_DAYS_SETTING, BASE_URL_SETTING,
BUNFIG_INSTALL_SCOPES_SETTING, BUN_INSTALL_MIN_RELEASE_AGE_SETTING,
CONCURRENCY_KEY_MAX_QUEUED_SETTING, CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING,
CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING, CRITICAL_ALERT_MUTE_UI_SETTING,
CRITICAL_ALERT_MUTE_ZOMBIE_JOB_RESTART_SETTING, CRITICAL_ERROR_CHANNELS_SETTING,
@@ -129,10 +130,10 @@ use windmill_worker::{
};
use crate::monitor::{
initial_load, load_concurrency_key_max_queued, load_dependency_job_tag,
load_dev_workspace_isolation, load_dev_workspace_isolation_skip_non_dev_forks,
load_dev_workspace_tag_suffix, load_disable_password_login,
load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir,
initial_load, load_api_base_url, load_api_base_url_applies_to, load_concurrency_key_max_queued,
load_dependency_job_tag, load_dev_workspace_isolation,
load_dev_workspace_isolation_skip_non_dev_forks, load_dev_workspace_tag_suffix,
load_disable_password_login, load_fork_workspace_tag_append_fork_suffix, load_keep_job_dir,
load_mcp_disable_token_query_param, load_metrics_debug_enabled, load_preview_tags_override,
load_require_preexisting_user, load_retention_period_overrides, load_tag_per_workspace_enabled,
load_tag_per_workspace_workspaces, load_workspace_fairness_duration_secs,
@@ -2051,6 +2052,12 @@ async fn process_notify_event(
MCP_DISABLE_TOKEN_QUERY_PARAM_SETTING => {
load_mcp_disable_token_query_param(db).await;
}
API_BASE_URL_SETTING => {
load_api_base_url(db).await;
}
API_BASE_URL_APPLIES_TO_SETTING => {
load_api_base_url_applies_to(db).await;
}
EXPOSE_METRICS_SETTING => {
tracing::info!("Metrics setting changed, restarting");
spawn_graceful_killpill(tx, db, 30, "metrics setting change", server_mode)
+59 -1
View File
@@ -54,7 +54,8 @@ use windmill_common::{
error,
flow_status::{FlowStatus, FlowStatusModule},
global_settings::{
get_or_create_jwt_secret, load_value_from_global_settings,
get_or_create_jwt_secret, load_value_from_global_settings, API_BASE_URL,
API_BASE_URL_APPLIES_TO_MCP, API_BASE_URL_APPLIES_TO_SETTING, API_BASE_URL_SETTING,
AUDIT_LOG_RETENTION_DAYS_SETTING, BASE_URL_SETTING, BUNFIG_INSTALL_SCOPES_SETTING,
BUN_INSTALL_MIN_RELEASE_AGE_SETTING, CONCURRENCY_KEY_MAX_QUEUED_SETTING,
CRITICAL_ALERTS_ON_DB_OVERSIZE_SETTING, CRITICAL_ALERTS_ON_TOKEN_EXPIRY_SETTING,
@@ -310,6 +311,12 @@ pub async fn initial_load(
false,
|v| async move { apply_mcp_disable_token_query_param(v) },
);
pass.setting(API_BASE_URL_SETTING, false, |v| async move {
apply_api_base_url(v)
});
pass.setting(API_BASE_URL_APPLIES_TO_SETTING, false, |v| async move {
apply_api_base_url_applies_to(v)
});
pass.setting(DEFAULT_TAGS_PER_WORKSPACE_SETTING, false, |v| async move {
apply_tag_per_workspace_enabled(v)
});
@@ -1739,6 +1746,38 @@ pub fn apply_mcp_disable_token_query_param(value: Option<serde_json::Value>) {
};
}
pub async fn load_api_base_url(db: &DB) {
match load_value_from_global_settings(db, API_BASE_URL_SETTING).await {
Ok(v) => apply_api_base_url(v),
Err(e) => tracing::error!("Error loading api_base_url setting: {e:#}"),
};
}
pub fn apply_api_base_url(value: Option<serde_json::Value>) {
let url = match value {
Some(serde_json::Value::String(s)) => s.trim().trim_end_matches('/').to_string(),
None => String::new(),
_ => return,
};
API_BASE_URL.store(Arc::new(url));
}
pub async fn load_api_base_url_applies_to(db: &DB) {
match load_value_from_global_settings(db, API_BASE_URL_APPLIES_TO_SETTING).await {
Ok(v) => apply_api_base_url_applies_to(v),
Err(e) => tracing::error!("Error loading api_base_url_applies_to setting: {e:#}"),
};
}
pub fn apply_api_base_url_applies_to(value: Option<serde_json::Value>) {
let mcp = match value {
Some(serde_json::Value::Array(items)) => items.iter().any(|i| i.as_str() == Some("mcp")),
None => true,
_ => return,
};
API_BASE_URL_APPLIES_TO_MCP.store(mcp, Ordering::Relaxed);
}
struct LogFile {
file_path: String,
hostname: String,
@@ -4172,6 +4211,9 @@ pub async fn monitor_db(
if let Err(e) = cleanup_flow_iterator_data_orphaned_jobs(&db).await {
tracing::error!("Error cleaning up flow_iterator_data: {:?}", e);
}
if let Err(e) = cleanup_ai_agent_checkpoint_orphaned_jobs(&db).await {
tracing::error!("Error cleaning up ai_agent_checkpoint: {:?}", e);
}
}
}
};
@@ -7729,6 +7771,22 @@ async fn cleanup_job_result_stream_orphaned_jobs(db: &DB) -> error::Result<()> {
Ok(())
}
/// A parked agent cancelled from outside, or a worker that died mid-segment, never deletes its
/// checkpoint itself.
async fn cleanup_ai_agent_checkpoint_orphaned_jobs(db: &DB) -> error::Result<()> {
let deleted = sqlx::query(
"DELETE FROM ai_agent_checkpoint c
WHERE NOT EXISTS (SELECT 1 FROM v2_job_queue q WHERE q.id = c.job_id)",
)
.execute(db)
.await?
.rows_affected();
if deleted > 0 {
tracing::info!("Cleaned up {deleted} orphaned ai_agent_checkpoint rows");
}
Ok(())
}
async fn cleanup_flow_iterator_data_orphaned_jobs(db: &DB) -> error::Result<()> {
let result = sqlx::query!(
"
+1
View File
@@ -37,6 +37,7 @@ _sqlx_migrations: version(bigint), description(text), installed_on(ts), success(
account: workspace_id(char), id(int), expires_at(ts), refresh_token(char), client(char), refresh_error(text), grant_type(char), cc_client_id(char), cc_client_secret(char), cc_token_url(char), mcp_server_url(text), scopes(text[])
FK: (workspace_id) -> workspace(id)
agent_token_blacklist: token(char), expires_at(ts), blacklisted_at(ts), blacklisted_by(char)
ai_agent_checkpoint: job_id(uuid), workspace_id(char), checkpoint(jsonb), created_at(ts)
ai_agent_memory: workspace_id(char), conversation_id(uuid), step_id(char), messages(jsonb), created_at(ts), updated_at(ts)
ai_free_token_daily_usage: day(date), cost_nanos(bigint), updated_at(ts)
ai_free_token_usage: email(char), cost_nanos(bigint), updated_at(ts)
+154
View File
@@ -0,0 +1,154 @@
use axum::{routing::post, Json, Router};
use serde_json::{json, Value};
use sqlx::{types::Uuid, Pool, Postgres};
use std::time::Duration;
use windmill_common::{flows::FlowValue, jobs::JobPayload};
use windmill_test_utils::{
completed_job, in_test_worker, listen_for_completed_jobs, ApiServer, RunJob, StreamFind,
};
async fn model(Json(body): Json<Value>) -> ([(&'static str, &'static str); 1], String) {
let answered = body["messages"]
.as_array()
.unwrap()
.iter()
.any(|m| m["role"] == "tool");
let delta = if answered {
json!({"role":"assistant", "content":"done"})
} else {
json!({"role":"assistant", "tool_calls":[{
"index":0, "id":"call_0", "type":"function",
"function":{"name":"delete_database", "arguments":"{}"}
}]})
};
let event = json!({"choices":[{"index":0, "delta":delta, "finish_reason":null}]});
let end = json!({"choices":[{"index":0, "delta":{}, "finish_reason":if answered {"stop"} else {"tool_calls"}}]});
(
[("content-type", "text/event-stream")],
format!("data: {event}\n\ndata: {end}\n\ndata: [DONE]\n\n"),
)
}
/// The agent parks on a call that requires approval, is answered through the endpoint the run
/// page and the chat use, and resumes: an approved call runs, a rejected one never does.
async fn answer(db: Pool<Postgres>, approved: bool) -> anyhow::Result<Value> {
std::env::set_var("ALLOW_PRIVATE_AI_BASE_URLS", "true");
let server = ApiServer::start(db.clone()).await?;
let port = server.addr.port();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let base = format!("http://{}", listener.local_addr()?);
let stub = tokio::spawn(async move {
axum::serve(
listener,
Router::new().route("/v1/chat/completions", post(model)),
)
.await
.unwrap();
});
let flow: FlowValue = serde_json::from_value(json!({"modules":[{"id":"agent","value":{
"type":"aiagent",
"tools":[{"id":"t0", "summary":"delete_database", "requires_approval":true, "value":{
"type":"rawscript", "language":"bun", "input_transforms":{},
"content":"export async function main() { return 'ran' }"
}}],
"input_transforms":{
"provider":{"type":"static","value":{"kind":"customai","model":"approval-test","resource":{"base_url":format!("{base}/v1")}}},
"user_message":{"type":"static","value":"delete it"},
"max_iterations":{"type":"static","value":3}
}
}}]}))?;
let id = RunJob::from(JobPayload::RawFlow {
value: flow,
path: Some("u/test/agent_approval".into()),
restarted_from: None,
})
.push(&db)
.await;
let notifications = listen_for_completed_jobs(&db).await;
let db_ = db.clone();
let answered = async move {
let agent = loop {
let parked: Option<Uuid> = sqlx::query_scalar(
"SELECT q.id FROM v2_job_queue q JOIN v2_job j USING (id)
JOIN ai_agent_checkpoint c ON c.job_id = q.id
WHERE j.parent_job = $1 AND q.suspend > 0",
)
.bind(id)
.fetch_optional(&db_)
.await
.unwrap();
if let Some(agent) = parked {
break agent;
}
tokio::time::sleep(Duration::from_millis(200)).await;
};
let token = windmill_common::auth::create_token_for_owner(
&db_,
"test-workspace",
"u/test-user",
"approval-test",
100,
"test@windmill.dev",
&Uuid::nil(),
None,
None,
)
.await
.unwrap();
let response = reqwest::Client::new()
.post(format!(
"http://localhost:{port}/api/w/test-workspace/jobs_u/flow/resume_suspended/{agent}"
))
.bearer_auth(token)
.json(&json!({"approved":approved, "payload":{"call_id":"call_0", "comment":"not today"}}))
.send()
.await
.unwrap();
assert!(
response.status().is_success(),
"{}",
response.text().await.unwrap()
);
notifications.find(&id).await;
};
tokio::time::timeout(Duration::from_secs(60), in_test_worker(&db, answered, port)).await?;
let result = completed_job(id, &db).await;
assert!(result.success, "{:?}", result.result);
let tool_jobs: i64 = sqlx::query_scalar(
"SELECT count(*) FROM v2_job WHERE parent_job IN (SELECT id FROM v2_job WHERE parent_job = $1)",
)
.bind(id)
.fetch_one(&db)
.await?;
assert_eq!(tool_jobs, approved as i64, "only an approved call runs");
stub.abort();
server.close().await?;
let result = result.json_result().expect("agent result");
Ok(result["messages"]
.as_array()
.unwrap()
.iter()
.find(|m| m["role"] == "tool")
.expect("the call's answer")["content"]
.clone())
}
#[sqlx::test(fixtures("base"))]
#[serial_test::serial]
async fn approved_call_runs_after_the_agent_resumes(db: Pool<Postgres>) -> anyhow::Result<()> {
assert_eq!(answer(db, true).await?, "\"ran\"");
Ok(())
}
#[sqlx::test(fixtures("base"))]
#[serial_test::serial]
async fn rejected_call_reaches_the_model_as_not_run(db: Pool<Postgres>) -> anyhow::Result<()> {
let content = answer(db, false).await?;
let content = content.as_str().unwrap();
assert!(
content.starts_with("Not run") && content.contains("not today"),
"{content}"
);
Ok(())
}
+5
View File
@@ -60,6 +60,11 @@ pub struct Tool {
pub module: Option<FlowModule>,
pub def: ToolDef,
pub mcp_source: Option<McpToolSource>,
/// Each call waits for a person to approve it, without the agent holding a worker.
pub requires_approval: bool,
/// Set on the built-in question tool, which the worker answers by parking the agent until
/// someone answers or this many seconds pass.
pub question_timeout_secs: Option<u32>,
}
#[derive(Deserialize, Serialize, Debug, Clone, PartialEq)]
+8 -1
View File
@@ -591,7 +591,9 @@ pub fn ensure_scopes_within_caller(
for requested in requested_restrictions {
if requested.starts_with("mcp:") {
if !caller_mcp.contains(requested.as_str()) {
// `mcp:session` (windmill_mcp's SESSION_SCOPE) grants nothing: it only picks
// the toolset an `mcp:all` token gets, and `mcp:all` is matched here itself.
if requested != "mcp:session" && !caller_mcp.contains(requested.as_str()) {
return Err(Error::PermissionDenied(format!(
"A scope-restricted token cannot grant MCP scope '{requested}' unless the \
caller holds the same scope verbatim"
@@ -1964,6 +1966,11 @@ mod tests {
opt_scopes(Some(vec!["mcp:scripts:*"])).as_deref()
)
.is_err());
assert!(ensure_scopes_within_caller(
&mcp_all,
opt_scopes(Some(vec!["mcp:all", "mcp:session"])).as_deref()
)
.is_ok());
}
#[test]
+6 -2
View File
@@ -63,8 +63,8 @@ use windmill_common::{
error::{self, pg_error_message, JsonResult, Result},
get_database_url,
global_settings::{
ACCENT_COLOR_SETTING, AI_CONFIG_SETTING, API_BASE_URL_SETTING,
APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
ACCENT_COLOR_SETTING, AI_CONFIG_SETTING, API_BASE_URL_APPLIES_TO_SETTING,
API_BASE_URL_SETTING, APP_WORKSPACED_ROUTE_SETTING, AUTOMATE_USERNAME_CREATION_SETTING,
CRITICAL_ALERT_MUTE_UI_SETTING, CUSTOM_TAGS_SETTING, DEFAULT_TAGS_WORKSPACES_SETTING,
DISABLE_HUB_SETTING, EMAIL_DOMAIN_SETTING, ENV_SETTINGS, EXTERNAL_INSTANCE_PG_SETTING,
GITHUB_APP_WEBHOOK_BASE_URL_SETTING, HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS_SETTING,
@@ -1267,6 +1267,10 @@ async fn run_setting_pre_write_hook(
}
}
}
API_BASE_URL_APPLIES_TO_SETTING if !value.is_null() => {
windmill_common::global_settings::validate_api_base_url_applies_to(value)
.map_err(|e| error::Error::BadRequest(format!("{key}: {e}")))?;
}
MAX_TOKEN_EXPIRATION_DAYS_SETTING => {
windmill_common::global_settings::parse_max_token_expiration_days(Some(value))
.map_err(|e| {
+10 -2
View File
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
version: 1.849.0
version: 1.850.0
title: Windmill API
contact:
@@ -1928,7 +1928,7 @@ paths:
- setting
responses:
"200":
description: the instance_banner, accent_color and api_base_url settings
description: the instance_banner, accent_color, api_base_url and api_base_url_applies_to settings
content:
application/json:
schema:
@@ -1937,6 +1937,7 @@ paths:
instance_banner: {}
accent_color: {}
api_base_url: {}
api_base_url_applies_to: {}
/settings_u/ruff_config:
get:
@@ -28473,6 +28474,13 @@ paths:
description: folder scoping the Hub project source (`{workspace}:{folder}`)
schema:
type: string
- name: version
in: query
required: false
description: id of a reviewed project version to export instead of the current one (see the hub's `/projects/{slug}/versions`)
schema:
type: integer
minimum: 1
responses:
"200":
description: raw Hub response body (status code is passed through from the Hub)
+15 -3
View File
@@ -536,14 +536,26 @@ async fn publish_migrations(
.await
}
#[derive(Deserialize)]
struct ExportQuery {
version: Option<u32>,
}
// Export is owner-scoped only when re-exporting your own draft; approved
// projects are public, so the folder scope is optional here.
// projects are public, so the folder scope is optional here. `version` pins one
// reviewed version instead of the current one; it is forwarded explicitly because
// the hub request is rebuilt rather than proxied, so an unforwarded param would
// silently export the current version instead.
async fn get_project_export(
ctx: HubPublishCtx,
Path((_workspace, slug)): Path<(String, ProjectSlug)>,
Query(query): Query<ExportQuery>,
) -> Result<impl IntoResponse, Error> {
ctx.get_maybe_unscoped(&format!("/projects/{}/export", slug))
.await
let path = match query.version {
Some(v) => format!("/projects/{}/export?version={}", slug, v),
None => format!("/projects/{}/export", slug),
};
ctx.get_maybe_unscoped(&path).await
}
async fn get_project_by_source(ctx: HubPublishCtx) -> Result<impl IntoResponse, Error> {
+64 -11
View File
@@ -35,6 +35,7 @@ use std::sync::Arc;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
use windmill_mcp::common::SESSION_SCOPE;
use windmill_mcp::server::{
LocalSessionManager, McpToken, MultiWorkspaceMcp, NoEndpointTools, Runner, SessionRunner,
StreamableHttpServerConfig, StreamableHttpService,
@@ -503,6 +504,17 @@ fn scoped_to_selection(scopes: &[String]) -> bool {
mcp.clone().next().is_some() && !mcp.any(|scope| scope == "mcp:all")
}
/// Whether the tools server's URLs (`/api/mcp/gateway`, `/api/mcp/w/{workspace}/mcp`) serve
/// a token the session's toolset: an `mcp:all` grant marked `mcp:session`, or narrowed by
/// `mcp:read_only` / `mcp:read_run`, which the consent page describes as session access.
/// Every other token there keeps the tools server it was issued for.
fn serves_session_toolset(scopes: &[String]) -> bool {
scopes.iter().any(|scope| scope == "mcp:all")
&& scopes
.iter()
.any(|scope| [SESSION_SCOPE, "mcp:read_only", "mcp:read_run"].contains(&scope.as_str()))
}
/// The path of a workspace-less MCP URL, when it is not the tools server's
/// `/api/mcp/gateway`: each URL is its own OAuth protected resource.
#[derive(Clone, Copy)]
@@ -519,6 +531,16 @@ pub async fn extract_and_store_workspace_id(
next.run(request).await
}
/// RFC 9728 challenge pointing at the endpoint's protected resource metadata. `scope` is what
/// a client requests: without it, Claude requests every scope the metadata lists, read-only
/// included, and the consent page would then default to read-only access.
fn www_authenticate_value(base_url: &str, endpoint: &str) -> String {
format!(
"Bearer resource_metadata=\"{base_url}/.well-known/oauth-protected-resource{endpoint}\", \
scope=\"mcp:all\""
)
}
/// Middleware that adds WWW-Authenticate header to 401 responses
/// This helps MCP clients discover the OAuth authorization server (RFC 9728)
pub async fn add_www_authenticate_header(
@@ -545,11 +567,8 @@ pub async fn add_www_authenticate_header(
if response.status() == StatusCode::UNAUTHORIZED {
let base_url = BASE_URL.load();
// RFC 9728: The resource parameter contains the protected resource URL.
// Clients derive the metadata URL by inserting /.well-known/oauth-protected-resource
// after the host, e.g., http://host/.well-known/oauth-protected-resource/api/mcp/w/test/mcp
let resource_url = format!("{}/api/mcp/w/{}/mcp", base_url, workspace_id);
let www_authenticate = format!("Bearer resource=\"{}\"", resource_url);
let www_authenticate =
www_authenticate_value(&base_url, &format!("/api/mcp/w/{}/mcp", workspace_id));
// Reconstruct response with the new header
let (mut parts, body) = response.into_parts();
@@ -667,7 +686,6 @@ pub async fn add_www_authenticate_header_gateway(
next: Next,
) -> Response {
use axum::http::StatusCode;
use windmill_common::BASE_URL;
let endpoint = request
.extensions()
@@ -677,9 +695,8 @@ pub async fn add_www_authenticate_header_gateway(
let response = next.run(request).await;
if response.status() == StatusCode::UNAUTHORIZED {
let base_url = BASE_URL.load();
let resource_url = format!("{}{}", base_url, endpoint);
let www_authenticate = format!("Bearer resource=\"{}\"", resource_url);
let www_authenticate =
www_authenticate_value(&windmill_common::global_settings::mcp_base_url(), endpoint);
let (mut parts, body) = response.into_parts();
parts.headers.insert(
@@ -735,7 +752,30 @@ pub async fn setup_mcp_server(
let service =
StreamableHttpService::new(move || Ok(runner.clone()), session_manager, service_config);
let router = Router::new().route_service("/", service.clone());
let router = Router::new().route(
"/",
axum::routing::any({
let (service, session_service) = (service.clone(), session_service.clone());
move |request: Request<axum::body::Body>| {
let (service, session_service) = (service.clone(), session_service.clone());
async move {
use tower::ServiceExt;
let session = request.extensions().get::<ApiAuthed>().is_some_and(|a| {
serves_session_toolset(a.scopes.as_deref().unwrap_or_default())
});
let response = if session {
session_service.oneshot(request).await
} else {
service.oneshot(request).await
};
match response {
Ok(response) => response.into_response(),
Err(never) => match never {},
}
}
}
}),
);
// `/api/mcp` serves both servers: a token scoped to a selection of scripts, flows and
// endpoints gets the tools server, any other the session's toolset. At `/mcp` itself,
// beside the `/mcp/...` routes, so it is merged rather than nested.
@@ -810,7 +850,7 @@ pub fn toolset_service() -> Router {
#[cfg(test)]
mod tests {
use super::scoped_to_selection;
use super::{scoped_to_selection, serves_session_toolset};
#[test]
fn only_a_token_without_mcp_all_is_a_selection() {
@@ -829,4 +869,17 @@ mod tests {
assert!(!selection(&[]));
assert!(!selection(&["variables:read"]));
}
#[test]
fn tools_server_urls_keep_unmarked_tokens() {
let session = |scopes: &[&str]| {
serves_session_toolset(&scopes.iter().map(|s| s.to_string()).collect::<Vec<_>>())
};
assert!(session(&["mcp:all", "mcp:session"]));
assert!(session(&["mcp:all", "mcp:read_only"]));
assert!(session(&["mcp:all", "mcp:read_run", "mcp:workspaces:a,b"]));
assert!(!session(&["mcp:all"]));
assert!(!session(&["mcp:favorites", "mcp:session"]));
assert!(!session(&["mcp:all:f/x/*", "mcp:read_only"]));
}
}
+66 -23
View File
@@ -12,6 +12,7 @@ use sqlx::FromRow;
use windmill_common::{
auth::{hash_token, TOKEN_PREFIX_LEN},
error::{Error, Result},
global_settings::mcp_base_url,
min_version::MIN_VERSION_SUPPORTS_TOKEN_HASH,
utils::rd_string,
BASE_URL, DB,
@@ -19,7 +20,7 @@ use windmill_common::{
use crate::db::ApiAuthed;
use windmill_api_auth::forbid_elevated_job_token;
use windmill_mcp::parse_mcp_scopes;
use windmill_mcp::{common::SESSION_SCOPE, parse_mcp_scopes};
/// Token expiration for MCP OAuth tokens (1 week in seconds)
const MCP_OAUTH_TOKEN_EXPIRATION_SECS: u64 = 7 * 24 * 60 * 60;
@@ -225,26 +226,41 @@ struct RefreshTokenRow {
revoked: bool,
}
fn supported_scopes() -> Vec<String> {
vec![
"mcp:all".to_string(),
"mcp:favorites".to_string(),
"mcp:scripts:*".to_string(),
"mcp:flows:*".to_string(),
"mcp:endpoints:*".to_string(),
"mcp:read_only".to_string(),
"mcp:read_run".to_string(),
"mcp:runnables_as_tools".to_string(),
/// The scopes that grant access. A protected resource advertises only these: MCP clients
/// that request every listed scope must not pick up a narrowing, which would make the
/// consent page start from a narrower access than they meant to ask for.
fn granting_scopes() -> Vec<String> {
[
"mcp:all",
"mcp:favorites",
"mcp:scripts:*",
"mcp:flows:*",
"mcp:endpoints:*",
]
.map(String::from)
.to_vec()
}
fn supported_scopes() -> Vec<String> {
let mut scopes = granting_scopes();
scopes.extend(["mcp:read_only", "mcp:read_run", "mcp:runnables_as_tools"].map(String::from));
scopes
}
/// GET /.well-known/oauth-authorization-server/api/w/:workspace_id/mcp/oauth/server
fn build_oauth_metadata(oauth_prefix: &str, base_url: &str) -> AuthorizationMetadata {
///
/// `client_base_url` serves what the MCP client calls itself, `base_url` the authorization
/// endpoint the user's browser opens.
fn build_oauth_metadata(
oauth_prefix: &str,
base_url: &str,
client_base_url: &str,
) -> AuthorizationMetadata {
AuthorizationMetadata {
issuer: format!("{}{}", base_url, oauth_prefix),
issuer: format!("{}{}", client_base_url, oauth_prefix),
authorization_endpoint: format!("{}{}/authorize", base_url, oauth_prefix),
token_endpoint: format!("{}{}/token", base_url, oauth_prefix),
registration_endpoint: Some(format!("{}/api/mcp/oauth/server/register", base_url)),
token_endpoint: format!("{}{}/token", client_base_url, oauth_prefix),
registration_endpoint: Some(format!("{}/api/mcp/oauth/server/register", client_base_url)),
scopes_supported: Some(supported_scopes()),
response_types_supported: Some(vec!["code".to_string()]),
grant_types_supported: Some(vec![
@@ -263,7 +279,7 @@ fn build_protected_resource_metadata(
ProtectedResourceMetadata {
resource: format!("{}{}", base_url, resource_path),
authorization_servers: vec![format!("{}{}", base_url, oauth_prefix)],
scopes_supported: Some(supported_scopes()),
scopes_supported: Some(granting_scopes()),
bearer_methods_supported: Some(vec!["header".to_string()]),
}
}
@@ -273,7 +289,7 @@ pub async fn workspaced_oauth_metadata(
) -> Json<AuthorizationMetadata> {
let base_url = BASE_URL.load();
let oauth_prefix = format!("/api/w/{}/mcp/oauth/server", workspace_id);
Json(build_oauth_metadata(&oauth_prefix, &base_url))
Json(build_oauth_metadata(&oauth_prefix, &base_url, &base_url))
}
/// GET /.well-known/oauth-protected-resource/api/mcp/w/:workspace_id/mcp
@@ -873,11 +889,14 @@ async fn oauth_approve_inner(
let code = format!("mcp-code-{}", rd_string(32));
let scopes: Vec<String> = form
let mut scopes: Vec<String> = form
.scope
.split_whitespace()
.map(|s| s.to_string())
.collect();
if scopes.iter().any(|s| s == "mcp:all") && !scopes.iter().any(|s| s == SESSION_SCOPE) {
scopes.push(SESSION_SCOPE.to_string());
}
// The approver's own token bounds what it may grant: a scope-restricted MCP
// token must not approve a broader one (e.g. mcp:scripts:f/x -> mcp:all). An
@@ -1026,25 +1045,24 @@ pub async fn gateway_oauth_metadata() -> Json<AuthorizationMetadata> {
Json(build_oauth_metadata(
"/api/mcp/gateway/oauth/server",
&base_url,
&mcp_base_url(),
))
}
pub async fn gateway_protected_resource_metadata() -> Json<ProtectedResourceMetadata> {
let base_url = BASE_URL.load();
Json(build_protected_resource_metadata(
"/api/mcp/gateway",
"/api/mcp/gateway/oauth/server",
&base_url,
&mcp_base_url(),
))
}
/// GET /.well-known/oauth-protected-resource/api/mcp
pub async fn gateway_session_protected_resource_metadata() -> Json<ProtectedResourceMetadata> {
let base_url = BASE_URL.load();
Json(build_protected_resource_metadata(
"/api/mcp",
"/api/mcp/gateway/oauth/server",
&base_url,
&mcp_base_url(),
))
}
@@ -1159,7 +1177,32 @@ pub fn gateway_authed_service() -> Router {
#[cfg(test)]
mod tests {
use super::validate_redirect_uri;
use super::{build_oauth_metadata, validate_redirect_uri};
#[test]
fn authorization_endpoint_stays_on_base_url() {
let m = build_oauth_metadata(
"/api/mcp/gateway/oauth/server",
"https://private.corp",
"https://mcp.example.com",
);
assert_eq!(
m.issuer,
"https://mcp.example.com/api/mcp/gateway/oauth/server"
);
assert_eq!(
m.token_endpoint,
"https://mcp.example.com/api/mcp/gateway/oauth/server/token"
);
assert_eq!(
m.registration_endpoint.as_deref(),
Some("https://mcp.example.com/api/mcp/oauth/server/register")
);
assert_eq!(
m.authorization_endpoint,
"https://private.corp/api/mcp/gateway/oauth/server/authorize"
);
}
#[test]
fn accepts_legitimate_redirect_uris() {
File diff suppressed because one or more lines are too long
+11 -40
View File
@@ -18,7 +18,7 @@ use crate::{
db::DB,
error::{to_anyhow, Error},
scripts::ScriptHash,
utils::{http_get_from_hub, StripPath},
utils::{http_get_from_hub, parse_hub_id, StripPath},
worker::{to_raw_value, Connection},
DEFAULT_HUB_BASE_URL, HUB_BASE_URL, PRIVATE_HUB_MIN_VERSION,
};
@@ -33,45 +33,13 @@ struct HubFlowResponse {
flow: HubFlow,
}
fn extract_hub_flow_id_from_path(path: &str) -> Result<i32, Error> {
let hub_flow_path = path.strip_prefix("hub/flows/").ok_or_else(|| {
Error::BadRequest(format!(
"expected hub flow path to start with hub/flows/ (got {path})"
))
})?;
let flow_id = hub_flow_path
.split('/')
.next()
.filter(|segment| !segment.is_empty())
.ok_or_else(|| {
Error::BadRequest(format!(
"expected hub flow path to include a numeric id after hub/flows/ (got {path})"
))
})?;
let flow_id = flow_id.parse::<i32>().map_err(|_| {
Error::BadRequest(format!(
"expected hub flow path to include a numeric id after hub/flows/ (got {path})"
))
})?;
if flow_id <= 0 {
return Err(Error::BadRequest(format!(
"expected hub flow path to include a positive numeric id after hub/flows/ (got {path})"
)));
}
Ok(flow_id)
}
pub async fn get_full_hub_flow_by_path(
path: StripPath,
http_client: &reqwest::Client,
db: Option<&DB>,
) -> crate::error::Result<HubFlow> {
let path = path.to_path();
let flow_id = extract_hub_flow_id_from_path(&path)?;
let flow_id = parse_hub_id(&path, "hub/flows/")?;
let hub_base_url = (**HUB_BASE_URL.load()).clone();
let hub_url = format!("{hub_base_url}/flows/{flow_id}/json");
@@ -472,33 +440,36 @@ mod tests {
#[test]
fn extract_hub_flow_id_accepts_id_only_paths() {
assert_eq!(extract_hub_flow_id_from_path("hub/flows/76").unwrap(), 76);
assert_eq!(parse_hub_id("hub/flows/76", "hub/flows/").unwrap(), 76);
}
#[test]
fn extract_hub_flow_id_accepts_id_and_slug_paths() {
assert_eq!(
extract_hub_flow_id_from_path("hub/flows/76/send-message-to-company-ai-assistant")
.unwrap(),
parse_hub_id(
"hub/flows/76/send-message-to-company-ai-assistant",
"hub/flows/"
)
.unwrap(),
76
);
}
#[test]
fn extract_hub_flow_id_rejects_non_numeric_ids() {
let err = extract_hub_flow_id_from_path("hub/flows/send_message").unwrap_err();
let err = parse_hub_id("hub/flows/send_message", "hub/flows/").unwrap_err();
assert!(matches!(err, Error::BadRequest(_)));
}
#[test]
fn extract_hub_flow_id_rejects_missing_ids() {
let err = extract_hub_flow_id_from_path("hub/flows/").unwrap_err();
let err = parse_hub_id("hub/flows/", "hub/flows/").unwrap_err();
assert!(matches!(err, Error::BadRequest(_)));
}
#[test]
fn extract_hub_flow_id_rejects_zero_ids() {
let err = extract_hub_flow_id_from_path("hub/flows/0").unwrap_err();
let err = parse_hub_id("hub/flows/0", "hub/flows/").unwrap_err();
assert!(matches!(err, Error::BadRequest(_)));
}
+67 -3
View File
@@ -188,11 +188,17 @@ pub const GITHUB_ENTERPRISE_APP_SETTING: &str = "github_enterprise_app";
/// from GitHub and a separate ingress fronts the API for inbound webhooks.
pub const GITHUB_APP_WEBHOOK_BASE_URL_SETTING: &str = "github_app_webhook_base_url";
/// Base URL the UI shows for the endpoints external clients call (webhooks, HTTP
/// routes, push trigger endpoints, the CLI), instead of the origin the UI is browsed on.
/// For deployments that serve the API on a separate domain or behind a gateway.
/// routes, push trigger endpoints, the CLI, the MCP server), instead of the origin the UI is
/// browsed on. For deployments that serve the API on a separate domain or behind a gateway.
/// The server reads it too, through [`mcp_base_url`], for the MCP gateway's OAuth metadata.
/// EE only. Readable by any authenticated user, like [`INSTANCE_BANNER_SETTING`]:
/// every user is shown URLs built from it.
pub const API_BASE_URL_SETTING: &str = "api_base_url";
/// Which of [`API_BASE_URL_SURFACES`] [`API_BASE_URL_SETTING`] applies to; unset means all,
/// so a surface added later follows the API base url until an admin narrows the list.
/// Lets an instance expose a single surface (say, only MCP) on a public host.
pub const API_BASE_URL_APPLIES_TO_SETTING: &str = "api_base_url_applies_to";
pub const API_BASE_URL_SURFACES: [&str; 4] = ["webhooks", "push_triggers", "cli", "mcp"];
/// Instance-wide announcement rendered above every page of the app (maintenance
/// windows, incidents). Readable by any authenticated user, unlike most settings:
/// the banner exists to be shown to everyone, so it must never hold anything the
@@ -341,6 +347,7 @@ pub struct InstanceUi {
pub instance_banner: Option<serde_json::Value>,
pub accent_color: Option<serde_json::Value>,
pub api_base_url: Option<serde_json::Value>,
pub api_base_url_applies_to: Option<serde_json::Value>,
}
pub async fn get_instance_ui(db: &Pool<Postgres>) -> error::Result<InstanceUi> {
@@ -349,7 +356,8 @@ pub async fn get_instance_ui(db: &Pool<Postgres>) -> error::Result<InstanceUi> {
&[
INSTANCE_BANNER_SETTING,
ACCENT_COLOR_SETTING,
API_BASE_URL_SETTING
API_BASE_URL_SETTING,
API_BASE_URL_APPLIES_TO_SETTING
] as &[&str]
)
.fetch_all(db)
@@ -360,12 +368,34 @@ pub async fn get_instance_ui(db: &Pool<Postgres>) -> error::Result<InstanceUi> {
INSTANCE_BANNER_SETTING => ui.instance_banner = Some(row.value),
ACCENT_COLOR_SETTING => ui.accent_color = Some(row.value),
API_BASE_URL_SETTING => ui.api_base_url = Some(row.value),
API_BASE_URL_APPLIES_TO_SETTING => ui.api_base_url_applies_to = Some(row.value),
_ => {}
}
}
Ok(ui)
}
/// Validate an [`API_BASE_URL_APPLIES_TO_SETTING`] value: a list of
/// [`API_BASE_URL_SURFACES`]. An unknown name is refused rather than ignored, since a
/// misspelt `mcp` would silently leave the MCP server on `base_url`.
pub fn validate_api_base_url_applies_to(value: &serde_json::Value) -> Result<(), String> {
let serde_json::Value::Array(items) = value else {
return Err("must be a list of surfaces".to_string());
};
for item in items {
match item.as_str() {
Some(s) if API_BASE_URL_SURFACES.contains(&s) => {}
_ => {
return Err(format!(
"must only list surfaces among {}",
API_BASE_URL_SURFACES.join(", ")
))
}
}
}
Ok(())
}
const SHELL_ACTIVE_CHARS: [char; 16] = [
'$', '`', '\'', '"', '\\', ';', '&', '|', '<', '>', '(', ')', '{', '}', '*', '!',
];
@@ -520,12 +550,37 @@ lazy_static::lazy_static! {
pub static ref HTTP_ROUTE_WORKSPACED_ROUTE: AtomicBool = AtomicBool::new(false);
pub static ref DISABLE_PASSWORD_LOGIN: AtomicBool = AtomicBool::new(false);
pub static ref MCP_DISABLE_TOKEN_QUERY_PARAM: AtomicBool = AtomicBool::new(false);
/// Empty means unset. See [`API_BASE_URL_SETTING`].
pub static ref API_BASE_URL: arc_swap::ArcSwap<String> =
arc_swap::ArcSwap::from_pointee(String::new());
/// Whether [`API_BASE_URL_APPLIES_TO_SETTING`] includes `mcp`.
pub static ref API_BASE_URL_APPLIES_TO_MCP: AtomicBool = AtomicBool::new(true);
/// Origins HTTP routes allow cross-origin when they configure none of their
/// own. Empty means unset, which keeps the historical `*`.
pub static ref HTTP_ROUTE_DEFAULT_ALLOWED_ORIGINS: arc_swap::ArcSwap<Vec<String>> =
arc_swap::ArcSwap::from_pointee(vec![]);
}
/// [`API_BASE_URL_SETTING`] when it applies to MCP, else `base_url`. The MCP gateway builds every URL it hands a
/// client to call from it: its resource identifier, the authorization server it names, and
/// that server's issuer, token and registration endpoints. Not the authorization endpoint:
/// the user's browser opens it, and the consent page it redirects to is served on `base_url`.
///
/// Enterprise only, like the UI that shows the MCP URL: an MCP client rejects metadata
/// naming a host other than the URL it was given, so the two must agree.
pub fn mcp_base_url() -> String {
#[cfg(feature = "enterprise")]
{
let configured = API_BASE_URL.load();
if !configured.is_empty()
&& API_BASE_URL_APPLIES_TO_MCP.load(std::sync::atomic::Ordering::Relaxed)
{
return configured.to_string();
}
}
crate::BASE_URL.load().to_string()
}
/// Whether an allowlist places no restriction at all.
///
/// `*` is the explicit "open on purpose" entry, and a route carrying it behaves
@@ -966,6 +1021,15 @@ pub fn workspace_integration_auth_endpoint(client_name: &str, base_url: &str) ->
mod tests {
use super::*;
#[test]
fn api_base_url_applies_to_accepts_only_known_surfaces() {
use serde_json::json;
assert!(validate_api_base_url_applies_to(&json!(["mcp", "webhooks"])).is_ok());
assert!(validate_api_base_url_applies_to(&json!([])).is_ok());
assert!(validate_api_base_url_applies_to(&json!(["mpc"])).is_err());
assert!(validate_api_base_url_applies_to(&json!("mcp")).is_err());
}
// `frontend/src/lib/tokenExpiration.test.ts` holds the same table for the token form's
// parser; the two must stay in step.
#[test]
+10 -1
View File
@@ -291,6 +291,8 @@ pub struct GlobalSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub api_base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_base_url_applies_to: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub email_domain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hub_base_url: Option<String>,
@@ -1472,7 +1474,7 @@ pub fn diff_worker_configs(
}
/// Declaratively replace the global settings, rejecting a
/// `github_app_webhook_base_url`, `api_base_url` or
/// `github_app_webhook_base_url`, `api_base_url`, `api_base_url_applies_to` or
/// `http_route_default_allowed_origins` the API would reject.
///
/// Every declarative writer (the `sync-config` CLI, the Kubernetes operator's
@@ -1532,6 +1534,13 @@ pub async fn sync_global_settings_declarative(
}
}
let applies_to_key = crate::global_settings::API_BASE_URL_APPLIES_TO_SETTING;
match desired.get(applies_to_key) {
None | Some(serde_json::Value::Null) => {}
Some(v) => crate::global_settings::validate_api_base_url_applies_to(v)
.map_err(|e| anyhow::anyhow!("{applies_to_key}: {e}"))?,
}
let banner_key = crate::global_settings::INSTANCE_BANNER_SETTING;
match desired.get(banner_key) {
None | Some(serde_json::Value::Null) => {}
+32 -47
View File
@@ -9,7 +9,7 @@
pub use windmill_types::scripts::*;
use crate::{
error::{to_anyhow, Error},
error::to_anyhow,
runnable_settings::{self},
utils::http_get_from_hub,
workspace_dependencies::WorkspaceDependenciesAnnotatedRefs,
@@ -22,7 +22,7 @@ use backon::ConstantBuilder;
use backon::{BackoffBuilder, Retryable};
use regex::Regex;
use crate::utils::StripPath;
use crate::utils::{parse_hub_id, StripPath};
/// The tag a script's dependency job runs on, given the script's own tag; `None` leaves it to
/// `push`'s default (the language tag). Every relock of a script must use this, or a relock
@@ -195,17 +195,12 @@ pub async fn get_hub_script_by_path(
http_client: &reqwest::Client,
db: &DB,
) -> crate::error::Result<String> {
let path = path
.to_path()
.strip_prefix("hub/")
.ok_or_else(|| Error::BadRequest("Impossible to remove prefix hex".to_string()))?;
let id = parse_hub_id(path.to_path(), "hub/")?;
let hub_base_url = (**HUB_BASE_URL.load()).clone();
//
let result = http_get_from_hub(
http_client,
&format!("{}/raw/{}.ts", hub_base_url, path),
&format!("{hub_base_url}/raw/{id}/script.ts"),
true,
None,
Some(db),
@@ -220,19 +215,11 @@ pub async fn get_hub_script_by_path(
match result {
Ok(result) => Ok(result),
Err(e) => {
if hub_base_url != DEFAULT_HUB_BASE_URL
&& path
.split("/")
.next()
.is_some_and(|x| x.parse::<i32>().is_ok_and(|x| x < PRIVATE_HUB_MIN_VERSION))
{
tracing::info!(
"Not found on private hub, fallback to default hub for {}",
path
);
if hub_base_url != DEFAULT_HUB_BASE_URL && id < PRIVATE_HUB_MIN_VERSION {
tracing::info!("Not found on private hub, fallback to default hub for {id}");
let content = http_get_from_hub(
http_client,
&format!("{}/raw/{}.ts", DEFAULT_HUB_BASE_URL, path),
&format!("{DEFAULT_HUB_BASE_URL}/raw/{id}/script.ts"),
true,
None,
Some(db),
@@ -257,15 +244,8 @@ pub async fn get_full_hub_script_by_path(
http_client: &reqwest::Client,
db: Option<&DB>,
) -> crate::error::Result<HubScript> {
let path = path
.to_path()
.strip_prefix("hub/")
.ok_or_else(|| Error::BadRequest("Impossible to remove prefix hex".to_string()))?;
let mut path_iterator = path.split("/");
let version = path_iterator
.next()
.ok_or_else(|| Error::internal_err(format!("expected hub path to have version number")))?;
let path = path.to_path();
let version = parse_hub_id(path, "hub/")?;
// A cache entry that cannot be read or parsed counts as a miss rather than an error:
// a truncated write leaves a file that exists but deserializes to nothing, and refetching
// it is always preferable to failing the job push it was read for.
@@ -288,10 +268,10 @@ pub async fn get_full_hub_script_by_path(
return Ok(script);
}
let script = get_full_hub_script_by_path_inner(path, http_client, db).await?;
let script = get_full_hub_script_by_path_inner(version, http_client, db).await?;
if let Err(e) = crate::worker::write_file(
&HUB_CACHE_DIR,
&version,
&version.to_string(),
&serde_json::to_string(&script).map_err(to_anyhow)?,
) {
tracing::error!("failed to write hub script {path} to cache: {e}");
@@ -302,7 +282,7 @@ pub async fn get_full_hub_script_by_path(
}
async fn get_full_hub_script_by_path_inner(
path: &str,
id: i32,
http_client: &reqwest::Client,
db: Option<&DB>,
) -> crate::error::Result<HubScript> {
@@ -311,7 +291,7 @@ async fn get_full_hub_script_by_path_inner(
let response = (|| async {
let response = http_get_from_hub(
http_client,
&format!("{}/raw2/{}", hub_base_url, path),
&format!("{hub_base_url}/raw2/{id}/script"),
true,
None,
db,
@@ -322,19 +302,12 @@ async fn get_full_hub_script_by_path_inner(
match response {
Ok(response) => Ok(response),
Err(e) => {
if hub_base_url != DEFAULT_HUB_BASE_URL
&& path.split("/").next().is_some_and(|x| {
x.parse::<i32>().is_ok_and(|x| x < PRIVATE_HUB_MIN_VERSION)
})
{
if hub_base_url != DEFAULT_HUB_BASE_URL && id < PRIVATE_HUB_MIN_VERSION {
// TODO: should only fallback to default hub if status is 404 (hub returns 500 currently)
tracing::info!(
"Not found on private hub, fallback to default hub for {}",
path
);
tracing::info!("Not found on private hub, fallback to default hub for {id}");
http_get_from_hub(
http_client,
&format!("{}/raw2/{}", DEFAULT_HUB_BASE_URL, path),
&format!("{DEFAULT_HUB_BASE_URL}/raw2/{id}/script"),
true,
None,
db,
@@ -355,9 +328,7 @@ async fn get_full_hub_script_by_path_inner(
.build(),
)
.notify(|err, dur| {
tracing::warn!(
"Could not get hub script at path {path}, retrying in {dur:#?}, err: {err:#?}"
);
tracing::warn!("Could not get hub script {id}, retrying in {dur:#?}, err: {err:#?}");
})
.sleep(tokio::time::sleep)
.await?;
@@ -365,7 +336,7 @@ async fn get_full_hub_script_by_path_inner(
let script = response
.json::<HubScript>()
.await
.context(format!("Decoding hub response for script at path {path}"))?;
.context(format!("Decoding hub response for script {id}"))?;
Ok(script)
}
@@ -509,3 +480,17 @@ pub async fn deploy_relocked_version(
Ok(new_hash)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hub_script_id_is_the_first_segment_only() {
let id = |p: &str| parse_hub_id(p, "hub/").ok();
assert_eq!(id("hub/28102/windmill/git_sync"), Some(28102));
assert_eq!(id("hub/28102/../19862/x"), Some(28102));
assert_eq!(id("hub/../19862/x"), None);
assert_eq!(id("hub/flows/76"), None);
}
}
+15
View File
@@ -216,6 +216,21 @@ impl StripPath {
}
}
/// The Hub serves scripts and flows by their numeric id alone and ignores the rest of the
/// path, so URLs and cache keys must be built from the parsed id: passing the path through
/// lets dot segments make the URL name one item while the cache key names another.
pub(crate) fn parse_hub_id(path: &str, prefix: &str) -> Result<i32> {
path.strip_prefix(prefix)
.and_then(|p| p.split('/').next())
.and_then(|id| id.parse::<i32>().ok())
.filter(|id| *id > 0)
.ok_or_else(|| {
Error::BadRequest(format!(
"expected a positive numeric id after {prefix} (got {path})"
))
})
}
/// Escape ILIKE special characters (`%`, `_`, `\`) so user input is matched
/// literally. Use this when building `ILIKE '%…%'` patterns from user-supplied
/// strings to prevent wildcard injection.
+1 -1
View File
@@ -9,7 +9,7 @@ pub mod transform;
pub mod types;
pub use schema::convert_schema_to_schema_type;
pub use scope::{is_resource_allowed, parse_mcp_scopes, McpScopeConfig};
pub use scope::{is_resource_allowed, parse_mcp_scopes, McpScopeConfig, SESSION_SCOPE};
pub use transform::{
apply_key_transformation, extract_hub_version_id_from_hashed, extract_path_prefix_from_hashed,
parse_tool_prefix, reverse_transform, reverse_transform_key, transform_hub_path,
+9
View File
@@ -161,6 +161,11 @@ fn pattern_covers(caller: &str, requested: &str) -> bool {
/// URL, which an older selection, carrying none of the newer scopes, still does.
pub const RUNNABLES_AS_TOOLS_SCOPE: &str = "mcp:runnables_as_tools";
/// Added server-side to an `mcp:all` OAuth grant so the tools server's URLs serve it the AI
/// session's toolset. Grants nothing: an un-narrowed `mcp:all` token without it predates the
/// session toolset there, and must keep the tools server's endpoint tools.
pub const SESSION_SCOPE: &str = "mcp:session";
/// Parse MCP scopes from token scope strings
pub fn parse_mcp_scopes(scopes: &[String]) -> Result<McpScopeConfig, String> {
let mut config = McpScopeConfig::default();
@@ -233,6 +238,10 @@ pub fn parse_mcp_scopes(scopes: &[String]) -> Result<McpScopeConfig, String> {
continue;
}
if scope == SESSION_SCOPE {
continue;
}
if let Some(ids) = scope.strip_prefix("mcp:workspaces:") {
config
.workspaces
+25
View File
@@ -218,6 +218,31 @@ pub enum AgentAction {
},
Message {},
WebSearch {},
/// The agent is parked until someone approves or rejects these calls of tools that require
/// it; it is answered once its `agent_actions_success` entry is written. `call_id` is the
/// first call's, and names the request.
Approval {
call_id: String,
calls: Vec<ApprovalCall>,
expires_at: chrono::DateTime<chrono::Utc>,
},
/// The agent is parked until someone answers its question, the same way.
Question {
call_id: String,
question: String,
/// Answers the model proposes; a free-text answer is always possible.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
choices: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
multi_select: bool,
expires_at: chrono::DateTime<chrono::Utc>,
},
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ApprovalCall {
pub function_name: String,
pub arguments: serde_json::Value,
}
#[derive(Serialize, Debug, Clone)]
+19 -4
View File
@@ -386,8 +386,9 @@ impl FlowValue {
for tool in tools {
match &tool.value {
ToolValue::FlowModule(module_value) => cb(module_value, &tool.id)?,
ToolValue::Mcp(_) => {}
ToolValue::Websearch(_) => {}
ToolValue::Mcp(_)
| ToolValue::Websearch(_)
| ToolValue::Question(_) => {}
}
}
}
@@ -943,6 +944,10 @@ pub struct AgentTool {
/// Caps the token of this tool's jobs, on top of the agent step's own restriction.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job_token_scopes: Option<Vec<String>>,
/// A person approves each call before it runs; the agent waits without holding a worker.
/// Flow module and MCP tools only.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub requires_approval: bool,
pub value: ToolValue,
}
@@ -972,8 +977,7 @@ impl From<&AgentTool> for Option<FlowModule> {
job_token_scopes: tool.job_token_scopes.clone(),
..Default::default()
}),
ToolValue::Mcp(_) => None,
ToolValue::Websearch(_) => None,
ToolValue::Mcp(_) | ToolValue::Websearch(_) | ToolValue::Question(_) => None,
}
}
}
@@ -984,6 +988,7 @@ pub enum ToolValue {
FlowModule(FlowModuleValue),
Mcp(McpToolValue),
Websearch(WebsearchToolValue),
Question(QuestionToolValue),
}
// Custom deserializer for backward compatibility with old flows
@@ -1002,6 +1007,7 @@ impl<'de> Deserialize<'de> for ToolValue {
FlowModule(FlowModuleValue),
Mcp(McpToolValue),
Websearch(WebsearchToolValue),
Question(QuestionToolValue),
}
if let Ok(tagged) = TaggedToolValue::deserialize(&content) {
@@ -1009,6 +1015,7 @@ impl<'de> Deserialize<'de> for ToolValue {
TaggedToolValue::FlowModule(v) => ToolValue::FlowModule(v),
TaggedToolValue::Mcp(v) => ToolValue::Mcp(v),
TaggedToolValue::Websearch(v) => ToolValue::Websearch(v),
TaggedToolValue::Question(v) => ToolValue::Question(v),
});
}
@@ -1034,6 +1041,14 @@ pub struct McpToolValue {
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct WebsearchToolValue {}
/// Lets the agent stop and wait for a person to answer a question it asks.
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct QuestionToolValue {
/// Seconds to wait for an answer before the question is reported to the model as timed out.
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u32>,
}
fn is_none_or_empty_vec<T>(expr: &Option<Vec<T>>) -> bool {
expr.is_none() || expr.as_ref().unwrap().is_empty()
}
@@ -44,11 +44,55 @@ pub(crate) struct AgentHistory {
last_request: Option<(usize, usize)>,
}
/// An `AgentHistory` as a parked agent stores it. `OpenAIMessage` never serializes its
/// `agent_action`, so each message carries it alongside: the result and the flow viewer index
/// actions by it.
#[derive(serde::Serialize, serde::Deserialize)]
pub(crate) struct HistoryCheckpoint {
context: Vec<TaggedMessage>,
result: Vec<TaggedMessage>,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct TaggedMessage {
#[serde(flatten)]
message: OpenAIMessage,
#[serde(default, skip_serializing_if = "Option::is_none")]
agent_action: Option<windmill_common::flow_status::AgentAction>,
}
fn tag(messages: &[OpenAIMessage]) -> Vec<TaggedMessage> {
messages
.iter()
.map(|m| TaggedMessage { message: m.clone(), agent_action: m.agent_action.clone() })
.collect()
}
fn untag(messages: Vec<TaggedMessage>) -> Vec<OpenAIMessage> {
messages
.into_iter()
.map(|t| OpenAIMessage { agent_action: t.agent_action, ..t.message })
.collect()
}
impl AgentHistory {
pub(crate) fn new(messages: Vec<OpenAIMessage>) -> Self {
Self { result: messages.clone(), context: messages, last_request: None }
}
pub(crate) fn checkpoint(&self) -> HistoryCheckpoint {
HistoryCheckpoint { context: tag(&self.context), result: tag(&self.result) }
}
/// No usage measurement survives a park, so the next projection estimates the whole prompt.
pub(crate) fn from_checkpoint(checkpoint: HistoryCheckpoint) -> Self {
Self {
context: untag(checkpoint.context),
result: untag(checkpoint.result),
last_request: None,
}
}
pub(crate) fn context(&self) -> &[OpenAIMessage] {
&self.context
}
@@ -676,6 +720,25 @@ async fn summarize_prefix(
#[cfg(test)]
mod tests {
// `OpenAIMessage` never serializes its action, and a parked agent's result and flow viewer
// index actions by it.
#[test]
fn checkpoint_keeps_agent_actions() {
use windmill_common::flow_status::AgentAction;
let mut tagged = message("assistant", "answer");
tagged.agent_action = Some(AgentAction::Message {});
let history = AgentHistory::new(vec![message("user", "question"), tagged]);
let json = serde_json::to_string(&history.checkpoint()).unwrap();
let restored = AgentHistory::from_checkpoint(serde_json::from_str(&json).unwrap());
let actions: Vec<bool> = restored
.result()
.iter()
.chain(restored.context())
.map(|m| m.agent_action.is_some())
.collect();
assert_eq!(actions, [false, true, false, true]);
}
#[test]
fn unusable_summaries_leave_context_and_usage_untouched() {
let mut history = AgentHistory::new(vec![
+1
View File
@@ -2,6 +2,7 @@
// This module will contain all AI-related execution logic
pub mod compaction;
pub mod pause;
pub mod stream_event_processor;
pub mod tools;
pub mod utils;
+377
View File
@@ -0,0 +1,377 @@
//! A call of a tool that requires approval, or of the built-in question tool, parks the agent
//! instead of running. The agent stores its conversation, releases its worker the way a
//! workflow-as-code parent does (`suspend_wac_parent`), and is pulled again when `suspend` reaches
//! 0 (an answer, or a rejection) or `suspend_until` passes (the request timing out).
//! `resume_suspended` answers it like a WAC approval, since its queue row is the one suspended.
//!
//! The checkpoint row outlives the segment that reads it, so a job restarted after its worker died
//! resumes from it again. It is deleted by the monitor once the job leaves the queue.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use windmill_ai::{
ai_types::OpenAIToolCall,
types::{OpenAIContent, OpenAIMessage, TokenUsage, Tool, ToolDef, ToolDefFunction},
};
use windmill_common::{
db::DB,
error::{self, Error},
flow_status::{AgentAction, ApprovalCall},
flows::QuestionToolValue,
worker::{to_raw_value, Connection},
};
use windmill_queue::MiniPulledJob;
use crate::ai::compaction::HistoryCheckpoint;
use crate::ai::utils::find_unique_tool_name;
use crate::wac_executor::{end_wac_segment, suspend_wac_parent, WacPark};
/// The name a run enables the question tool by, reserved like `WEBSEARCH_ENABLED_NAME`: the
/// model-facing name may be renamed away from a flow module tool that already holds it.
pub(crate) const QUESTION_ENABLED_NAME: &str = "__wm_question";
/// Matches a suspended flow step's default.
const DEFAULT_TIMEOUT_SECS: u32 = 1800;
pub(crate) fn question_timeout(value: &QuestionToolValue) -> u32 {
value.timeout.unwrap_or(DEFAULT_TIMEOUT_SECS).max(1)
}
pub(crate) fn question_tool(timeout_secs: u32, existing: &[ToolDef]) -> Tool {
let parameters = serde_json::json!({
"type": "object",
"properties": {
"question": {
"type": "string",
"description": "The question, as the person answering will read it."
},
"choices": {
"type": "array",
"items": { "type": "string" },
"description": "Answers to offer, when the likely answers are known. The person \
can still answer in their own words."
},
"multi_select": {
"type": "boolean",
"description": "Whether the person may pick several choices. The answer is then \
a list."
}
},
"required": ["question"],
});
Tool {
def: ToolDef {
r#type: "function".to_string(),
function: ToolDefFunction {
name: find_unique_tool_name("ask_question", Some(existing)),
description: Some(
"Ask a person a question you cannot answer yourself, such as a choice only \
they can make or information you do not have. This run stops until someone \
answers from the flow's run page or chat, or the question times out, then \
resumes with the answer as this tool's result."
.to_string(),
),
parameters: to_raw_value(&parameters),
},
},
module: None,
mcp_source: None,
requires_approval: false,
question_timeout_secs: Some(timeout_secs),
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub(crate) enum PendingRequest {
/// Calls of tools that require approval, run once approved.
Approval {
calls: Vec<OpenAIToolCall>,
},
Question {
function_name: String,
},
}
/// What the agent waits on, read back to answer it when it is pulled again.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub(crate) struct PendingPause {
/// The question's call, or an approval's first call: the answer must name it.
pub call_id: String,
pub expires_at: DateTime<Utc>,
pub request: PendingRequest,
#[serde(skip)]
pub action: Option<AgentAction>,
}
fn expires_at(timeout_secs: u32) -> DateTime<Utc> {
Utc::now() + chrono::Duration::seconds(timeout_secs as i64)
}
/// The model's arguments turned into a park, or the error the model is answered with instead.
pub(crate) fn start_question(
timeout_secs: u32,
tool_call: &OpenAIToolCall,
) -> Result<PendingPause, String> {
let args: serde_json::Value = serde_json::from_str(&tool_call.function.arguments)
.map_err(|e| format!("Invalid arguments: {e}"))?;
let question = args
.get("question")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or("`question` is required")?
.to_string();
let choices = args
.get("choices")
.and_then(|v| v.as_array())
.into_iter()
.flatten()
.filter_map(|c| c.as_str().map(str::trim).filter(|s| !s.is_empty()))
.map(str::to_string)
.collect::<Vec<_>>();
let multi_select = args
.get("multi_select")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let expires_at = expires_at(timeout_secs);
Ok(PendingPause {
call_id: tool_call.id.clone(),
expires_at,
request: PendingRequest::Question { function_name: tool_call.function.name.clone() },
action: Some(AgentAction::Question {
call_id: tool_call.id.clone(),
question,
choices,
multi_select,
expires_at,
}),
})
}
/// One request for all of a turn's calls that require approval: they are answered together.
pub(crate) fn start_approval(calls: Vec<&OpenAIToolCall>) -> PendingPause {
let call_id = calls[0].id.clone();
let expires_at = expires_at(DEFAULT_TIMEOUT_SECS);
let shown = calls
.iter()
.map(|call| ApprovalCall {
function_name: call.function.name.clone(),
arguments: serde_json::from_str(&call.function.arguments)
.unwrap_or_else(|_| serde_json::Value::String(call.function.arguments.clone())),
})
.collect();
PendingPause {
action: Some(AgentAction::Approval { call_id: call_id.clone(), calls: shown, expires_at }),
call_id,
expires_at,
request: PendingRequest::Approval { calls: calls.into_iter().cloned().collect() },
}
}
/// Everything `run_agent` holds across iterations that the next segment needs.
#[derive(Serialize, Deserialize)]
pub(crate) struct AgentCheckpoint {
pub history: HistoryCheckpoint,
pub actions: Vec<AgentAction>,
pub content: Option<OpenAIContent>,
pub usage: Option<TokenUsage>,
pub events: String,
pub reasoning: String,
pub next_iteration: usize,
pub pending: PendingPause,
/// `resume_job` rows never leave while the agent is queued, so each answer must skip the
/// ones earlier parks already read.
#[serde(default)]
pub consumed_resume_rows: Vec<Uuid>,
}
pub(crate) async fn load_checkpoint(
db: &DB,
job_id: &Uuid,
) -> error::Result<Option<AgentCheckpoint>> {
let raw: Option<sqlx::types::Json<Box<serde_json::value::RawValue>>> =
sqlx::query_scalar("SELECT checkpoint FROM ai_agent_checkpoint WHERE job_id = $1")
.bind(job_id)
.fetch_optional(db)
.await?;
raw.map(|r| {
serde_json::from_str(r.0.get())
.map_err(|e| Error::internal_err(format!("Invalid AI agent checkpoint: {e}")))
})
.transpose()
}
pub(crate) enum ParkOutcome {
Parked,
/// A cancel reached the queue row first, so nothing was written.
Cancelled,
}
/// Park the agent until its approval is answered or expires.
pub(crate) async fn park(
db: &DB,
conn: &Connection,
job: &MiniPulledJob,
checkpoint: &AgentCheckpoint,
) -> error::Result<ParkOutcome> {
let remaining_ms = (checkpoint.pending.expires_at - Utc::now()).num_milliseconds();
let suspend_secs = (remaining_ms as f64 / 1000.0).max(1.0);
let checkpoint = serde_json::to_value(checkpoint).map_err(|e| {
Error::internal_err(format!("Failed to serialize AI agent checkpoint: {e}"))
})?;
let mut tx = db.begin().await?;
// One event: an answer, or a rejection, which zeroes `suspend` outright.
let segment_ms =
match suspend_wac_parent(&mut tx, &job.id, &job.workspace_id, 1, suspend_secs).await? {
WacPark::Parked(ms) => ms,
WacPark::Cancelled(_) => return Ok(ParkOutcome::Cancelled),
};
sqlx::query(
"INSERT INTO ai_agent_checkpoint (job_id, workspace_id, checkpoint) VALUES ($1, $2, $3)
ON CONFLICT (job_id) DO UPDATE SET checkpoint = EXCLUDED.checkpoint",
)
.bind(&job.id)
.bind(&job.workspace_id)
.bind(&checkpoint)
.execute(&mut *tx)
.await?;
tx.commit().await?;
end_wac_segment(conn, job, segment_ms);
Ok(ParkOutcome::Parked)
}
pub(crate) enum Resumed {
/// The answer, and whether it counts as a success on the run page.
Answered(Answer, bool),
/// Woken by an answer to an earlier request; the parked one still waits.
StillPending,
}
pub(crate) enum Answer {
/// The tool message answering the question.
Question(OpenAIMessage),
/// The approved calls run.
Run,
/// Each call is refused with this message.
Refuse(String),
}
/// Read the answer to the parked request.
///
/// `suspend` is zeroed in the same transaction as the read, so `resume_suspended` refuses any
/// answer sent while the agent runs instead of storing a row the next request would take. An answer
/// that names another request (the page sends the call id it shows) is read and ignored: a stale
/// page must not answer a request its user never saw.
pub(crate) async fn answer_pause(
db: &DB,
job_id: &Uuid,
pending: &PendingPause,
actions: &[AgentAction],
consumed_resume_rows: &mut Vec<Uuid>,
) -> error::Result<Resumed> {
let mut tx = db.begin().await?;
sqlx::query("UPDATE v2_job_queue SET suspend = 0 WHERE id = $1")
.bind(job_id)
.execute(&mut *tx)
.await?;
let rows: Vec<(
Uuid,
sqlx::types::Json<serde_json::Value>,
Option<String>,
bool,
)> = sqlx::query_as(
"SELECT id, value, approver, approved FROM resume_job
WHERE job = $1 AND id <> ALL($2) ORDER BY created_at ASC",
)
.bind(job_id)
.bind(&*consumed_resume_rows)
.fetch_all(&mut *tx)
.await?;
tx.commit().await?;
consumed_resume_rows.extend(rows.iter().map(|(id, ..)| *id));
let answer = rows.into_iter().find(|(_, value, ..)| {
value
.0
.get("call_id")
.and_then(|v| v.as_str())
.map_or(true, |call_id| call_id == pending.call_id)
});
let Some((_, value, approver, approved)) = answer else {
if Utc::now() < pending.expires_at {
return Ok(Resumed::StillPending);
}
let answer = match pending.request {
PendingRequest::Approval { .. } => Answer::Refuse(
"Not run: nobody approved this call before the request timed out.".to_string(),
),
PendingRequest::Question { .. } => Answer::Question(question_message(
pending,
actions,
serde_json::json!({
"timed_out": true,
"message": "Nobody answered before the question timed out.",
}),
)),
};
return Ok(Resumed::Answered(answer, false));
};
let approver = approver.unwrap_or_else(|| "anonymous".to_string());
let text = |key: &str| {
value
.0
.get(key)
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
};
let answer = match (&pending.request, approved) {
(PendingRequest::Approval { .. }, true) => Answer::Run,
(PendingRequest::Approval { .. }, false) => Answer::Refuse(match text("comment") {
Some(comment) => {
format!("Not run: {approver} rejected this call. Their comment: {comment}")
}
None => format!("Not run: {approver} rejected this call."),
}),
(PendingRequest::Question { .. }, true) => Answer::Question(question_message(
pending,
actions,
serde_json::json!({
// A list when several choices were picked.
"answer": match value.0.get("answer") {
Some(serde_json::Value::Array(picked)) => serde_json::json!(picked),
_ => serde_json::json!(text("answer").unwrap_or_default()),
},
"answered_by": approver,
}),
)),
(PendingRequest::Question { .. }, false) => Answer::Question(question_message(
pending,
actions,
serde_json::json!({ "declined": true, "declined_by": approver }),
)),
};
Ok(Resumed::Answered(answer, approved))
}
fn question_message(
pending: &PendingPause,
actions: &[AgentAction],
result: serde_json::Value,
) -> OpenAIMessage {
let action = actions
.iter()
.rev()
.find(|a| matches!(a, AgentAction::Question { call_id, .. } if *call_id == pending.call_id))
.cloned();
OpenAIMessage {
role: "tool".to_string(),
content: Some(OpenAIContent::Text(result.to_string())),
tool_call_id: Some(pending.call_id.clone()),
agent_action: action,
..Default::default()
}
}
@@ -26,14 +26,16 @@ impl Clone for StreamEventProcessor {
}
impl StreamEventProcessor {
/// Create a new StreamEventProcessor that persists events to the database.
pub fn new(conn: &Connection, job: &MiniPulledJob) -> Self {
/// Create a new StreamEventProcessor that persists events to the database, after the
/// `first_offset` events the job already streamed: a row written at an offset already taken
/// is appended to that row, out of order.
pub fn new(conn: &Connection, job: &MiniPulledJob, first_offset: i32) -> Self {
let (tx, mut rx) = tokio::sync::mpsc::channel::<String>(100);
let conn = conn.clone();
let job_id = job.id.clone();
let workspace_id = job.workspace_id.clone();
let handle = tokio::spawn(async move {
let mut offset = -1;
let mut offset = first_offset - 1;
while let Some(event) = rx.recv().await {
offset += 1;
match tokio::time::timeout(
+225 -11
View File
@@ -1,3 +1,4 @@
use crate::ai::pause::{start_approval, start_question, PendingPause};
use crate::ai::stream_event_processor::StreamEventProcessor;
use crate::ai::utils::{
add_message_to_conversation, execute_mcp_tool, get_step_name_from_flow,
@@ -97,12 +98,35 @@ pub async fn execute_tool_calls(
actions: &mut Vec<AgentAction>,
final_events_str: &mut String,
structured_output_tool_name: &Option<String>,
) -> Result<(Vec<OpenAIMessage>, Option<OpenAIContent>, bool), Error> {
gate: ApprovalGate<'_>,
) -> Result<ToolCallsOutcome, Error> {
let mut messages = Vec::new();
let mut used_structured_output_tool = false;
let mut final_content = None;
let mut calls = tool_calls.iter().peekable();
let CallPlan { to_run, refusals, deferred, pause } = plan_calls(
tool_calls,
tools,
&gate,
structured_output_tool_name.as_deref(),
);
// Answered calls were streamed as running when the agent parked.
let announced = matches!(gate, ApprovalGate::Ask);
for call in deferred.iter().chain(refusals.iter().map(|(call, _)| call)) {
if let Some(stream_event_processor) = ctx.stream_event_processor.filter(|_| announced) {
let event = StreamingEvent::ToolExecution {
call_id: call.id.clone(),
function_name: call.function.name.clone(),
};
stream_event_processor.send(event, final_events_str).await?;
}
}
for (call, refusal) in refusals {
refuse_tool_call(&mut ctx, call, &refusal, &mut messages, final_events_str).await?;
}
let mut calls = to_run.into_iter().peekable();
while let Some(tool_call) = calls.next() {
// Stream tool call progress
if let Some(stream_event_processor) = ctx.stream_event_processor {
@@ -142,7 +166,6 @@ pub async fn execute_tool_calls(
.find(|t| t.def.function.name == tool_call.function.name);
if let Some(tool) = tool {
// Check if this is an MCP tool
if let Some(mcp_source) = &tool.mcp_source {
execute_mcp_tool_call(
&mut ctx,
@@ -155,7 +178,17 @@ pub async fn execute_tool_calls(
)
.await?;
} else if tool.module.is_some() && interpolating_args(tool_call) {
refuse_tool_call(&mut ctx, tool_call, &mut messages, final_events_str).await?;
let error_msg = "Tool call refused: its arguments could not be read, or reference \
variables, resources or reserved names ($var:, $jsonvar:, \
$res:, $encrypted:, $WM_)";
refuse_tool_call(
&mut ctx,
tool_call,
error_msg,
&mut messages,
final_events_str,
)
.await?;
} else if tool.module.is_some() {
let mut batch = vec![(tool_call, tool)];
while let Some(next_call) = calls.peek() {
@@ -192,7 +225,114 @@ pub async fn execute_tool_calls(
}
}
Ok((messages, final_content, used_structured_output_tool))
Ok(ToolCallsOutcome { messages, final_content, used_structured_output_tool, pause })
}
/// A turn's calls: run now, refused with a message, or held for a person's answer.
struct CallPlan<'a> {
to_run: Vec<&'a OpenAIToolCall>,
refusals: Vec<(&'a OpenAIToolCall, String)>,
deferred: Vec<&'a OpenAIToolCall>,
pause: Option<PendingPause>,
}
fn plan_calls<'a>(
tool_calls: &'a [OpenAIToolCall],
tools: &[Tool],
gate: &ApprovalGate<'_>,
structured_output_tool_name: Option<&str>,
) -> CallPlan<'a> {
let tool_of = |call: &OpenAIToolCall| {
tools
.iter()
.find(|t| t.def.function.name == call.function.name)
};
let mut plan = CallPlan { to_run: vec![], refusals: vec![], deferred: vec![], pause: None };
match gate {
ApprovalGate::Answered(Some(refusal)) => plan
.refusals
.extend(tool_calls.iter().map(|call| (call, refusal.to_string()))),
ApprovalGate::Answered(None) => {
for call in tool_calls {
match tool_of(call) {
Some(_) => plan.to_run.push(call),
None => plan
.refusals
.push((call, "Not run: this tool is no longer available.".into())),
}
}
}
// Calls that need a person wait for the answer; every other call runs now.
ApprovalGate::Ask => {
// A call the run would refuse anyway is refused now rather than shown to the approver.
let held = |call: &OpenAIToolCall| {
tool_of(call).is_some_and(|t| {
t.requires_approval && !(t.module.is_some() && interpolating_args(call))
})
};
let approval_calls: Vec<_> = tool_calls.iter().filter(|call| held(call)).collect();
for call in tool_calls {
match tool_of(call) {
_ if held(call) => {}
Some(Tool { question_timeout_secs: Some(timeout_secs), .. }) => {
let refusal = if !approval_calls.is_empty() {
"Not run: ask it again once the pending approval is answered."
} else if plan.pause.is_some() {
"Not run: ask one question at a time."
} else {
match start_question(*timeout_secs, call) {
Ok(question) => {
plan.pause = Some(question);
plan.deferred.push(call);
continue;
}
Err(e) => {
plan.refusals.push((call, e));
continue;
}
}
};
plan.refusals.push((call, refusal.to_string()));
}
_ => plan.to_run.push(call),
}
}
if !approval_calls.is_empty() {
plan.deferred.extend(approval_calls.iter().copied());
plan.pause = Some(start_approval(approval_calls));
}
// The structured answer ends the run, so it cannot come before the answer it waits on.
if plan.pause.is_some() {
if let Some(at) = plan
.to_run
.iter()
.position(|c| Some(c.function.name.as_str()) == structured_output_tool_name)
{
plan.refusals.push((
plan.to_run.remove(at),
"Not run: give your answer once the pending request is answered.".into(),
));
}
}
}
}
plan
}
pub struct ToolCallsOutcome {
pub messages: Vec<OpenAIMessage>,
pub final_content: Option<OpenAIContent>,
pub used_structured_output_tool: bool,
/// The turn asked a person something: the agent parks, those calls unanswered.
pub pause: Option<PendingPause>,
}
pub enum ApprovalGate<'a> {
/// Park on calls of tools that require approval, and on a question.
Ask,
/// A person answered an approval request for these calls: run them, or refuse each with
/// this message.
Answered(Option<&'a str>),
}
/// Whether the model wrote an argument the tool's job would interpolate with the agent's token,
@@ -207,15 +347,13 @@ fn interpolating_args(tool_call: &OpenAIToolCall) -> bool {
async fn refuse_tool_call(
ctx: &mut ToolExecutionContext<'_>,
tool_call: &OpenAIToolCall,
error_msg: &str,
messages: &mut Vec<OpenAIMessage>,
final_events_str: &mut String,
) -> Result<(), Error> {
let error_msg = "Tool call refused: its arguments could not be read, or reference variables, \
resources or reserved names ($var:, $jsonvar:, $res:, $encrypted:, $WM_)"
.to_string();
messages.push(OpenAIMessage {
role: "tool".to_string(),
content: Some(OpenAIContent::Text(error_msg.clone())),
content: Some(OpenAIContent::Text(error_msg.to_string())),
tool_call_id: Some(tool_call.id.clone()),
..Default::default()
});
@@ -223,12 +361,12 @@ async fn refuse_tool_call(
let event = StreamingEvent::ToolResult {
call_id: tool_call.id.clone(),
function_name: tool_call.function.name.clone(),
result: error_msg.clone(),
result: error_msg.to_string(),
success: false,
};
stream_event_processor.send(event, final_events_str).await?;
}
let (content, extras) = windmill_tool_row(tool_call, false, &error_msg);
let (content, extras) = windmill_tool_row(tool_call, false, error_msg);
let agent_job_id = ctx.job.id;
add_tool_message_to_chat(ctx, Some(agent_job_id), &content, false, Some(extras)).await;
Ok(())
@@ -1134,6 +1272,82 @@ mod tests {
);
}
#[test]
fn only_calls_that_need_a_person_wait_for_one() {
use super::{plan_calls, ApprovalGate};
use crate::ai::pause::PendingRequest;
use windmill_ai::types::{Tool, ToolDef, ToolDefFunction};
let tool = |name: &str, requires_approval: bool, question: bool| Tool {
module: None,
def: ToolDef {
r#type: "function".to_string(),
function: ToolDefFunction {
name: name.to_string(),
description: None,
parameters: RawValue::from_string("{}".to_string()).unwrap(),
},
},
mcp_source: None,
requires_approval,
question_timeout_secs: question.then_some(60),
};
let call = |id: &str, name: &str| OpenAIToolCall {
id: id.to_string(),
function: OpenAIFunction {
name: name.to_string(),
arguments: r#"{"question":"which one?"}"#.to_string(),
},
r#type: "function".to_string(),
extra_content: None,
};
let tools = [
tool("list", false, false),
tool("delete", true, false),
tool("ask", false, true),
];
let ids =
|calls: &[&OpenAIToolCall]| calls.iter().map(|c| c.id.clone()).collect::<Vec<_>>();
let calls = [
call("1", "list"),
call("2", "delete"),
call("3", "ask"),
call("4", "delete"),
call("5", "answer"),
];
let plan = plan_calls(&calls, &tools, &ApprovalGate::Ask, Some("answer"));
assert_eq!(ids(&plan.to_run), ["1"]);
assert_eq!(ids(&plan.deferred), ["2", "4"]);
assert_eq!(
plan.refusals
.iter()
.map(|(c, _)| c.id.as_str())
.collect::<Vec<_>>(),
["3", "5"]
);
let pause = plan.pause.unwrap();
assert_eq!(pause.call_id, "2");
assert!(matches!(pause.request, PendingRequest::Approval { calls } if calls.len() == 2));
let calls = [call("1", "ask"), call("2", "list"), call("3", "ask")];
let plan = plan_calls(&calls, &tools, &ApprovalGate::Ask, Some("answer"));
assert_eq!(ids(&plan.to_run), ["2"]);
assert_eq!(ids(&plan.deferred), ["1"]);
assert_eq!(plan.refusals.len(), 1);
let calls = [call("1", "delete"), call("2", "gone")];
let plan = plan_calls(&calls, &tools, &ApprovalGate::Answered(None), None);
assert_eq!(ids(&plan.to_run), ["1"]);
assert_eq!(plan.refusals[0].0.id, "2");
let plan = plan_calls(
&calls,
&tools,
&ApprovalGate::Answered(Some("rejected")),
None,
);
assert!(plan.to_run.is_empty() && plan.refusals.len() == 2);
}
#[test]
fn a_model_written_reference_is_caught_however_it_is_spelled() {
let call = |arguments: &str| OpenAIToolCall {
+36
View File
@@ -307,6 +307,40 @@ pub async fn update_flow_status_module_with_actions_success(
Ok(())
}
/// Record the outcome of the last of `action_count` actions, dropping any recorded past it.
pub async fn set_flow_status_module_last_action_success(
db: &DB,
parent_job: &Uuid,
action_count: usize,
action_success: bool,
) -> Result<(), Error> {
if let Step::Step { idx: step, .. } = get_step_of_flow_status(db, parent_job.to_owned()).await?
{
sqlx::query(
"UPDATE v2_job_status SET
flow_status = jsonb_set(
flow_status,
array['modules', $2::TEXT, 'agent_actions_success'],
COALESCE((
SELECT jsonb_agg(e ORDER BY i)
FROM jsonb_array_elements(
flow_status->'modules'->$2->'agent_actions_success'
) WITH ORDINALITY AS t(e, i)
WHERE i < $3
), '[]'::jsonb) || to_jsonb(ARRAY[$4::bool])
)
WHERE id = $1",
)
.bind(parent_job)
.bind(step as i32)
.bind(action_count as i64)
.bind(action_success)
.execute(db)
.await?;
}
Ok(())
}
/// Get step name from the flow module (summary if exists, else id)
pub fn get_step_name_from_flow(
summary: Option<&str>,
@@ -405,6 +439,8 @@ fn convert_mcp_tools_to_windmill_tools(
tool_name: mcp_tool.name.to_string(),
resource_path: resource_path.to_string(),
}),
requires_approval: false,
question_timeout_secs: None,
})
})
.collect()
+272 -30
View File
@@ -2,13 +2,19 @@ use crate::ai::compaction::{
memory_within_capacity, persisted_bytes, AgentHistory, CompactionRequest, CompactionTrigger,
Compactor,
};
use crate::ai::tools::{execute_tool_calls, ToolAbortHandles, ToolExecutionContext};
use crate::ai::pause::{
answer_pause, load_checkpoint, park, question_timeout, question_tool, AgentCheckpoint, Answer,
ParkOutcome, PendingRequest, Resumed, QUESTION_ENABLED_NAME,
};
use crate::ai::tools::{
execute_tool_calls, ApprovalGate, ToolAbortHandles, ToolCallsOutcome, ToolExecutionContext,
};
use crate::ai::utils::{
add_message_to_conversation, any_tool_needs_previous_result, cleanup_mcp_clients,
filter_schema_by_input_transforms, find_unique_tool_name, get_flow_context,
get_flow_job_runnable_and_raw_flow, get_step_name_from_flow, load_mcp_tools,
parse_raw_script_schema, update_flow_status_module_with_actions,
update_flow_status_module_with_actions_success,
parse_raw_script_schema, set_flow_status_module_last_action_success,
update_flow_status_module_with_actions, update_flow_status_module_with_actions_success,
};
use crate::memory_common::MAX_MEMORY_SIZE_BYTES;
use crate::memory_oss::{memory_storage_capacity_bytes, read_from_memory, write_to_memory};
@@ -17,7 +23,10 @@ use async_recursion::async_recursion;
use regex::Regex;
use serde_json::value::RawValue;
use sha2::Digest;
use std::{collections::HashMap, sync::Arc};
use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use uuid::Uuid;
#[cfg(feature = "bedrock")]
use windmill_ai::ai_bedrock::check_env_credentials;
@@ -42,7 +51,7 @@ use windmill_ai::{
proxy::{
common_outbound_headers, needs_unavailable_oauth_exchange, retain_effective_credentials,
},
query_builder::{BuildRequestArgs, ParsedResponse},
query_builder::{BuildRequestArgs, ParsedResponse, StreamEventSink},
retry::{as_transient, into_final_error, response_error, send_error, Backoff, MAX_RETRIES},
types::*,
utils::{pinned_ai_client_for, should_use_structured_output_tool},
@@ -585,6 +594,9 @@ fn flow_module_tool_name(summary: Option<&str>) -> Result<&str, Error> {
Some(name) if name == WEBSEARCH_ENABLED_NAME => Err(Error::internal_err(format!(
"Invalid tool name: {name:?} is reserved for enabling web search"
))),
Some(name) if name == QUESTION_ENABLED_NAME => Err(Error::internal_err(format!(
"Invalid tool name: {name:?} is reserved for the question tool"
))),
Some(name) if TOOL_NAME_REGEX.is_match(name) => Ok(name),
other => Err(Error::internal_err(format!("Invalid tool name: {other:?}"))),
}
@@ -601,6 +613,7 @@ fn tool_enabled_name(tool: &AgentTool) -> Option<&str> {
match &tool.value {
ToolValue::Mcp(mcp) => Some(mcp.resource_path.trim_start_matches("$res:")),
ToolValue::Websearch(_) => Some(WEBSEARCH_ENABLED_NAME),
ToolValue::Question(_) => Some(QUESTION_ENABLED_NAME),
_ => tool.summary.as_deref(),
}
}
@@ -898,6 +911,23 @@ pub async fn handle_ai_agent_job(
// constrain a shared resource); don't advertise them — invoking one would only fail the depth
// check as a third level.
let tools = if direct_parent_job_kind == JobKind::AIAgent {
// A nested agent writes no flow status, so nothing would show a question or an approval
// request, and its parent agent holds a worker while it waits: it cannot park either. A
// tool that requires approval is dropped rather than run unapproved.
let (dropped, tools): (Vec<_>, Vec<_>) =
tools.into_iter().partition(|t| t.requires_approval);
if !dropped.is_empty() {
let names: Vec<&str> = dropped
.iter()
.map(|t| tool_enabled_name(t).unwrap_or(&t.id))
.collect();
let message = format!(
"Tools that require approval are left out of an agent used as a tool, which cannot \
wait for an answer: {}\n",
names.join(", ")
);
append_logs(&job.id, &job.workspace_id, message, conn).await;
}
tools
.into_iter()
.filter(|t| {
@@ -905,7 +935,7 @@ pub async fn handle_ai_agent_job(
&t.value,
ToolValue::FlowModule(
FlowModuleValue::AIAgent { .. } | FlowModuleValue::AIDecision { .. }
)
) | ToolValue::Question(_)
)
})
.collect()
@@ -935,8 +965,17 @@ pub async fn handle_ai_agent_job(
#[allow(unused_mut)]
let mut mcp_configs: Vec<crate::ai::utils::McpResourceConfig> = Vec::new();
let mut has_websearch = false;
let mut question_timeout_secs = None;
// Flow module tool ids and MCP resource paths whose calls each wait for approval.
let mut approval_required: HashSet<String> = HashSet::new();
for tool in tools {
if tool.requires_approval {
approval_required.insert(match &tool.value {
ToolValue::Mcp(mcp) => mcp.resource_path.trim_start_matches("$res:").to_string(),
_ => tool.id.clone(),
});
}
match &tool.value {
#[allow(unused_variables)]
ToolValue::Mcp(mcp_config) => {
@@ -981,6 +1020,9 @@ pub async fn handle_ai_agent_job(
tracing::debug!("WebSearch tool enabled");
has_websearch = true;
}
ToolValue::Question(question) => {
question_timeout_secs = Some(question_timeout(question));
}
}
}
@@ -990,6 +1032,7 @@ pub async fn handle_ai_agent_job(
let db = db;
let job = job;
let user_description = tool_descriptions.get(&t.id).cloned();
let requires_approval = approval_required.contains(&t.id);
async move {
let summary = flow_module_tool_name(t.summary.as_deref())?;
@@ -1124,8 +1167,10 @@ pub async fn handle_ai_agent_job(
}),
},
},
requires_approval,
module: Some(t),
mcp_source: None,
question_timeout_secs: None,
})
}
}))
@@ -1137,11 +1182,21 @@ pub async fn handle_ai_agent_job(
let mcp_clients = if !mcp_configs.is_empty() {
let (clients, mcp_tools) =
load_mcp_tools(db, &job.workspace_id, mcp_configs, client).await?;
tools.extend(mcp_tools);
tools.extend(mcp_tools.into_iter().map(|mut tool| {
tool.requires_approval = tool
.mcp_source
.as_ref()
.is_some_and(|s| approval_required.contains(&s.resource_path));
tool
}));
clients
} else {
HashMap::new()
};
if let Some(timeout_secs) = question_timeout_secs {
let defs: Vec<ToolDef> = tools.iter().map(|t| t.def.clone()).collect();
tools.push(question_tool(timeout_secs, &defs));
}
if let Some(enabled) = enabled_tools {
let matchable: Vec<&str> = roster_names.iter().map(|s| s.as_str()).collect();
@@ -1265,7 +1320,10 @@ pub async fn handle_ai_agent_job(
}
},
};
if result.is_err() {
if result
.as_ref()
.is_err_and(|e| !matches!(e, Error::WacSuspended(_)))
{
for handle in tool_abort_handles.lock().unwrap().drain(..) {
handle.abort();
}
@@ -1275,6 +1333,34 @@ pub async fn handle_ai_agent_job(
result
}
/// Park the agent on its pending approval, as the error `run_agent` returns.
async fn park_agent(
db: &DB,
conn: &Connection,
job: &MiniPulledJob,
checkpoint: &AgentCheckpoint,
cancel_rx: &tokio::sync::watch::Receiver<bool>,
) -> Error {
match park(db, conn, job, checkpoint).await {
Ok(ParkOutcome::Parked) => Error::WacSuspended(format!(
"AI agent {} parked on {}",
job.id, checkpoint.pending.call_id
)),
Ok(ParkOutcome::Cancelled) => {
// The cancel is on the queue row but not yet seen by the poller, which is what
// completes the job as cancelled rather than failed. Wait for it to notice.
let mut cancel_rx = cancel_rx.clone();
let _ = tokio::time::timeout(
std::time::Duration::from_secs(30),
cancel_rx.wait_for(|cancelled| *cancelled),
)
.await;
Error::ExecutionErr("Job cancelled".to_string())
}
Err(e) => e,
}
}
/// OpenAI rejects a `prompt_cache_key` over 64 characters
/// (`Invalid 'prompt_cache_key': string too long`), and a runnable path alone can pass
/// that. Fold an over-long key into a digest of itself: same step still yields the same
@@ -1380,6 +1466,27 @@ pub async fn run_agent(
// Fetch flow context for input transforms context, chat and memory
let mut flow_context = get_flow_context(db, job).await;
// Set when the job parked on an approval or a question and has been pulled again. Read
// whatever the roster says now: a linked agent can lose a tool while one of its runs is parked.
let resumed = match load_checkpoint(db, &job.id).await? {
Some(mut checkpoint) => {
let answer = answer_pause(
db,
&job.id,
&checkpoint.pending,
&checkpoint.actions,
&mut checkpoint.consumed_resume_rows,
)
.await?;
match answer {
Resumed::StillPending => {
return Err(park_agent(db, conn, job, &checkpoint, &cancel_rx).await)
}
Resumed::Answered(message, success) => Some((checkpoint, message, success)),
}
}
None => None,
};
// The run's memory id is also the chat conversation id, which a step's own memory id never
// replaces.
@@ -1437,7 +1544,7 @@ pub async fn run_agent(
return Err(Error::internal_err(missing.to_string()));
}
if matches!(output_type, OutputType::Text) {
if matches!(output_type, OutputType::Text) && resumed.is_none() {
match &history {
HistorySource::Messages(provided) => messages.extend(provided.iter().cloned()),
HistorySource::Managed { memory_id, bound } => {
@@ -1538,10 +1645,27 @@ pub async fn run_agent(
}
}
let mut messages = AgentHistory::new(messages);
let mut actions = vec![];
let mut content = None;
let mut final_usage: Option<TokenUsage> = None;
let mut final_events_str = String::new();
let mut final_reasoning = String::new();
let mut first_iteration = 0;
let mut consumed_resume_rows = vec![];
let mut resumed_answer = None;
let (mut messages, mut actions, mut content, mut final_usage) = match resumed {
Some((checkpoint, message, success)) => {
final_events_str = checkpoint.events;
final_reasoning = checkpoint.reasoning;
first_iteration = checkpoint.next_iteration;
consumed_resume_rows = checkpoint.consumed_resume_rows;
resumed_answer = Some((checkpoint.pending, message, success));
(
AgentHistory::from_checkpoint(checkpoint.history),
checkpoint.actions,
checkpoint.content,
checkpoint.usage,
)
}
None => (AgentHistory::new(messages), vec![], None, None),
};
// Check if this provider supports tools with the current output type
let supports_tools = query_builder.supports_tools_with_output_type(output_type);
@@ -1596,13 +1720,12 @@ pub async fn run_agent(
let user_wants_streaming = streaming_requested(args.streaming);
*has_stream = user_wants_streaming && is_text_output;
let mut final_events_str = String::new();
let mut final_reasoning = String::new();
// Always create a StreamEventProcessor for text output (use silent mode if user doesn't want streaming)
let stream_event_processor = if is_text_output {
let mut stream_event_processor = if is_text_output {
if user_wants_streaming {
Some(StreamEventProcessor::new(conn, job))
// Each streamed event is one line of the accumulated string.
let streamed = final_events_str.lines().count() as i32;
Some(StreamEventProcessor::new(conn, job, streamed))
} else {
Some(StreamEventProcessor::new_silent())
}
@@ -1670,8 +1793,81 @@ pub async fn run_agent(
windmill_ai::providers::anthropic::FederatedAccessToken,
> = None;
if let Some((pending, answer, success)) = resumed_answer {
if let Some(parent_job) = parent_job {
// Rewritten whole rather than appended: a job restarted after its worker died
// resumes from the same checkpoint and must not record the answer twice.
update_flow_status_module_with_actions(db, parent_job, &actions).await?;
set_flow_status_module_last_action_success(db, parent_job, actions.len(), success)
.await?;
}
match (pending.request, answer) {
(PendingRequest::Question { function_name }, Answer::Question(message)) => {
if let (Some(processor), Some(OpenAIContent::Text(result))) =
(stream_event_processor.as_ref(), message.content.as_ref())
{
let event = StreamingEvent::ToolResult {
call_id: pending.call_id.clone(),
function_name,
result: result.clone(),
success,
};
processor.send(event, &mut final_events_str).await?;
}
messages.push(message);
}
(PendingRequest::Approval { calls }, answer) => {
let refusal = match &answer {
Answer::Refuse(refusal) => Some(refusal.as_str()),
_ => None,
};
let tool_execution_ctx = ToolExecutionContext {
db,
conn,
job,
parent_job,
summary: &summary,
flow_step_id_override,
client,
worker_dir,
base_internal_url,
worker_name,
hostname,
occupancy_metrics,
killpill_rx,
stream_event_processor: stream_event_processor.as_ref(),
flow_context: &mut flow_context,
omit_output_from_conversation,
preserve_step_tags,
reasoning: None,
previous_result: &previous_result,
id_context: &id_context,
tool_abort_handles: tool_abort_handles.clone(),
job_completed_tx: job_completed_tx.clone(),
};
let outcome = execute_tool_calls(
tool_execution_ctx,
&calls,
&tools,
mcp_clients,
&mut actions,
&mut final_events_str,
&structured_output_tool_name,
ApprovalGate::Answered(refusal),
)
.await?;
messages.extend(outcome.messages);
}
(PendingRequest::Question { .. }, _) => {
return Err(Error::internal_err(
"A question was answered as an approval".to_string(),
))
}
}
}
// Main agent loop
for i in 0..max_iterations {
for i in first_iteration..max_iterations {
// Check if parent was canceled — stop iterating but let current tool calls finish
if *cancel_rx.borrow() {
return Err(Error::ExecutionErr("Job cancelled".to_string()));
@@ -2277,17 +2473,22 @@ pub async fn run_agent(
job_completed_tx: job_completed_tx.clone(),
};
let (tool_messages, tool_content, tool_used_structured_output) =
execute_tool_calls(
tool_execution_ctx,
&tool_calls,
&tools,
mcp_clients,
&mut actions,
&mut final_events_str,
&structured_output_tool_name,
)
.await?;
let ToolCallsOutcome {
messages: tool_messages,
final_content: tool_content,
used_structured_output_tool: tool_used_structured_output,
pause,
} = execute_tool_calls(
tool_execution_ctx,
&tool_calls,
&tools,
mcp_clients,
&mut actions,
&mut final_events_str,
&structured_output_tool_name,
ApprovalGate::Ask,
)
.await?;
messages.extend(tool_messages);
@@ -2331,6 +2532,42 @@ pub async fn run_agent(
}
used_structured_output_tool = tool_used_structured_output;
if let Some(mut pending) = pause {
actions.extend(pending.action.take());
// The next segment streams from where this one stops, so this one's events
// must be written first.
if let Some(handle) = stream_event_processor.take().and_then(|p| p.to_handle())
{
let _ = handle.await;
}
let checkpoint = AgentCheckpoint {
history: messages.checkpoint(),
actions: actions.clone(),
content: content.clone(),
usage: final_usage.clone(),
events: final_events_str,
reasoning: final_reasoning,
next_iteration: i + 1,
pending,
consumed_resume_rows,
};
let parked = park_agent(db, conn, job, &checkpoint, &cancel_rx).await;
// Written once parked: the run page offers an answer only for a request that
// `resume_suspended` can take. The park is committed, so a failure here only
// costs the page its banner.
if let (Error::WacSuspended(_), Some(parent_job)) = (&parked, parent_job) {
if let Err(e) =
update_flow_status_module_with_actions(db, parent_job, &actions).await
{
tracing::error!(
"Failed to show the pending request of {}: {e}",
job.id
);
}
}
return Err(parked);
}
// Check cancellation after tool calls complete to avoid a wasted LLM call
if *cancel_rx.borrow() {
return Err(Error::ExecutionErr("Job cancelled".to_string()));
@@ -3068,6 +3305,7 @@ mod tests {
its.insert(key.to_string(), js(expr));
AgentTool {
job_token_scopes: None,
requires_approval: false,
id: id.to_string(),
summary: None,
description: None,
@@ -3096,6 +3334,7 @@ mod tests {
script_tool("b", "y", "keep_me"),
AgentTool {
job_token_scopes: None,
requires_approval: false,
id: "m".to_string(),
summary: None,
description: None,
@@ -3144,6 +3383,7 @@ mod tests {
fn named(id: &str, summary: &str) -> AgentTool {
AgentTool {
job_token_scopes: None,
requires_approval: false,
id: id.to_string(),
summary: Some(summary.to_string()),
description: None,
@@ -3160,6 +3400,7 @@ mod tests {
fn mcp(id: &str, summary: &str, path: &str) -> AgentTool {
AgentTool {
job_token_scopes: None,
requires_approval: false,
id: id.to_string(),
summary: Some(summary.to_string()),
description: None,
@@ -3173,6 +3414,7 @@ mod tests {
fn websearch(id: &str, summary: Option<&str>) -> AgentTool {
AgentTool {
job_token_scopes: None,
requires_approval: false,
id: id.to_string(),
summary: summary.map(str::to_string),
description: None,
+38 -10
View File
@@ -8,9 +8,8 @@ use windmill_queue::{append_logs, CanceledBy, MiniPulledJob};
use crate::{
common::{
build_command_with_isolation, create_args_and_out_file, deno_not_installed_error,
get_reserved_variables,
parse_npm_config, read_file, read_result, start_child_process, OccupancyMetrics,
StreamNotifier,
get_reserved_variables, parse_npm_config, read_file, read_result, start_child_process,
OccupancyMetrics, StreamNotifier,
},
get_proxy_envs_for_lang,
handle_child::handle_child,
@@ -278,6 +277,15 @@ async fn refuse_links_in_shared_dir(job_dir: &str) -> error::Result<()> {
Ok(())
}
// An admin may pass a config of their own, or --no-config, through DENO_FLAGS; deno
// refuses --config next to --no-config or a second --config, so it then stands on its
// own. `-c` also takes its value attached (`-c/path`, `-c=/path`).
fn deno_flags_choose_config(flags: &[String]) -> bool {
flags.iter().any(|f| {
f.starts_with("-c") || f == "--config" || f.starts_with("--config=") || f == "--no-config"
})
}
#[tracing::instrument(level = "trace", skip_all)]
pub async fn handle_deno_job(
requirements_o: Option<&String>,
@@ -508,13 +516,9 @@ try {{
deno_config_path = Some(format!("{job_dir}/deno.json"));
}
}
// An admin may pass a config of their own through DENO_FLAGS; deno refuses it next
// to --no-config or a second --config, so it then stands on its own.
let config_in_deno_flags = DENO_FLAGS.as_ref().is_some_and(|flags| {
flags
.iter()
.any(|f| f == "-c" || f == "--config" || f.starts_with("--config="))
});
let config_in_deno_flags = DENO_FLAGS
.as_ref()
.is_some_and(|flags| deno_flags_choose_config(flags));
//do not cache local dependencies
let child = {
@@ -899,3 +903,27 @@ pub async fn start_worker(
)
.await
}
#[cfg(test)]
mod tests {
use super::deno_flags_choose_config;
fn flags(s: &str) -> Vec<String> {
s.split(' ').map(str::to_string).collect()
}
#[test]
fn deno_flags_config_detection() {
for f in [
"-A -c /etc/deno.json",
"-A -c/etc/deno.json",
"-A -c=/etc/deno.json",
"-A --config /etc/deno.json",
"-A --config=/etc/deno.json",
"-A --no-config",
] {
assert!(deno_flags_choose_config(&flags(f)), "{f}");
}
assert!(!deno_flags_choose_config(&flags("-A --cached-only")));
}
}
+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.849.0";
export const VERSION = "v1.850.0";
export async function login(email: string, password: string): Promise<string> {
return await windmill.UserService.login({
+1
View File
@@ -244,6 +244,7 @@ interface ChatState {
history: 'server' | 'local' | 'none'
loadingMessages: boolean
hasMoreMessages: boolean
runningJobId?: string // the flow job the running turn follows
}
interface ChatMessage {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/chat-sdk",
"version": "1.849.0",
"version": "1.850.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/chat-sdk",
"version": "1.849.0",
"version": "1.850.0",
"license": "Apache-2.0",
"devDependencies": {
"@ai-sdk/react": "^4.0.102",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@windmill-labs/chat-sdk",
"description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app",
"version": "1.849.0",
"version": "1.850.0",
"author": "Ruben Fiszel",
"license": "Apache-2.0",
"homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme",
+2
View File
@@ -305,6 +305,7 @@ class ChatImpl implements Chat {
async #followTurn(turn: Turn, isNew: boolean): Promise<void> {
let nextTurn: RunningTurn | undefined
this.#set({ runningJobId: turn.jobId })
try {
const stopPolling = this.#state.history === 'server' ? this.#startPolling(turn) : () => {}
let result: unknown
@@ -327,6 +328,7 @@ class ChatImpl implements Chat {
this.#failTurn(turn, e)
} finally {
if (this.#turn === turn) this.#turn = undefined
if (this.#state.runningJobId === turn.jobId) this.#set({ runningJobId: undefined })
}
if (nextTurn) await this.resumeTurn(nextTurn)
}
+2
View File
@@ -80,6 +80,8 @@ export interface ChatState {
history: HistoryMode
loadingMessages: boolean
hasMoreMessages: boolean
/** The flow job the running turn follows, once it is known. */
runningJobId?: string
}
export type TokenSource = string | (() => string | Promise<string>)
+11 -5
View File
@@ -9,6 +9,12 @@
// frontend/src/lib/components/flows/agentToolUtils.ts applies the same rules.
const TOOL_NAME_REGEX = /^[a-zA-Z0-9_]+$/;
const WEBSEARCH_ENABLED_NAME = "__wm_web_search";
// The worker's `*_ENABLED_NAME`s: built-in tools, each named by its reserved name.
const BUILTIN_ENABLED_NAMES = new Set([
WEBSEARCH_ENABLED_NAME,
"__wm_question",
]);
const BUILTIN_TOOL_TYPES = new Set(["websearch", "question"]);
// `forbiddenIds` in frontend/src/lib/components/flows/idUtils.ts.
const RESERVED_IDS = new Set([
@@ -134,10 +140,10 @@ function checkAgentTools(
if (!Array.isArray(tools)) return;
const seen = new Map<string, number>();
tools.forEach((tool: any, i: number) => {
// An mcp entry exposes its server's own tool names and a websearch entry is
// named by `WEBSEARCH_ENABLED_NAME`; neither summary reaches the model.
// An mcp entry exposes its server's own tool names and a built-in entry is
// named by its reserved name; neither summary reaches the model.
const kind = tool?.value?.tool_type;
if (kind === "mcp" || kind === "websearch") return;
if (kind === "mcp" || BUILTIN_TOOL_TYPES.has(kind)) return;
const at = `${agent.pointer}/value/tools/${i}`;
const name = tool?.summary;
if (typeof name !== "string" || name.length === 0) {
@@ -152,9 +158,9 @@ function checkAgentTools(
);
return;
}
if (name === WEBSEARCH_ENABLED_NAME) {
if (BUILTIN_ENABLED_NAMES.has(name)) {
report.errors.push(
`${at} tool name '${name}' is reserved for enabling web search`,
`${at} tool name '${name}' is reserved for a built-in tool`,
);
return;
}
+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.849.0";
export const VERSION = "1.850.0";
+8 -5
View File
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -44,7 +44,10 @@ ENV UV_TOOL_DIR=/usr/local/uv
ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
# Install system dependencies
RUN apt-get update \
# Bump to force a fresh apt-get upgrade past the build cache; keep in step with ../Dockerfile.
ARG APT_REFRESH=1
RUN echo "apt refresh ${APT_REFRESH}" \
&& 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 \
+4 -1
View File
@@ -44,7 +44,10 @@ ENV UV_TOOL_DIR=/usr/local/uv
ENV PATH=/usr/local/bin:/root/.local/bin:/tmp/.local/bin:$PATH
# Install system dependencies
RUN apt-get update \
# Bump to force a fresh apt-get upgrade past the build cache; keep in step with ../Dockerfile.
ARG APT_REFRESH=1
RUN echo "apt refresh ${APT_REFRESH}" \
&& 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 \
+5 -4
View File
@@ -4,12 +4,13 @@
anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick"
without any identifying data leaving the instance.
It currently carries 78 registered actions across twenty-nine features (`ai_session`, `ai_chat`,
It currently carries 86 registered actions across thirty-three features (`ai_session`, `ai_chat`,
`ai_fix`, `ai_agent`, `ai_decision`, `ai_agent_eval`, `app_sandbox`, `datatable`, `db_manager`, `flow_editor`,
`flow_run`, `flow_step`, `home`, `header_nav`, `run_form`, `debugger`, `trigger`, `command_script`,
`hub_script`, `usage_meter`, `sso_groups_claim`, `cloud_trial_offer`, `job_token_scopes`,
`worker_draining`, `worker_group`, `worker_resource_stats`, `dev_workspace_isolation`,
`slack_agent`, `variable_expiration`). Nearly all of the
`mcp`, `mcp_workspace`, `hub_script`, `usage_meter`, `sso_groups_claim`, `cloud_trial_offer`,
`job_token_scopes`, `worker_draining`, `worker_group`, `worker_resource_stats`,
`dev_workspace_isolation`, `slack_agent`, `variable_expiration`, `anthropic_federation`,
`deployment_request`). Nearly all of the
product is uninstrumented, so new user-facing work is the opportunity to change that.
## When to instrument
+45
View File
@@ -156,6 +156,51 @@ older editor baked into `memory` stays a fallback behind the run's id until the
*Keep as memory id* or *Use the run's memory id*. In a chat flow it is dropped on save, since the
conversation id always took precedence there.
## Approval and questions
Two things park the agent rather than run: a call of a tool with `requires_approval` (flow module
and MCP tools), and a call of the built-in `question` tool, which the model sees as `ask_question`.
Both live in `windmill-worker/src/ai/pause.rs`.
- **Park.** In a turn that calls such tools, every other call runs right away. The calls that
require approval are gathered into one request, answered together; a question is its own request,
so a turn that also asks for approval has its question refused, as is a second question. The
worker stores the conversation, actions, usage, accumulated stream, next iteration and the
waiting calls in `ai_agent_checkpoint`, then parks the agent's own queue row with
`suspend_wac_parent`: `suspend = 1`, `suspend_until` at the timeout (1800s for an approval, the
question tool's `timeout` otherwise). The job is not completed, its worker is free, and the flow
stays on the step.
- **Answer.** `resume_suspended` with the agent job's id already treats a self-suspended job like a
WAC approval: it inserts the `resume_job` row and decrements, or zeroes on a rejection, the
agent's own `suspend`. The run page offers the form from the pending `approval` or `question`
agent action (Approve/Reject with the calls and their arguments, or the question with its
`choices` and a text answer, sent as a list when `multi_select`), and so does a chat flow's chat,
on the waiting call's tool card while the turn's top-level agent step waits (it polls the turn's
flow job only when a top-level agent step has such a tool or links a saved agent, read off the
flow rather than the stream, which a non-streaming agent leaves empty: the card is then added at
the end of the turn). Both send that request's `call_id` in the payload. There are no signed resume URLs, Slack or Teams messages,
approval page or approver conditions for it: whoever can answer a suspended step without
conditions can answer it.
- **Resume.** The suspended pull takes the row back and `run_agent` finds the checkpoint. In one
transaction it zeroes `suspend`, so `resume_suspended` refuses answers while the agent runs, and
reads the unread `resume_job` rows. The first one naming the parked request (or naming none)
answers it; one naming an earlier request, from a page left open, is read and ignored, and the
agent parks again for the time left. Approved calls then run as any call would; rejected ones,
and those still unanswered at the timeout, are answered as not run, with the approver's comment.
A call whose tool left the roster while parked is answered as no longer available. The agent
then skips memory and the prompt and continues the loop. The step's stream continues at the
offset the first segment reached. Resolution is live here too: a linked agent edited while
parked resumes with its new definition.
- **Cleanup.** The checkpoint row stays while the job is queued, so a job restarted after its
worker died resumes from it, and reads the same answer again: approved calls that ran before
the worker died run again. The monitor deletes rows whose job left the queue; the table has no
foreign key, since one would lock `v2_job_queue` on every write.
- **Limits.** A nested agent tool is never offered the question tool or a tool that requires
approval: it writes no flow status, and its parent holds a worker while it runs. The banner at the
top of the run page shows only a request from a top-level step, as does the chat; one from a step
inside a loop or branch is answered from its graph node. The chat transcript of a chat flow
records neither the request nor its answer.
## Drafts
The agent editor edits the resource through a **per-user resource draft** (`draft` table,
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "@windmill-labs/components",
"version": "1.849.0",
"version": "1.850.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@windmill-labs/components",
"version": "1.849.0",
"version": "1.850.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@windmill-labs/components",
"version": "1.849.0",
"version": "1.850.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",
+20 -4
View File
@@ -3,10 +3,23 @@ import { instanceUi } from '$lib/instanceUi'
const ui = fromStore(instanceUi)
/**
* The groups of external-client URLs the `api_base_url` setting can be narrowed to, through
* `api_base_url_applies_to`. Mirrors `API_BASE_URL_SURFACES` on the server.
*/
export const API_BASE_URL_SURFACES = [
{ value: 'webhooks', label: 'Webhooks and HTTP routes' },
{ value: 'push_triggers', label: 'Push triggers (GCP, Azure)' },
{ value: 'cli', label: 'CLI and API examples' },
{ value: 'mcp', label: 'MCP server' }
] as const
export type ApiBaseUrlSurface = (typeof API_BASE_URL_SURFACES)[number]['value']
/**
* Base of every URL shown for an external client to call (webhooks, HTTP routes, push
* trigger endpoints, the CLI remote): the `api_base_url` instance setting, else the
* origin being browsed. Append `/api/...` to it.
* trigger endpoints, the CLI remote, the MCP server): the `api_base_url` instance setting
* when it applies to `surface`, else the origin being browsed. Append `/api/...` to it.
*
* Not for URLs the editor itself calls or opens (captures, previews, OAuth popups, links
* to pages): those must stay on the browsing origin, where the session cookie lives.
@@ -14,8 +27,11 @@ const ui = fromStore(instanceUi)
* Reactive: the setting lands after page load, so read it inside a `$derived` or the
* template rather than caching the result.
*/
export function apiBaseUrl(): string {
export function apiBaseUrl(surface: ApiBaseUrlSurface): string {
const setting = ui.current?.api_base_url
const configured = typeof setting === 'string' ? setting.trim() : ''
return configured || window.location.origin
const appliesTo = ui.current?.api_base_url_applies_to
// Unset means every surface; the server reads it the same way for MCP.
const applies = !Array.isArray(appliesTo) || appliesTo.includes(surface)
return (applies && configured) || window.location.origin
}
+8 -45
View File
@@ -414,58 +414,21 @@ input[type='time']::-webkit-calendar-picker-indicator {
}
/* Settings search highlight (used by both the drawer and the setup page) */
[data-setting-key] {
[data-setting-key],
[data-setting] {
transition: outline 0.8s ease;
outline: 2px solid transparent;
outline-offset: -2px;
}
[data-setting-key].setting-highlight {
[data-setting].setting-highlight {
outline-offset: 4px;
border-radius: 4px;
}
[data-setting-key].setting-highlight,
[data-setting].setting-highlight {
outline: 2px solid rgb(var(--color-border-accent, 59 130 246));
}
.svelte-flow__edges {
z-index: -10;
}
/* The composer's glide, defined once: the Home ↔ AI Sessions view transition below and the
in-page move on a session's first message (the spacer under AIChatDisplay's composer) both
read it. */
:root {
--composer-glide-duration: 380ms;
--composer-glide-easing: cubic-bezier(0.2, 0.8, 0.2, 1);
}
/* Home ↔ AI Sessions (homeSessionsTransition.svelte.ts). The composer glides from where it
was to where it lands; everything else, Home's list and the session's greeting included, is
the page cross-fading, kept short. The pseudo-elements hang off :root itself, so no space
before `::view-transition-*`: with one, the selector looks for a descendant and never
matches. */
:root[data-home-sessions-transition]::view-transition-group(composer) {
animation-duration: var(--composer-glide-duration);
animation-timing-function: var(--composer-glide-easing);
}
:root[data-home-sessions-transition]::view-transition-old(root),
:root[data-home-sessions-transition]::view-transition-new(root) {
animation-duration: 150ms;
}
/* The session's greeting sits above where the composer lands, so it waits for the composer:
shown while the composer is still gliding, it reads as text placed in mid-air. It starts as
the composer settles and rises into place on the same easing, so the two read as one motion.
Leaving, it goes with the page. */
:root[data-home-sessions-transition='enter']::view-transition-new(greeting) {
animation: greeting-in 260ms var(--composer-glide-easing)
calc(var(--composer-glide-duration) * 0.6) both;
}
:root[data-home-sessions-transition]::view-transition-old(greeting) {
animation-duration: 150ms;
}
@keyframes greeting-in {
from {
opacity: 0;
transform: translateY(4px);
filter: blur(2px);
}
}
:root[data-home-sessions-transition='fade'] * {
view-transition-name: none !important;
}
@@ -0,0 +1,122 @@
<script lang="ts">
import { answerAgentRequest, type AgentRequest } from './agentRequest'
import { Button } from './common'
import TextInput from './text_input/TextInput.svelte'
import ApprovalActions from './approvals/ApprovalActions.svelte'
import { displayDate } from '$lib/utils'
interface Props {
/** The workspace the job ran in, never the navigation workspace. */
workspaceId: string
/** The agent step's own job: it is the one parked, so it is the one answered. */
agentJobId: string
/** Its call id goes with the answer, so a page left open on an earlier request cannot
* answer the one the agent waits on now. */
request: AgentRequest
/** Whether the request still waits for an answer. */
pending: boolean
}
let { workspaceId, agentJobId, request, pending }: Props = $props()
let text = $state('')
let picked = $state<string[]>([])
let answered = $state<boolean | undefined>(undefined)
const choices = $derived(request.type === 'question' ? (request.choices ?? []) : [])
const multiSelect = $derived(request.type === 'question' && !!request.multi_select)
// A typed answer joins the picked choices when several can be picked, and replaces the pick
// otherwise.
const answer = $derived.by((): string | string[] => {
const typed = text.trim()
if (multiSelect) return typed ? [...picked, typed] : picked
return typed || picked[0] || ''
})
function pick(choice: string) {
if (multiSelect) {
picked = picked.includes(choice) ? picked.filter((c) => c !== choice) : [...picked, choice]
} else {
picked = [choice]
text = ''
}
}
async function onAnswer(approved: boolean) {
const value = request.type === 'question' ? answer : text.trim()
await answerAgentRequest(workspaceId, { agentJobId, action: request }, approved, value)
answered = approved
}
</script>
<div class="flex flex-col gap-2 w-full text-xs">
{#if request.type === 'question'}
<div class="text-emphasis font-semibold">The agent {pending ? 'asks' : 'asked'} a question</div>
<div class="text-primary whitespace-pre-wrap">{request.question}</div>
{:else}
<div class="text-emphasis font-semibold">
The agent {pending ? 'asks' : 'asked'} to run {request.calls.length === 1
? 'this call'
: 'these calls'}
</div>
{#each request.calls as call, i (i)}
<div class="flex flex-col gap-1">
<span class="font-mono text-primary">{call.function_name}</span>
<pre
class="text-2xs bg-surface-secondary rounded-md p-2 overflow-auto max-h-40 whitespace-pre-wrap break-all"
>{JSON.stringify(call.arguments, null, 2)}</pre
>
</div>
{/each}
{/if}
{#if answered !== undefined}
<div class="text-secondary">
{#if request.type === 'question'}
{answered ? 'Answered' : 'Declined'}, the agent resumes shortly.
{:else}
{answered ? 'Approved' : 'Rejected'}, the agent resumes shortly.
{/if}
</div>
{:else if pending}
<div class="text-hint">Expires {displayDate(request.expires_at)}</div>
{#if choices.length > 0}
<div class="flex flex-wrap gap-2">
{#each choices as choice (choice)}
<Button
variant="default"
unifiedSize="sm"
selected={picked.includes(choice)}
onClick={() => pick(choice)}
>
{choice}
</Button>
{/each}
</div>
{/if}
<TextInput
bind:value={text}
inputProps={{
placeholder:
request.type === 'question'
? choices.length > 0
? 'Or answer in your own words'
: 'Your answer'
: 'Reason for the agent, if you reject (optional)',
oninput: () => {
if (!multiSelect) picked = []
}
}}
size="sm"
/>
{#if request.type === 'question'}
<ApprovalActions
approveLabel="Send answer"
rejectTitle="Decline to answer"
approveDisabled={answer.length === 0}
{onAnswer}
/>
{:else}
<ApprovalActions approveLabel="Approve" rejectTitle="Reject" {onAnswer} />
{/if}
{/if}
</div>
@@ -6,7 +6,7 @@
const operatingWorkspace = useOperatingWorkspace()
let url = $derived(`${apiBaseUrl()}/`)
let url = $derived(`${apiBaseUrl('cli')}/`)
</script>
<Section label="CLI quick setup">
@@ -65,6 +65,8 @@
import { deepEqual } from 'fast-equals'
import WorkspaceDeployLayout from './WorkspaceDeployLayout.svelte'
import DeploymentRequestPanel from './deploymentRequest/DeploymentRequestPanel.svelte'
import AiReview from './deploymentRequest/AiReview.svelte'
import { copilotInfo } from '$lib/aiStore'
import { userStore } from '$lib/stores'
import CompareModeToggle, { type CompareMode } from './CompareModeToggle.svelte'
import CompareTargetPicker from './CompareTargetPicker.svelte'
@@ -430,6 +432,7 @@
)
let selectedItems = $state<string[]>([])
let selectedDiffs = $derived(selectableDiffs.filter((d) => selectedItems.includes(getItemKey(d))))
// Selected items that carry a pending draft. They're opt-in (excluded from the
// default selection), so a non-empty list means the user explicitly picked an
@@ -1807,8 +1810,18 @@
{#snippet footer()}
{#if !nothingToAct}
<div class="flex items-center justify-between">
<div></div>
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<!-- The request form and an open request carry their own review. -->
{#if mergeIntoParent && $copilotInfo.enabled && !hasOpenDeploymentRequest && !deploymentRequestPanel?.isDialogOpen()}
<AiReview
diffs={selectedDiffs}
forkWorkspaceId={currentWorkspaceId}
{parentWorkspaceId}
source="selection"
/>
{/if}
</div>
<div class="flex flex-col items-end gap-2">
<!-- Always show the deploy footer: only the items visible to the user are
@@ -1885,6 +1898,7 @@
{parentWorkspaceId}
currentUsername={$userStore?.username ?? ''}
isAdmin={$userStore?.is_admin ?? false}
diffs={comparison?.diffs.filter((d) => diffActionableInDirection(d, true)) ?? []}
onStateChange={(open) => {
hasOpenDeploymentRequest = open
}}
@@ -48,11 +48,14 @@
AI_TOOL_MESSAGE_PREFIX,
AI_MCP_TOOL_CALL_PREFIX,
AI_WEBSEARCH_PREFIX,
AI_APPROVAL_PREFIX,
AI_QUESTION_PREFIX,
getAgentActionStateId,
getToolCallId
} from './graph/renderers/nodes/AIToolNode.svelte'
import JobAssetsViewer from './assets/JobAssetsViewer.svelte'
import McpToolCallDetails from './McpToolCallDetails.svelte'
import AgentApprovalRequest from './AgentApprovalRequest.svelte'
import GfmMarkdown from './GfmMarkdown.svelte'
import JobOtelTraces from './JobOtelTraces.svelte'
import JobDetailHeader from './runs/JobDetailHeader.svelte'
@@ -780,6 +783,10 @@
job_id: action.job_id,
type: success != undefined ? (success ? 'Success' : 'Failure') : 'InProgress'
})
} else if (action.type == 'approval' || action.type == 'question') {
setModuleState(stateId, {
type: success != undefined ? (success ? 'Success' : 'Failure') : 'WaitingForEvents'
})
} else if (action.type == 'mcp_tool_call') {
setModuleState(stateId, {
type: success != undefined ? (success ? 'Success' : 'Failure') : 'InProgress'
@@ -2127,6 +2134,25 @@
{/if}
{:else if selectedNode?.startsWith(AI_WEBSEARCH_PREFIX)}
<Alert type="info" title="Web search was used in this step" />
{:else if selectedNode?.startsWith(AI_APPROVAL_PREFIX) || selectedNode?.startsWith(AI_QUESTION_PREFIX)}
{@const [, agentModuleId, actionIndex] = selectedNode.split('-')}
{@const agentNode = localModuleStates?.[agentModuleId]}
{@const approvalIndex = parseInt(actionIndex)}
{@const approval = agentNode?.agent_actions?.[approvalIndex]}
{#if (approval?.type === 'approval' || approval?.type === 'question') && agentNode?.job_id}
<div class="p-4">
<!-- One form per request: its answered state must not carry over to the next. -->
{#key `${agentNode.job_id}-${approval.call_id}`}
<AgentApprovalRequest
workspaceId={job.workspace_id ?? ''}
agentJobId={agentNode.job_id}
request={approval}
pending={job.type === 'QueuedJob' &&
agentNode?.agent_actions_success?.[approvalIndex] === undefined}
/>
{/key}
</div>
{/if}
{:else if selectedNode}
{@const node = localModuleStates[selectedNode]}
{#if selectedNode == 'end'}
@@ -1,11 +1,10 @@
<script lang="ts">
import { mergeSchema } from '$lib/common'
import { type Job, JobService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { ExternalLink, X } from 'lucide-svelte'
import { ExternalLink } from 'lucide-svelte'
import DisplayResult from './DisplayResult.svelte'
import Tooltip from './Tooltip.svelte'
import { Button } from './common'
import ApprovalActions from './approvals/ApprovalActions.svelte'
import SchemaForm from './SchemaForm.svelte'
import { twMerge } from 'tailwind-merge'
import { untrack } from 'svelte'
@@ -54,7 +53,6 @@
default_payload = args
approvalPageUrl = job_result?.['approvalPage']
actionTaken = false
hide_cancel = job?.raw_flow?.modules?.[approvalStep]?.suspend?.hide_cancel ?? false
schema = mergeSchema(
job?.raw_flow?.modules?.[approvalStep]?.suspend?.resume_form?.schema ?? {},
@@ -62,31 +60,21 @@
)
}
let loading = $state(false)
let actionTaken = $state(false)
async function continu(approve: boolean) {
if (!workspaceId) {
sendUserToast('Cannot resume: the job has no workspace', true)
return
}
loading = true
try {
await JobService.resumeSuspended({
workspace: workspaceId,
jobId: job?.id ?? '',
requestBody: {
payload: approve ? (default_payload as any) : undefined,
approved: approve
}
})
actionTaken = true
onAction?.(approve)
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? 'Failed', true)
} finally {
loading = false
throw new Error('Cannot resume: the job has no workspace')
}
await JobService.resumeSuspended({
workspace: workspaceId,
jobId: job?.id ?? '',
requestBody: {
payload: approve ? (default_payload as any) : undefined,
approved: approve
}
})
onAction?.(approve)
}
let approvalStep = $derived((job?.flow_status?.step ?? 1) - 1)
// Everything this panel shows (description, resume form enums, approval page) is
// fetched from the suspended job — a recording carries none of it — and Resume /
@@ -108,34 +96,16 @@
<DisplayResult {workspaceId} noControls result={description} language={job?.language} />
<div class="mt-2"></div>
{/if}
<div>
<div class={twMerge('flex gap-2 items-center', light ? 'flex-col' : 'flex-row ')}>
{#if !hide_cancel}
<div>
<Button
title="Cancel the step"
iconOnly
startIcon={{ icon: X }}
variant="default"
disabled={loading || actionTaken}
destructive
unifiedSize="md"
on:click={() => continu(false)}
/>
</div>
{/if}
<div>
<Button
variant="accent"
onClick={() => continu(true)}
disabled={loading || actionTaken}
unifiedSize="md"
>
Resume
<Tooltip class="text-white">Resume or approve this suspended step</Tooltip>
</Button>
</div>
<!-- A new approval step gets usable buttons again. -->
{#key job?.flow_status?.modules?.[approvalStep]?.job}
<ApprovalActions
approveLabel="Resume"
approveTooltip="Resume or approve this suspended step"
rejectTitle="Cancel the step"
hideReject={hide_cancel}
{light}
onAnswer={continu}
>
{#if approvalPageUrl}
<a
href={approvalPageUrl}
@@ -161,7 +131,7 @@
variable
</Tooltip>
{/if}
</div>
</div>
</ApprovalActions>
{/key}
</div>
{/if}
@@ -29,6 +29,7 @@
import InstancePgSettings from './instanceSettings/InstancePgSettings.svelte'
import GhesAppSettings from './instanceSettings/GhesAppSettings.svelte'
import WebhookBaseUrlSetting from './instanceSettings/WebhookBaseUrlSetting.svelte'
import ApiBaseUrlSetting from './instanceSettings/ApiBaseUrlSetting.svelte'
import WsConnectivityTest from './instanceSettings/WsConnectivityTest.svelte'
import InstanceBannerSetting from './instanceSettings/InstanceBannerSetting.svelte'
import AccentColorSetting from './instanceSettings/AccentColorSetting.svelte'
@@ -885,6 +886,12 @@
<InstancePgSettings {values} {markSettingSaved} disabled={loading} />
{:else if setting.fieldType == 'github_enterprise_app'}
<GhesAppSettings {values} disabled={loading || !$enterpriseLicense} />
{:else if setting.fieldType == 'api_base_url'}
<ApiBaseUrlSetting
{values}
placeholder={setting.placeholder}
disabled={loading || !$enterpriseLicense}
/>
{:else if setting.fieldType == 'webhook_base_url'}
<WebhookBaseUrlSetting {values} disabled={loading || !$enterpriseLicense} />
{:else if setting.fieldType == 'ws_connectivity'}
@@ -3,7 +3,8 @@
scimSamlSetting,
settings,
settingsKeys,
instanceSettingsSaved
instanceSettingsSaved,
type Setting
} from './instanceSettings'
import { Alert, Button, Tab, TabContent, Tabs } from '$lib/components/common'
import { SettingService, SettingsService } from '$lib/gen'
@@ -502,9 +503,10 @@
const categorySettings = getSettingsForCategory(category)
const obj: Record<string, any> = {}
for (const s of categorySettings) {
const normalized = normalizeValue(vals[s.key], s.key)
if (normalized !== undefined) {
obj[s.key] = vals[s.key]
for (const key of [s.key, ...(s.companionKeys ?? [])]) {
if (normalizeValue(vals[key], key) !== undefined) {
obj[key] = vals[key]
}
}
}
if (category === 'Auth/OAuth/SAML') {
@@ -570,11 +572,16 @@
return dirtyCategories[category] ?? false
}
/** The global settings a field owns: its `key` and any companion keys it also writes. */
function settingKeys(s: Setting): string[] {
return [s.key, ...(s.companionKeys ?? [])]
}
export function discardCategory(category: string) {
const categorySettings = getSettingsForCategory(category)
for (const s of categorySettings) {
const v = initialValues[s.key]
$values[s.key] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined
for (const key of categorySettings.flatMap(settingKeys)) {
const v = initialValues[key]
$values[key] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined
}
if (category === 'Auth/OAuth/SAML') {
oauths = JSON.parse(JSON.stringify(initialOauths))
@@ -653,10 +660,18 @@
})
)
const companionKeys = categorySettings
.filter((x) => x.storage === 'setting')
.flatMap((x) => x.companionKeys ?? [])
.filter((key) => !deepEqual(initialValues?.[key], $values?.[key]))
for (const key of companionKeys) {
await SettingService.setGlobal({ key, requestBody: { value: $values?.[key] ?? null } })
}
// Update only the saved category's initial values
for (const s of categorySettings) {
const v = $values[s.key]
initialValues[s.key] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined
for (const key of categorySettings.flatMap(settingKeys)) {
const v = $values[key]
initialValues[key] = v !== undefined ? JSON.parse(JSON.stringify(v)) : undefined
}
instanceSettingsSaved.update((n) => n + 1)
if (categorySettings.some((s) => s.key === 'base_url')) {
+1 -1
View File
@@ -247,7 +247,7 @@ ${Object.entries(displayArgs)
If using the wmill client in your code, set the following env variables:
<pre
><code
>BASE_URL="{apiBaseUrl()}"
>BASE_URL="{apiBaseUrl('cli')}"
WM_TOKEN="{'<TOKEN>'}"</code
></pre
>
+2 -1
View File
@@ -2,6 +2,7 @@
import { displayDate, msToReadableTime } from '$lib/utils'
import type { CompletedJob, QueuedJob } from '$lib/gen'
import Badge from './common/badge/Badge.svelte'
import { isSuspended } from './agentRequest'
import { forLater } from '$lib/forLater'
import { Circle } from 'lucide-svelte'
import NoWorkerWithTagWarning from './runs/NoWorkerWithTagWarning.svelte'
@@ -58,7 +59,7 @@
/>
{/if}
</Badge>
{:else if job && 'running' in job && job.running && job.suspend}
{:else if job && 'running' in job && job.running && isSuspended(job)}
<div>
<Badge {large} color="violet">
Suspended
@@ -1,19 +1,16 @@
<script lang="ts">
import Toggle from './Toggle.svelte'
import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from './common/toggleButton-v2/ToggleButton.svelte'
import ScopeSelector from './settings/ScopeSelector.svelte'
import SettingToggle from './itemSettings/SettingToggle.svelte'
interface Props {
/** `null`/`undefined`: the job token has the full permissions of the identity it runs as. */
value: string[] | null | undefined
kind: 'script' | 'flow' | 'step'
/** Match the toggles listed around it. */
size?: 'xs' | 'sm'
textClass?: string
}
let { value = $bindable(), kind, size = 'sm', textClass }: Props = $props()
let { value = $bindable(), kind }: Props = $props()
const OIDC_ONLY = ['oidc:write']
@@ -32,58 +29,48 @@
function setMode(next: Mode) {
customPicked = next === 'custom'
value =
next === 'oidc_only' ? [...OIDC_ONLY] : next === 'no_api' ? [] : [...(value ?? [])]
value = next === 'oidc_only' ? [...OIDC_ONLY] : next === 'no_api' ? [] : [...(value ?? [])]
}
</script>
<div class="flex flex-col gap-2">
<Toggle
{size}
{textClass}
checked={value != null}
on:change={() => {
if (value != null) {
value = null
} else {
setMode('oidc_only')
}
}}
options={{
right: 'Restrict the job token',
rightTooltip:
"Limit what the WM_TOKEN of this " +
kind +
"'s jobs can do through the API, e.g. only mint an OIDC token. Use it for code that handles untrusted input, such as AI agents."
}}
/>
{#if value != null}
<ToggleButtonGroup selected={mode} onSelected={(v) => setMode(v)}>
{#snippet children({ item })}
<ToggleButton value="oidc_only" label="OIDC token only" {item} />
<ToggleButton value="no_api" label="No API access" {item} />
<ToggleButton value="custom" label="Custom" {item} />
{/snippet}
</ToggleButtonGroup>
{#if mode === 'custom'}
<ScopeSelector
bind:selectedScopes={() => value ?? [], (scopes) => (value = [...scopes])}
emptyLabel="No scopes selected. The job token can only reach its own job."
/>
{/if}
<p class="text-2xs text-secondary">
{#if kind === 'step'}
The <code>WM_TOKEN</code> of every job this step runs can only do what these scopes allow, on top
of the flow's own restriction. Loop and branch bodies, and AI agent tools, inherit it.
{:else}
The <code>WM_TOKEN</code> of every job of this {kind} can only do what these scopes allow. Jobs
it starts, {kind === 'flow' ? 'its steps, ' : ''}and AI agent tools inherit the restriction.
{/if}
Variables and resources passed as <code>$var:</code>/<code>$res:</code> inputs, an AI agent's provider
resource, relative imports and object storage each need a read scope, and script state
(<code>getState</code>/<code>setState</code>) needs <code>resources:write</code>. Write scopes on scripts,
flows, schedules or triggers let a job escape the restriction. Jobs with a restricted token never
run on dedicated workers.
</p>
<SettingToggle
setting="job-token"
label="Restrict the job token"
tooltip={`Limit what the WM_TOKEN of this ${kind}'s jobs can do through the API, e.g. only mint an OIDC token. Use it for code that handles untrusted input, such as AI agents.`}
checked={value != null}
onchange={(on) => {
if (on) {
setMode('oidc_only')
} else {
value = null
}
}}
>
<ToggleButtonGroup selected={mode} onSelected={(v) => setMode(v)}>
{#snippet children({ item })}
<ToggleButton value="oidc_only" label="OIDC token only" {item} />
<ToggleButton value="no_api" label="No API access" {item} />
<ToggleButton value="custom" label="Custom" {item} />
{/snippet}
</ToggleButtonGroup>
{#if mode === 'custom'}
<ScopeSelector
bind:selectedScopes={() => value ?? [], (scopes) => (value = [...scopes])}
emptyLabel="No scopes selected. The job token can only reach its own job."
/>
{/if}
</div>
<p class="text-2xs text-secondary">
{#if kind === 'step'}
The <code>WM_TOKEN</code> of every job this step runs can only do what these scopes allow, on top
of the flow's own restriction. Loop and branch bodies, and AI agent tools, inherit it.
{:else}
The <code>WM_TOKEN</code> of every job of this {kind} can only do what these scopes allow. Jobs
it starts, {kind === 'flow' ? 'its steps, ' : ''}and AI agent tools inherit the restriction.
{/if}
Variables and resources passed as <code>$var:</code>/<code>$res:</code> inputs, an AI agent's
provider resource, relative imports and object storage each need a read scope, and script state
(<code>getState</code>/<code>setState</code>) needs <code>resources:write</code>. Write scopes
on scripts, flows, schedules or triggers let a job escape the restriction. Jobs with a
restricted token never run on dedicated workers.
</p>
</SettingToggle>
@@ -1,8 +1,8 @@
<script lang="ts">
/**
* Badge for a workspace locked against direct edits, with the rule names, the route into the dev
* workspace and the admin bypass in its popover. Renders nothing for operators, who have no edit
* affordance for it to explain.
* Badge for a workspace locked against direct edits, with the rule names and the route into the
* dev workspace in its popover, and the admin bypass toggle beside it. Renders nothing for
* operators, who have no edit affordance for it to explain.
*/
import { userWorkspaces, workspaceStore } from '$lib/stores'
import { resource } from 'runed'
@@ -106,7 +106,7 @@
</script>
{#if ($roleCaps.code || $roleCaps.flows !== 'none' || $roleCaps.apps !== 'none') && activeDeployRulesets.length > 0}
<div class="my-2">
<div class="my-2 flex flex-wrap items-center gap-3">
<Popover
placement="bottom-start"
class={triggerClass}
@@ -152,15 +152,17 @@
{editAdvice}
</p>
{/if}
{#if canBypass && !sessionReadOnly}
<Toggle
size="xs"
bind:checked={overrideChecked}
options={{ right: 'Bypass restriction' }}
/>
{/if}
</div>
{/snippet}
</Popover>
<!-- Outside the popover: the badge reads as plain text, so a bypass behind it goes unfound. -->
{#if canBypass && !sessionReadOnly}
<Toggle
size="2xs"
color="red"
bind:checked={overrideChecked}
options={{ right: 'Bypass restriction' }}
/>
{/if}
</div>
{/if}
@@ -1,283 +0,0 @@
<script lang="ts">
import { createBubbler } from 'svelte/legacy'
import Section from '$lib/components/Section.svelte'
import Label from '$lib/components/Label.svelte'
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import Alert from '$lib/components/common/alert/Alert.svelte'
import { SecondsInput } from '$lib/components/common'
import WorkerTagPicker from '$lib/components/WorkerTagPicker.svelte'
import DebounceLimit from '$lib/components/flows/DebounceLimit.svelte'
import { enterpriseLicense } from '$lib/stores'
import { isCloudHosted } from '$lib/cloud'
import type { Schema } from '$lib/common'
import type { ScriptAdvancedSettingsFields } from './scriptSettings'
const bubble = createBubbler()
interface Props {
script: ScriptAdvancedSettingsFields
// Workspace to read worker tags from (defaults to $workspaceStore inside the picker).
workspaceId?: string | undefined
}
let { script = $bindable(), workspaceId = undefined }: Props = $props()
</script>
<div class="flex flex-col gap-8">
<Section label="Worker group tag (queue)">
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/worker_groups">
The script will be executed on a worker configured to listen to this worker group tag
(queue). For instance, you could setup an "highmem", or "gpu" tag.
</Tooltip>
{/snippet}
<WorkerTagPicker bind:tag={script.tag} {workspaceId} />
</Section>
<Section label="Concurrency limits" eeOnly>
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/concurrency_limits">
Allowed concurrency within a given timeframe
</Tooltip>
{/snippet}
<Toggle
size="sm"
checked={Boolean(script.concurrent_limit)}
on:change={() => {
if (script.concurrent_limit && script.concurrent_limit != undefined) {
script.concurrent_limit = undefined
script.concurrency_time_window_s = undefined
script.concurrency_key = undefined
} else {
script.concurrent_limit = 1
}
}}
options={{ right: 'Concurrency limits' }}
/>
{#if Boolean(script.concurrent_limit)}
<div class="flex flex-col gap-4 mt-2">
<Label label="Max number of executions within the time window">
<div class="flex flex-row gap-2 max-w-sm whitespace-nowrap">
<input
disabled={!$enterpriseLicense}
bind:value={script.concurrent_limit}
type="number"
/>
</div>
</Label>
<Label label="Time window in seconds">
<SecondsInput
disabled={!$enterpriseLicense}
bind:seconds={script.concurrency_time_window_s}
/>
</Label>
<Label label="Custom concurrency key (optional)">
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/concurrency_limits#custom-concurrency-key"
>
Concurrency keys are global, you can have them be workspace specific using the
variable `$workspace`. You can also use an argument's value using `$args[name_of_arg]`</Tooltip
>
{/snippet}
<input
disabled={!$enterpriseLicense}
type="text"
bind:value={script.concurrency_key}
placeholder={`$workspace/script/${script.path ?? ''}-$args[foo]`}
/>
</Label>
</div>
{/if}
</Section>
<Section label="Cache">
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/caching">
Cache the results for each possible inputs
</Tooltip>
{/snippet}
<div class="flex gap-2 shrink flex-col">
<Toggle
size="sm"
bind:checked={() => !!script.cache_ttl, (v) => (script.cache_ttl = v ? 300 : undefined)}
options={{ right: 'Cache the results for each possible inputs' }}
/>
{#if script.cache_ttl}
<div class="text-2xs text-secondary">How long to keep the cache valid</div>
<SecondsInput bind:seconds={script.cache_ttl} />
<Toggle
size="2xs"
bind:checked={
() => script.cache_ignore_s3_path, (v) => (script.cache_ignore_s3_path = v || undefined)
}
options={{
right: 'Ignore S3 Object paths for caching purposes',
rightTooltip:
'If two S3 objects passed as input have the same content, they will hit the same cache entry, regardless of their path.'
}}
/>
{/if}
</div>
</Section>
<Section label="Timeout">
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/script_editor/settings#timeout">
Add a custom timeout for this script
</Tooltip>
{/snippet}
<div class="flex gap-2 shrink flex-col">
<Toggle
size="sm"
checked={Boolean(script.timeout)}
on:change={() => {
if (script.timeout && script.timeout != undefined) {
script.timeout = undefined
} else {
script.timeout = 300
}
}}
options={{ right: 'Add a custom timeout for this script' }}
/>
{#if Boolean(script.timeout)}
<span class="text-xs font-semibold text-emphasis leading-none mt-2">Timeout duration</span>
<SecondsInput bind:seconds={script.timeout} />
{/if}
</div>
</Section>
<Section label="Debouncing">
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/job_debouncing">
Debounce Jobs
</Tooltip>
{/snippet}
<DebounceLimit
size="sm"
bind:debounce_delay_s={script.debounce_delay_s}
bind:debounce_key={script.debounce_key}
bind:debounce_args_to_accumulate={script.debounce_args_to_accumulate}
bind:max_total_debouncing_time={script.max_total_debouncing_time}
bind:max_total_debounces_amount={script.max_total_debounces_amount}
schema={script.schema as Schema}
placeholder={`$workspace/script/${script.path ?? ''}-$args[foo]`}
/>
</Section>
<Section label="Perpetual script">
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/script_editor/perpetual_scripts">
Restart the script upon ending unless cancelled
</Tooltip>
{/snippet}
<Toggle
size="sm"
checked={Boolean(script.restart_unless_cancelled)}
on:change={() => {
script.restart_unless_cancelled = script.restart_unless_cancelled ? undefined : true
}}
options={{ right: 'Restart upon ending unless cancelled' }}
/>
</Section>
<Section label="Dedicated workers" eeOnly>
{#snippet header()}
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/dedicated_workers">
In this mode, the script is meant to be run on dedicated workers that run the script at
native speed. Can reach &gt;1500rps per dedicated worker. Only available on enterprise
edition and for Python3, Deno, Bun and Bunnative.
</Tooltip>
{/snippet}
<Toggle
disabled={!$enterpriseLicense ||
isCloudHosted() ||
(script.language != 'bun' &&
script.language != 'bunnative' &&
script.language != 'python3' &&
script.language != 'deno')}
size="sm"
checked={Boolean(script.dedicated_worker)}
on:change={() => {
script.dedicated_worker = script.dedicated_worker ? undefined : true
}}
options={{ right: 'Script is run on dedicated workers' }}
/>
{#if script.dedicated_worker}
<div class="py-2">
<Alert type="info" title="Require dedicated workers">
A worker group needs to be configured to listen to this script. Select it in the dedicated
workers section of the worker group configuration.
</Alert>
</div>
{/if}
</Section>
<Section label="Delete after completion" eeOnly>
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/script_editor/settings#delete-after-use"
>
The logs, arguments and results of the job will be completely deleted from Windmill after
the specified delay once it is complete. Set to 0 for immediate deletion. The deletion is
irreversible. This settings ONLY applies when the script is used within a flow or triggered
synchronously.
</Tooltip>
{/snippet}
<div class="flex gap-2 shrink flex-col">
<Toggle
disabled={!$enterpriseLicense}
size="sm"
checked={script.delete_after_secs != null}
on:change={() => {
script.delete_after_secs = script.delete_after_secs != null ? undefined : 0
}}
options={{ right: 'Delete logs, arguments and results after completion' }}
/>
{#if script.delete_after_secs != null}
<SecondsInput bind:seconds={script.delete_after_secs} disabled={!$enterpriseLicense} />
{/if}
</div>
</Section>
{#if !isCloudHosted()}
<Section label="High priority script" eeOnly>
{#snippet header()}
<Tooltip
documentationLink="https://www.windmill.dev/docs/core_concepts/jobs#high-priority-jobs"
>
Jobs from script labeled as high priority take precedence over the other jobs when in the
jobs queue.
{#if !$enterpriseLicense}This is a feature only available on enterprise edition.{/if}
</Tooltip>
{/snippet}
<Toggle
disabled={!$enterpriseLicense || isCloudHosted()}
size="sm"
checked={script.priority !== undefined && script.priority > 0}
on:change={() => {
script.priority = script.priority ? undefined : 100
}}
options={{ right: 'Label as high priority' }}
>
{#snippet right()}
<input
type="number"
class="!w-16 ml-4"
disabled={script.priority === undefined}
bind:value={script.priority}
onfocus={bubble('focus')}
onchange={() => {
if (script.priority && script.priority > 100) {
script.priority = 100
} else if (script.priority && script.priority < 0) {
script.priority = 0
}
}}
/>
{/snippet}
</Toggle>
</Section>
{/if}
</div>
File diff suppressed because it is too large Load Diff
@@ -1353,7 +1353,7 @@
<p class="text-xs text-tertiary">Requires a superadmin token.</p>
<pre
class="mt-1 p-2 bg-surface-secondary rounded text-xs overflow-x-auto whitespace-pre-wrap break-all"
>curl -X POST '{apiBaseUrl()}/api/configs/update/worker__{name}' \
>curl -X POST '{apiBaseUrl('cli')}/api/configs/update/worker__{name}' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer &lt;token&gt;' \
-d '{JSON.stringify(cleanWorkerGroupConfig(nconfig ?? {}), null, 2)}'</pre
@@ -0,0 +1,60 @@
import { JobService, type FlowStatus, type FlowStatusModule } from '$lib/gen'
export type AgentRequest = Extract<
NonNullable<FlowStatusModule['agent_actions']>[number],
{ type: 'approval' | 'question' }
>
/** The approval or question an agent step's actions end on, while nobody has answered it. */
export function waitingRequest(
mod: Pick<FlowStatusModule, 'agent_actions' | 'agent_actions_success'>
) {
const actions = mod.agent_actions
const last = actions?.[actions.length - 1]
if (
!last ||
(last.type !== 'approval' && last.type !== 'question') ||
mod.agent_actions_success?.[actions!.length - 1] !== undefined
) {
return undefined
}
return last
}
/** The approval or question a flow's current agent step is parked on: the step stays in
* progress while its own job waits. */
export function pendingAgentApproval(job: { flow_status?: FlowStatus } | undefined) {
const mod = job?.flow_status?.modules?.[job?.flow_status?.step]
if (mod?.type !== 'InProgress' || !mod.job) return undefined
const action = waitingRequest(mod)
return action && { agentJobId: mod.job, action }
}
/** Suspended, as the run's badge and icon say: an approval step, or an agent waiting on a person. */
export function isSuspended(job: { suspend?: number; flow_status?: FlowStatus }) {
return !!job.suspend || !!pendingAgentApproval(job)
}
export type PendingAgentRequest = NonNullable<ReturnType<typeof pendingAgentApproval>>
/** `answer` is the question's answer, a list when several choices were picked, or the reason given
* with a rejection. Its call id goes with the answer, so a page left open on an earlier request
* cannot answer the one waiting now. */
export async function answerAgentRequest(
workspace: string,
{ agentJobId, action }: PendingAgentRequest,
approved: boolean,
answer?: string | string[]
) {
await JobService.resumeSuspended({
workspace,
jobId: agentJobId,
requestBody: {
approved,
payload: {
call_id: action.call_id,
...(answer?.length ? { [action.type === 'question' ? 'answer' : 'comment']: answer } : {})
}
}
})
}
@@ -90,6 +90,15 @@ export function buildAgentTrace(messages: AgentMessage[]): AgentTraceEntry[] {
entries.push({ kind: 'search' })
continue
}
if (action?.type === 'question') {
entries.push({
kind: 'tool',
name: 'Ask a question',
args: message.tool_call_id ? argsByCallId.get(message.tool_call_id) : undefined,
result: contentText(message.content)
})
continue
}
// Every message this run produced is tagged, including the agent narrating
// its next move and its final answer. Untagged ones are the prompt, the
// question, or a previous turn replayed out of memory — history loses its
@@ -0,0 +1,76 @@
<script lang="ts">
import type { Snippet } from 'svelte'
import { X } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { sendUserToast } from '$lib/toast'
import { Button } from '../common'
import Tooltip from '../Tooltip.svelte'
interface Props {
approveLabel: string
approveTooltip?: string
rejectTitle: string
hideReject?: boolean
/** Stacks the buttons, for the graph's popover. */
light?: boolean
/** Holds the approve button back, e.g. until an answer is typed. */
approveDisabled?: boolean
/** Throws to report a failure; the buttons stay usable then. */
onAnswer: (approved: boolean) => Promise<void>
/** Shown after the buttons, on the same row. */
children?: Snippet
}
let {
approveLabel,
approveTooltip,
rejectTitle,
hideReject = false,
light = false,
approveDisabled = false,
onAnswer,
children
}: Props = $props()
let loading = $state(false)
let answered = $state(false)
async function answer(approved: boolean) {
loading = true
try {
await onAnswer(approved)
answered = true
} catch (e: any) {
sendUserToast(e?.body ?? e?.message ?? 'Failed', true)
} finally {
loading = false
}
}
</script>
<div class={twMerge('flex gap-2 items-center', light ? 'flex-col' : 'flex-row')}>
{#if !hideReject}
<Button
title={rejectTitle}
iconOnly
startIcon={{ icon: X }}
variant="default"
disabled={loading || answered}
destructive
unifiedSize="md"
onClick={() => answer(false)}
/>
{/if}
<Button
variant="accent"
onClick={() => answer(true)}
disabled={loading || answered || approveDisabled}
unifiedSize="md"
>
{approveLabel}
{#if approveTooltip}
<Tooltip class="text-white">{approveTooltip}</Tooltip>
{/if}
</Button>
{@render children?.()}
</div>
@@ -8,7 +8,6 @@
import type ShareModal from '$lib/components/ShareModal.svelte'
import { FlowService, type Flow } from '$lib/gen'
import { roleCapabilities, userStore, userWorkspaces, workspaceStore } from '$lib/stores'
import { useEditRights } from '$lib/operatorWriteRights'
import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte'
import { createEventDispatcher } from 'svelte'
import Badge from '../badge/Badge.svelte'
@@ -45,8 +44,6 @@
import EditInForkButton from './EditInForkButton.svelte'
import { isCloudHosted } from '$lib/cloud'
const editRights = useEditRights()
interface Props {
flow: Flow & {
draft_only?: boolean
@@ -67,6 +64,8 @@
depth?: number
menuOpen?: boolean
showEditButton?: boolean
/** The acting role may author flows; an operator's row hides the authoring actions. */
canAuthor: boolean
keyboardSelected?: boolean
rowSelection?: RowSelection
}
@@ -82,6 +81,7 @@
depth = 0,
menuOpen = $bindable(false),
showEditButton = $bindable(true),
canAuthor,
keyboardSelected = false,
rowSelection = undefined
}: Props = $props()
@@ -129,7 +129,7 @@
let scheduleEditor: ScheduleEditor | undefined = $state(undefined)
let flowHistory: FlowHistory | undefined = $state(undefined)
let hideForOperator = $derived(!editRights.roleCanAuthor('flow'))
let hideForOperator = $derived(!canAuthor)
</script>
{#if menuOpen}
@@ -1,6 +1,6 @@
<script lang="ts">
import Label from '../Label.svelte'
import TextInput from '../text_input/TextInput.svelte'
import SettingToggle from '../itemSettings/SettingToggle.svelte'
import { copilotInfo } from '$lib/aiStore'
interface Props {
@@ -9,12 +9,22 @@
}
let { prompt = $bindable(), type = 'script' }: Props = $props()
// An empty prompt is stored as undefined, so the row keeps its own open state to show the field
// between turning the toggle on and typing.
let open = $state(false)
</script>
{#if !$copilotInfo.workspaceDisabled}
<Label
<SettingToggle
setting="ai-prompt"
label="AI prompt"
tooltip="An AI choosing this {type}'s inputs already reads its description and each field's description. Anything written here is given to it as extra guidance: mention specific fields and how they interact."
checked={open || !!prompt}
onchange={(on) => {
open = on
if (!on) prompt = undefined
}}
>
<TextInput
underlyingInputEl="textarea"
@@ -24,5 +34,5 @@
rows: 3
}}
/>
</Label>
</SettingToggle>
{/if}
@@ -20,7 +20,6 @@
wideLayout = false,
emptyHint,
starter = undefined,
viewTransitionNames = false,
onSent = undefined,
inputPreface,
actingOn,
@@ -45,7 +44,6 @@
wideLayout?: boolean
emptyHint?: import('svelte').Snippet
starter?: import('./starter/starterPrompts.svelte').ChatStarter
viewTransitionNames?: boolean
onSent?: () => void
inputPreface?: import('svelte').Snippet
/** Forwarded to AIChatDisplay: where the session acts, first among the controls. */
@@ -222,7 +220,6 @@
{wideLayout}
{emptyHint}
{starter}
{viewTransitionNames}
{onSent}
{inputPreface}
{actingOn}
@@ -118,7 +118,6 @@
wideLayout = false,
emptyHint,
starter = undefined,
viewTransitionNames = false,
onSent = undefined,
inputPreface,
actingOn,
@@ -160,11 +159,6 @@
* both mount it, so the two are one entry point rather than look-alikes. Replaces
* `emptyHint`. */
starter?: ChatStarter
/** Names the composer for the view transition between Home and AI Sessions, so it
* glides between its two places, a send from Home included: that lands it under the
* session's first message. Only one chat on the page may carry the name: a duplicate
* makes the browser skip the transition. */
viewTransitionNames?: boolean
/** After the composer hands a new turn to the host. */
onSent?: () => void
inputPreface?: Snippet
@@ -865,10 +859,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
has none: there the composer is one control among the page's. -->
{#if centreComposer}
<div class="flex-1 min-h-0 flex flex-col justify-end">
<div
class={columnClass}
style:view-transition-name={viewTransitionNames ? 'greeting' : undefined}
>
<div class={columnClass}>
<SessionGreeting />
</div>
</div>
@@ -1003,7 +994,6 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
aria-label="Message composer"
bind:this={composerEl}
class="relative {columnClass} pb-2"
style:view-transition-name={viewTransitionNames ? 'composer' : undefined}
onfocusin={(e) => {
if (homeComposer && e.target instanceof HTMLTextAreaElement) composerOpened = true
}}
@@ -1450,7 +1440,7 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. -->
Always mounted and shrunk on the first message rather than removed: flex-grow
transitions, so the composer slides down to the bottom instead of jumping. -->
<div
class="min-h-0 basis-0 transition-[flex-grow] duration-[var(--composer-glide-duration)] ease-[var(--composer-glide-easing)] motion-reduce:transition-none {centreComposer
class="min-h-0 basis-0 transition-[flex-grow] duration-[380ms] ease-[cubic-bezier(0.2,0.8,0.2,1)] motion-reduce:transition-none {centreComposer
? 'grow'
: 'grow-0'}"
></div>
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -45,8 +45,8 @@ export function smoothHeight(node: HTMLElement, params: SmoothHeightParams) {
ro.observe(inner)
}
// No measuring at mount: the content's height is not settled yet (a newly mounted session
// composer reads ~60px too tall), and a view transition captures the box at that height.
// The observer's first callback comes after layout and sets the right one.
// composer reads ~60px too tall). The observer's first callback comes after layout and sets
// the right one.
observe()
return {
@@ -0,0 +1,273 @@
<script lang="ts">
import { Alert, Badge, Button } from '$lib/components/common'
import type { AIProviderModel, WorkspaceItemDiff } from '$lib/gen'
import { copilotInfo, copilotSessionModel } from '$lib/aiStore'
import Select from '$lib/components/select/Select.svelte'
import { sendUserToast } from '$lib/toast'
import { logFeatureUsage } from '$lib/utils/featureUsage'
import { Loader2, MessageSquarePlus, Sparkles, WandSparkles, X } from 'lucide-svelte'
import OpenInSessionButton, {
type OpenInSessionSource
} from '$lib/components/sessions/OpenInSessionButton.svelte'
import { AIBtnClasses } from '$lib/components/copilot/chat/AIButtonStyle'
import { buildCompareUrl } from '$lib/components/copilot/chat/global/pageNavigation'
import type { SessionTarget } from '$lib/components/sessions/sessionState.svelte'
import { reviewDeployment, type AiReviewFinding, type AiReviewResult } from './aiReview'
interface Props {
diffs: WorkspaceItemDiff[]
forkWorkspaceId: string
parentWorkspaceId: string
/** Where the review runs: the items selected for deploy, the request form, or an open request. */
source: 'selection' | 'request_form' | 'open_request'
/** Absent before the request exists: findings are then only shown, for the author to act on. */
onPost?: (finding: AiReviewFinding) => Promise<void>
}
let { diffs, forkWorkspaceId, parentWorkspaceId, source, onPost }: Props = $props()
let running = $state(false)
let result = $state<AiReviewResult | undefined>(undefined)
let error = $state<string | undefined>(undefined)
let postingId = $state<string | undefined>(undefined)
let abortController: AbortController | undefined
// `diffs` can change under a finished or running review (a selection is edited, the
// comparison refreshes), so the result is tied to the items it actually covered.
const itemsKey = (ds: WorkspaceItemDiff[]) =>
ds
.map((d) => `${d.kind}:${d.path}`)
.sort()
.join('\n')
let reviewed = $state<{ key: string; count: number } | undefined>(undefined)
let stale = $derived(reviewed != undefined && reviewed.key !== itemsKey(diffs))
const modelKey = (m: AIProviderModel) => `${m.provider}:${m.model}`
// Local to this review: picking a model here must not change the chat's own selection,
// which is only the default until something is picked.
let pickedModelKey = $state<string | undefined>(undefined)
let chatModel = $derived(
$copilotSessionModel ?? $copilotInfo.defaultModel ?? $copilotInfo.aiModels[0]
)
let selectedModelKey = $derived(pickedModelKey ?? (chatModel && modelKey(chatModel)))
async function run() {
abortController?.abort()
const controller = new AbortController()
abortController = controller
running = true
error = undefined
result = undefined
reviewed = { key: itemsKey(diffs), count: diffs.length }
logFeatureUsage('deployment_request', 'ai_review', { key: source })
try {
const review = await reviewDeployment(diffs, forkWorkspaceId, parentWorkspaceId, controller, {
model: $copilotInfo.aiModels.find((m) => modelKey(m) === selectedModelKey)
})
if (!controller.signal.aborted) result = review
} catch (e: any) {
if (!controller.signal.aborted) error = e?.message ?? String(e)
} finally {
if (abortController === controller) running = false
}
}
$effect(() => () => abortController?.abort())
function stop() {
abortController?.abort()
running = false
}
function dismiss(finding: AiReviewFinding) {
if (result) result.findings = result.findings.filter((f) => f.id !== finding.id)
}
async function post(finding: AiReviewFinding): Promise<boolean> {
if (!onPost) return false
postingId = finding.id
try {
await onPost(finding)
logFeatureUsage('deployment_request', 'ai_finding_posted', { key: finding.severity })
dismiss(finding)
return true
} catch (e: any) {
sendUserToast(`Failed to post comment: ${e?.body || e?.message}`, true)
return false
} finally {
postingId = undefined
}
}
let postingAll = $state(false)
// One at a time, in the listed order, stopping at the first failure: posted findings leave
// the list, so a retry posts only what is left.
async function postAll() {
postingAll = true
try {
for (const f of [...(result?.findings ?? [])]) {
if (!(await post(f))) break
}
} finally {
postingAll = false
}
}
const severityColor = { high: 'red', medium: 'yellow', low: 'gray' } as const
const SESSION_TARGET_KINDS: string[] = ['script', 'flow', 'raw_app']
// The session is pinned to the fork: the fixes belong in the workspace under review, and a
// session left to pick its own would start in whatever workspace the user last used.
function fixSource(findings: AiReviewFinding[]): OpenInSessionSource {
const lines = findings.map(
(f) => `- [${f.severity}] ${f.kind && f.path ? `${f.kind} ${f.path}: ` : ''}${f.comment}`
)
const one = findings.length === 1 ? findings[0] : undefined
const common = {
workspaceId: forkWorkspaceId,
seedPrompt: `An AI review of this workspace's changes, before deploying them to ${parentWorkspaceId}, found ${one ? 'this issue' : 'these issues'}. Fix ${one ? 'it' : 'them'} here:\n\n${lines.join('\n')}`,
autoSend: true,
beforeOpen: () =>
logFeatureUsage('deployment_request', 'ai_fix_in_session', {
key: one ? 'finding' : 'all'
})
}
if (one && SESSION_TARGET_KINDS.includes(one.kind)) {
return { ...common, target: { kind: one.kind as SessionTarget['kind'], path: one.path } }
}
return {
...common,
page: () => buildCompareUrl({ workspace_id: forkWorkspaceId, mode: 'fork' })
}
}
</script>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
{#if running}
<Button variant="default" unifiedSize="sm" startIcon={{ icon: X }} onclick={stop}>
Stop review
</Button>
<span class="flex items-center gap-1 text-xs text-secondary">
<Loader2 size={12} class="animate-spin" />
Reviewing {reviewed?.count} changed item{reviewed?.count === 1 ? '' : 's'}…
</span>
{:else}
<Button
variant="default"
unifiedSize="sm"
startIcon={{ icon: Sparkles }}
disabled={diffs.length === 0}
onclick={run}
>
{result ? 'Review again with AI' : 'Review with AI'}
</Button>
{#if $copilotInfo.aiModels.length > 1}
<div class="w-56">
<Select
size="sm"
items={$copilotInfo.aiModels.map((m) => ({ label: m.model, value: modelKey(m) }))}
bind:value={() => selectedModelKey, (v) => (pickedModelKey = v)}
/>
</div>
{/if}
{#if diffs.length === 0}
<span class="text-xs text-secondary">
{source === 'selection' ? 'Select items to review.' : 'No changes to review.'}
</span>
{/if}
{/if}
</div>
{#if error}
<Alert type="error" title="AI review failed" size="xs">{error}</Alert>
{/if}
{#if result}
<div class="flex flex-col gap-2 border rounded-md p-2 bg-surface-secondary">
{#if stale}
<Alert type="warning" title="Out of date" size="xs">
The items have changed since this review. Review again to cover the current ones.
</Alert>
{/if}
{#if result.summary}
<div class="text-xs text-secondary">{result.summary}</div>
{/if}
{#if result.unreadable.length > 0}
<Alert type="warning" title="Not reviewed" size="xs">
Could not load the contents of: {result.unreadable.join(', ')}
</Alert>
{/if}
{#if result.omitted.length > 0}
<div class="text-2xs text-secondary">
Reviewed by name only, too large to include: {result.omitted.join(', ')}
</div>
{/if}
{#if result.findings.length > 1}
<div class="flex gap-2 self-start">
<OpenInSessionButton
source={fixSource(result.findings)}
btnClasses={AIBtnClasses('default')}
label="Fix all in AI session"
tooltip="Open an AI session in the fork and fix every issue listed"
btnProps={{ iconOnly: false, unifiedSize: 'xs', startIcon: { icon: WandSparkles } }}
/>
{#if onPost}
<Button
variant="default"
unifiedSize="xs"
startIcon={{ icon: MessageSquarePlus }}
disabled={postingId != undefined || postingAll}
loading={postingAll}
onclick={postAll}
>
Post all as comments
</Button>
{/if}
</div>
{/if}
{#if result.findings.length === 0}
<div class="text-xs text-secondary">No issues left to review.</div>
{:else if source === 'request_form'}
<div class="text-2xs text-secondary">
Fix these in your fork before sending, or send as is. Reviewers can run their own review.
</div>
{/if}
{#each result.findings as f (f.id)}
<div class="border rounded-md p-2 flex flex-col gap-1 bg-surface">
<div class="flex items-center gap-2 flex-wrap text-xs">
<Badge color={severityColor[f.severity]} size="xs">{f.severity}</Badge>
{#if f.kind && f.path}
<Badge color="blue" size="xs">{f.kind}:{f.path}</Badge>
{/if}
</div>
<div class="text-xs text-primary whitespace-pre-wrap">{f.comment}</div>
<div class="flex gap-2 self-end">
<OpenInSessionButton
source={fixSource([f])}
btnClasses={AIBtnClasses('default')}
label="Fix in AI session"
tooltip="Open an AI session in the fork and fix this issue"
btnProps={{ iconOnly: false, unifiedSize: 'xs', startIcon: { icon: WandSparkles } }}
/>
{#if onPost}
<Button
variant="default"
unifiedSize="xs"
startIcon={{ icon: MessageSquarePlus }}
disabled={postingId != undefined || postingAll}
loading={postingId === f.id}
onclick={() => post(f)}
>
Post as comment
</Button>
{/if}
<Button variant="subtle" unifiedSize="xs" onclick={() => dismiss(f)}>Dismiss</Button>
</div>
</div>
{/each}
</div>
{/if}
</div>
@@ -8,21 +8,33 @@
WorkspaceService,
type DeploymentRequest,
type DeploymentRequestComment,
type DeploymentRequestEligibleDeployer
type DeploymentRequestEligibleDeployer,
type WorkspaceItemDiff
} from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { copilotInfo } from '$lib/aiStore'
import { Send, Plus, X, ArchiveX } from 'lucide-svelte'
import AiReview from './AiReview.svelte'
import type { AiReviewFinding } from './aiReview'
interface Props {
forkWorkspaceId: string
parentWorkspaceId: string
currentUsername: string
isAdmin: boolean
/** The changes the request would merge into the parent, for the AI review. */
diffs: WorkspaceItemDiff[]
onStateChange?: (hasOpenRequest: boolean) => void
}
let { forkWorkspaceId, parentWorkspaceId, currentUsername, isAdmin, onStateChange }: Props =
$props()
let {
forkWorkspaceId,
parentWorkspaceId,
currentUsername,
isAdmin,
diffs,
onStateChange
}: Props = $props()
let request = $state<DeploymentRequest | null>(null)
let loading = $state(true)
@@ -114,6 +126,20 @@
}
}
async function postAiFinding(finding: AiReviewFinding): Promise<void> {
if (!request) return
const created = await WorkspaceService.createDeploymentRequestComment({
workspace: forkWorkspaceId,
id: request.id,
requestBody: {
body: finding.comment,
anchor_kind: finding.kind || undefined,
anchor_path: finding.path || undefined
}
})
request = { ...request, comments: [...request.comments, created] }
}
async function postComment(body: string, parentId?: number) {
if (!request || !body.trim()) return
posting = true
@@ -233,6 +259,11 @@
Cancel
</Button>
</div>
{#if $copilotInfo.enabled}
<div class="border-t pt-2">
<AiReview {diffs} {forkWorkspaceId} {parentWorkspaceId} source="request_form" />
</div>
{/if}
</div>
{:else}
<div class="flex items-center justify-between flex-wrap gap-2 text-xs">
@@ -261,6 +292,15 @@
</div>
<div class="border rounded-md bg-surface p-3 flex flex-col gap-3">
{#if $copilotInfo.enabled}
<AiReview
{diffs}
{forkWorkspaceId}
{parentWorkspaceId}
source="open_request"
onPost={postAiFinding}
/>
{/if}
{#if topLevel.length === 0}
<div class="text-secondary text-xs">No comments yet. Be the first to leave feedback.</div>
{/if}
@@ -0,0 +1,84 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkspaceItemDiff } from '$lib/gen'
const h = vi.hoisted(() => ({ prompt: '', workspace: '' as string | undefined, reads: 0 }))
vi.mock('$lib/components/copilot/lib', () => ({
getNonStreamingCompletion: async (
messages: { content: string }[],
_abort: AbortController,
options?: { workspace?: string }
) => {
h.prompt = messages[1].content
h.workspace = options?.workspace
return '{"summary":"s","findings":[]}'
}
}))
// Mirrors the shared loader, which answers `{}` instead of throwing when a read fails.
vi.mock('$lib/utils_workspace_deploy', () => ({
getItemValue: async (_kind: string, path: string) => {
h.reads++
if (path === 'f/a/unreadable') return {}
return { content: path.startsWith('f/big/') ? 'x'.repeat(30_000) : 'x' }
}
}))
const { parseReviewAnswer, reviewDeployment } = await import('./aiReview')
function diff(path: string): WorkspaceItemDiff {
return {
kind: 'script',
path,
ahead: 1,
behind: 0,
has_changes: true,
exists_in_source: true,
exists_in_fork: true
}
}
beforeEach(() => {
h.reads = 0
})
describe('parseReviewAnswer', () => {
it('anchors only findings on items in the diff, and ranks by severity', () => {
const answer =
'```json\n' +
JSON.stringify({
summary: 's',
findings: [
{ kind: 'flow', path: 'f/not/changed', severity: 'low', comment: 'phantom' },
{ kind: 'script', path: 'f/a/b', severity: 'high', comment: 'real' }
]
}) +
'\n```'
const { findings } = parseReviewAnswer(answer, [diff('f/a/b')])
expect(findings.map((f) => [f.comment, f.kind, f.path])).toEqual([
['real', 'script', 'f/a/b'],
['phantom', '', '']
])
})
})
describe('reviewDeployment', () => {
it('reports an item whose read failed instead of reviewing it as emptied', async () => {
const result = await reviewDeployment(
[diff('f/a/b'), diff('f/a/unreadable')],
'fork',
'parent',
new AbortController()
)
expect(result.unreadable).toEqual(['script:f/a/unreadable'])
expect(h.prompt).toContain('### script f/a/unreadable (modified)\nContents could not be read.')
expect(h.workspace).toBe('fork')
})
it('stops reading items once a section no longer fits the budget', async () => {
const diffs = Array.from({ length: 30 }, (_, i) => diff(`f/big/${i}`))
const result = await reviewDeployment(diffs, 'fork', 'parent', new AbortController())
// Only the first batch of 6 is read, both sides each.
expect(h.reads).toBe(12)
expect(result.omitted).toContain('script:f/big/29')
})
})
@@ -0,0 +1,188 @@
import type { AIProviderModel, WorkspaceItemDiff } from '$lib/gen'
import { getNonStreamingCompletion } from '$lib/components/copilot/lib'
import { getItemValue } from '$lib/utils_workspace_deploy'
import type { Kind } from '$lib/utils_deployable'
export type AiReviewSeverity = 'high' | 'medium' | 'low'
export type AiReviewFinding = {
id: string
kind: string
path: string
severity: AiReviewSeverity
comment: string
}
export type AiReviewResult = {
summary: string
findings: AiReviewFinding[]
/** Changed items whose contents did not fit in the prompt and were reviewed by name only. */
omitted: string[]
/** Changed items whose contents failed to load, so the model did not review them. */
unreadable: string[]
}
// A variable's value is read decrypted and a resource's value holds credentials, so neither may
// reach the model: they are reviewed by name and change type only.
const VALUE_WITHHELD_KINDS = new Set(['variable', 'resource'])
const PER_SIDE_CHAR_CAP = 20_000
const TOTAL_CHAR_BUDGET = 160_000
const FETCH_BATCH = 6
// Under the Anthropic SDK's non-streaming pre-flight max_tokens limit.
const MAX_OUTPUT_TOKENS = 8_000
const SYSTEM_PROMPT = `You review a Windmill deployment request: a set of changes made in a fork workspace that will be merged into its parent workspace. Each changed item is given with its parent version (what is deployed now) and its fork version (what will be deployed). An item can be a script, flow, app, schedule, trigger, folder, resource type, datatable migration, variable or resource.
Report only issues a reviewer should act on before approving the merge:
- bugs and logic errors introduced by the change
- breaking changes: removed or renamed inputs, changed input types, changed outputs that callers may rely on
- risky operational changes: schedules retimed, triggers pointed at a different script or flow, folder owners or permissions, app policies, destructive migrations
- hardcoded secrets or credentials
- deletions that look unintended
Do not comment on style, naming, formatting or lockfiles, and do not praise. Do not repeat a finding for every item it touches. An item with no issue gets no finding.
Answer with JSON only, no prose around it, in this shape:
{"summary": "<one or two sentences on what the change does and its overall risk>", "findings": [{"kind": "<item kind as given>", "path": "<item path as given>", "severity": "high" | "medium" | "low", "comment": "<the issue and what to do about it, under 80 words>"}]}`
function changeType(diff: WorkspaceItemDiff): 'added' | 'deleted' | 'modified' {
if (!diff.exists_in_source) return 'added'
if (!diff.exists_in_fork) return 'deleted'
return 'modified'
}
function render(value: unknown): string {
if (value == null) return '(none)'
// Flows and apps nest their inline scripts' lockfiles, so they are dropped at any depth.
const text =
typeof value === 'string'
? value
: JSON.stringify(value, (key, v) => (key === 'lock' ? undefined : v), 2)
return text.length > PER_SIDE_CHAR_CAP
? `${text.slice(0, PER_SIDE_CHAR_CAP)}\n… (truncated, ${text.length - PER_SIDE_CHAR_CAP} more characters)`
: text
}
async function itemSection(
diff: WorkspaceItemDiff,
forkWorkspaceId: string,
parentWorkspaceId: string
): Promise<{ text: string; unreadable: boolean }> {
const change = changeType(diff)
const header = `### ${diff.kind} ${diff.path} (${change})`
if (VALUE_WITHHELD_KINDS.has(diff.kind)) {
return { text: `${header}\nValue withheld from review.`, unreadable: false }
}
const kind = diff.kind as Kind
const [parent, fork] = await Promise.all([
change === 'added' ? undefined : getItemValue(kind, diff.path, parentWorkspaceId),
change === 'deleted' ? undefined : getItemValue(kind, diff.path, forkWorkspaceId)
])
// `getItemValue` answers `{}` when a read fails, which would pass as an emptied item.
if (isEmptyRead(parent) || isEmptyRead(fork)) {
return { text: `${header}\nContents could not be read.`, unreadable: true }
}
return {
text: `${header}\n#### Parent version\n${render(parent)}\n#### Fork version\n${render(fork)}`,
unreadable: false
}
}
function isEmptyRead(value: unknown): boolean {
return value != null && typeof value === 'object' && Object.keys(value as object).length === 0
}
export function parseReviewAnswer(
text: string,
diffs: WorkspaceItemDiff[]
): Omit<AiReviewResult, 'omitted' | 'unreadable'> {
const start = text.indexOf('{')
const end = text.lastIndexOf('}')
if (start === -1 || end <= start) {
throw new Error('The AI answer was not in the expected format')
}
const parsed = JSON.parse(text.slice(start, end + 1)) as {
summary?: unknown
findings?: unknown
}
// Anchoring a comment needs a (kind, path) the fork's diff actually has, so a finding naming
// anything else is kept but unanchored rather than posted against a phantom item.
const known = new Set(diffs.map((d) => `${d.kind}:${d.path}`))
const findings: AiReviewFinding[] = []
for (const [i, raw] of (Array.isArray(parsed.findings) ? parsed.findings : []).entries()) {
if (!raw || typeof raw.comment !== 'string' || !raw.comment.trim()) continue
const anchored = known.has(`${raw.kind}:${raw.path}`)
findings.push({
id: String(i),
kind: anchored ? raw.kind : '',
path: anchored ? raw.path : '',
severity: ['high', 'medium', 'low'].includes(raw.severity) ? raw.severity : 'medium',
comment: raw.comment.trim()
})
}
const rank: Record<AiReviewSeverity, number> = { high: 0, medium: 1, low: 2 }
findings.sort((a, b) => rank[a.severity] - rank[b.severity])
return { summary: typeof parsed.summary === 'string' ? parsed.summary : '', findings }
}
/** Reviews the changes `diffs` carry from the fork into its parent with the workspace's AI model. */
export async function reviewDeployment(
diffs: WorkspaceItemDiff[],
forkWorkspaceId: string,
parentWorkspaceId: string,
abortController: AbortController,
options?: {
/** Defaults to the chat's current model. */
model?: AIProviderModel
}
): Promise<AiReviewResult> {
const included: string[] = []
const omitted: string[] = []
const unreadable: string[] = []
let used = 0
// Fetched a batch at a time so a large fork stops reading items once the budget is spent.
// The first section that does not fit spends it: `used` alone can stay under the cap forever.
let budgetSpent = false
for (let i = 0; i < diffs.length; i += FETCH_BATCH) {
const batch = diffs.slice(i, i + FETCH_BATCH)
if (budgetSpent) {
omitted.push(...batch.map((d) => `${d.kind}:${d.path}`))
continue
}
const sections = await Promise.all(
batch.map((d) => itemSection(d, forkWorkspaceId, parentWorkspaceId))
)
if (abortController.signal.aborted) throw new Error('aborted')
sections.forEach(({ text: section, unreadable: failed }, j) => {
if (failed) unreadable.push(`${batch[j].kind}:${batch[j].path}`)
if (!budgetSpent && used + section.length <= TOTAL_CHAR_BUDGET) {
included.push(section)
used += section.length
} else {
budgetSpent = true
omitted.push(`${batch[j].kind}:${batch[j].path}`)
}
})
}
let user = `Fork workspace: ${forkWorkspaceId}\nParent workspace: ${parentWorkspaceId}\n\n${included.join('\n\n')}`
if (omitted.length > 0) {
user += `\n\nAlso changed, contents not shown for size:\n${omitted.map((o) => `- ${o}`).join('\n')}`
}
const answer = await getNonStreamingCompletion(
[
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: user }
],
abortController,
// Bound to the fork: without `workspace` the call goes through whichever workspace the
// app has switched to by the time the item reads finish.
{
maxTokensCap: MAX_OUTPUT_TOKENS,
forceModelProvider: options?.model,
workspace: forkWorkspaceId
}
)
return { ...parseReviewAnswer(answer ?? '', diffs), omitted, unreadable }
}
@@ -1,80 +0,0 @@
<script lang="ts">
import Toggle from '$lib/components/Toggle.svelte'
import { FlowService, ScriptService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
const operatingWorkspace = useOperatingWorkspace()
interface Props {
kind: 'script' | 'flow'
scriptOrFlowPath: string
errorHandlerMuted: boolean | undefined
textDisabled?: boolean
color?: 'nord' | 'red' | 'blue' | undefined
}
let {
kind,
scriptOrFlowPath,
errorHandlerMuted = $bindable(),
textDisabled = false,
color = undefined
}: Props = $props()
let toggleState = $state(errorHandlerMuted)
async function toggleErrorHandler(): Promise<void> {
toggleState = !toggleState
if ($operatingWorkspace !== undefined) {
try {
if (kind === 'flow') {
await FlowService.toggleWorkspaceErrorHandlerForFlow({
workspace: $operatingWorkspace,
path: scriptOrFlowPath,
requestBody: {
muted: !errorHandlerMuted
}
})
} else {
await ScriptService.toggleWorkspaceErrorHandlerForScript({
workspace: $operatingWorkspace,
path: scriptOrFlowPath,
requestBody: {
muted: !errorHandlerMuted
}
})
}
} catch (error) {
sendUserToast(
`Error while toggling Workspace Error Handler: ${error.body || error.message}`,
true
)
toggleState = false
return
}
errorHandlerMuted = !errorHandlerMuted
toggleState = errorHandlerMuted
sendUserToast(
errorHandlerMuted ? 'Workspace error handler muted' : 'Workspace error handler active',
false
)
}
}
</script>
<Toggle
textClass="font-medium"
{color}
size="xs"
checked={toggleState}
on:change={toggleErrorHandler}
options={{
right: 'Mute',
rightTooltip: 'Disable workspace error handler, EE only',
rightDocumentationLink:
'https://www.windmill.dev/docs/core_concepts/error_handling#workspace-error-handler'
}}
{textDisabled}
/>
@@ -1,11 +1,12 @@
<script lang="ts">
import Toggle from '$lib/components/Toggle.svelte'
import Tooltip from '$lib/components/Tooltip.svelte'
import { SecondsInput } from '$lib/components/common'
import { enterpriseLicense } from '$lib/stores'
import Label from '../Label.svelte'
import type { Schema } from '$lib/common'
import { slideDynamic } from '$lib/transitions'
import Select from '$lib/components/select/Select.svelte'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import SettingToggle from '$lib/components/itemSettings/SettingToggle.svelte'
let {
debounce_delay_s = $bindable(),
@@ -14,11 +15,7 @@
max_total_debouncing_time = $bindable(),
max_total_debounces_amount = $bindable(),
schema = undefined,
placeholder,
size = 'xs',
color = undefined,
fontClass = 'font-normal',
indentContent = false
placeholder
}: {
debounce_delay_s: number | undefined
debounce_key: string | undefined
@@ -27,11 +24,6 @@
max_total_debounces_amount: number | undefined
schema?: Schema
placeholder: string
size: 'xs' | 'sm'
color?: 'nord' | undefined
fontClass?: string
/** Align the fields under the toggle's label, as the flow step settings do. */
indentContent?: boolean
} = $props()
// Check if an originalType like "string | string[]" is a top-level
@@ -90,89 +82,83 @@
}
})
// Presence, not truthiness: a single backspace takes the seeded 1s to 0 while the
// user is still typing, and reading that as off would disable the field mid-edit.
let off = $derived(!$enterpriseLicense || debounce_delay_s === undefined)
let disabled = $derived(!$enterpriseLicense)
</script>
<div>
<div class="flex flex-row items-center gap-2">
<Toggle
textClass={fontClass}
{color}
{size}
disabled={!$enterpriseLicense}
checked={debounce_delay_s !== undefined}
on:change={() => {
if (debounce_delay_s !== undefined) {
debounce_delay_s = undefined
debounce_key = undefined
} else {
debounce_delay_s = 1
}
}}
options={{
right: 'Debouncing',
rightTooltip: 'Consolidate multiple executions into a single run within a time window',
rightDocumentationLink: 'https://www.windmill.dev/docs/core_concepts/job_debouncing'
}}
class="py-1"
eeOnly={true}
<!-- Presence, not truthiness: a single backspace takes the seeded 1s to 0 while the user is
still typing, and reading that as off would close the fields mid-edit. -->
<SettingToggle
setting="debounce"
label="Debouncing"
tooltip="Consolidate multiple executions into a single run within a time window"
documentationLink="https://www.windmill.dev/docs/core_concepts/job_debouncing"
eeOnly
{disabled}
checked={debounce_delay_s !== undefined}
onchange={(on) => {
if (on) {
debounce_delay_s = 1
} else {
debounce_delay_s = undefined
debounce_key = undefined
}
}}
>
<Label label="Delay in seconds">
<SecondsInput {disabled} bind:seconds={debounce_delay_s} />
</Label>
<Label label="Custom debounce key (optional)">
{#snippet header()}
<Tooltip>
Debounce keys are global. Use `$workspace` to make one workspace specific, and
`$args[name_of_arg]` to use an argument's value.
</Tooltip>
{/snippet}
<TextInput bind:value={debounce_key} inputProps={{ type: 'text', disabled, placeholder }} />
</Label>
<Label label="Argument to accumulate (optional)">
{#snippet header()}
<Tooltip>
A list argument whose values are appended together across the debounced executions.
</Tooltip>
{/snippet}
<!-- A saved argument the schema no longer has stays listed, so it can still be cleared. -->
<Select
items={(selectedArg && !arrayArgs.includes(selectedArg)
? [...arrayArgs, selectedArg]
: arrayArgs
).map((arg) => ({ value: arg, label: arg }))}
bind:value={() => selectedArg || undefined, (v) => (selectedArg = v ?? '')}
placeholder="None"
clearable
disabled={disabled || (arrayArgs.length === 0 && !selectedArg)}
/>
</div>
{#if !off}
<div class="flex flex-col gap-4 mt-2 {indentContent ? 'pl-9' : ''}" transition:slideDynamic>
<Label label="Delay in seconds">
<SecondsInput disabled={off} bind:seconds={debounce_delay_s} />
</Label>
<Label label="Custom debounce key (optional)">
{#snippet header()}
<Tooltip>
Debounce keys are global, you can have them be workspace specific using the variable
`$workspace`. You can also use an argument's value using `$args[name_of_arg]`</Tooltip
>
{/snippet}
<input type="text" disabled={off} bind:value={debounce_key} {placeholder} />
</Label>
<Label label="Argument to accumulate (optional)">
{#snippet header()}
<Tooltip>
Select a list-type argument to accumulate across debounced executions. Values from each
debounced execution will be appended together.</Tooltip
>
{/snippet}
<select disabled={off} bind:value={selectedArg}>
<option value="">None</option>
{#each arrayArgs as arg}
<option value={arg}>{arg}</option>
{/each}
</select>
{#if arrayArgs.length === 0}
<div class="text-xs text-gray-500 mt-1">
No list-type arguments found in schema. Define arguments with type "array" to enable
accumulation.
</div>
{/if}
</Label>
<Label label="Max total debouncing time (optional)">
{#snippet header()}
<Tooltip>
Maximum total time (in seconds) that a job can be debounced before it must execute. Once
this time is reached, the job will run regardless of ongoing debouncing.</Tooltip
>
{/snippet}
<SecondsInput disabled={off} bind:seconds={max_total_debouncing_time} />
</Label>
<Label label="Max total debounces amount (optional)">
{#snippet header()}
<Tooltip>
Maximum number of times a job can be debounced before it must execute. Once this count
is reached, the job will run regardless of ongoing debouncing.</Tooltip
>
{/snippet}
<input type="number" disabled={off} bind:value={max_total_debounces_amount} min="0" />
</Label>
</div>
{/if}
</div>
{#if arrayArgs.length === 0}
<div class="text-2xs text-secondary">
No list argument in the schema. Add an argument of type "array" to accumulate it.
</div>
{/if}
</Label>
<Label label="Max total debouncing time (optional)">
{#snippet header()}
<Tooltip>
The longest a run can be held back by debouncing. Past it, the run starts whatever
debouncing is ongoing.
</Tooltip>
{/snippet}
<SecondsInput {disabled} bind:seconds={max_total_debouncing_time} />
</Label>
<Label label="Max total debounces amount (optional)">
{#snippet header()}
<Tooltip>
The most times a run can be debounced. Past it, the run starts whatever debouncing is
ongoing.
</Tooltip>
{/snippet}
<TextInput
class="w-24"
bind:value={max_total_debounces_amount}
inputProps={{ type: 'number', min: 0, disabled }}
/>
</Label>
</SettingToggle>
@@ -2,6 +2,7 @@
import { type Job } from '$lib/gen'
import ProgressBar from '../progressBar/ProgressBar.svelte'
import { forLater } from '$lib/forLater'
import { waitingRequest } from '../agentRequest'
import type { Snippet } from 'svelte'
interface Props {
@@ -79,7 +80,13 @@
if (modules.length > maxDone) {
const nextModule = modules[maxDone]
if (nextModule.type === 'InProgress') {
if (nextModule.type === 'InProgress' && waitingRequest(nextModule)) {
// An agent waiting on a person reads like an approval step.
newIsWaitingForEvents = true
if (nextModule.id) {
newCurrentStepId = nextModule.id
}
} else if (nextModule.type === 'InProgress') {
newNextInProgress = true
// Get the step ID if available
if (nextModule.id) {
@@ -24,7 +24,7 @@ import {
// Lives here rather than beside the other config helpers: `agentResourceUtils` is a leaf, and
// importing tool-name validation into it would cycle back through `flowInfers` and pull the whole
// editor into every module that reads a brain key.
import { getToolNameError } from './agentToolUtils'
import { getToolNameError, isNamedTool, type AgentTool } from './agentToolUtils'
/**
* Why a run of this config would fail before it started, if it would. Only the conditions the
@@ -49,12 +49,10 @@ function agentConfigRunError(args: Record<string, any> | undefined): string | un
return 'Tools must be a list. Fix it in the resource editor before deploying.'
}
// Only a flowmodule tool's summary is a callable name: the worker never reads an mcp or
// websearch summary, so a blank one there is not an error and must not count as a sibling.
// Same filter as `collectInvalidAgentToolNames`, which is the rule the graph enforces.
// built-in tool's summary, so a blank one there is not an error and must not count as a
// sibling. Same filter as `collectInvalidAgentToolNames`, which is the rule the graph enforces.
const tools = (args?.tools ?? []) as Record<string, any>[]
const named = tools.filter(
(t) => t?.value?.tool_type !== 'mcp' && t?.value?.tool_type !== 'websearch'
)
const named = tools.filter((t) => isNamedTool(t as AgentTool))
const siblingNames = named.map((t) => t?.summary ?? '')
for (const tool of named) {
const err = getToolNameError(tool?.summary ?? '', tool?.value?.tool_type, siblingNames)
@@ -2,6 +2,7 @@ import type { FlowModule } from '$lib/gen'
import {
getToolNameError,
isFlowModuleTool,
isNamedTool,
type AgentTool,
type FlowModuleTool
} from './agentToolUtils'
@@ -195,10 +196,8 @@ export function collectInvalidAgentToolNames(modules: unknown): InvalidAgentTool
forEachAiAgentModule(modules, (mod, v) => {
const tools: AgentTool[] = Array.isArray(v.tools) ? v.tools : []
// Only a flowmodule tool's summary is a callable name: the worker never reads an mcp or
// websearch summary, so a blank one there must stay writable (both default to '').
const named = tools.filter(
(tool) => tool.value?.tool_type !== 'mcp' && tool.value?.tool_type !== 'websearch'
)
// built-in tool's summary, so a blank one there must stay writable.
const named = tools.filter(isNamedTool)
const siblingNames = named.map((tool) => tool.summary ?? '')
for (const tool of named) {
const name = tool.summary ?? ''
@@ -49,4 +49,11 @@ describe('toolEnabledName', () => {
`'${WEBSEARCH_ENABLED_NAME}' is a reserved name`
)
})
it("names the question tool by the worker's reserved name", () => {
expect(
toolEnabledName({ id: 't', summary: 'label', value: { tool_type: 'question' } } as any)
).toBe('__wm_question')
expect(getToolNameError('__wm_question')).toBe(`'__wm_question' is a reserved name`)
})
})
@@ -10,6 +10,26 @@ import { forbiddenIds } from './idUtils'
* search. */
export const WEBSEARCH_ENABLED_NAME = '__wm_web_search'
/** Tools the worker answers itself rather than by running a module. None is named by its
* `summary`: each has a reserved `enabledName` (mirroring the `*_ENABLED_NAME`s in the worker),
* and the model sees a fixed name the worker picks. `question` parks the agent until someone
* answers (`windmill-worker/src/ai/pause.rs`). */
export const BUILTIN_TOOLS = {
websearch: { label: 'Web search', enabledName: WEBSEARCH_ENABLED_NAME },
question: { label: 'Ask a question', enabledName: '__wm_question' }
} as const
export type BuiltinToolType = keyof typeof BUILTIN_TOOLS
export function builtinToolType(tool: AgentTool | undefined): BuiltinToolType | undefined {
const type = tool?.value?.tool_type
return type && type in BUILTIN_TOOLS ? (type as BuiltinToolType) : undefined
}
/** Whether a tool is named by its `summary`, which only the model-facing kinds are. */
export function isNamedTool(tool: AgentTool | undefined): boolean {
return tool?.value?.tool_type !== 'mcp' && builtinToolType(tool) === undefined
}
/**
* A tool's `summary` is the name the LLM sees, and the worker rejects any name that does not match
* `^[a-zA-Z0-9_]+$` (`ai_executor.rs`), so an unvalidated name fails on every run of the flow.
@@ -21,10 +41,10 @@ export const WEBSEARCH_ENABLED_NAME = '__wm_web_search'
*/
export function getToolNameError(
name: string,
kind?: 'mcp' | 'websearch' | (string & {}),
kind?: 'mcp' | BuiltinToolType | (string & {}),
siblingNames?: string[]
): string | undefined {
if (kind === 'websearch') return undefined
if (kind && kind in BUILTIN_TOOLS) return undefined
if (kind === 'mcp') {
return name.length > 0 ? undefined : 'Tool name must not be empty'
}
@@ -36,7 +56,10 @@ export function getToolNameError(
if (!/^[a-zA-Z0-9_]+$/.test(name)) {
return 'Tool name must only contain letters, numbers and underscores'
}
if (forbiddenIds.includes(name) || name === WEBSEARCH_ENABLED_NAME) {
if (
forbiddenIds.includes(name) ||
Object.values(BUILTIN_TOOLS).some((builtin) => builtin.enabledName === name)
) {
return `'${name}' is a reserved name`
}
if (siblingNames && siblingNames.filter((n) => n === name).length > 1) {
@@ -48,6 +71,7 @@ export function getToolNameError(
export const SPECIAL_TOOL_KINDS = [
'mcpTool',
'websearchTool',
'questionTool',
'aiAgentTool',
'aiDecisionTool'
] as const
@@ -154,8 +178,9 @@ export function toolEnabledName(tool: AgentTool): string | undefined {
if (value?.tool_type === 'mcp') {
return (value?.resource_path as string | undefined)?.replace(/^\$res:/, '') || undefined
}
if (value?.tool_type === 'websearch') {
return WEBSEARCH_ENABLED_NAME
const builtin = builtinToolType(tool)
if (builtin) {
return BUILTIN_TOOLS[builtin].enabledName
}
return toolDisplayName(tool)
}
@@ -233,13 +258,17 @@ export function createMcpTool(id: string): McpTool {
export function createWebsearchTool(id: string): WebsearchTool {
return {
id,
summary: 'Web Search',
summary: 'Web search',
value: {
tool_type: 'websearch'
}
}
}
export function createQuestionTool(id: string): AgentTool {
return { id, summary: BUILTIN_TOOLS.question.label, value: { tool_type: 'question' } }
}
/**
* Convert a FlowModuleTool to a FlowModule for use with loadFlowModuleState etc.
* Strips the extra `tool_type` field and maps AgentTool fields to FlowModule fields.
@@ -15,7 +15,12 @@ is that one, since both answer the same question about what an assistant can see
summarizeAgentBrain,
type AIAgentConfig
} from '../agentResourceUtils'
import { toolDisplayName, type AgentTool } from '../agentToolUtils'
import {
BUILTIN_TOOLS,
builtinToolType,
toolDisplayName,
type AgentTool
} from '../agentToolUtils'
type Section = 'model' | 'instructions' | 'tools'
@@ -71,7 +76,8 @@ is that one, since both answer the same question about what an assistant can see
function toolKind(tool: AgentTool): string {
const value = tool?.value as Record<string, any> | undefined
if (value?.tool_type === 'mcp') return 'MCP server'
if (value?.tool_type === 'websearch') return 'Web search'
const builtin = builtinToolType(tool)
if (builtin) return BUILTIN_TOOLS[builtin].label
switch (value?.type) {
case 'script':
return `Script ${value.path ?? ''}`.trim()
@@ -1,7 +1,13 @@
<script lang="ts">
import { ChevronRight, Plus, Wrench, X } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import { getToolNameError, toolDisplayName } from '../agentToolUtils'
import {
BUILTIN_TOOLS,
builtinToolType,
getToolNameError,
isNamedTool,
toolDisplayName
} from '../agentToolUtils'
import type { AgentTool } from '../agentToolUtils'
import { Button } from '$lib/components/common'
import Popover from '$lib/components/meltComponents/Popover.svelte'
@@ -42,15 +48,14 @@
if (value?.type === 'aiagent') return 'agent'
if (value?.type === 'aidecision') return 'decision'
if (value?.tool_type === 'mcp') return 'MCP'
if (value?.tool_type === 'websearch') return 'web search'
const builtin = builtinToolType(tool)
if (builtin) return BUILTIN_TOOLS[builtin].label.toLowerCase()
return undefined
}
/** Only a flow module tool's summary is the name the model is given; an mcp or websearch
/** Only a flow module tool's summary is the name the model is given; an mcp or built-in tool's
* summary is never sent, so a blank one there is not an error. Same rule as `agentToolTree`. */
function isNamed(tool: AgentTool): boolean {
return tool?.value?.tool_type !== 'mcp' && tool?.value?.tool_type !== 'websearch'
}
const isNamed = isNamedTool
let siblingNames = $derived(tools.filter(isNamed).map((tool) => tool?.summary ?? ''))
@@ -103,6 +108,7 @@
)}
on:pickMcpTool={() => (onAddTool?.({ kind: 'mcpTool' }), close())}
on:pickWebsearchTool={() => (onAddTool?.({ kind: 'websearchTool' }), close())}
on:pickQuestionTool={() => (onAddTool?.({ kind: 'questionTool' }), close())}
on:pickAiAgentTool={() => (onAddTool?.({ kind: 'aiAgentTool' }), close())}
on:pickAiDecisionTool={() => (onAddTool?.({ kind: 'aiDecisionTool' }), close())}
/>
@@ -5,6 +5,7 @@
import FlowModuleComponent from './FlowModuleComponent.svelte'
import McpToolEditor from './McpToolEditor.svelte'
import WebsearchToolDisplay from './WebsearchToolDisplay.svelte'
import QuestionToolDisplay from './QuestionToolDisplay.svelte'
interface Props {
tool: AgentTool
@@ -62,6 +63,9 @@
isAgentTool={true}
{staticOnly}
bind:toolDescription={tool.description}
bind:toolRequiresApproval={
() => tool.requires_approval, (v) => (tool.requires_approval = v || undefined)
}
{siblingToolNames}
/>
{:else if isMcpTool(tool)}
@@ -69,4 +73,6 @@
<McpToolEditor bind:tool {noEditor} />
{:else if isWebsearchTool(tool)}
<WebsearchToolDisplay {noEditor} />
{:else if tool.value?.tool_type === 'question'}
<QuestionToolDisplay bind:tool {noEditor} />
{/if}
@@ -77,7 +77,7 @@
let userSettings: UserSettings | undefined = $state(undefined)
let token = $state('')
let target = $derived({
api: `${apiBaseUrl()}/api/w/${workspace}`,
api: `${apiBaseUrl('webhooks')}/api/w/${workspace}`,
agentPath,
chat,
token
@@ -119,7 +119,7 @@
resolvedCount={resolvedModuleIds.length}
/>
{:else if selectedId?.startsWith('settings')}
<FlowSettings {enableAi} {noEditor} />
<FlowSettings {enableAi} {noEditor} {selectedId} {newFlow} />
{:else if selectedId === 'Input'}
<FlowInput
{noEditor}
@@ -71,7 +71,8 @@
signDebugRequest,
getDebugErrorMessage
} from '$lib/components/debug'
import { Bug, Terminal } from 'lucide-svelte'
import { Bug, Hand, Terminal } from 'lucide-svelte'
import Popover from '$lib/components/Popover.svelte'
import { sendUserToast } from '$lib/utils'
import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte'
@@ -133,6 +134,8 @@
/** Lets the agent's Tools section add a tool through the graph's own insert path. */
flowModuleSchemaMap?: import('../map/FlowModuleSchemaMap.svelte').default
toolDescription?: string | undefined
/** The tool's `requires_approval` setting; only an agent tool has one. */
toolRequiresApproval?: boolean
siblingToolNames?: string[]
}
@@ -153,6 +156,7 @@
staticOnly = false,
flowModuleSchemaMap = undefined,
toolDescription = $bindable(undefined),
toolRequiresApproval = $bindable(),
siblingToolNames = undefined
}: Props = $props()
@@ -1149,6 +1153,20 @@
label="Chat"
/>
{/if}
{#if isAgentTool}
<Tab value="tool_settings" label="Tool settings">
{#snippet extra()}
{#if toolRequiresApproval}
<div class="flex flex-row items-center gap-1 text-secondary">
<Popover notClickable>
<Hand size={12} />
{#snippet text()}Require approval · On{/snippet}
</Popover>
</div>
{/if}
{/snippet}
</Tab>
{/if}
{#if !preprocessorModule && !isAgentTool}
<Tab value="advanced" label="Run settings">
{#snippet extra()}
@@ -1395,6 +1413,24 @@
/>
</Section>
</div>
{:else if visibleSelected === 'tool_settings' && isAgentTool}
<div class="flex-1 overflow-auto p-4">
<Section label="Approval">
<Toggle
size="xs"
textClass="text-xs font-normal text-primary"
disabled={noEditor}
bind:checked={
() => toolRequiresApproval ?? false, (v) => (toolRequiresApproval = v)
}
options={{
right: 'Require approval',
rightTooltip:
'Each call waits for someone to approve or reject it from the run page or the chat, without holding a worker. Other calls in the same turn run right away. A rejected call is reported to the agent as not run. An agent used as a tool of another agent cannot wait, so it leaves such tools out.'
}}
/>
</Section>
</div>
{:else if visibleSelected === 'advanced'}
<FlowRunSettings
bind:this={runSettings}
@@ -55,7 +55,4 @@
bind:max_total_debounces_amount
schema={flowStateStore.val[selectedId]?.schema}
placeholder={`$workspace/flow/<flow_path>-${flowModule.id}`}
size="xs"
fontClass="text-xs font-normal text-primary"
indentContent
/>
@@ -29,6 +29,7 @@
import FlowModuleDebounce from './FlowModuleDebounce.svelte'
import FlowModuleMock from './FlowModuleMock.svelte'
import JobTokenScopesSetting from '$lib/components/JobTokenScopesSetting.svelte'
import SettingGroup from '$lib/components/itemSettings/SettingGroup.svelte'
import WorkspaceScriptSettingInfo from './WorkspaceScriptSettingInfo.svelte'
import { slideDynamic } from '$lib/transitions'
@@ -117,21 +118,13 @@
)
</script>
{#snippet sectionHeader(title: string)}
<div class="text-[11px] font-medium uppercase tracking-[0.04em] text-hint">
{title}
</div>
{/snippet}
<div
bind:this={rootEl}
class="flex flex-col gap-8 {embedded ? '' : 'flex-1 min-h-0 overflow-auto p-4 pb-8'}"
style={embedded ? undefined : 'scrollbar-gutter: stable'}
>
{#if !isFailure}
<section class="flex flex-col gap-3">
{@render sectionHeader('Flow control')}
<SettingGroup label="Flow control">
<div class="flex flex-col gap-6">
<div data-setting="skip">
<FlowModuleSkip bind:flowModule {parentModule} {previousModule} />
@@ -148,13 +141,11 @@
<div data-setting="sleep">
<FlowModuleSleep previousModuleId={previousModule?.id} bind:flowModule {isAgentTool} />
</div>
</div></section
</div></SettingGroup
>
{/if}
<section class="flex flex-col gap-3">
{@render sectionHeader('Execution policy')}
<SettingGroup label="Execution policy">
<div class="flex flex-col gap-6">
{#if !loopSubset}
<div data-setting="retries">
@@ -348,48 +339,42 @@
</div>
{/if}
<div data-setting="job-token">
<JobTokenScopesSetting
bind:value={flowModule.job_token_scopes}
kind="step"
size="xs"
textClass="text-xs font-normal text-primary"
/>
</div>
</div></section
<JobTokenScopesSetting bind:value={flowModule.job_token_scopes} kind="step" />
</div></SettingGroup
>
{#if s3Language && onApplyS3Snippet && !isFailure}
<section class="flex flex-col gap-3" data-setting="s3">
{@render sectionHeader('S3 snippets')}
<p class="text-xs text-tertiary">
Read and write S3 objects, and use Polars or DuckDB to run efficient ETL processes.
</p>
<div class="flex flex-row items-center justify-between gap-2">
<ToggleButtonGroup bind:selected={s3Kind} class="w-auto">
{#snippet children({ item })}
{#if s3Language === 'deno'}
<ToggleButton value="s3_client" small label="S3 lite client" {item} />
{:else}
<ToggleButton value="s3_client" small label="Boto3" {item} />
<ToggleButton value="polars" small label="Polars" {item} />
<ToggleButton value="duckdb" small label="DuckDB" {item} />
{/if}
{/snippet}
</ToggleButtonGroup>
<Button
size="xs"
variant="default"
on:click={() => s3Snippet && onApplyS3Snippet?.(s3Snippet)}
>
Apply snippet
</Button>
</div>
{#if s3Snippet}
<div class="overflow-auto max-h-64 border rounded-md">
<HighlightCode language={s3Language} code={s3Snippet} />
<div data-setting="s3">
<SettingGroup label="S3 snippets" class="flex flex-col gap-3">
<p class="text-xs text-tertiary">
Read and write S3 objects, and use Polars or DuckDB to run efficient ETL processes.
</p>
<div class="flex flex-row items-center justify-between gap-2">
<ToggleButtonGroup bind:selected={s3Kind} class="w-auto">
{#snippet children({ item })}
{#if s3Language === 'deno'}
<ToggleButton value="s3_client" small label="S3 lite client" {item} />
{:else}
<ToggleButton value="s3_client" small label="Boto3" {item} />
<ToggleButton value="polars" small label="Polars" {item} />
<ToggleButton value="duckdb" small label="DuckDB" {item} />
{/if}
{/snippet}
</ToggleButtonGroup>
<Button
unifiedSize="xs"
variant="default"
on:click={() => s3Snippet && onApplyS3Snippet?.(s3Snippet)}
>
Apply snippet
</Button>
</div>
{/if}
</section>
{#if s3Snippet}
<div class="overflow-auto max-h-64 border rounded-md">
<HighlightCode language={s3Language} code={s3Snippet} />
</div>
{/if}
</SettingGroup>
</div>
{/if}
</div>

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