diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml
index 2d70ad7f7a..e1628729d8 100644
--- a/.github/workflows/codex-pr-review.yml
+++ b/.github/workflows/codex-pr-review.yml
@@ -42,12 +42,14 @@ jobs:
# skips auto-review for every private member.
# Dependabot-triggered runs get a separate secret scope, so this job has no API
# key and cannot review. workflow_call stays open for an explicit request.
+ # `[skip-ai-review]` in the PR body opts out of the automatic run only.
if: |
github.event_name == 'workflow_call' ||
(
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.fork == false &&
- github.event.pull_request.user.login != 'dependabot[bot]'
+ github.event.pull_request.user.login != 'dependabot[bot]' &&
+ !contains(github.event.pull_request.body, '[skip-ai-review]')
)
permissions:
contents: read
diff --git a/.github/workflows/pi-pr-review.yml b/.github/workflows/pi-pr-review.yml
index a18dc28ff2..4b57db7b78 100644
--- a/.github/workflows/pi-pr-review.yml
+++ b/.github/workflows/pi-pr-review.yml
@@ -40,12 +40,14 @@ jobs:
# skips auto-review for every private member.
# Dependabot-triggered runs get a separate secret scope, so this job has no API
# key and cannot review. workflow_call stays open for an explicit request.
+ # `[skip-ai-review]` in the PR body opts out of the automatic run only.
if: |
github.event_name == 'workflow_call' ||
(
github.event.pull_request.draft == false &&
github.event.pull_request.head.repo.fork == false &&
- github.event.pull_request.user.login != 'dependabot[bot]'
+ github.event.pull_request.user.login != 'dependabot[bot]' &&
+ !contains(github.event.pull_request.body, '[skip-ai-review]')
)
permissions:
contents: read
diff --git a/.github/workflows/pr-ready-review.yml b/.github/workflows/pr-ready-review.yml
index 90714cba53..4b88273cad 100644
--- a/.github/workflows/pr-ready-review.yml
+++ b/.github/workflows/pr-ready-review.yml
@@ -39,12 +39,14 @@ jobs:
# skips auto-review for every private member.
# Dependabot-triggered runs get a separate secret scope, so this job has no API
# key and cannot review. workflow_call stays open for an explicit request.
+ # `[skip-ai-review]` in the PR body opts out of the automatic run only.
if: |
github.event_name == 'workflow_call' ||
(
(github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true) &&
github.event.pull_request.head.repo.fork == false &&
- github.event.pull_request.user.login != 'dependabot[bot]'
+ github.event.pull_request.user.login != 'dependabot[bot]' &&
+ !contains(github.event.pull_request.body, '[skip-ai-review]')
)
permissions:
contents: read
diff --git a/.github/workflows/weekly-pr-summary.yml b/.github/workflows/weekly-pr-summary.yml
index 9d1f0631fb..14551e9943 100644
--- a/.github/workflows/weekly-pr-summary.yml
+++ b/.github/workflows/weekly-pr-summary.yml
@@ -39,9 +39,10 @@ jobs:
- This gives you the exact 7-day window (store these in variables for use in commands)
2. **Fetch ONLY Merged PRs from Past Week**:
- - Command: `gh pr list --repo ${{ github.repository }} --state merged --search "merged:>=$CUTOFF_DATE" --limit 100 --json number,title,author,mergedAt,url`
+ - Command: `gh pr list --repo ${{ github.repository }} --state merged --search "merged:>=$CUTOFF_DATE" --limit 100 --json number,title,author,mergedAt,url,body --jq '[.[] | select((.body // "") | contains("[skip-ai-review]") | not) | del(.body)]'`
- This returns ONLY PRs that were merged in the last 7 days
- The --search flag filters by merge date using GitHub's search syntax
+ - The --jq filter drops PRs whose body contains `[skip-ai-review]`; never fetch or mention those PRs
- **FILTER OUT** any PRs with titles starting with "chore: release" or "chore(release)"
3. **Categorize PRs**: Group PRs into three categories by analyzing titles and labels:
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
index ef65812b73..9edb1a8e87 100644
--- a/.release-please-manifest.json
+++ b/.release-please-manifest.json
@@ -1,3 +1,3 @@
{
- ".": "1.847.0"
+ ".": "1.849.0"
}
diff --git a/AGENTS.md b/AGENTS.md
index c2aae7159d..835c8a4edb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -48,7 +48,7 @@ Open-source platform for internal tools, workflows, API integrations, background
enforced rather than cosmetic, and why a right that is granted-unless-withdrawn needs `Option`
fields and a jsonb merge rather than a serde default
- **Operator builder rights**: `docs/operator-builder-rights.md` — the workspace settings that let
- operators compose flows and full-code apps, what the composition check must cover, which
+ operators compose flows, AI agents and full-code apps, what the composition check must cover, which
raw-app endpoint stays closed on purpose, and why either right costs a full seat
- **Auth surface**: `docs/auth-surface.md` — credential precedence, session/cache invalidation
scope, which token labels email their owner at expiry, how OAuth login matches `login_type`, and
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 12e61b56bc..d0fccb9912 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,62 @@
# Changelog
+## [1.849.0](https://github.com/windmill-labs/windmill/compare/v1.848.3...v1.849.0) (2026-10-25)
+
+
+### Features
+
+* per-user Anthropic Workload Identity Federation for Windmill AI ([#11998](https://github.com/windmill-labs/windmill/issues/11998)) ([6c8fd6c](https://github.com/windmill-labs/windmill/commit/6c8fd6cfe2057f2822230bdf9ad2058f537df996))
+
+## [1.848.3](https://github.com/windmill-labs/windmill/compare/v1.848.2...v1.848.3) (2026-10-25)
+
+
+### Bug Fixes
+
+* pin node to 24.22.1 in the worker image for CVE-2026-111497 ([#11995](https://github.com/windmill-labs/windmill/issues/11995)) ([0e79e93](https://github.com/windmill-labs/windmill/commit/0e79e934b6342150ee31de08a19400de482b1969))
+
+## [1.848.2](https://github.com/windmill-labs/windmill/compare/v1.848.1...v1.848.2) (2026-10-24)
+
+
+### Bug Fixes
+
+* tighten file handling in deno/bun, stricter codebase tarball,executor isolation ([#11990](https://github.com/windmill-labs/windmill/issues/11990)) ([ccf8892](https://github.com/windmill-labs/windmill/commit/ccf889269b8897be707cde639b2531d522c286c9))
+
+## [1.848.1](https://github.com/windmill-labs/windmill/compare/v1.848.0...v1.848.1) (2026-10-23)
+
+
+### Bug Fixes
+
+* restore workspace pg_dump changes reverted by 812ff6ea5e ([#11988](https://github.com/windmill-labs/windmill/issues/11988)) ([45d3c95](https://github.com/windmill-labs/windmill/commit/45d3c951bb8827c67daaaf75ed52f3c3e1215911))
+* use linkedin openid connect default scopes ([#11953](https://github.com/windmill-labs/windmill/issues/11953)) ([001313a](https://github.com/windmill-labs/windmill/commit/001313aa242c6de053a9deb0c6cdb64580ce1cee))
+
+## [1.848.0](https://github.com/windmill-labs/windmill/compare/v1.847.0...v1.848.0) (2026-10-23)
+
+
+### Features
+
+* add a use/plan/build mode picker and read-only prod sessions ([#11940](https://github.com/windmill-labs/windmill/issues/11940)) ([d6c2b2c](https://github.com/windmill-labs/windmill/commit/d6c2b2c68344bb95939701c418116b2e7d4715a3))
+* composer pills that teach Windmill from the workspace ([#11889](https://github.com/windmill-labs/windmill/issues/11889)) ([ebeea51](https://github.com/windmill-labs/windmill/commit/ebeea514926f60295f3f11f516f4ccda3448f829))
+* fill docusign, salesforce and quickbooks fields on connect ([#11960](https://github.com/windmill-labs/windmill/issues/11960)) ([11f2a4c](https://github.com/windmill-labs/windmill/commit/11f2a4c7ffac980036f839759ae93da6ff3f22e4))
+* group home row menus and open them at their final height ([#11949](https://github.com/windmill-labs/windmill/issues/11949)) ([91bf9b6](https://github.com/windmill-labs/windmill/commit/91bf9b67a74afb419659719949484f1cfd5338c0))
+* let flow builder operators build AI agents ([#11898](https://github.com/windmill-labs/windmill/issues/11898)) ([e5dedd1](https://github.com/windmill-labs/windmill/commit/e5dedd147e1db0c4f36796ba292188bcdb0efc5a))
+* manage and create folders from the home tree view ([#11943](https://github.com/windmill-labs/windmill/issues/11943)) ([da443d2](https://github.com/windmill-labs/windmill/commit/da443d2fb263ecaf90320809b1bb2fc8a4f65b20))
+* run a workspace handler when a variable's value expires ([#10837](https://github.com/windmill-labs/windmill/issues/10837)) ([7a29abc](https://github.com/windmill-labs/windmill/commit/7a29abc7eec491c35217445c1c08486ff030a22c))
+
+
+### Bug Fixes
+
+* change an app's access mode under the deploy row lock ([#11965](https://github.com/windmill-labs/windmill/issues/11965)) ([725b15f](https://github.com/windmill-labs/windmill/commit/725b15f546cf20c2e5908acffb105d73fe907558))
+* fork a raw app into the raw app builder, in its session when there ([#11978](https://github.com/windmill-labs/windmill/issues/11978)) ([3d6a2bc](https://github.com/windmill-labs/windmill/commit/3d6a2bcef2e284d64690db04db32a2326a4b0670))
+* home's acting-on follows the workspace home switches to ([#11946](https://github.com/windmill-labs/windmill/issues/11946)) ([18c6936](https://github.com/windmill-labs/windmill/commit/18c69360c9a8e9a9fe4c7c1d9e825a2b7be403a9))
+* keep a staged flow path across reloads and follow moves in session tabs ([#11967](https://github.com/windmill-labs/windmill/issues/11967)) ([6b56656](https://github.com/windmill-labs/windmill/commit/6b56656bd650f2db5fbd74e23b55b926c6f69dd7))
+* keep the log indexer starting when a stored day chunk is broken ([#11979](https://github.com/windmill-labs/windmill/issues/11979)) ([79fe8ab](https://github.com/windmill-labs/windmill/commit/79fe8ab03f6f052bf7fe763b6f8a0a23fe922611))
+* list usr columns explicitly in whoami's user lookup ([#11970](https://github.com/windmill-labs/windmill/issues/11970)) ([fb193d7](https://github.com/windmill-labs/windmill/commit/fb193d7a816ae97fa8fc60608270296e159dfd3c))
+* make the autoscaling Test scaling button run the integration ([#11972](https://github.com/windmill-labs/windmill/issues/11972)) ([72c6b10](https://github.com/windmill-labs/windmill/commit/72c6b10821cfa35ff3deb3a3ecc729ed75a9dbc6))
+* return variable.value as text so widening it changes no result type ([#11976](https://github.com/windmill-labs/windmill/issues/11976)) ([f6f3a0a](https://github.com/windmill-labs/windmill/commit/f6f3a0a1f24cd9ea0d78207d6848147ebd73bea0))
+* run unstamped git auto-pulls as a superadmin when no admin ([#11973](https://github.com/windmill-labs/windmill/issues/11973)) ([c89a827](https://github.com/windmill-labs/windmill/commit/c89a82750ed0a461d8d4f1502f019bc0da4ed58c))
+* send app editor inspector picks to a new AI session as a chip ([#11966](https://github.com/windmill-labs/windmill/issues/11966)) ([28d2556](https://github.com/windmill-labs/windmill/commit/28d2556c941f66cad353eca3809f43e8d8cf3c05))
+* share settings.yaml's git sync block between a fork and its parent ([#11974](https://github.com/windmill-labs/windmill/issues/11974)) ([fe38a1d](https://github.com/windmill-labs/windmill/commit/fe38a1d3763a11f8b0bc4b07d61824d3a7b51850))
+
## [1.847.0](https://github.com/windmill-labs/windmill/compare/v1.846.1...v1.847.0) (2026-10-22)
diff --git a/Dockerfile b/Dockerfile
index ad3a79a2b6..93862fe0b9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -144,6 +144,9 @@ ARG KUBECTL_VERSION=1.37.1
ARG HELM_VERSION=3.21.4
# NOTE: If changing, also change go version in workspace dependencies template at WorkspaceDependenciesEditor.svelte
ARG GO_VERSION=1.27.1
+# Pinned to the patch: an unpinned install is cached by the image build and stops picking up
+# security releases.
+ARG NODE_VERSION=24.22.1
ARG APP=/usr/src/app
ARG WITH_POWERSHELL=true
ARG WITH_KUBECTL=true
@@ -252,8 +255,8 @@ RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_r
RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $NEW_INSTANCE_PY --compile-bytecode
-RUN curl -sL https://deb.nodesource.com/setup_24.x | bash -
-RUN apt-get -y update && apt-get install -y --no-install-recommends curl procps nodejs awscli && apt-get clean \
+RUN curl -sL https://deb.nodesource.com/setup_${NODE_VERSION%%.*}.x | bash -
+RUN apt-get -y update && apt-get install -y --no-install-recommends curl procps nodejs=${NODE_VERSION}-1nodesource1 awscli && apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# The npm bundled with nodejs is replaced by a pinned one: it is what `//npm` scripts install with
diff --git a/LICENSE b/LICENSE
index aea6c58c6f..7e682e7dd1 100644
--- a/LICENSE
+++ b/LICENSE
@@ -30,5 +30,18 @@ quotas set in the software and a right to distribute the community edition as is
but not to sell, resell, serve as a managed service, modify or wrap under any
form without an explicit agreement.
+To re-expose directly any Windmill parts to your users (see
+https://www.windmill.dev/docs/misc/white_labelling) as a feature of your
+product, with the exception of iframed public Windmill "apps", or to build a
+feature on top of "Windmill Community Edition" that you sell commercially or
+embed in a distributable product or binary, you must get a commercial license.
+Contact us at sales@windmill.dev if you have any questions. To do the same from
+the binary compiled from the source code in this repository without the
+"enterprise" feature flag, you must comply with the AGPLv3 license terms and
+conditions or get a commercial license from Windmill Labs, Inc.
+
+To use Windmill "Community Edition" as is internally in your organization, or to
+use its APIs as is, you do NOT need a commercial license.
+
All third party components incorporated into the Windmill Software are licensed
under the original license provided by the owner of the applicable component.
diff --git a/README.md b/README.md
index 597a9142b2..df877e2c0f 100644
--- a/README.md
+++ b/README.md
@@ -3,10 +3,10 @@
-Open-source developer platform for internal code: APIs, background jobs, workflows and UIs. Self-hostable alternative to Retool, Pipedream, Superblocks and a simplified Temporal with autogenerated UIs and custom UIs to trigger workflows and scripts as internal apps.
+Open-source, self-hostable code-first platform for workflows, apps and AI agents, running on the fastest self-hostable orchestrator. Engineers write code in 20+ languages and sync it with Git. Business teams build on the same platform with AI, while review, promotion between environments and isolated workers keep engineers in control of what ships. Self-hostable alternative to Lovable, Retool, n8n and a simplified Temporal.
-Scripts are turned into sharable UIs automatically, and can be composed together into flows or used into richer apps built with low-code. Supported languages: Python, TypeScript, Go, Bash, SQL, GraphQL, PowerShell, Rust, and more.
+Scripts are turned into sharable UIs, APIs and cron jobs automatically, and can be composed together into flows, data pipelines, AI agents or full-code React and Svelte apps. Supported languages: Python, TypeScript, Go, Bash, SQL, GraphQL, PowerShell, Rust, and more.
@@ -34,7 +34,7 @@ Scripts are turned into sharable UIs automatically, and can be composed together
Try it - Website - Docs - Discord - Hub - Contributing
-# Windmill - Developer platform for APIs, background jobs, workflows and UIs
+# Windmill - Code-first platform for workflows, apps and AI agents
Windmill is fully open-sourced (AGPLv3) and Windmill Labs offers dedicated instances and commercial support and licenses.
@@ -42,10 +42,12 @@ Windmill is fully open-sourced (AGPLv3) and Windmill Labs offers dedicated insta
https://github.com/user-attachments/assets/d80de1d9-64de-4d89-aacd-6df23fa81fc4
-- [Windmill - Developer platform for APIs, background jobs, workflows and UIs](#windmill---developer-platform-for-apis-background-jobs-workflows-and-uis)
+- [Windmill - Code-first platform for workflows, apps and AI agents](#windmill---code-first-platform-for-workflows-apps-and-ai-agents)
+ - [Build with AI, ship with review](#build-with-ai-ship-with-review)
- [Main Concepts](#main-concepts)
- - [Show me some actual script code](#show-me-some-actual-script-code)
- - [Local Development](#local-development)
+ - [What the code looks like](#what-the-code-looks-like)
+ - [Data: data tables, DuckDB and DuckLake](#data-data-tables-duckdb-and-ducklake)
+ - [Develop locally or in the browser](#develop-locally-or-in-the-browser)
- [Stack](#stack)
- [Fastest Self-Hostable Workflow Engine](#fastest-self-hostable-workflow-engine)
- [Security](#security)
@@ -59,13 +61,21 @@ https://github.com/user-attachments/assets/d80de1d9-64de-4d89-aacd-6df23fa81fc4
- [License](#license)
- [Integrations](#integrations)
- [Environment Variables](#environment-variables)
- - [Run a local dev setup](#run-a-local-dev-setup)
- - [Frontend only](#frontend-only)
- - [Backend + Frontend](#backend--frontend)
- [Contributing](#contributing)
- - [Contributors](#contributors)
- [Copyright](#copyright)
+## Build with AI, ship with review
+
+Business teams and engineers build on the same Windmill instance. What the AI builds is an ordinary script, flow or app, and goes through the same review as code written by engineers:
+
+- [AI sessions](https://www.windmill.dev/docs/core_concepts/ai_sessions) build scripts, flows and apps with AI in a workspace fork, with a live preview and a diff to review before deploying.
+- [Workspace roles](https://www.windmill.dev/docs/core_concepts/roles_and_permissions#workspace-roles) decide what each team can author, and [folders](https://www.windmill.dev/docs/core_concepts/groups_and_folders) scope the resources, skills and MCP servers its AI can reach.
+- [Protection rulesets](https://www.windmill.dev/docs/core_concepts/protection_rulesets) block direct deployment, so changes reach production only after an engineer reviews and deploys them.
+- [Dev workspaces](https://www.windmill.dev/docs/advanced/dev_workspaces) keep their own credentials, and promote to prod through Compare & Deploy or a Git pull request.
+- [Worker groups](https://www.windmill.dev/docs/core_concepts/worker_groups) run AI-built code on its own workers, or on a [separate instance](https://www.windmill.dev/docs/core_concepts/staging_prod#deploy-to-a-workspace-on-another-instance).
+
+See [let business teams build with AI safely](https://www.windmill.dev/docs/advanced/ai_guardrails) for the full setup. Engineers keep writing code locally with the [CLI](https://www.windmill.dev/docs/advanced/cli), the [VS Code extension](https://www.windmill.dev/docs/cli_local_dev/vscode-extension) and their coding agent, synced with [Git](https://www.windmill.dev/docs/advanced/git_sync).
+
## Main Concepts
1. Define a minimal and generic script in Python, TypeScript, Go or Bash that solves a specific task. The code can be defined in the provided Web IDE or synchronized with your own GitHub repo (e.g. through VS Code extension): [provided Web IDE](https://www.windmill.dev/docs/code_editor) or [synchronized with your own GitHub repo](https://www.windmill.dev/docs/advanced/cli/sync) (e.g. through [VS Code](https://www.windmill.dev/docs/cli_local_dev/vscode-extension) extension):
@@ -90,54 +100,93 @@ Scripts and flows can be triggered by [schedules](https://www.windmill.dev/docs/
Build your entire infra on top of Windmill!
-## Show me some actual script code
+## What the code looks like
+
+A script is a plain function. Its typed arguments become a generated form, an HTTP endpoint and a CLI command:
```typescript
-//import any dependency from npm
import * as wmill from "windmill-client";
-import * as cowsay from "cowsay@1.5.0";
-
-// fill the type, or use the +Resource type to get a type-safe reference to a resource
-type Postgresql = {
- host: string;
- port: number;
- user: string;
- dbname: string;
- sslmode: string;
- password: string;
-};
+import dayjs from "dayjs"; // any npm package, resolved and pinned automatically
export async function main(
- a: number,
- b: "my" | "enum",
- c: Postgresql,
- d = "inferred type string from default arg",
- e = { nested: "object" }
- //f: wmill.Base64
+ customerId: string,
+ plan: "starter" | "pro", // a union of strings becomes a dropdown
+ trialDays = 14 // defaults infer the type
) {
- const email = process.env["WM_EMAIL"];
- // variables are permissioned and by path
- let variable = await wmill.getVariable("f/company-folder/my_secret");
- const lastTimeRun = await wmill.getState();
- // logs are printed and always inspectable
- console.log(cowsay.say({ text: "hello " + email + " " + lastTimeRun }));
- await wmill.setState(Date.now());
+ const trialEnd = dayjs().add(trialDays, "day").toISOString();
- // return is serialized as JSON
- return { foo: d, variable };
+ // a data table: the workspace's Postgres, queried without a connection string
+ const sql = wmill.datatable();
+ await sql`INSERT INTO trials (customer_id, plan, trial_end) VALUES (${customerId}, ${plan}, ${trialEnd})`.fetch();
+
+ // secrets are permissioned by path
+ const slackToken = await wmill.getVariable("f/sales/slack_token");
+ await fetch("https://slack.com/api/chat.postMessage", {
+ method: "POST",
+ headers: { Authorization: `Bearer ${slackToken}`, "Content-Type": "application/json" },
+ body: JSON.stringify({ channel: "#sales", text: `New ${plan} trial: ${customerId}` }),
+ });
+
+ console.log(`Trial started for ${customerId}`); // logs stream live in the UI
+ return { customerId, plan, trialEnd }; // returned as JSON
}
```
-## Local Development
+Workflows are code too. Each task runs as its own job with its own logs, and waiting for an approval releases the worker:
-Windmill supports multiple ways to develop locally and sync with your instance:
+```typescript
+import { task, waitForApproval, workflow } from "windmill-client";
+
+async function plan(env: string) {
+ return { env, changes: 3 };
+}
+
+async function apply(plan: { env: string; changes: number }) {
+ return `applied ${plan.changes} changes to ${plan.env}`;
+}
+
+export const main = workflow(async (env: string) => {
+ const changes = await task(plan)(env);
+ const approval = await waitForApproval({ timeout: 3600 });
+ if (!approval.approved) {
+ return `rejected by ${approval.approver}`;
+ }
+ return await task(apply)(changes);
+});
+```
+
+Flows can also be built in the visual [flow editor](https://www.windmill.dev/docs/flows/flow_editor) or as YAML, with scripts like the first one as steps. See [workflows as code](https://www.windmill.dev/docs/core_concepts/workflows_as_code).
+
+## Data: data tables, DuckDB and DuckLake
+
+- [Data tables](https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables) give each workspace managed Postgres tables, queried with type-checked SQL from TypeScript, Python and DuckDB scripts without exposing a connection string.
+- [DuckDB](https://www.windmill.dev/docs/integrations/duckdb) scripts query S3 and Azure Blob files (CSV, Parquet, JSON), data tables, Postgres, MySQL and BigQuery in SQL, on your workers.
+- [DuckLake](https://www.windmill.dev/docs/core_concepts/persistent_storage/ducklake) stores large datasets as lakehouse tables in your workspace object storage, queried in plain SQL.
+- [Pipelines](https://www.windmill.dev/docs/core_concepts/pipelines) (alpha) wire scripts by the data they read and write instead of a hand-drawn DAG. This script runs whenever `events` is materialized, and materializes its own table:
+
+```sql
+-- pipeline
+-- on ducklake://main/events
+-- materialize ducklake://main/events_by_kind
+ATTACH 'ducklake://main' AS dl;
+SELECT kind, count(*) AS n
+FROM dl.events
+GROUP BY kind;
+```
+
+Existing [dbt projects](https://www.windmill.dev/docs/getting_started/scripts_quickstart/dbt) run unmodified as scripts. See the [pipelines quickstart](https://www.windmill.dev/docs/getting_started/pipeline_quickstart).
+
+## Develop locally or in the browser
+
+Engineers work from their editor and coding agent, business teams build with AI in the browser, and both land in the same workspace:
| Tool | Description |
|------|-------------|
-| **[CLI](https://www.windmill.dev/docs/advanced/cli)** | Sync scripts from local files or GitHub, run scripts/flows from the command line |
-| **[VS Code Extension](https://www.windmill.dev/docs/cli_local_dev/vscode-extension)** | Edit and test scripts & flows directly from VS Code / Cursor with full IDE support |
-| **[Git Sync](https://www.windmill.dev/docs/advanced/git_sync)** | Two-way sync between Windmill and your Git repository |
-| **[Claude Code](https://www.windmill.dev/docs/core_concepts/ai_generation)** | AI-assisted development with Claude for scripts, flows, and apps |
+| **[CLI](https://www.windmill.dev/docs/advanced/cli)** | Sync a workspace with local files and Git, run scripts and flows from the command line. `wmill init` generates `AGENTS.md` and [skill files](https://www.windmill.dev/docs/misc/guides/local_dev_with_ai) for Claude Code, Cursor and other AI tools |
+| **[VS Code extension](https://www.windmill.dev/docs/cli_local_dev/vscode-extension)** | Edit and test scripts and flows from VS Code or Cursor with full IDE support |
+| **[Git sync](https://www.windmill.dev/docs/advanced/git_sync)** | Two-way sync between a workspace and your Git repository |
+| **[MCP server](https://www.windmill.dev/docs/core_concepts/mcp)** | Let coding agents and AI clients run scripts, manage resources and monitor jobs in a workspace |
+| **[AI sessions](https://www.windmill.dev/docs/core_concepts/ai_sessions)** | The same agent loop in the browser, with nothing to install: plan mode, skills and MCP servers, a live preview, and a diff to review before deploying, in a fork of the workspace |
https://github.com/user-attachments/assets/c541c326-e9ae-4602-a09a-1989aaded1e9
@@ -185,11 +234,12 @@ back to the database is ~50ms. A typical lightweight deno job will take around
## Architecture
-
+Stateless API servers and workers share a PostgreSQL database that holds the job queue and all workspace state (remote agent workers go through the API servers instead). Workers pull jobs by tag, so each worker group can run in its own network, for example dev workers that only reach dev systems and prod workers that only run reviewed code.
-
-
-
+
+
+
+
## How to self-host
@@ -230,15 +280,11 @@ Configure OAuth and SSO (Google Workspace, Microsoft/Azure, Okta) directly from
### License
-The Community Edition is free to use internally. For commercial redistribution or managed services, contact . See [LICENSE](./LICENSE) and [Pricing](https://www.windmill.dev/pricing) for details.
+- Using the Community Edition as is, internally or through its APIs, is free and needs no commercial license, within the limits and quotas set in the software.
+- Selling, reselling or serving the Community Edition as a managed service, modifying or wrapping it, or re-exposing it to your users as a feature of your product (iframed public apps excepted) requires a commercial license: contact .
+- The binary built from this repository without the `enterprise` feature flag is open source under [AGPLv3](./LICENSE-AGPL): the same uses are then allowed under the AGPLv3 terms, or with a commercial license. The API clients and the OpenAPI spec are [Apache 2.0](./LICENSE-APACHE).
-The "Community Edition" of Windmill available in the docker images hosted under ghcr.io/windmill-labs/windmill and the github binary releases contains the files under the AGPLv3 and Apache 2 sources but also includes proprietary and non-public code and features which are not open source and under the following terms: Windmill Labs, Inc. grants a right to use all the features of the "Community Edition" for free without restrictions other than the limits and quotas set in the software and a right to distribute the community edition as is but not to sell, resell, serve Windmill as a managed service, modify or wrap under any form without an explicit agreement.
-
-The binary compilable from source code in this repository without the "enterprise" feature flag is open-source under the [LICENSE-AGPLv3](https://github.com/windmill-labs/windmill/blob/main/LICENSE-AGPL) License terms and conditions.
-
-To [re-expose directly any Windmill parts to your users](https://www.windmill.dev/docs/misc/white_labelling) as a feature of your product, with the exception of iframed public Windmill "apps", or to build a feature on top of "Windmill Community Edition" that you sell commercially or embed in a distributable product or binary, you must get a commercial license. Contact us at if you have any questions. To do the same from the binary compiled from the source code in this repository without the "enterprise" feature flag, you must comply with the AGPLv3 license terms and conditions or get a commercial license from Windmill Labs, Inc.
-
-To use Windmill "Community Edition" as is internally in your organization, or to use its APIs as is, you do NOT need a commercial license.
+See [LICENSE](./LICENSE) for the full terms and [Pricing](https://www.windmill.dev/pricing) for plans.
### Integrations
@@ -249,88 +295,17 @@ On self-hosted instances, you might want to import all the approved resource typ
## Environment Variables
-| Environment Variable name | Default | Description | Api Server/Worker/All |
-| ----------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- |
-| DATABASE_URL | | The Postgres database url. | All |
-| WORKER_GROUP | default | The worker group the worker belongs to and get its configuration pulled from | Worker |
-| MODE | standalone | The mode if the binary. Possible values: standalone, worker, server, agent | All |
-| METRICS_ADDR | None | (ee only) The socket addr at which to expose Prometheus metrics at the /metrics path. Set to "true" to expose it on port 8001 | All |
-| JSON_FMT | false | Output the logs in json format instead of logfmt | All |
-| BASE_URL | http://localhost:8000 | The base url that is exposed publicly to access your instance. Is overriden by the instance settings if any. | Server |
-| ZOMBIE_JOB_TIMEOUT | 30 | The timeout after which a job is considered to be zombie if the worker did not send pings about processing the job (every server check for zombie jobs every 30s) | Server |
-| RESTART_ZOMBIE_JOBS | true | If true then a zombie job is restarted (in-place with the same uuid and some logs), if false the zombie job is failed | Server |
-| NATIVE_MODE | false | Enable native mode: sets NUM_WORKERS=8, rejects non-native jobs (nativets, postgresql, mysql, etc.) | Worker |
-| SLEEP_QUEUE | 50 | The number of ms to sleep in between the last check for new jobs in the DB. It is multiplied by NUM_WORKERS such that in average, for one worker instance, there is one pull every SLEEP_QUEUE ms. | Worker |
-| KEEP_JOB_DIR | false | Keep the job directory after the job is done. Useful for debugging. | Worker |
-| EXIT_AFTER_N_JOBS | None | Exit the worker process after it has executed that many jobs, so that a supervisor restarts it and no process runs more than that many, bar the steps of a same-worker flow it has started, which it always finishes (set it to 1 for a process per job; jobs handed to a dedicated worker, and the worker's own init and periodic scripts, do not count). Not counting the init and periodic scripts means they run again on every restart: an init script's runtime is added to the latency of every batch of that many jobs, and a periodic script fires once per process start whatever its interval says. The worker's shell in the workers page also starts backed off rather than after the two minutes it otherwise takes, since a process due to be recycled cannot count on living that long: the first command of a session can wait up to 15s, later ones are immediate. For deployments that isolate executions by process lifetime rather than with nsjail; note that a container restart resets the process, not the container filesystem, so caches and `/tmp` survive it. The worker name is then derived from the hostname instead of being random, so the restarted worker keeps its row in the workers list (an agent worker keeps the row but restarts its job count). Use one worker per process: workers of one process share its environment, so the first to reach the limit shuts the others down too. | Worker |
-| WORKER_SUFFIX | None | Pins the last part of the worker name, which is otherwise random, so that a restarted worker keeps its row in the workers list. Only needed when several worker processes of the same worker group run on one host, since the name is derived from the hostname: give each of them a distinct value, as two processes sharing one must never happen. At most 64 letters, digits and underscores; anything else is refused at startup. | Worker |
-| LICENSE_KEY (EE only) | None | License key checked at startup for the Enterprise Edition of Windmill | Worker |
-| SLACK_SIGNING_SECRET | None | The signing secret of your Slack app. See [Slack documentation](https://api.slack.com/authentication/verifying-requests-from-slack) | Server |
-| COOKIE_DOMAIN | None | The domain of the cookie. If not set, the cookie will be set by the browser based on the full origin | Server |
-| DENO_PATH | /usr/bin/deno | The path to the deno binary. | Worker |
-| PYTHON_PATH | | The path to the python binary if wanting to not have it managed by uv. | Worker |
-| GO_PATH | /usr/bin/go | The path to the go binary. | Worker |
-| GOPRIVATE | | The GOPRIVATE env variable to use private go modules | Worker |
-| GOPROXY | | The GOPROXY env variable to use | Worker |
-| NETRC | | The netrc content to use a private go registry | Worker |
-| PY_CONCURRENT_DOWNLOADS | 20 | Sets the maximum number of in-flight concurrent python downloads that windmill will perform at any given time. | Worker |
-| PATH | None | The path environment variable, usually inherited | Worker |
-| HOME | None | The home directory to use for Go and Bash , usually inherited | Worker |
-| DATABASE_CONNECTIONS | 50 (Server)/3 (Worker) | The max number of connections in the database connection pool | All |
-| SUPERADMIN_SECRET | None | A token that would let the caller act as a virtual superadmin superadmin@windmill.dev | Server |
-| TIMEOUT_WAIT_RESULT | 20 | The number of seconds to wait before timeout on the 'run_wait_result' endpoint | Worker |
-| QUEUE_LIMIT_WAIT_RESULT | None | The number of max jobs in the queue before rejecting immediately the request in 'run_wait_result' endpoint. Takes precedence on the query arg. If none is specified, there are no limit. | Worker |
-| DENO_AUTH_TOKENS | None | Custom DENO_AUTH_TOKENS to pass to worker to allow the use of private modules | Worker |
-| DISABLE_RESPONSE_LOGS | false | Disable response logs | Server |
-| CREATE_WORKSPACE_REQUIRE_SUPERADMIN | true | If true, only superadmins can create new workspaces | Server |
-| MIN_FREE_DISK_SPACE_MB | 15000 | Minimum amount of free space on worker. Sends critical alert if worker has less free space. | Worker |
-| RUN_UPDATE_CA_CERTIFICATE_AT_START | false | If true, runs CA certificate update command at startup before other initialization | All |
-| RUN_UPDATE_CA_CERTIFICATE_PATH | /usr/sbin/update-ca-certificates | Path to the CA certificate update command/script to run when RUN_UPDATE_CA_CERTIFICATE_AT_START is true | All |
-| GOOGLE_APPLICATION_CREDENTIALS | None | (ee only) Credentials file for GCP Pub/Sub triggers that authenticate as the instance rather than through a `gcloud` resource (workspace admins only). Application default credentials also resolve the gcloud well-known file and the GCE metadata server. Workload Identity Federation files work with the `file`, `url` and `aws` credential sources; the `executable` source is not supported. | Server |
+The variables most deployments set:
-## Run a local dev setup
+| Environment Variable name | Default | Description | Api Server/Worker/All |
+| ------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------- |
+| DATABASE_URL | | The Postgres database url. | All |
+| MODE | standalone | The mode of the binary: standalone, server, worker, agent (EE), indexer (EE image, full-text search), mcp | All |
+| WORKER_GROUP | default | The worker group the worker belongs to and gets its configuration pulled from | Worker |
+| BASE_URL | http://localhost:8000 | The base url that is exposed publicly to access your instance. Is overriden by the instance settings if any. | Server |
+| LICENSE_KEY (EE only) | None | License key checked at startup for the Enterprise Edition of Windmill | Worker |
-We recommend using [Nix](./frontend/README_DEV.md#nix). See [./frontend/README_DEV.md](./frontend/README_DEV.md) for all options.
-
-### Frontend only
-
-Uses the backend of with local frontend (hot-reload):
-
-```bash
-cd frontend
-npm install
-npm run generate-backend-client # or generate-backend-client-mac on Mac
-npm run dev
-```
-
-Windmill available at `http://localhost/`
-
-### Backend + Frontend
-
-See the [./frontend/README_DEV.md](./frontend/README_DEV.md) file for all
-running options.
-
-1. Start a local Postgres database using for instance the `start-dev-db.sh` script which will make a database available at `postgres://postgres:changeme@localhost:5432/windmill`
- Then run the migrations using the following command:
- ```
- cargo install sqlx-cli
- env DATABASE_URL= sqlx migrate run
- ```
- This will also avoid compile time issue with sqlx's `query!` macro.
-2. (optional, linux only) Install [nsjail](https://github.com/google/nsjail) and have it accessible in
- your PATH
-3. Install bun and python3 (+ any languages you want to use), have the bins at `/usr/bin/bun` and
- `/usr/local/bin/python3` or set the corresponding environment variables.
-4. (optional) Install the [lld linker](https://lld.llvm.org/)
-5. Go to `frontend/`:
- 1. `npm install`, `npm run generate-backend-client` then `REMOTE=http://localhost:8000 npm run dev`
- 2. You might need to set some extra heap space for the node runtime
- `export NODE_OPTIONS="--max-old-space-size=4096"`
- 3. Create an empty `frontend/build` folder using `mkdir frontend/build`
-6. Go to `backend/`:
- 1. `env DATABASE_URL= RUST_LOG=info cargo run`
- 2. You can specify any feature flag you want to enable, for example `cargo run --features python` to enable the python executor.
-7. Windmill should be available at `http://localhost:3000`
+All other variables (database pool, queue, runtimes, private registries, certificates, zombie jobs and more) are documented in the [environment variables reference](https://www.windmill.dev/docs/core_concepts/environment_variables).
## Contributing
@@ -338,11 +313,7 @@ At this time, we are not seeking outside contribution. Bug reports and feature r
welcome, and small, trivially-verified PRs that fix a problem are still accepted. See
[CONTRIBUTING.md](./CONTRIBUTING.md) for the full policy.
-## Contributors
-
-
-
-
+To run Windmill from source, see [frontend/README_DEV.md](./frontend/README_DEV.md).
## Copyright
diff --git a/ai_evals/README.md b/ai_evals/README.md
index d458e019d0..67592daee4 100644
--- a/ai_evals/README.md
+++ b/ai_evals/README.md
@@ -195,6 +195,15 @@ profile:
operatorSettings: { builder_apps: true } # also builder_flows, manage_schedules, manage_triggers
```
+A role that builds nothing (no builder rights, `manage_schedules` and `manage_triggers` both
+`false`) holds the session in Use, as the chat does in production: the toolset is the
+read-only one and the prompt carries the session section that names the role as the reason.
+
+`judgeAssistantText: true` hands the judge what the assistant said alongside the mode's
+output. Use it when the deliverable is the answer itself, such as a refusal and where to go
+instead: the `global` judge otherwise reads only the drafts, and a substring check cannot tell
+"you can't create it yourself" from "create it yourself".
+
`toolExpect.toolCallArgs` entries support `sharedByAtLeast: `: at least `n` recorded
calls to that tool must carry the same non-blank string in the field. Use it for calls that
have to share an identifier, like two test runs of one chat conversation.
diff --git a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts
index e70dbfcfe5..c648848387 100644
--- a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts
+++ b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts
@@ -5,9 +5,17 @@ import type { ChatCompletionMessageParam } from "openai/resources/chat/completio
import type { AIProvider, OperatorSettings } from "$lib/gen/types.gen";
import {
globalToolsFor,
- prepareGlobalSystemMessage,
prepareGlobalUserMessage,
} from "../../../../../frontend/src/lib/components/copilot/chat/global/core";
+import { assembleGlobalSystemMessage } from "../../../../../frontend/src/lib/components/copilot/chat/global/globalAssembly";
+import {
+ canBuild,
+ USE_MODE_ACCESS,
+} from "../../../../../frontend/src/lib/components/copilot/chat/sessionCapabilities";
+import {
+ FULL_CAPABILITIES,
+ legacyOperatorCapabilities,
+} from "../../../../../frontend/src/lib/roleCapabilities";
import {
clearGlobalDrafts,
getGlobalDraft,
@@ -155,15 +163,24 @@ export async function runGlobalEval(
options.workspaceFixtures ?? {},
);
seedLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []);
- // No deploy rules in the mock workspace, so they always pass.
- const access =
+ // No deploy rules in the mock workspace, so they always pass. The role is read as a server
+ // that predates workspace roles describes it, from `operator_settings`.
+ const roleAccess =
options.profile &&
capabilitiesForRole({
isAdmin: false,
- operator: options.profile.operator,
deployRulesPass: true,
- operatorSettings: options.profile.operatorSettings as OperatorSettings,
+ capabilities: options.profile.operator
+ ? legacyOperatorCapabilities(
+ options.profile.operatorSettings as OperatorSettings,
+ )
+ : FULL_CAPABILITIES,
});
+ // A session whose role builds nothing is held in Use, as the chat manager holds it: the
+ // read-only toolset, and the session section that names the role as the reason.
+ const heldInUse =
+ !!options.sessionChat && !!roleAccess && !canBuild(roleAccess);
+ const access = heldInUse ? USE_MODE_ACCESS : roleAccess;
seedOperatorSettings(workspaceRoot, options.profile);
// Declared out here only so `finally` can reach it; a malformed fixture throws while
// building it, and everything seeded above still has to be torn down.
@@ -179,7 +196,7 @@ export async function runGlobalEval(
);
}
// Production resolves a profile for session chats only; elsewhere every tool ships.
- if (!options.sessionChat && access) {
+ if (!options.sessionChat && roleAccess) {
throw new Error(
"This case sets runtime.profile, which only a session chat applies — set runtime.sessionChat: true on the case.",
);
@@ -203,10 +220,13 @@ export async function runGlobalEval(
: undefined;
// Pass the seeded identity straight to the prompt builder rather than mutating
// the process-global `userStore`, so concurrent cases never race on it.
- const baseSystemMessage = prepareGlobalSystemMessage(undefined, {
+ const baseSystemMessage = assembleGlobalSystemMessage(undefined, {
user: options.user,
previewTools: options.sessionChat ?? false,
access,
+ ...(heldInUse && {
+ sessionContext: { workspaceId: workspaceRoot, useMode: "role" as const },
+ }),
});
const helpers = {
...evalArtifacts.helpers,
diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts
index 69398555f0..1de463690b 100644
--- a/ai_evals/adapters/frontend/mockBackend.ts
+++ b/ai_evals/adapters/frontend/mockBackend.ts
@@ -11,8 +11,13 @@ import type {
ListableApp,
ListableResource,
ListableVariable,
+ Folder,
+ Group,
+ QueuedJob,
Resource,
- Script
+ Schedule,
+ Script,
+ WorkerPing
} from '../../../frontend/src/lib/gen'
import type {
DataMetric,
@@ -97,6 +102,14 @@ export interface BenchmarkWorkspaceResource {
resource_type: string
value?: Record
description?: string
+ /** A database's tables, as `get_db_schema` reads them: schema, table, then column. */
+ dbSchema?: Record>>
+}
+
+export interface BenchmarkDbColumn {
+ type: string
+ required: boolean
+ default?: string
}
/** A resource type the instance holds. */
@@ -117,6 +130,48 @@ export interface BenchmarkWorkspaceJob {
logs?: string
args?: Record
result?: unknown
+ /** Defaults to `completed`. `suspended` is a running flow parked on an approval step. */
+ state?: 'completed' | 'queued' | 'running' | 'suspended'
+ tag?: string
+ schedulePath?: string
+ /** When the run was created, relative to the case start. Unset = now. */
+ createdMinutesAgo?: number
+ /** Completed runs only: how long the run took. */
+ durationMs?: number
+}
+
+/** A worker of the instance, as `list_workers` reports it. */
+export interface BenchmarkWorkspaceWorker {
+ worker: string
+ worker_group: string
+ custom_tags?: string[]
+ lastPingSecondsAgo?: number
+ jobs_executed?: number
+}
+
+/** A folder of the benchmark workspace, as `read_workspace_item` reads it. */
+export interface BenchmarkWorkspaceFolder {
+ name: string
+ summary?: string
+ owners?: string[]
+ /** Who else may use the folder, as `u/` or `g/`: `true` grants write. */
+ extra_perms?: Record
+}
+
+export interface BenchmarkWorkspaceGroup {
+ name: string
+ summary?: string
+ members?: string[]
+}
+
+export interface BenchmarkWorkspaceSchedule {
+ path: string
+ /** Six-field cron. */
+ schedule: string
+ script_path: string
+ is_flow?: boolean
+ enabled?: boolean
+ summary?: string
}
export interface BenchmarkWorkspaceRunnables {
@@ -133,15 +188,28 @@ export interface BenchmarkWorkspaceRunnables {
ducklakes?: string[]
dataMetrics?: DataMetric[]
jobs?: BenchmarkWorkspaceJob[]
+ /** The instance's workers. Unset falls through to the real backend's; `[]` is none. */
+ workers?: BenchmarkWorkspaceWorker[]
+ schedules?: BenchmarkWorkspaceSchedule[]
+ folders?: BenchmarkWorkspaceFolder[]
+ groups?: BenchmarkWorkspaceGroup[]
+ /** The workspace settings document (`get_workspace_settings` reads it). */
+ workspaceSettings?: Record
}
type BenchmarkCompletedJob = CompletedJob & { type: 'CompletedJob' }
+type BenchmarkQueuedJob = QueuedJob & { type: 'QueuedJob' }
const benchmarkWorkspaces = new Set()
const benchmarkWorkspaceRunnables = new Map()
// Keyed by `${workspace}::${jobId}` so concurrent attempts (or distinct cases)
// can seed the same fixed job id without clobbering each other's entry.
-const benchmarkJobs = new Map()
+const benchmarkJobs = new Map<
+ string,
+ { workspace: string; job: BenchmarkCompletedJob | BenchmarkQueuedJob }
+>()
+// What set_workspace_settings applied, per workspace, so get_workspace_settings reads it back.
+const benchmarkWorkspaceSettings = new Map>()
function benchmarkJobKey(workspace: string, jobId: string): string {
return `${workspace}::${jobId}`
@@ -152,11 +220,12 @@ export function resetBenchmarkMockBackend(): void {
benchmarkWorkspaceRunnables.clear()
benchmarkJobs.clear()
benchmarkDrafts.clear()
+ benchmarkWorkspaceSettings.clear()
}
// Stand-in for FolderService.createFolder so the global create_folder tool runs in
-// memory instead of mutating the real backend. Folders aren't otherwise modelled
-// (no folder-listing in evals), so this just echoes the created name.
+// memory instead of mutating the real backend. It echoes the name without adding the
+// folder to the fixture folders that `listBenchmarkFolders` reads.
export function createBenchmarkFolder(_workspace: string, name: string): string {
return name
}
@@ -180,6 +249,14 @@ export function registerBenchmarkWorkspaceRunnables(
})
// Seed any fixture jobs so list_runs / get_run have data to return.
for (const seed of runnables.jobs ?? []) {
+ const createdAt =
+ seed.createdMinutesAgo === undefined
+ ? undefined
+ : new Date(Date.now() - seed.createdMinutesAgo * 60_000).toISOString()
+ if (seed.state && seed.state !== 'completed') {
+ createBenchmarkQueuedJob(workspace, seed, createdAt)
+ continue
+ }
createBenchmarkCompletedJob({
workspace,
id: seed.id,
@@ -190,14 +267,24 @@ export function registerBenchmarkWorkspaceRunnables(
label: seed.label,
logs: seed.logs,
args: seed.args,
- result: seed.result
+ result: seed.result,
+ tag: seed.tag,
+ schedulePath: seed.schedulePath,
+ createdAt,
+ durationMs: seed.durationMs
})
}
+ if (runnables.workspaceSettings) {
+ benchmarkWorkspaceSettings.set(workspace, structuredClone(runnables.workspaceSettings))
+ } else {
+ benchmarkWorkspaceSettings.delete(workspace)
+ }
}
export function unregisterBenchmarkWorkspace(workspace: string): void {
benchmarkWorkspaces.delete(workspace)
benchmarkWorkspaceRunnables.delete(workspace)
+ benchmarkWorkspaceSettings.delete(workspace)
clearBenchmarkDrafts(workspace)
for (const [jobId, entry] of benchmarkJobs.entries()) {
if (entry.workspace === workspace) {
@@ -389,6 +476,15 @@ export function queryBenchmarkResourceTypes(
.slice(0, limit)
}
+/** The tables a fixture seeds for a database resource; `null` when it seeds none. */
+export function getBenchmarkDbSchemaSeed(
+ workspace: string,
+ path: string
+): { resourceType: string; schema: NonNullable } | null {
+ const seed = benchmarkWorkspaceRunnables.get(workspace)?.resources?.find((r) => r.path === path)
+ return seed?.dbSchema ? { resourceType: seed.resource_type, schema: seed.dbSchema } : null
+}
+
/** A seeded resource with its value, as `ResourceService.getResource` returns it. Covers both
* seed kinds, so it agrees with `existsResource` and `listResource` — both of those report AI
* providers too, and a case that lists resources and then reads one by path would otherwise get
@@ -481,18 +577,24 @@ export function createBenchmarkCompletedJob(input: {
id?: string
createdBy?: string
label?: string
+ tag?: string
+ schedulePath?: string
+ createdAt?: string
+ durationMs?: number
}): string {
const jobId = input.id ?? `benchmark-job-${randomUUID()}`
- const now = new Date().toISOString()
+ const createdAt = input.createdAt ?? new Date().toISOString()
+ const durationMs = input.durationMs ?? 0
const job: BenchmarkCompletedJob = {
type: 'CompletedJob',
id: jobId,
workspace_id: input.workspace,
created_by: input.createdBy ?? 'ai-evals',
- created_at: now,
- started_at: now,
- completed_at: now,
- duration_ms: 0,
+ created_at: createdAt,
+ started_at: createdAt,
+ completed_at: new Date(Date.parse(createdAt) + durationMs).toISOString(),
+ duration_ms: durationMs,
+ schedule_path: input.schedulePath,
success: input.success ?? true,
script_path: input.scriptPath,
script_hash: input.scriptHash,
@@ -506,7 +608,7 @@ export function createBenchmarkCompletedJob(input: {
is_skipped: false,
email: 'ai-evals@local',
visible_to_owner: true,
- tag: 'benchmark',
+ tag: input.tag ?? 'benchmark',
labels: input.label ? [input.label] : undefined
}
@@ -517,17 +619,58 @@ export function createBenchmarkCompletedJob(input: {
return jobId
}
+/** A run still in the queue: waiting for a worker, running, or a flow parked on an
+ * approval step (running with a suspend outstanding, as the backend reports it). */
+function createBenchmarkQueuedJob(
+ workspace: string,
+ seed: BenchmarkWorkspaceJob,
+ createdAt = new Date().toISOString()
+): string {
+ const jobId = seed.id ?? `benchmark-job-${randomUUID()}`
+ const started = seed.state === 'running' || seed.state === 'suspended'
+ const job: BenchmarkQueuedJob = {
+ type: 'QueuedJob',
+ id: jobId,
+ workspace_id: workspace,
+ created_by: seed.createdBy ?? 'ai-evals',
+ created_at: createdAt,
+ scheduled_for: createdAt,
+ started_at: started ? createdAt : undefined,
+ running: started,
+ suspend: seed.state === 'suspended' ? 1 : undefined,
+ script_path: seed.scriptPath,
+ schedule_path: seed.schedulePath,
+ args: seed.args,
+ logs: seed.logs,
+ canceled: false,
+ job_kind: seed.jobKind ?? 'script',
+ permissioned_as: 'u/ai-evals',
+ is_flow_step: false,
+ email: 'ai-evals@local',
+ visible_to_owner: true,
+ tag: seed.tag ?? 'benchmark'
+ }
+ benchmarkJobs.set(benchmarkJobKey(workspace, jobId), { workspace, job })
+ return jobId
+}
+
export function getBenchmarkCompletedJob(
workspace: string,
jobId: string
): BenchmarkCompletedJob | null {
const entry = benchmarkJobs.get(benchmarkJobKey(workspace, jobId))
- if (!entry) {
+ if (!entry || entry.job.type !== 'CompletedJob') {
return null
}
return structuredClone(entry.job)
}
+/** Any seeded run, finished or not, as `JobService.getJob` returns it. */
+export function getBenchmarkJob(workspace: string, jobId: string): Job | null {
+ const entry = benchmarkJobs.get(benchmarkJobKey(workspace, jobId))
+ return entry ? (structuredClone(entry.job) as Job) : null
+}
+
/**
* List seeded/recorded jobs for a benchmark workspace, most recent first —
* the shape `JobService.listJobs` returns. Returns `null` for a non-benchmark
@@ -550,7 +693,7 @@ export function listBenchmarkJobs(workspace: string): Job[] | null {
* "not found" error for an unknown id, matching the backend 404.
*/
export function getBenchmarkJobLogs(workspace: string, jobId: string): string {
- const job = getBenchmarkCompletedJob(workspace, jobId)
+ const job = getBenchmarkJob(workspace, jobId)
if (!job) {
throw new Error(`Job Logs not found for "${jobId}"`)
}
@@ -562,7 +705,7 @@ export function getBenchmarkJobLogs(workspace: string, jobId: string): string {
* tree. Fixture jobs are single runs with no steps, so only the root entry.
*/
export function getBenchmarkFlowAllResults(workspace: string, jobId: string) {
- const job = getBenchmarkCompletedJob(workspace, jobId)
+ const job = getBenchmarkJob(workspace, jobId)
if (!job) {
throw new Error(`Job "${jobId}" not found in benchmark workspace`)
}
@@ -575,8 +718,9 @@ export function getBenchmarkFlowAllResults(workspace: string, jobId: string) {
depth: 0,
sibling_index: 1,
sibling_count: 1,
- status: job.success ? 'success' : 'failure',
- success: job.success
+ ...('success' in job
+ ? { status: job.success ? 'success' : 'failure', success: job.success }
+ : { status: job.suspend ? 'suspended' : job.running ? 'running' : 'queued' })
}
],
truncated: false,
@@ -584,6 +728,156 @@ export function getBenchmarkFlowAllResults(workspace: string, jobId: string) {
}
}
+// ============= Workers, schedules =============
+
+/** Mirror `WorkerService.listWorkers`; `null` when no case seeded workers. Workers name no
+ * workspace, so this answers from whichever case seeded them: run cases whose worker seeds
+ * differ one at a time. */
+export function listBenchmarkWorkers(): WorkerPing[] | null {
+ let workers: BenchmarkWorkspaceWorker[] | undefined
+ for (const workspace of benchmarkWorkspaces) {
+ workers ??= benchmarkWorkspaceRunnables.get(workspace)?.workers
+ }
+ if (!workers) return null
+ return workers.map((w) => ({
+ worker: w.worker,
+ worker_instance: `${w.worker}-instance`,
+ worker_group: w.worker_group,
+ custom_tags: w.custom_tags,
+ last_ping: w.lastPingSecondsAgo ?? 2,
+ started_at: BENCHMARK_TIMESTAMP,
+ ip: '10.0.0.1',
+ jobs_executed: w.jobs_executed ?? 0,
+ wm_version: 'benchmark'
+ }))
+}
+
+function buildBenchmarkSchedule(workspace: string, s: BenchmarkWorkspaceSchedule): Schedule {
+ return {
+ path: s.path,
+ edited_by: 'benchmark',
+ edited_at: BENCHMARK_TIMESTAMP,
+ schedule: s.schedule,
+ timezone: 'Etc/UTC',
+ enabled: s.enabled ?? true,
+ script_path: s.script_path,
+ is_flow: s.is_flow ?? false,
+ extra_perms: {},
+ email: 'ai-evals@local',
+ summary: s.summary,
+ workspace_id: workspace
+ } as unknown as Schedule
+}
+
+export function listBenchmarkSchedules(workspace: string): Schedule[] {
+ return (benchmarkWorkspaceRunnables.get(workspace)?.schedules ?? []).map((s) =>
+ buildBenchmarkSchedule(workspace, s)
+ )
+}
+
+export function getBenchmarkSchedule(workspace: string, path: string): Schedule | null {
+ const s = benchmarkWorkspaceRunnables.get(workspace)?.schedules?.find((e) => e.path === path)
+ return s ? buildBenchmarkSchedule(workspace, s) : null
+}
+
+// ============= Folders, groups, workspace settings =============
+
+function buildBenchmarkFolder(workspace: string, f: BenchmarkWorkspaceFolder): Folder {
+ return {
+ workspace_id: workspace,
+ name: f.name,
+ summary: f.summary,
+ owners: f.owners ?? [],
+ extra_perms: f.extra_perms ?? {}
+ } as Folder
+}
+
+export function listBenchmarkFolders(workspace: string): Folder[] {
+ return (benchmarkWorkspaceRunnables.get(workspace)?.folders ?? []).map((f) =>
+ buildBenchmarkFolder(workspace, f)
+ )
+}
+
+export function getBenchmarkFolder(workspace: string, name: string): Folder | null {
+ const f = benchmarkWorkspaceRunnables.get(workspace)?.folders?.find((e) => e.name === name)
+ return f ? buildBenchmarkFolder(workspace, f) : null
+}
+
+export function listBenchmarkGroups(workspace: string): Group[] {
+ return (benchmarkWorkspaceRunnables.get(workspace)?.groups ?? []).flatMap((g) => {
+ const group = getBenchmarkGroup(workspace, g.name)
+ return group ? [group] : []
+ })
+}
+
+export function getBenchmarkGroup(workspace: string, name: string): Group | null {
+ const g = benchmarkWorkspaceRunnables.get(workspace)?.groups?.find((e) => e.name === name)
+ return g
+ ? ({
+ workspace_id: workspace,
+ name: g.name,
+ summary: g.summary,
+ members: g.members ?? [],
+ extra_perms: {}
+ } as Group)
+ : null
+}
+
+const DATA_TABLE_DATABASE_TYPES = ['postgresql', 'instance', 'external_instance']
+
+/** Refuse a data table entry the backend's `DataTableDatabase` would not deserialize. */
+function assertBenchmarkDataTables(value: unknown): void {
+ if (value === undefined || value === null) return
+ const datatables = (value as { datatables?: Record }).datatables ?? {}
+ for (const [name, entry] of Object.entries(datatables)) {
+ const { database, reference } = (entry ?? {}) as {
+ database?: { resource_type?: unknown; resource_path?: unknown }
+ reference?: unknown
+ }
+ // A fork's pointer at its parent's entry owns no database.
+ if (reference && !database) continue
+ if (!DATA_TABLE_DATABASE_TYPES.includes(String(database?.resource_type))) {
+ throw new Error(
+ `datatable.datatables.${name}.database.resource_type must be one of ${DATA_TABLE_DATABASE_TYPES.join(', ')}`
+ )
+ }
+ if (typeof database?.resource_path !== 'string' || database.resource_path === '') {
+ throw new Error(`datatable.datatables.${name}.database.resource_path is required`)
+ }
+ }
+}
+
+/** Mirror `WorkspaceService.getWorkspaceSettingsDocument`; an unseeded workspace has an
+ * empty document. */
+export function getBenchmarkWorkspaceSettingsDocument(workspace: string): Record {
+ return structuredClone(benchmarkWorkspaceSettings.get(workspace) ?? {})
+}
+
+/** Mirror `WorkspaceService.applyWorkspaceSettingsDocument` for a partial document: each
+ * top-level key replaces the stored one, `null` clears it. */
+export function applyBenchmarkWorkspaceSettingsDocument(input: {
+ workspace: string
+ requestBody: { document: Record; dry_run?: boolean }
+}): {
+ changes: Array<{ key: string; before: unknown; after: unknown }>
+ applied: boolean
+} {
+ assertBenchmarkDataTables(input.requestBody.document.datatable)
+ const current = benchmarkWorkspaceSettings.get(input.workspace) ?? {}
+ const changes = Object.entries(input.requestBody.document)
+ .filter(([key, after]) => JSON.stringify(current[key] ?? null) !== JSON.stringify(after ?? null))
+ .map(([key, after]) => ({ key, before: current[key] ?? null, after: after ?? null }))
+ if (!input.requestBody.dry_run) {
+ const next = { ...current }
+ for (const { key, after } of changes) {
+ if (after === null) delete next[key]
+ else next[key] = structuredClone(after)
+ }
+ benchmarkWorkspaceSettings.set(input.workspace, next)
+ }
+ return { changes, applied: !input.requestBody.dry_run }
+}
+
// ============= Drafts (per-user, DB-backed in production) =============
/**
diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts
index b24ccbe5e4..754de224ac 100644
--- a/ai_evals/adapters/frontend/vitestAdapter.test.ts
+++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts
@@ -85,12 +85,52 @@ vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({
vi.mock('$lib/components/vscode', () => ({}))
+// get_db_schema reads a database's tables by running a query job; a benchmark database
+// answers with the tables its fixture seeds instead.
+vi.mock('$lib/components/apps/components/display/dbtable/metadata', async () => {
+ const actual = await vi.importActual(
+ '$lib/components/apps/components/display/dbtable/metadata'
+ )
+ const { getBenchmarkDbSchemaSeed } = await import('./mockBackend')
+ const { stringifySchema } = await import('$lib/components/copilot/lib')
+ const { resourceTypeToLang } = await import(
+ '$lib/components/apps/components/display/dbtable/utils'
+ )
+ return {
+ ...actual,
+ getDbSchemas: async (
+ resourceType: string,
+ resourcePath: string,
+ workspace: string | undefined,
+ ...rest: unknown[]
+ ) => {
+ const seed = workspace ? getBenchmarkDbSchemaSeed(workspace, resourcePath) : null
+ if (!seed) return actual.getDbSchemas(resourceType, resourcePath, workspace, ...rest)
+ const dbSchema = {
+ lang: resourceTypeToLang(seed.resourceType),
+ schema: seed.schema,
+ publicOnly: !!seed.schema.public
+ }
+ return { ...dbSchema, stringified: stringifySchema(dbSchema) }
+ }
+ }
+})
+
vi.mock('$lib/gen', async () => {
const actual = await vi.importActual('$lib/gen')
const {
getBenchmarkAppByPath,
- getBenchmarkCompletedJob,
+ getBenchmarkJob,
getBenchmarkCompletedJobResultMaybe,
+ listBenchmarkWorkers,
+ listBenchmarkSchedules,
+ getBenchmarkSchedule,
+ listBenchmarkFolders,
+ getBenchmarkFolder,
+ getBenchmarkGroup,
+ listBenchmarkGroups,
+ getBenchmarkWorkspaceSettingsDocument,
+ applyBenchmarkWorkspaceSettingsDocument,
getBenchmarkDatatableSchema,
getBenchmarkDatatableMigrationsStatus,
createBenchmarkDatatableMigration,
@@ -175,7 +215,29 @@ vi.mock('$lib/gen', async () => {
createFolder: async (data: { workspace: string; requestBody: { name: string } }) =>
hasBenchmarkWorkspace(data.workspace)
? createBenchmarkFolder(data.workspace, data.requestBody.name)
- : actual.FolderService.createFolder(data)
+ : actual.FolderService.createFolder(data),
+ listFolders: async (data: { workspace: string }) =>
+ hasBenchmarkWorkspace(data.workspace)
+ ? listBenchmarkFolders(data.workspace)
+ : actual.FolderService.listFolders(data),
+ getFolder: async (data: { workspace: string; name: string }) => {
+ if (!hasBenchmarkWorkspace(data.workspace)) return actual.FolderService.getFolder(data)
+ const folder = getBenchmarkFolder(data.workspace, data.name)
+ if (!folder) throw new Error(`Folder "${data.name}" not found in benchmark workspace`)
+ return folder
+ }
+ }),
+ GroupService: wrapService(actual.GroupService, {
+ listGroups: async (data: { workspace: string }) =>
+ hasBenchmarkWorkspace(data.workspace)
+ ? listBenchmarkGroups(data.workspace)
+ : actual.GroupService.listGroups(data),
+ getGroup: async (data: { workspace: string; name: string }) => {
+ if (!hasBenchmarkWorkspace(data.workspace)) return actual.GroupService.getGroup(data)
+ const group = getBenchmarkGroup(data.workspace, data.name)
+ if (!group) throw new Error(`Group "${data.name}" not found in benchmark workspace`)
+ return group
+ }
}),
ScriptService: wrapService(actual.ScriptService, {
listScripts: async (data: { workspace: string }) =>
@@ -357,7 +419,7 @@ vi.mock('$lib/gen', async () => {
: actual.JobService.runFlowByPath(data),
getJob: async (data: { workspace: string; id: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
- const job = getBenchmarkCompletedJob(data.workspace, data.id)
+ const job = getBenchmarkJob(data.workspace, data.id)
if (!job) {
throw new Error(`Job "${data.id}" not found in benchmark workspace`)
}
@@ -383,6 +445,14 @@ vi.mock('$lib/gen', async () => {
: actual.JobService.getFlowAllResults(data)
}),
WorkspaceService: wrapService(actual.WorkspaceService, {
+ getWorkspaceSettingsDocument: async (data: { workspace: string }) =>
+ hasBenchmarkWorkspace(data.workspace)
+ ? getBenchmarkWorkspaceSettingsDocument(data.workspace)
+ : actual.WorkspaceService.getWorkspaceSettingsDocument(data),
+ applyWorkspaceSettingsDocument: async (data: any) =>
+ hasBenchmarkWorkspace(data.workspace)
+ ? applyBenchmarkWorkspaceSettingsDocument(data)
+ : actual.WorkspaceService.applyWorkspaceSettingsDocument(data),
getCopilotInfo: async (data: { workspace: string }) =>
hasBenchmarkWorkspace(data.workspace)
? (getBenchmarkAiConfig(data.workspace) ?? {})
@@ -436,13 +506,21 @@ vi.mock('$lib/gen', async () => {
? { metrics: listBenchmarkDataMetrics(data.workspace) ?? [] }
: actual.DataMetricService.listDataMetrics(data)
}),
+ WorkerService: wrapService(actual.WorkerService, {
+ listWorkers: async (data: Record) =>
+ listBenchmarkWorkers() ?? actual.WorkerService.listWorkers(data)
+ }),
ScheduleService: wrapService(actual.ScheduleService, {
existsSchedule: async (data: { workspace: string; path: string }) =>
hasBenchmarkWorkspace(data.workspace) ? false : actual.ScheduleService.existsSchedule(data),
listSchedules: async (data: { workspace: string }) =>
- hasBenchmarkWorkspace(data.workspace) ? [] : actual.ScheduleService.listSchedules(data),
+ hasBenchmarkWorkspace(data.workspace)
+ ? listBenchmarkSchedules(data.workspace)
+ : actual.ScheduleService.listSchedules(data),
getSchedule: async (data: { workspace: string; path: string }) => {
if (hasBenchmarkWorkspace(data.workspace)) {
+ const schedule = getBenchmarkSchedule(data.workspace, data.path)
+ if (schedule) return schedule
throw new Error(`Schedule "${data.path}" not found in benchmark workspace`)
}
return actual.ScheduleService.getSchedule(data)
diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml
index 136f125e59..f0df4ae6e3 100644
--- a/ai_evals/cases/global.yaml
+++ b/ai_evals/cases/global.yaml
@@ -3231,6 +3231,33 @@
- - admin
skipJudge: true
+# A role with schedules and triggers withdrawn builds nothing, which holds the session in Use.
+# Its user cannot create a script by hand either, so the way forward is an admin, not steps.
+- id: global-use-role-script-goes-to-admin
+ prompt: |-
+ Write a Python script that checks whether https://status.example.com is up and returns
+ true or false.
+ initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json
+ runtime:
+ maxTurns: 6
+ sessionChat: true
+ profile:
+ operator: true
+ operatorSettings:
+ manage_schedules: false
+ manage_triggers: false
+ validate:
+ draftCountExactly: 0
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - admin
+ judgeAssistantText: true
+ judgeChecklist:
+ - the reply puts the refusal on the user's role in this workspace, not only on this session or its mode
+ - the reply suggests asking a workspace admin, who can create the script or give the role more rights
+ - the reply does not tell or invite the user to create the script themselves with the role they have now, with steps or with code offered for them to paste in; code offered for an admin to add, or saying they could once an admin grants their role more, is fine
+ - the reply does not suggest switching this session to Use & build or Plan; saying a role with more rights could build is fine
+
# An agent from the Agents page is an `ai_agent` resource. Asked about one by name, the model
# has to read it as a resource rather than look for a flow or script of that name.
- id: global-agent-read-as-resource
@@ -3283,3 +3310,626 @@
- Agents page
- agent editor
skipJudge: true
+
+# --- Composer suggestions ---
+# The prompts the pills under Home's composer put in the box
+# (frontend/src/lib/components/copilot/chat/starter/composerSuggestions.ts), word for word.
+# A pill is only worth offering if the session chat can act on its prompt, so each one is
+# pinned here against a workspace where the prompt has something to find.
+
+- id: global-composer-run-queue
+ prompt: |-
+ Is anything stuck in this workspace's queue? Which jobs are waiting or suspended, and why?
+ initial: ai_evals/fixtures/frontend/global/initial/composer_incidents.json
+ runtime:
+ maxTurns: 10
+ sessionChat: true
+ validate:
+ draftCountExactly: 0
+ toolExpect:
+ requiredToolsUsed:
+ - list_runs
+ - list_workers
+ forbiddenToolsUsed:
+ - deploy_workspace_item
+ - delete_workspace_item
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - gpu-renderer
+ - - refund_approval
+ - approval
+ - approve
+ skipJudge: true
+ judgeChecklist:
+ - finds the 12 gpu_render runs queued on the gpu-renderer tag that no connected worker serves
+ - finds the refund_approval flow suspended on its approval step, and says approving it unblocks it
+
+- id: global-composer-worker-health
+ prompt: |-
+ Are the workers healthy and keeping up with the queue? Compare how long jobs wait before they start with how busy each worker group is.
+ initial: ai_evals/fixtures/frontend/global/initial/composer_incidents.json
+ runtime:
+ maxTurns: 10
+ sessionChat: true
+ validate:
+ draftCountExactly: 0
+ toolExpect:
+ requiredToolsUsed:
+ - list_workers
+ - list_runs
+ forbiddenToolsUsed:
+ - deploy_workspace_item
+ - delete_workspace_item
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - gpu-renderer
+ skipJudge: true
+ judgeChecklist:
+ - reads the connected workers and the queued runs instead of guessing
+ - points out that the gpu-renderer runs wait because no worker serves that tag
+
+- id: global-composer-schedules
+ prompt: |-
+ Do any schedules in this workspace keep failing? If so, which ones and why?
+ initial: ai_evals/fixtures/frontend/global/initial/composer_incidents.json
+ runtime:
+ maxTurns: 10
+ sessionChat: true
+ validate:
+ draftCountExactly: 0
+ toolExpect:
+ requiredToolsUsed:
+ - list_runs
+ forbiddenToolsUsed:
+ - deploy_workspace_item
+ - delete_workspace_item
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - charge_cards_every_minute
+ - - insufficient funds
+ - declined
+ skipJudge: true
+ judgeChecklist:
+ - names f/billing/charge_cards_every_minute as the failing schedule, not import_orders_hourly
+ - gives the cause from its runs' logs (card declined, insufficient funds)
+
+- id: global-composer-recent-runs
+ prompt: |-
+ Look at the recent runs in this workspace: did any fail, and why? Is anything getting slower?
+ initial: ai_evals/fixtures/frontend/global/initial/composer_incidents.json
+ runtime:
+ maxTurns: 8
+ sessionChat: true
+ validate:
+ draftCountExactly: 0
+ toolExpect:
+ requiredToolsUsed:
+ - list_runs
+ forbiddenToolsUsed:
+ - deploy_workspace_item
+ - delete_workspace_item
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - charge_cards
+ - - insufficient funds
+ - declined
+ skipJudge: true
+ judgeChecklist:
+ - names charge_cards as the failing script and gives the cause from its logs
+ - does not invent a slowdown the run durations do not show
+
+# The empty-workspace examples, sent as the composer sends them. The workspace holds nothing,
+# so the model has to stage what it needs; one answer covers a question about the Slack setup.
+- id: global-composer-example-daily-metrics
+ prompt: |-
+ Every weekday at 9am, query postgres for yesterday's signups and revenue and post a summary to the #metrics Slack channel
+ initial: ai_evals/fixtures/frontend/global/initial/user_admin_empty.json
+ runtime:
+ maxTurns: 16
+ sessionChat: true
+ userAnswers:
+ - Use placeholders for the postgres and Slack connections, I'll fill them in later
+ validate:
+ draftCountAtLeast: 1
+ toolExpect:
+ forbiddenToolsUsed:
+ - deploy_workspace_item
+ - delete_workspace_item
+ judgeChecklist:
+ - stages a script or flow that queries postgres for the previous day's signups and revenue
+ - posts the summary to the #metrics Slack channel
+ - runs it on a schedule at 9am on weekdays only
+
+- id: global-composer-example-refund-approval
+ prompt: |-
+ Build a flow that asks for approval in Slack before issuing a refund in Stripe, and only refunds if someone approves
+ initial: ai_evals/fixtures/frontend/global/initial/user_admin_empty.json
+ runtime:
+ maxTurns: 16
+ sessionChat: true
+ userAnswers:
+ - Use placeholders for the Stripe and Slack connections, I'll fill them in later
+ validate:
+ requiredDrafts:
+ - type: flow
+ valueIncludes:
+ - suspend
+ toolExpect:
+ requiredToolsUsed:
+ - write_flow
+ forbiddenToolsUsed:
+ - deploy_workspace_item
+ - delete_workspace_item
+ judgeChecklist:
+ - posts an approval request to Slack before any refund is issued
+ - suspends the flow on an approval step and continues only once someone approves
+ - issues the refund through Stripe only on approval, never when it is rejected
+
+- id: global-composer-example-ticket-triage
+ prompt: |-
+ When a support ticket arrives by webhook, classify its urgency with an LLM and post the urgent ones to the #support Slack channel
+ initial: ai_evals/fixtures/frontend/global/initial/user_admin_empty.json
+ runtime:
+ maxTurns: 16
+ sessionChat: true
+ userAnswers:
+ - Use placeholders for the LLM and Slack connections, I'll fill them in later
+ validate:
+ draftCountAtLeast: 1
+ toolExpect:
+ forbiddenToolsUsed:
+ - deploy_workspace_item
+ - delete_workspace_item
+ judgeChecklist:
+ - stages a script or flow that takes the ticket as the input a webhook delivers
+ - classifies the ticket's urgency with an LLM call; an `aidecision` step (Windmill's built-in LLM decision, with `choice` or `noul` questions) counts
+ - posts only the urgent tickets to the #support Slack channel
+
+# The pills that name what an established workspace holds: a teammate's flow, a starred
+# script, a connected database and channel.
+- id: global-composer-walk-through-flow
+ prompt: |-
+ Walk me through what the flow f/billing/charge_customers does, step by step.
+ initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json
+ runtime:
+ maxTurns: 6
+ sessionChat: true
+ validate:
+ draftCountExactly: 0
+ toolExpect:
+ requiredToolsUsed:
+ - read_workspace_item
+ forbiddenToolsUsed:
+ - write_flow
+ - patch_flow_json
+ - deploy_workspace_item
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - due today
+ - invoices due
+ - fetch_due
+ - - receipt
+ skipJudge: true
+ judgeChecklist:
+ - reads the flow and describes its three steps in order
+
+- id: global-composer-run-favorite
+ prompt: |-
+ Run the script f/ops/check_ssl_expiry for me.
+ initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json
+ runtime:
+ maxTurns: 8
+ sessionChat: true
+ userAnswers:
+ - windmill.dev
+ validate:
+ draftCountExactly: 0
+ # The script needs a domain the prompt does not give. Opening the run form for the user to
+ # fill and asking for the domain in chat are both right, so neither is pinned; what is pinned
+ # is that the deployed script is read and nothing is previewed or written.
+ toolExpect:
+ requiredToolsUsed:
+ - read_workspace_item
+ forbiddenToolsUsed:
+ - test_run_script
+ - write_script
+ - deploy_workspace_item
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - domain
+ # Running produces no draft, and the judge cannot observe runs; validate via tool use.
+ skipJudge: true
+ judgeChecklist:
+ - reads the deployed script and either opens its run form or asks for the domain it needs
+
+- id: global-composer-daily-summary
+ prompt: |-
+ Every weekday at 9am, summarize yesterday's activity in the Postgres database f/data/warehouse and post it to Slack with the resource f/team/slack
+ initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json
+ runtime:
+ maxTurns: 16
+ sessionChat: true
+ userAnswers:
+ - Post it in #metrics, with new signups, new invoices and what they add up to
+ validate:
+ draftCountAtLeast: 1
+ toolExpect:
+ # "Yesterday's activity" means whatever the tables hold, so they are read first.
+ requiredToolsUsed:
+ - get_db_schema
+ forbiddenToolsUsed:
+ - deploy_workspace_item
+ - delete_workspace_item
+ judgeChecklist:
+ - queries the warehouse's real tables (signups, invoices) for the previous day through f/data/warehouse
+ - posts the summary to Slack through the resource f/team/slack; a channel parameter with a default is fine, since the prompt names none
+ - runs on a schedule at 9am on weekdays only
+
+# Pills that teach the piece a workspace is missing.
+- id: global-composer-setup-datatable
+ prompt: |-
+ This workspace has no data table yet. Walk me through setting one up in the workspace settings, then show me how a script writes to it.
+ initial: ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json
+ runtime:
+ maxTurns: 20
+ sessionChat: true
+ userAnswers:
+ - Back it with the Postgres resource f/data/warehouse and call it main
+ # Opening the data table settings for the admin and writing them after a confirmation are
+ # both a way through; either way the script example is what teaches the concept.
+ toolExpect:
+ requiredToolsAnyOf:
+ - - open_page
+ - set_workspace_settings
+ forbiddenToolsUsed:
+ - deploy_workspace_item
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - datatable(
+ - datatable()
+ skipJudge: true
+ judgeChecklist:
+ - shows where data tables are set up and a script writing to one with wmill.datatable()
+
+- id: global-composer-store-data
+ prompt: |-
+ How should my scripts store data in Windmill? Explain data tables and show me how a script reads and writes one.
+ initial: ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json
+ runtime:
+ maxTurns: 8
+ sessionChat: true
+ # The pill is for developers: an admin is offered "Set up a data table" instead.
+ profile:
+ operator: false
+ toolExpect:
+ forbiddenToolsUsed:
+ - set_workspace_settings
+ - deploy_workspace_item
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - datatable(
+ - datatable()
+ skipJudge: true
+ judgeChecklist:
+ - explains data tables and shows a script reading and writing one with wmill.datatable()
+
+- id: global-composer-connect-slack
+ prompt: |-
+ Help me connect Slack to this workspace so my scripts can post messages.
+ initial: ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json
+ runtime:
+ maxTurns: 12
+ sessionChat: true
+ userAnswers:
+ - Use a bot token, I will paste it myself
+ # A Slack resource whose token is a secret variable, or the workspace's own Slack connection
+ # in settings: both connect Slack, so either path is accepted.
+ toolExpect:
+ requiredToolsAnyOf:
+ - - write_resource
+ - open_page
+ forbiddenToolsUsed:
+ - deploy_workspace_item
+ skipJudge: true
+ judgeChecklist:
+ - stages a Slack resource with its token in a secret variable, or opens the workspace's Slack settings
+
+- id: global-composer-store-secret
+ prompt: |-
+ I have an API key my scripts need. Store it as a secret and show me how a script reads it with wmill.getVariable.
+ initial: ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json
+ runtime:
+ maxTurns: 10
+ sessionChat: true
+ userAnswers:
+ - It is for the Stripe API; I will paste the value myself
+ validate:
+ requiredDrafts:
+ - type: variable
+ valueIncludes:
+ - '"is_secret": true'
+ toolExpect:
+ forbiddenToolsUsed:
+ - deploy_workspace_item
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - getVariable
+ - get_variable
+ - "$var:"
+ judgeChecklist:
+ - stages a secret variable with a placeholder value instead of inventing the key
+ - any example script reads the variable rather than embedding the key; an example given only in the chat is checked separately, so do not score its absence here
+
+# Pills that name what an established workspace holds, read from composer_connected_workspace.
+- id: global-composer-explore-datatable
+ prompt: |-
+ List the tables in the data table main and tell me what each one holds.
+ initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json
+ runtime:
+ maxTurns: 6
+ sessionChat: true
+ validate:
+ draftCountExactly: 0
+ toolExpect:
+ requiredToolsAnyOf:
+ - - list_datatables
+ - get_datatable_table_schema
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - feedback
+ - - onboarding_tasks
+ skipJudge: true
+ judgeChecklist:
+ - names the feedback and onboarding_tasks tables and what each holds
+
+- id: global-composer-app-on-datatable
+ prompt: |-
+ Build a full-code app to browse and edit the rows of a table in the data table main.
+ initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json
+ runtime:
+ maxTurns: 30
+ sessionChat: true
+ userAnswers:
+ - The feedback table
+ validate:
+ requiredDrafts:
+ - type: app
+ toolExpect:
+ requiredToolsUsed:
+ - init_app
+ forbiddenToolsUsed:
+ - deploy_workspace_item
+ judgeChecklist:
+ - builds a full-code app whose backend reads the rows of a table in the main data table through wmill.datatable(); `sql.raw` is the documented way to inline a table or column name
+ - lets the user edit a row and saves the change back to the table
+
+- id: global-composer-add-app-page
+ prompt: |-
+ Add a page to the app f/ops/console, and ask me what it should show.
+ initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json
+ runtime:
+ maxTurns: 24
+ sessionChat: true
+ userAnswers:
+ - A page listing the latest feedback from the main data table
+ # The pill asks the chat to ask: in chat or through askUserQuestion. A question in chat ends
+ # the run before the page is written, so the page itself is not pinned.
+ toolExpect:
+ requiredToolsAnyOf:
+ - - read_app_file
+ - read_workspace_item
+ forbiddenToolsUsed:
+ - init_app
+ - deploy_workspace_item
+ skipJudge: true
+ judgeChecklist:
+ - reads the app, then asks what the new page should show before writing it
+
+- id: global-composer-walk-through-agent
+ prompt: |-
+ Walk me through what the AI agent f/support/triage_agent does and which tools it can use.
+ initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json
+ runtime:
+ maxTurns: 6
+ sessionChat: true
+ validate:
+ draftCountExactly: 0
+ toolExpect:
+ requiredToolsUsed:
+ - read_workspace_item
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - billing
+ - - no tools
+ - any tools
+ - no tool
+ skipJudge: true
+ judgeChecklist:
+ - explains that the agent labels tickets as billing, bug or question and has no tools
+
+- id: global-composer-channel-in-script
+ prompt: |-
+ Show me how a script uses the resource f/team/slack to post a Slack message, with a short example.
+ initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json
+ runtime:
+ maxTurns: 8
+ sessionChat: true
+ toolExpect:
+ forbiddenToolsUsed:
+ - deploy_workspace_item
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - chat.postMessage
+ skipJudge: true
+ judgeChecklist:
+ - shows a script that takes the f/team/slack resource and posts a message with chat.postMessage
+
+- id: global-composer-postgres-trigger
+ prompt: |-
+ Run a script whenever a row is inserted into a table of the Postgres database f/data/warehouse, and explain how Postgres triggers work.
+ initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json
+ runtime:
+ maxTurns: 20
+ sessionChat: true
+ userAnswers:
+ - The signups table
+ validate:
+ requiredDrafts:
+ - type: trigger
+ triggerKind: postgres
+ toolExpect:
+ forbiddenToolsUsed:
+ - deploy_workspace_item
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - publication
+ - replication
+ judgeChecklist:
+ - stages a Postgres trigger on f/data/warehouse that fires on inserts, limited to one table rather than every table
+ - the triggered script takes the inserted row as its input
+
+- id: global-composer-http-endpoint
+ prompt: |-
+ Expose the script f/ops/check_ssl_expiry as an HTTP API endpoint that requires a Windmill token, and show me how to call it.
+ initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json
+ runtime:
+ maxTurns: 12
+ sessionChat: true
+ # "Expose" asks for a live route, so deploying it, behind the user's confirmation, is right.
+ validate:
+ requiredDrafts:
+ - type: trigger
+ triggerKind: http
+ valueIncludes:
+ - f/ops/check_ssl_expiry
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - curl
+ - fetch(
+ judgeChecklist:
+ - stages an HTTP route that runs f/ops/check_ssl_expiry and passes it the domain
+ - the route requires a Windmill token (authentication_method windmill), so it is not public
+ - how to call it is explained in the chat, which is checked separately; do not score it
+
+- id: global-composer-schedule-script
+ prompt: |-
+ Run the script f/ops/check_ssl_expiry every weekday at 9am. Ask me for any input it needs, and explain how schedules work.
+ initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json
+ runtime:
+ maxTurns: 12
+ sessionChat: true
+ userAnswers:
+ - windmill.dev
+ # The script needs a domain the prompt does not give, so the chat reads the script and asks
+ # for it: in chat, which ends the run, or with askUserQuestion, which goes on to the schedule.
+ toolExpect:
+ requiredToolsUsed:
+ - read_workspace_item
+ forbiddenToolsUsed:
+ - deploy_workspace_item
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - domain
+ - - cron
+ - schedule
+ skipJudge: true
+ judgeChecklist:
+ - reads the script, asks for the domain it needs, and explains how schedules work
+
+- id: global-composer-webhook
+ prompt: |-
+ How do I start the flow f/billing/charge_customers from another app with its webhook? Show me the URL and an example request.
+ initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json
+ runtime:
+ maxTurns: 8
+ sessionChat: true
+ validate:
+ draftCountExactly: 0
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - jobs/run
+ - - token
+ - Authorization
+ skipJudge: true
+ judgeChecklist:
+ - gives the flow's webhook URL and an example request with a token
+
+- id: global-composer-who-can-see
+ prompt: |-
+ Who can see and edit the flow f/billing/charge_customers, and why?
+ initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json
+ runtime:
+ maxTurns: 8
+ sessionChat: true
+ validate:
+ draftCountExactly: 0
+ toolExpect:
+ requiredToolsUsed:
+ - read_workspace_item
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - finance
+ - - bob
+ skipJudge: true
+ judgeChecklist:
+ - explains that access comes from the billing folder - its owner ada, bob with write, the finance group with read
+
+- id: global-composer-operator-what-can-i-run
+ prompt: |-
+ Which scripts and flows can I run in this workspace, and what does each one do?
+ initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json
+ runtime:
+ maxTurns: 6
+ sessionChat: true
+ profile:
+ operator: true
+ validate:
+ draftCountExactly: 0
+ toolExpect:
+ requiredToolsUsed:
+ - list_workspace_items
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - list_open_orders
+ - List open orders
+ skipJudge: true
+ judgeChecklist:
+ - lists the runnable scripts and flows with what each does
+
+- id: global-composer-hub-scripts
+ prompt: |-
+ Find scripts on the Windmill Hub for Slack that I could use with the resource f/team/slack.
+ initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json
+ runtime:
+ maxTurns: 8
+ sessionChat: true
+ validate:
+ draftCountExactly: 0
+ toolExpect:
+ requiredToolsAnyOf:
+ - - search_hub_scripts
+ - get_hub_integration
+ skipJudge: true
+ judgeChecklist:
+ - lists Slack scripts from the Hub that take a Slack resource
+
+- id: global-composer-mcp
+ prompt: |-
+ How do I connect Claude to this workspace with MCP so it can run my scripts and flows?
+ initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json
+ runtime:
+ maxTurns: 8
+ sessionChat: true
+ validate:
+ draftCountExactly: 0
+ toolExpect:
+ requiredToolsAnyOf:
+ - - search_docs
+ - read_docs_page
+ assistantExpect:
+ requiredMentionsAnyOf:
+ - - token
+ - MCP URL
+ skipJudge: true
+ judgeChecklist:
+ - explains the MCP setup from the docs: the MCP URL with a token, added to Claude
diff --git a/ai_evals/core/cases.ts b/ai_evals/core/cases.ts
index c745f36a15..0e9e400a74 100644
--- a/ai_evals/core/cases.ts
+++ b/ai_evals/core/cases.ts
@@ -24,6 +24,7 @@ interface RawEvalCase {
assistantExpect?: EvalCase["assistantExpect"];
judgeChecklist?: string[];
skipJudge?: boolean;
+ judgeAssistantText?: boolean;
runtime?: EvalCaseRuntimeSpec;
}
export function getRepoRoot(): string {
@@ -54,6 +55,7 @@ export async function loadCases(mode: EvalMode): Promise {
assistantExpect: entry.assistantExpect,
judgeChecklist: entry.judgeChecklist,
skipJudge: entry.skipJudge,
+ judgeAssistantText: entry.judgeAssistantText,
runtime: entry.runtime,
}));
}
diff --git a/ai_evals/core/models.ts b/ai_evals/core/models.ts
index cf131337fd..3c469c4fd3 100644
--- a/ai_evals/core/models.ts
+++ b/ai_evals/core/models.ts
@@ -82,6 +82,19 @@ export const EVAL_MODELS: EvalModelSpec[] = [
model: "opus",
},
},
+ {
+ id: "sonnet-5",
+ label: "Claude Sonnet 5",
+ aliases: ["sonnet-5", "claude-sonnet-5"],
+ frontend: {
+ provider: "anthropic",
+ model: "claude-sonnet-5",
+ },
+ cli: {
+ runtime: "claude-code",
+ model: "claude-sonnet-5",
+ },
+ },
{
id: "sonnet-5.5",
label: "Claude Sonnet 5.5",
diff --git a/ai_evals/core/runSuite.ts b/ai_evals/core/runSuite.ts
index c2d80e5d6e..831cac1f05 100644
--- a/ai_evals/core/runSuite.ts
+++ b/ai_evals/core/runSuite.ts
@@ -245,15 +245,18 @@ async function runCaseAttempts(input: {
input.judgeModel !== null &&
!input.evalCase.skipJudge
) {
+ const output = input.modeRunner.prepareJudgeActual
+ ? input.modeRunner.prepareJudgeActual(run.actual)
+ : run.actual;
const judge = await judgeOutput({
mode: input.modeRunner.mode,
prompt: input.evalCase.prompt,
checklist: input.evalCase.judgeChecklist,
initial,
expected: input.modeRunner.mode === "cli" ? undefined : expected,
- actual: input.modeRunner.prepareJudgeActual
- ? input.modeRunner.prepareJudgeActual(run.actual)
- : run.actual,
+ actual: input.evalCase.judgeAssistantText
+ ? { assistantReply: run.assistantText, output }
+ : output,
model: input.judgeModel,
});
diff --git a/ai_evals/core/types.ts b/ai_evals/core/types.ts
index 192a0c7ac7..b13f6264ac 100644
--- a/ai_evals/core/types.ts
+++ b/ai_evals/core/types.ts
@@ -41,7 +41,8 @@ export interface EvalCaseRuntimeSpec {
// refused until the model hands over a plan with exit_plan_mode.
planMode?: boolean;
// Global session chats: the user's rights in the workspace, as production resolves them for
- // a session from a non-admin role. Unset means full access.
+ // a session from a non-admin role. Unset means full access. A role that builds nothing holds
+ // the session in Use, with the read-only toolset and the session section that says why.
profile?: EvalCaseProfile;
}
@@ -273,6 +274,12 @@ export interface EvalCase {
assistantExpect?: AssistantValidationSpec;
judgeChecklist?: string[];
skipJudge?: boolean;
+ /**
+ * The judge also reads what the assistant said, for a case whose deliverable is the answer
+ * itself: the `global` judge otherwise sees only the drafts. Needs a mode whose runner
+ * reports `assistantText`.
+ */
+ judgeAssistantText?: boolean;
runtime?: EvalCaseRuntimeSpec;
}
diff --git a/ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json b/ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json
new file mode 100644
index 0000000000..db30ec00e8
--- /dev/null
+++ b/ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json
@@ -0,0 +1,313 @@
+{
+ "user": {
+ "username": "admin",
+ "is_admin": true
+ },
+ "workspace": {
+ "flows": [
+ {
+ "path": "f/billing/charge_customers",
+ "summary": "Charge customers with due invoices",
+ "description": "Every night, charges each customer whose invoice is due and emails a receipt.",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "dry_run": {
+ "type": "boolean",
+ "default": false
+ }
+ },
+ "required": []
+ },
+ "value": {
+ "modules": [
+ {
+ "id": "fetch_due",
+ "summary": "Fetch invoices due today from the warehouse",
+ "value": {
+ "type": "rawscript",
+ "language": "postgresql",
+ "content": "-- database f/data/warehouse\nSELECT id, customer_id, amount_cents FROM invoices WHERE due_date = current_date AND paid = false;",
+ "input_transforms": {
+ "database": {
+ "type": "static",
+ "value": "$res:f/data/warehouse"
+ }
+ }
+ }
+ },
+ {
+ "id": "charge",
+ "summary": "Charge each invoice",
+ "value": {
+ "type": "forloopflow",
+ "iterator": {
+ "type": "javascript",
+ "expr": "results.fetch_due"
+ },
+ "skip_failures": false,
+ "modules": [
+ {
+ "id": "charge_one",
+ "summary": "Charge one invoice through the payment API",
+ "value": {
+ "type": "rawscript",
+ "language": "bun",
+ "content": "export async function main(invoice: { id: number; amount_cents: number }) {\n const res = await fetch('https://payments.example.com/charge', { method: 'POST', body: JSON.stringify(invoice) })\n if (!res.ok) throw new Error(`charge failed for invoice ${invoice.id}`)\n return await res.json()\n}\n",
+ "input_transforms": {
+ "invoice": {
+ "type": "javascript",
+ "expr": "flow_input.iter.value"
+ }
+ }
+ }
+ }
+ ]
+ }
+ },
+ {
+ "id": "email_receipts",
+ "summary": "Email a receipt to each charged customer",
+ "value": {
+ "type": "rawscript",
+ "language": "bun",
+ "content": "// Each charge is what the payment API answered, which carries the customer to email.\nexport async function main(charges: { id: string; customer_id: number }[]) {\n for (const charge of charges) {\n const res = await fetch('https://mail.example.com/receipts', { method: 'POST', body: JSON.stringify(charge) })\n if (!res.ok) throw new Error(`receipt failed for charge ${charge.id}`)\n }\n return { sent: charges.length }\n}\n",
+ "input_transforms": {
+ "charges": {
+ "type": "javascript",
+ "expr": "results.charge"
+ }
+ }
+ }
+ }
+ ]
+ }
+ }
+ ],
+ "scripts": [
+ {
+ "path": "f/ops/check_ssl_expiry",
+ "summary": "Check SSL certificate expiry",
+ "language": "bun",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "type": "object",
+ "properties": {
+ "domain": {
+ "type": "string",
+ "description": "The domain whose certificate to check"
+ }
+ },
+ "required": [
+ "domain"
+ ]
+ },
+ "content": "export async function main(domain: string) {\n const res = await fetch(`https://ssl-check.example.com/${domain}`)\n return await res.json()\n}\n"
+ }
+ ],
+ "resources": [
+ {
+ "path": "f/data/warehouse",
+ "resource_type": "postgresql",
+ "description": "Production warehouse",
+ "value": {
+ "host": "db.internal",
+ "port": 5432,
+ "dbname": "warehouse",
+ "user": "windmill",
+ "password": "$var:f/data/warehouse_password"
+ },
+ "dbSchema": {
+ "public": {
+ "customers": {
+ "id": {
+ "type": "integer",
+ "required": true
+ },
+ "email": {
+ "type": "text",
+ "required": true
+ },
+ "plan": {
+ "type": "text",
+ "required": false
+ },
+ "created_at": {
+ "type": "timestamp",
+ "required": true,
+ "default": "now()"
+ }
+ },
+ "invoices": {
+ "id": {
+ "type": "integer",
+ "required": true
+ },
+ "customer_id": {
+ "type": "integer",
+ "required": true
+ },
+ "amount_cents": {
+ "type": "integer",
+ "required": true
+ },
+ "due_date": {
+ "type": "date",
+ "required": true
+ },
+ "paid": {
+ "type": "boolean",
+ "required": true,
+ "default": "false"
+ },
+ "created_at": {
+ "type": "timestamp",
+ "required": true,
+ "default": "now()"
+ }
+ },
+ "signups": {
+ "id": {
+ "type": "integer",
+ "required": true
+ },
+ "email": {
+ "type": "text",
+ "required": true
+ },
+ "source": {
+ "type": "text",
+ "required": false
+ },
+ "created_at": {
+ "type": "timestamp",
+ "required": true,
+ "default": "now()"
+ }
+ }
+ }
+ }
+ },
+ {
+ "path": "f/team/slack",
+ "resource_type": "slack",
+ "description": "Team Slack bot",
+ "value": {
+ "token": "$var:f/team/slack_token"
+ }
+ },
+ {
+ "path": "f/support/triage_agent",
+ "resource_type": "ai_agent",
+ "description": "Labels incoming support tickets",
+ "value": {
+ "provider": {
+ "kind": "anthropic",
+ "model": "claude-sonnet-5",
+ "resource": ""
+ },
+ "output_type": "text",
+ "system_prompt": "Read the support ticket and answer with one label: billing, bug or question.",
+ "memory": {
+ "kind": "off"
+ },
+ "tools": []
+ }
+ }
+ ],
+ "apps": [
+ {
+ "path": "f/ops/console",
+ "summary": "Ops console",
+ "value": {
+ "files": {
+ "/App.tsx": "import { useState } from 'react'\nimport { backend } from './wmill'\n\nexport default function App() {\n\tconst [domain, setDomain] = useState('')\n\tconst [result, setResult] = useState()\n\treturn (\n\t\t\n\t\t\t
Ops console \n\t\t\t
setDomain(e.target.value)} />\n\t\t\t
setResult(await backend.check_ssl({ domain }))}>Check SSL \n\t\t\t
{JSON.stringify(result, null, 2)} \n\t\t
\n\t)\n}\n"
+ },
+ "runnables": {
+ "check_ssl": {
+ "type": "path",
+ "runType": "script",
+ "path": "f/ops/check_ssl_expiry",
+ "fields": {}
+ }
+ }
+ }
+ }
+ ],
+ "datatables": [
+ {
+ "datatable_name": "main",
+ "schemas": {
+ "public": {
+ "feedback": {
+ "columns": {
+ "id": "int4",
+ "customer_email": "text",
+ "rating": "int4",
+ "comment": "text",
+ "created_at": "timestamptz"
+ },
+ "rows": [
+ {
+ "id": 1,
+ "customer_email": "ada@example.com",
+ "rating": 5,
+ "comment": "Fast support",
+ "created_at": "2026-10-01T09:00:00Z"
+ }
+ ]
+ },
+ "onboarding_tasks": {
+ "columns": {
+ "id": "int4",
+ "customer_email": "text",
+ "task": "text",
+ "done": "bool"
+ },
+ "rows": [
+ {
+ "id": 1,
+ "customer_email": "ada@example.com",
+ "task": "Connect billing",
+ "done": false
+ }
+ ]
+ }
+ }
+ }
+ }
+ ],
+ "folders": [
+ {
+ "name": "billing",
+ "summary": "Billing jobs",
+ "owners": [
+ "u/ada"
+ ],
+ "extra_perms": {
+ "g/finance": false,
+ "u/bob": true
+ }
+ },
+ {
+ "name": "ops",
+ "summary": "Operations",
+ "owners": [
+ "u/admin"
+ ],
+ "extra_perms": {}
+ }
+ ],
+ "groups": [
+ {
+ "name": "finance",
+ "summary": "Finance team",
+ "members": [
+ "carol",
+ "dan"
+ ]
+ }
+ ]
+ }
+}
diff --git a/ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json b/ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json
new file mode 100644
index 0000000000..b719ba0751
--- /dev/null
+++ b/ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json
@@ -0,0 +1,22 @@
+{
+ "user": {
+ "username": "admin",
+ "is_admin": true
+ },
+ "workspace": {
+ "resources": [
+ {
+ "path": "f/data/warehouse",
+ "resource_type": "postgresql",
+ "description": "Production warehouse",
+ "value": {
+ "host": "db.internal",
+ "port": 5432,
+ "dbname": "warehouse",
+ "user": "windmill",
+ "password": "$var:f/data/warehouse_password"
+ }
+ }
+ ]
+ }
+}
diff --git a/ai_evals/fixtures/frontend/global/initial/composer_incidents.json b/ai_evals/fixtures/frontend/global/initial/composer_incidents.json
new file mode 100644
index 0000000000..4799b895ea
--- /dev/null
+++ b/ai_evals/fixtures/frontend/global/initial/composer_incidents.json
@@ -0,0 +1,83 @@
+{
+ "workspace": {
+ "scripts": [
+ {
+ "path": "f/billing/charge_cards",
+ "summary": "Charge cards",
+ "language": "bash",
+ "content": "echo \"charging card for customer $1\"\ncurl -sf https://payments.example.com/charge || { echo \"card declined: insufficient funds\" >&2; exit 1; }\n"
+ },
+ {
+ "path": "f/sales/import_orders",
+ "summary": "Import orders",
+ "language": "bash",
+ "content": "echo \"imported $(date +%s) orders\"\n"
+ },
+ {
+ "path": "f/media/gpu_render",
+ "summary": "GPU render",
+ "language": "bash",
+ "content": "echo \"rendering frame\"\n"
+ }
+ ],
+ "flows": [
+ {
+ "path": "f/billing/refund_approval",
+ "summary": "Refund approval",
+ "value": {
+ "modules": [
+ {
+ "id": "a",
+ "summary": "Ask a manager to approve the refund",
+ "value": { "type": "rawscript", "language": "bash", "content": "echo \"refund requested\"", "input_transforms": {} },
+ "suspend": { "required_events": 1, "timeout": 86400 }
+ },
+ {
+ "id": "b",
+ "summary": "Issue the refund",
+ "value": { "type": "rawscript", "language": "bash", "content": "echo \"refunded\"", "input_transforms": {} }
+ }
+ ]
+ }
+ }
+ ],
+ "schedules": [
+ { "path": "f/billing/charge_cards_every_minute", "schedule": "0 * * * * *", "script_path": "f/billing/charge_cards", "summary": "Charge pending cards every minute" },
+ { "path": "f/sales/import_orders_hourly", "schedule": "0 0 * * * *", "script_path": "f/sales/import_orders", "summary": "Import orders every hour" }
+ ],
+ "workers": [
+ {
+ "worker": "wk-default-7f3a2",
+ "worker_group": "default",
+ "custom_tags": ["bash", "python3", "bun", "deno", "go", "flow", "dependency"],
+ "lastPingSecondsAgo": 3,
+ "jobs_executed": 214
+ }
+ ],
+ "jobs": [
+ { "id": "01930000-0000-7000-8000-00000000c001", "scriptPath": "f/billing/charge_cards", "schedulePath": "f/billing/charge_cards_every_minute", "createdBy": "schedule", "success": false, "createdMinutesAgo": 1, "durationMs": 41, "tag": "bash", "logs": "charging card for customer 4412\ncard declined: insufficient funds\nexit code 1" },
+ { "id": "01930000-0000-7000-8000-00000000c002", "scriptPath": "f/billing/charge_cards", "schedulePath": "f/billing/charge_cards_every_minute", "createdBy": "schedule", "success": false, "createdMinutesAgo": 2, "durationMs": 38, "tag": "bash", "logs": "charging card for customer 4412\ncard declined: insufficient funds\nexit code 1" },
+ { "id": "01930000-0000-7000-8000-00000000c003", "scriptPath": "f/billing/charge_cards", "schedulePath": "f/billing/charge_cards_every_minute", "createdBy": "schedule", "success": false, "createdMinutesAgo": 3, "durationMs": 44, "tag": "bash", "logs": "charging card for customer 4412\ncard declined: insufficient funds\nexit code 1" },
+ { "id": "01930000-0000-7000-8000-00000000c004", "scriptPath": "f/billing/charge_cards", "schedulePath": "f/billing/charge_cards_every_minute", "createdBy": "schedule", "success": false, "createdMinutesAgo": 4, "durationMs": 40, "tag": "bash", "logs": "charging card for customer 4412\ncard declined: insufficient funds\nexit code 1" },
+ { "id": "01930000-0000-7000-8000-00000000c005", "scriptPath": "f/billing/charge_cards", "createdBy": "alice", "success": false, "createdMinutesAgo": 30, "durationMs": 52, "tag": "bash", "logs": "charging card for customer 4412\ncard declined: insufficient funds\nexit code 1" },
+ { "id": "01930000-0000-7000-8000-00000000c006", "scriptPath": "f/billing/charge_cards", "createdBy": "alice", "success": false, "createdMinutesAgo": 31, "durationMs": 47, "tag": "bash", "logs": "charging card for customer 4412\ncard declined: insufficient funds\nexit code 1" },
+ { "id": "01930000-0000-7000-8000-00000000d001", "scriptPath": "f/sales/import_orders", "schedulePath": "f/sales/import_orders_hourly", "createdBy": "schedule", "success": true, "createdMinutesAgo": 10, "durationMs": 820, "tag": "bash", "logs": "imported 112 orders" },
+ { "id": "01930000-0000-7000-8000-00000000d002", "scriptPath": "f/sales/import_orders", "schedulePath": "f/sales/import_orders_hourly", "createdBy": "schedule", "success": true, "createdMinutesAgo": 70, "durationMs": 790, "tag": "bash", "logs": "imported 98 orders" },
+ { "id": "01930000-0000-7000-8000-00000000d003", "scriptPath": "f/sales/import_orders", "schedulePath": "f/sales/import_orders_hourly", "createdBy": "schedule", "success": true, "createdMinutesAgo": 130, "durationMs": 805, "tag": "bash", "logs": "imported 104 orders" },
+ { "id": "01930000-0000-7000-8000-00000000d004", "scriptPath": "f/sales/import_orders", "createdBy": "bob", "success": true, "createdMinutesAgo": 45, "durationMs": 811, "tag": "bash", "logs": "imported 3 orders" },
+ { "id": "01930000-0000-7000-8000-00000000e001", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 22, "tag": "gpu-renderer" },
+ { "id": "01930000-0000-7000-8000-00000000e002", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 22, "tag": "gpu-renderer" },
+ { "id": "01930000-0000-7000-8000-00000000e003", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 22, "tag": "gpu-renderer" },
+ { "id": "01930000-0000-7000-8000-00000000e004", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 21, "tag": "gpu-renderer" },
+ { "id": "01930000-0000-7000-8000-00000000e005", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 21, "tag": "gpu-renderer" },
+ { "id": "01930000-0000-7000-8000-00000000e006", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 21, "tag": "gpu-renderer" },
+ { "id": "01930000-0000-7000-8000-00000000e007", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 20, "tag": "gpu-renderer" },
+ { "id": "01930000-0000-7000-8000-00000000e008", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 20, "tag": "gpu-renderer" },
+ { "id": "01930000-0000-7000-8000-00000000e009", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 20, "tag": "gpu-renderer" },
+ { "id": "01930000-0000-7000-8000-00000000e010", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 19, "tag": "gpu-renderer" },
+ { "id": "01930000-0000-7000-8000-00000000e011", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 19, "tag": "gpu-renderer" },
+ { "id": "01930000-0000-7000-8000-00000000e012", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 19, "tag": "gpu-renderer" },
+ { "id": "01930000-0000-7000-8000-00000000f001", "scriptPath": "f/billing/refund_approval", "jobKind": "flow", "createdBy": "bob", "state": "suspended", "createdMinutesAgo": 40, "tag": "flow", "logs": "refund requested" }
+ ]
+ }
+}
diff --git a/backend/.sqlx/query-020c031c3de6c85577e30421ada9d39a5a47ca1b6cf3dbfd6988aa0694d7364c.json b/backend/.sqlx/query-020c031c3de6c85577e30421ada9d39a5a47ca1b6cf3dbfd6988aa0694d7364c.json
deleted file mode 100644
index e8edd87bda..0000000000
--- a/backend/.sqlx/query-020c031c3de6c85577e30421ada9d39a5a47ca1b6cf3dbfd6988aa0694d7364c.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "SELECT value FROM variable WHERE path = $1 AND workspace_id = $2 AND is_secret = true",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "value",
- "type_info": "Varchar"
- }
- ],
- "parameters": {
- "Left": [
- "Text",
- "Text"
- ]
- },
- "nullable": [
- false
- ]
- },
- "hash": "020c031c3de6c85577e30421ada9d39a5a47ca1b6cf3dbfd6988aa0694d7364c"
-}
diff --git a/backend/.sqlx/query-7a92837ad2a1181580c1e0d059fd6bc0ece84d3c315635de52bd2b364c238a71.json b/backend/.sqlx/query-08af141e847f9bb6a4c26ccad3bd84d2a06b9e55557757d407787078af1ecad7.json
similarity index 62%
rename from backend/.sqlx/query-7a92837ad2a1181580c1e0d059fd6bc0ece84d3c315635de52bd2b364c238a71.json
rename to backend/.sqlx/query-08af141e847f9bb6a4c26ccad3bd84d2a06b9e55557757d407787078af1ecad7.json
index 331816c897..4031b62a9e 100644
--- a/backend/.sqlx/query-7a92837ad2a1181580c1e0d059fd6bc0ece84d3c315635de52bd2b364c238a71.json
+++ b/backend/.sqlx/query-08af141e847f9bb6a4c26ccad3bd84d2a06b9e55557757d407787078af1ecad7.json
@@ -1,12 +1,12 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT value, account, (now() > account.expires_at) as is_expired, is_secret, path from variable\n LEFT JOIN account ON variable.account = account.id WHERE variable.path = $1 AND variable.workspace_id = $2",
+ "query": "SELECT value::text AS \"value!\", account, (now() > account.expires_at) as is_expired, is_secret, path from variable\n LEFT JOIN account ON variable.account = account.id WHERE variable.path = $1 AND variable.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
- "name": "value",
- "type_info": "Varchar"
+ "name": "value!",
+ "type_info": "Text"
},
{
"ordinal": 1,
@@ -36,12 +36,12 @@
]
},
"nullable": [
- false,
+ null,
true,
null,
false,
false
]
},
- "hash": "7a92837ad2a1181580c1e0d059fd6bc0ece84d3c315635de52bd2b364c238a71"
+ "hash": "08af141e847f9bb6a4c26ccad3bd84d2a06b9e55557757d407787078af1ecad7"
}
diff --git a/backend/.sqlx/query-146f0e42ada3068a5cdae0ffdbb54b63f8c06c9143b16ce399170c1b5a6b911e.json b/backend/.sqlx/query-1cf81ee935e6e820463820519e3a402d5290b494dac690c9a11f121eeefeab8e.json
similarity index 56%
rename from backend/.sqlx/query-146f0e42ada3068a5cdae0ffdbb54b63f8c06c9143b16ce399170c1b5a6b911e.json
rename to backend/.sqlx/query-1cf81ee935e6e820463820519e3a402d5290b494dac690c9a11f121eeefeab8e.json
index 9fd6daded5..da40d27d9f 100644
--- a/backend/.sqlx/query-146f0e42ada3068a5cdae0ffdbb54b63f8c06c9143b16ce399170c1b5a6b911e.json
+++ b/backend/.sqlx/query-1cf81ee935e6e820463820519e3a402d5290b494dac690c9a11f121eeefeab8e.json
@@ -1,12 +1,12 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT value, is_secret FROM variable WHERE path = $1 AND workspace_id = $2",
+ "query": "SELECT value::text AS \"value!\", is_secret FROM variable WHERE path = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
- "name": "value",
- "type_info": "Varchar"
+ "name": "value!",
+ "type_info": "Text"
},
{
"ordinal": 1,
@@ -21,9 +21,9 @@
]
},
"nullable": [
- false,
+ null,
false
]
},
- "hash": "146f0e42ada3068a5cdae0ffdbb54b63f8c06c9143b16ce399170c1b5a6b911e"
+ "hash": "1cf81ee935e6e820463820519e3a402d5290b494dac690c9a11f121eeefeab8e"
}
diff --git a/backend/.sqlx/query-2c0ab7571e1a7c4290315bc3efccb4db9e0c9aee05596a594f81975a0cdb74d1.json b/backend/.sqlx/query-2c0ab7571e1a7c4290315bc3efccb4db9e0c9aee05596a594f81975a0cdb74d1.json
deleted file mode 100644
index 9c4b3eb842..0000000000
--- a/backend/.sqlx/query-2c0ab7571e1a7c4290315bc3efccb4db9e0c9aee05596a594f81975a0cdb74d1.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "SELECT value FROM variable WHERE workspace_id = $1 AND path = $2",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "value",
- "type_info": "Varchar"
- }
- ],
- "parameters": {
- "Left": [
- "Text",
- "Text"
- ]
- },
- "nullable": [
- false
- ]
- },
- "hash": "2c0ab7571e1a7c4290315bc3efccb4db9e0c9aee05596a594f81975a0cdb74d1"
-}
diff --git a/backend/.sqlx/query-2d5ff8fc102ae0c452c1f9cd5cd30fd0a3b4e6e746c659da1767edafd139d45e.json b/backend/.sqlx/query-30f69fd4baef17716d26cc59bf6369d6768972478def50010f680103d7c4281b.json
similarity index 50%
rename from backend/.sqlx/query-2d5ff8fc102ae0c452c1f9cd5cd30fd0a3b4e6e746c659da1767edafd139d45e.json
rename to backend/.sqlx/query-30f69fd4baef17716d26cc59bf6369d6768972478def50010f680103d7c4281b.json
index 2939d5ac88..3933b5e6a2 100644
--- a/backend/.sqlx/query-2d5ff8fc102ae0c452c1f9cd5cd30fd0a3b4e6e746c659da1767edafd139d45e.json
+++ b/backend/.sqlx/query-30f69fd4baef17716d26cc59bf6369d6768972478def50010f680103d7c4281b.json
@@ -1,12 +1,12 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT value, is_secret, description\n FROM variable\n WHERE workspace_id = $1 AND path = $2",
+ "query": "SELECT value::text AS \"value!\", is_secret, description, value_valid_until\n FROM variable\n WHERE workspace_id = $1 AND path = $2",
"describe": {
"columns": [
{
"ordinal": 0,
- "name": "value",
- "type_info": "Varchar"
+ "name": "value!",
+ "type_info": "Text"
},
{
"ordinal": 1,
@@ -17,6 +17,11 @@
"ordinal": 2,
"name": "description",
"type_info": "Varchar"
+ },
+ {
+ "ordinal": 3,
+ "name": "value_valid_until",
+ "type_info": "Timestamptz"
}
],
"parameters": {
@@ -26,10 +31,11 @@
]
},
"nullable": [
+ null,
false,
false,
- false
+ true
]
},
- "hash": "2d5ff8fc102ae0c452c1f9cd5cd30fd0a3b4e6e746c659da1767edafd139d45e"
+ "hash": "30f69fd4baef17716d26cc59bf6369d6768972478def50010f680103d7c4281b"
}
diff --git a/backend/.sqlx/query-32c7dba63931c9bd35f82f145d095bcfb8f9400809ade1b173bac05d61bfe77a.json b/backend/.sqlx/query-32c7dba63931c9bd35f82f145d095bcfb8f9400809ade1b173bac05d61bfe77a.json
new file mode 100644
index 0000000000..0e2bcbd955
--- /dev/null
+++ b/backend/.sqlx/query-32c7dba63931c9bd35f82f145d095bcfb8f9400809ade1b173bac05d61bfe77a.json
@@ -0,0 +1,24 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO variable\n (workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, value_valid_until, labels, edited_by)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Varchar",
+ "Varchar",
+ "Varchar",
+ "Bool",
+ "Varchar",
+ "Int4",
+ "Bool",
+ "Timestamptz",
+ "Timestamptz",
+ "TextArray",
+ "Varchar"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "32c7dba63931c9bd35f82f145d095bcfb8f9400809ade1b173bac05d61bfe77a"
+}
diff --git a/backend/.sqlx/query-b656f38e5f1d6a0799767a775b8550027cf7a3bb202269e8dda92d31bc8789dd.json b/backend/.sqlx/query-3420c007c141c5d9e3748c18de03d4b51216ba05e2951ce045ff58112c1db361.json
similarity index 63%
rename from backend/.sqlx/query-b656f38e5f1d6a0799767a775b8550027cf7a3bb202269e8dda92d31bc8789dd.json
rename to backend/.sqlx/query-3420c007c141c5d9e3748c18de03d4b51216ba05e2951ce045ff58112c1db361.json
index 6ad2fc0ae1..b7b2516d40 100644
--- a/backend/.sqlx/query-b656f38e5f1d6a0799767a775b8550027cf7a3bb202269e8dda92d31bc8789dd.json
+++ b/backend/.sqlx/query-3420c007c141c5d9e3748c18de03d4b51216ba05e2951ce045ff58112c1db361.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT path, value, is_secret FROM variable WHERE workspace_id = $1",
+ "query": "SELECT path, value::text AS \"value!\", is_secret FROM variable WHERE workspace_id = $1",
"describe": {
"columns": [
{
@@ -10,8 +10,8 @@
},
{
"ordinal": 1,
- "name": "value",
- "type_info": "Varchar"
+ "name": "value!",
+ "type_info": "Text"
},
{
"ordinal": 2,
@@ -26,9 +26,9 @@
},
"nullable": [
false,
- false,
+ null,
false
]
},
- "hash": "b656f38e5f1d6a0799767a775b8550027cf7a3bb202269e8dda92d31bc8789dd"
+ "hash": "3420c007c141c5d9e3748c18de03d4b51216ba05e2951ce045ff58112c1db361"
}
diff --git a/backend/.sqlx/query-71b410dbc6cdd0252de2f8646395903c9fa60a5435fa73c587da2b913203cf18.json b/backend/.sqlx/query-36121d10cb03d45fc14aacd08750fb944a54c1172ffa2b990ba157e676f66f51.json
similarity index 50%
rename from backend/.sqlx/query-71b410dbc6cdd0252de2f8646395903c9fa60a5435fa73c587da2b913203cf18.json
rename to backend/.sqlx/query-36121d10cb03d45fc14aacd08750fb944a54c1172ffa2b990ba157e676f66f51.json
index d74d5e6ba3..8d7e440538 100644
--- a/backend/.sqlx/query-71b410dbc6cdd0252de2f8646395903c9fa60a5435fa73c587da2b913203cf18.json
+++ b/backend/.sqlx/query-36121d10cb03d45fc14aacd08750fb944a54c1172ffa2b990ba157e676f66f51.json
@@ -1,12 +1,12 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT v.value, a.id AS account_id, a.refresh_token\n FROM variable v\n JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account\n WHERE v.workspace_id = $1 AND v.path = $2\n AND a.client = $3 AND a.is_workspace_integration = true",
+ "query": "SELECT v.value::text AS \"value!\", a.id AS account_id, a.refresh_token\n FROM variable v\n JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account\n WHERE v.workspace_id = $1 AND v.path = $2\n AND a.client = $3 AND a.is_workspace_integration = true",
"describe": {
"columns": [
{
"ordinal": 0,
- "name": "value",
- "type_info": "Varchar"
+ "name": "value!",
+ "type_info": "Text"
},
{
"ordinal": 1,
@@ -27,10 +27,10 @@
]
},
"nullable": [
- false,
+ null,
false,
false
]
},
- "hash": "71b410dbc6cdd0252de2f8646395903c9fa60a5435fa73c587da2b913203cf18"
+ "hash": "36121d10cb03d45fc14aacd08750fb944a54c1172ffa2b990ba157e676f66f51"
}
diff --git a/backend/.sqlx/query-37275b26b092ed3ae1a11b78da5227a40a49d9435107e71033740f11cc8d2417.json b/backend/.sqlx/query-37275b26b092ed3ae1a11b78da5227a40a49d9435107e71033740f11cc8d2417.json
deleted file mode 100644
index 7560df9dd2..0000000000
--- a/backend/.sqlx/query-37275b26b092ed3ae1a11b78da5227a40a49d9435107e71033740f11cc8d2417.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2",
- "describe": {
- "columns": [],
- "parameters": {
- "Left": [
- "Varchar",
- "Text"
- ]
- },
- "nullable": []
- },
- "hash": "37275b26b092ed3ae1a11b78da5227a40a49d9435107e71033740f11cc8d2417"
-}
diff --git a/backend/.sqlx/query-3c951e76bd0d86a488372e54fdfe4f5a2923cff642905c6410267be2eab24853.json b/backend/.sqlx/query-3c951e76bd0d86a488372e54fdfe4f5a2923cff642905c6410267be2eab24853.json
new file mode 100644
index 0000000000..a41e413243
--- /dev/null
+++ b/backend/.sqlx/query-3c951e76bd0d86a488372e54fdfe4f5a2923cff642905c6410267be2eab24853.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2 AND archived = false AND deleted = false)",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "exists",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "3c951e76bd0d86a488372e54fdfe4f5a2923cff642905c6410267be2eab24853"
+}
diff --git a/backend/.sqlx/query-40790b7f44b40b0c64bbabd3bc6c984000e12f33bc1cea460e8f5dd55ea59eec.json b/backend/.sqlx/query-40790b7f44b40b0c64bbabd3bc6c984000e12f33bc1cea460e8f5dd55ea59eec.json
new file mode 100644
index 0000000000..ab8bc4e241
--- /dev/null
+++ b/backend/.sqlx/query-40790b7f44b40b0c64bbabd3bc6c984000e12f33bc1cea460e8f5dd55ea59eec.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT value::text AS \"value!\" FROM variable WHERE workspace_id = $1 AND path = $2",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "value!",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "40790b7f44b40b0c64bbabd3bc6c984000e12f33bc1cea460e8f5dd55ea59eec"
+}
diff --git a/backend/.sqlx/query-4ba98c3b07a3bd2fa51e4ddf93c182b3ac165cb6a343ad616409fa80b4b93484.json b/backend/.sqlx/query-4ba98c3b07a3bd2fa51e4ddf93c182b3ac165cb6a343ad616409fa80b4b93484.json
new file mode 100644
index 0000000000..8cead5cb8e
--- /dev/null
+++ b/backend/.sqlx/query-4ba98c3b07a3bd2fa51e4ddf93c182b3ac165cb6a343ad616409fa80b4b93484.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE workspace_settings SET variable_expiration_failed_at = NULL\n WHERE workspace_id = $1 AND variable_expiration_failed_at IS NOT NULL\n RETURNING 1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "?column?",
+ "type_info": "Int4"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "4ba98c3b07a3bd2fa51e4ddf93c182b3ac165cb6a343ad616409fa80b4b93484"
+}
diff --git a/backend/.sqlx/query-56bb398d53c1c54b27fe3b61b866c752ea54871db71a8859311e18bf6b6da82c.json b/backend/.sqlx/query-56bb398d53c1c54b27fe3b61b866c752ea54871db71a8859311e18bf6b6da82c.json
new file mode 100644
index 0000000000..17a9650d3e
--- /dev/null
+++ b/backend/.sqlx/query-56bb398d53c1c54b27fe3b61b866c752ea54871db71a8859311e18bf6b6da82c.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT value AS \"value: sqlx::types::Json\"\n FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "value: sqlx::types::Json",
+ "type_info": "Jsonb"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": [
+ true
+ ]
+ },
+ "hash": "56bb398d53c1c54b27fe3b61b866c752ea54871db71a8859311e18bf6b6da82c"
+}
diff --git a/backend/.sqlx/query-1587639f13a1a3ad97bd324df38661f3e846f1992704c86ddad74dea151b0ec4.json b/backend/.sqlx/query-5d344853a65a2438c87f12124428609933e4363c11455709e1785228abd3366f.json
similarity index 61%
rename from backend/.sqlx/query-1587639f13a1a3ad97bd324df38661f3e846f1992704c86ddad74dea151b0ec4.json
rename to backend/.sqlx/query-5d344853a65a2438c87f12124428609933e4363c11455709e1785228abd3366f.json
index 0ed678d485..386435e806 100644
--- a/backend/.sqlx/query-1587639f13a1a3ad97bd324df38661f3e846f1992704c86ddad74dea151b0ec4.json
+++ b/backend/.sqlx/query-5d344853a65a2438c87f12124428609933e4363c11455709e1785228abd3366f.json
@@ -1,12 +1,12 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT value, is_secret, path from variable WHERE variable.path = $1 AND variable.workspace_id = $2",
+ "query": "SELECT value::text AS \"value!\", is_secret, path from variable WHERE variable.path = $1 AND variable.workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
- "name": "value",
- "type_info": "Varchar"
+ "name": "value!",
+ "type_info": "Text"
},
{
"ordinal": 1,
@@ -26,10 +26,10 @@
]
},
"nullable": [
- false,
+ null,
false,
false
]
},
- "hash": "1587639f13a1a3ad97bd324df38661f3e846f1992704c86ddad74dea151b0ec4"
+ "hash": "5d344853a65a2438c87f12124428609933e4363c11455709e1785228abd3366f"
}
diff --git a/backend/.sqlx/query-0c2e53960dc7786c848079b155e62994d4a861f1dbb9752c0a27428d83040e13.json b/backend/.sqlx/query-698758d7ef61b3f1de00088a36fe9f2e94c1c25b02bf324a730e4f734eb61c16.json
similarity index 57%
rename from backend/.sqlx/query-0c2e53960dc7786c848079b155e62994d4a861f1dbb9752c0a27428d83040e13.json
rename to backend/.sqlx/query-698758d7ef61b3f1de00088a36fe9f2e94c1c25b02bf324a730e4f734eb61c16.json
index 5c33539878..17e8d20bb5 100644
--- a/backend/.sqlx/query-0c2e53960dc7786c848079b155e62994d4a861f1dbb9752c0a27428d83040e13.json
+++ b/backend/.sqlx/query-698758d7ef61b3f1de00088a36fe9f2e94c1c25b02bf324a730e4f734eb61c16.json
@@ -1,12 +1,12 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT value, is_secret FROM variable WHERE path = $1",
+ "query": "SELECT value::text AS \"value!\", is_secret FROM variable WHERE path = $1",
"describe": {
"columns": [
{
"ordinal": 0,
- "name": "value",
- "type_info": "Varchar"
+ "name": "value!",
+ "type_info": "Text"
},
{
"ordinal": 1,
@@ -20,9 +20,9 @@
]
},
"nullable": [
- false,
+ null,
false
]
},
- "hash": "0c2e53960dc7786c848079b155e62994d4a861f1dbb9752c0a27428d83040e13"
+ "hash": "698758d7ef61b3f1de00088a36fe9f2e94c1c25b02bf324a730e4f734eb61c16"
}
diff --git a/backend/.sqlx/query-74742f785888f280bf1d7e28793150c3ddc2fd4be3b8c682f6faa7b917387593.json b/backend/.sqlx/query-74742f785888f280bf1d7e28793150c3ddc2fd4be3b8c682f6faa7b917387593.json
new file mode 100644
index 0000000000..5bf83d1b33
--- /dev/null
+++ b/backend/.sqlx/query-74742f785888f280bf1d7e28793150c3ddc2fd4be3b8c682f6faa7b917387593.json
@@ -0,0 +1,214 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n variable_expiration_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "workspace_id",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 1,
+ "name": "slack_team_id",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 2,
+ "name": "teams_team_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "teams_team_name",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 4,
+ "name": "teams_team_guid",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 5,
+ "name": "slack_name",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 6,
+ "name": "slack_command_script",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 7,
+ "name": "teams_command_script",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 8,
+ "name": "slack_email",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 9,
+ "name": "slack_oauth_client_id",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 10,
+ "name": "slack_oauth_client_secret",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 11,
+ "name": "customer_id",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 12,
+ "name": "plan",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 13,
+ "name": "webhook",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 14,
+ "name": "ai_config",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 15,
+ "name": "dbt_warehouses",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 16,
+ "name": "large_file_storage",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 17,
+ "name": "datatable",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 18,
+ "name": "ducklake",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 19,
+ "name": "git_sync",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 20,
+ "name": "deploy_ui",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 21,
+ "name": "default_app",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 22,
+ "name": "default_scripts",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 23,
+ "name": "mute_critical_alerts",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 24,
+ "name": "color",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 25,
+ "name": "operator_settings",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 26,
+ "name": "git_app_installations",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 27,
+ "name": "auto_invite",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 28,
+ "name": "error_handler",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 29,
+ "name": "success_handler",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 30,
+ "name": "variable_expiration_handler",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 31,
+ "name": "public_app_execution_limit_per_minute",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 32,
+ "name": "error_handler_fallback_to_instance_alerts",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ false,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ false,
+ true,
+ true,
+ true,
+ true,
+ true,
+ false
+ ]
+ },
+ "hash": "74742f785888f280bf1d7e28793150c3ddc2fd4be3b8c682f6faa7b917387593"
+}
diff --git a/backend/.sqlx/query-77d599e4f7c574dffac4824f37127c7ae2ef5f27d665ad9018f5cdea0f6f2cb1.json b/backend/.sqlx/query-77d599e4f7c574dffac4824f37127c7ae2ef5f27d665ad9018f5cdea0f6f2cb1.json
deleted file mode 100644
index f504d7a809..0000000000
--- a/backend/.sqlx/query-77d599e4f7c574dffac4824f37127c7ae2ef5f27d665ad9018f5cdea0f6f2cb1.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2",
- "describe": {
- "columns": [],
- "parameters": {
- "Left": [
- "Varchar",
- "Text"
- ]
- },
- "nullable": []
- },
- "hash": "77d599e4f7c574dffac4824f37127c7ae2ef5f27d665ad9018f5cdea0f6f2cb1"
-}
diff --git a/backend/.sqlx/query-809a2dd647ddfc6fa79ca2c9039e4ebfc4b2880600a03b82e60b89c4a1fd5cc8.json b/backend/.sqlx/query-809a2dd647ddfc6fa79ca2c9039e4ebfc4b2880600a03b82e60b89c4a1fd5cc8.json
new file mode 100644
index 0000000000..cb33edacdf
--- /dev/null
+++ b/backend/.sqlx/query-809a2dd647ddfc6fa79ca2c9039e4ebfc4b2880600a03b82e60b89c4a1fd5cc8.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at, value_valid_until)\n SELECT $2, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at, value_valid_until\n FROM variable\n WHERE workspace_id = $1",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Varchar"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "809a2dd647ddfc6fa79ca2c9039e4ebfc4b2880600a03b82e60b89c4a1fd5cc8"
+}
diff --git a/backend/.sqlx/query-9a3677f9bfc1b5b679578572302129371c7c62e30d77ba6c5e74b575de635b30.json b/backend/.sqlx/query-9a3677f9bfc1b5b679578572302129371c7c62e30d77ba6c5e74b575de635b30.json
new file mode 100644
index 0000000000..db0e11a62b
--- /dev/null
+++ b/backend/.sqlx/query-9a3677f9bfc1b5b679578572302129371c7c62e30d77ba6c5e74b575de635b30.json
@@ -0,0 +1,26 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE variable SET expiration_dispatched_for = $3, expiration_notified_at = now()\n WHERE workspace_id = $1 AND path = $2 AND value_valid_until = $3\n AND expiration_dispatched_for IS NOT DISTINCT FROM $4\n AND expiration_notified_at IS NOT DISTINCT FROM $5\n RETURNING 1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "?column?",
+ "type_info": "Int4"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text",
+ "Timestamptz",
+ "Timestamptz",
+ "Timestamptz"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "9a3677f9bfc1b5b679578572302129371c7c62e30d77ba6c5e74b575de635b30"
+}
diff --git a/backend/.sqlx/query-9aaee16823c7d3fc0954274ba71bc2eae7d950f759d146339c30df039cf10fd8.json b/backend/.sqlx/query-9aaee16823c7d3fc0954274ba71bc2eae7d950f759d146339c30df039cf10fd8.json
new file mode 100644
index 0000000000..38bad3fcde
--- /dev/null
+++ b/backend/.sqlx/query-9aaee16823c7d3fc0954274ba71bc2eae7d950f759d146339c30df039cf10fd8.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT p.email FROM password p LEFT JOIN usr m ON m.workspace_id = $1 AND m.email = p.email WHERE p.super_admin AND NOT p.disabled AND (m.email IS NOT NULL AND NOT m.disabled OR m.email IS NULL AND NOT EXISTS (SELECT 1 FROM usr c WHERE c.workspace_id = $1 AND c.username = COALESCE(p.username, p.email))) ORDER BY m.email IS NULL, p.email LIMIT 1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "email",
+ "type_info": "Varchar"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "9aaee16823c7d3fc0954274ba71bc2eae7d950f759d146339c30df039cf10fd8"
+}
diff --git a/backend/.sqlx/query-9bbd373847d5cc8e0d84349dbea73af0d18e3b4ce2eb7a20929f8c4c101141b6.json b/backend/.sqlx/query-9bbd373847d5cc8e0d84349dbea73af0d18e3b4ce2eb7a20929f8c4c101141b6.json
new file mode 100644
index 0000000000..ab555a2efd
--- /dev/null
+++ b/backend/.sqlx/query-9bbd373847d5cc8e0d84349dbea73af0d18e3b4ce2eb7a20929f8c4c101141b6.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO variable (workspace_id, path, value, is_secret, description, value_valid_until)\n VALUES ('test-workspace', 'u/admin/expiring', 'v', false, 'same', '2027-01-01T00:00:00Z')",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": []
+ },
+ "hash": "9bbd373847d5cc8e0d84349dbea73af0d18e3b4ce2eb7a20929f8c4c101141b6"
+}
diff --git a/backend/.sqlx/query-9c2b997126090a5bc191f8d7460e14e0f95b81c61241ffd0c9d633e562c4c4f6.json b/backend/.sqlx/query-9c2b997126090a5bc191f8d7460e14e0f95b81c61241ffd0c9d633e562c4c4f6.json
new file mode 100644
index 0000000000..ecb5eb2c4b
--- /dev/null
+++ b/backend/.sqlx/query-9c2b997126090a5bc191f8d7460e14e0f95b81c61241ffd0c9d633e562c4c4f6.json
@@ -0,0 +1,14 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE workspace_settings SET variable_expiration_handler = NULL WHERE workspace_id = $1",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "9c2b997126090a5bc191f8d7460e14e0f95b81c61241ffd0c9d633e562c4c4f6"
+}
diff --git a/backend/.sqlx/query-9dabc87e05f72fbd4b586c80d9c7f41c3a72838c5d4c5f795638506084561894.json b/backend/.sqlx/query-9dabc87e05f72fbd4b586c80d9c7f41c3a72838c5d4c5f795638506084561894.json
new file mode 100644
index 0000000000..6557494459
--- /dev/null
+++ b/backend/.sqlx/query-9dabc87e05f72fbd4b586c80d9c7f41c3a72838c5d4c5f795638506084561894.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler FROM workspace_settings WHERE workspace_id = $2",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Varchar",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "9dabc87e05f72fbd4b586c80d9c7f41c3a72838c5d4c5f795638506084561894"
+}
diff --git a/backend/.sqlx/query-61f3627cbd2a50b745c4e491ed8ee71e58db22b7f53202c5360a01c92091fc08.json b/backend/.sqlx/query-a0f799d0211f0a7bd649747a6627b82cd0127581ad831089231317af587bb25a.json
similarity index 54%
rename from backend/.sqlx/query-61f3627cbd2a50b745c4e491ed8ee71e58db22b7f53202c5360a01c92091fc08.json
rename to backend/.sqlx/query-a0f799d0211f0a7bd649747a6627b82cd0127581ad831089231317af587bb25a.json
index f97d5e310a..5ac876fa73 100644
--- a/backend/.sqlx/query-61f3627cbd2a50b745c4e491ed8ee71e58db22b7f53202c5360a01c92091fc08.json
+++ b/backend/.sqlx/query-a0f799d0211f0a7bd649747a6627b82cd0127581ad831089231317af587bb25a.json
@@ -1,12 +1,12 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT value, is_secret\n FROM variable\n WHERE path = $1 AND workspace_id = $2",
+ "query": "SELECT value::text AS \"value!\", is_secret\n FROM variable\n WHERE path = $1 AND workspace_id = $2",
"describe": {
"columns": [
{
"ordinal": 0,
- "name": "value",
- "type_info": "Varchar"
+ "name": "value!",
+ "type_info": "Text"
},
{
"ordinal": 1,
@@ -21,9 +21,9 @@
]
},
"nullable": [
- false,
+ null,
false
]
},
- "hash": "61f3627cbd2a50b745c4e491ed8ee71e58db22b7f53202c5360a01c92091fc08"
+ "hash": "a0f799d0211f0a7bd649747a6627b82cd0127581ad831089231317af587bb25a"
}
diff --git a/backend/.sqlx/query-a4badf66d2ae524d15925869c9bfa6b590cb357e19233318d2a2a31555dbc515.json b/backend/.sqlx/query-a4badf66d2ae524d15925869c9bfa6b590cb357e19233318d2a2a31555dbc515.json
new file mode 100644
index 0000000000..828e5ba470
--- /dev/null
+++ b/backend/.sqlx/query-a4badf66d2ae524d15925869c9bfa6b590cb357e19233318d2a2a31555dbc515.json
@@ -0,0 +1,23 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT value::text AS \"value!\" FROM variable WHERE path = $1 AND workspace_id = $2 AND is_secret = true",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "value!",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "a4badf66d2ae524d15925869c9bfa6b590cb357e19233318d2a2a31555dbc515"
+}
diff --git a/backend/.sqlx/query-af37e5579b729a1aedcd63ab70d189c7e0376ca1f0071ae8360183ed1300d2bb.json b/backend/.sqlx/query-af37e5579b729a1aedcd63ab70d189c7e0376ca1f0071ae8360183ed1300d2bb.json
new file mode 100644
index 0000000000..c832fdeeda
--- /dev/null
+++ b/backend/.sqlx/query-af37e5579b729a1aedcd63ab70d189c7e0376ca1f0071ae8360183ed1300d2bb.json
@@ -0,0 +1,238 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n variable_expiration_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url,\n add_admins_and_developers_to_forks\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "workspace_id",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 1,
+ "name": "slack_team_id",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 2,
+ "name": "teams_team_id",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 3,
+ "name": "teams_team_name",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 4,
+ "name": "teams_team_guid",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 5,
+ "name": "slack_name",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 6,
+ "name": "slack_command_script",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 7,
+ "name": "teams_command_script",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 8,
+ "name": "slack_email",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 9,
+ "name": "slack_oauth_client_id",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 10,
+ "name": "slack_oauth_client_secret",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 11,
+ "name": "customer_id",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 12,
+ "name": "plan",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 13,
+ "name": "webhook",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 14,
+ "name": "ai_config",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 15,
+ "name": "dbt_warehouses",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 16,
+ "name": "large_file_storage",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 17,
+ "name": "datatable",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 18,
+ "name": "ducklake",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 19,
+ "name": "git_sync",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 20,
+ "name": "deploy_ui",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 21,
+ "name": "default_app",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 22,
+ "name": "default_scripts",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 23,
+ "name": "mute_critical_alerts",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 24,
+ "name": "color",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 25,
+ "name": "operator_settings",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 26,
+ "name": "git_app_installations",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 27,
+ "name": "auto_invite",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 28,
+ "name": "error_handler",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 29,
+ "name": "success_handler",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 30,
+ "name": "variable_expiration_handler",
+ "type_info": "Jsonb"
+ },
+ {
+ "ordinal": 31,
+ "name": "public_app_execution_limit_per_minute",
+ "type_info": "Int4"
+ },
+ {
+ "ordinal": 32,
+ "name": "error_handler_fallback_to_instance_alerts",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 33,
+ "name": "guest_access_enabled",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 34,
+ "name": "guest_jwt_public_key",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 35,
+ "name": "guest_jwt_jwks_url",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 36,
+ "name": "add_admins_and_developers_to_forks",
+ "type_info": "Bool"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ false,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ true,
+ false,
+ true,
+ true,
+ true,
+ true,
+ true,
+ false,
+ false,
+ true,
+ true,
+ false
+ ]
+ },
+ "hash": "af37e5579b729a1aedcd63ab70d189c7e0376ca1f0071ae8360183ed1300d2bb"
+}
diff --git a/backend/.sqlx/query-44a12919fb154055f1142cc078ef131f8a0c9cdb37cfba6283a6718480b02a4b.json b/backend/.sqlx/query-b2c35e00474b8fa335d9485c1f98018dc033582642988ed7a3181989fef72fed.json
similarity index 63%
rename from backend/.sqlx/query-44a12919fb154055f1142cc078ef131f8a0c9cdb37cfba6283a6718480b02a4b.json
rename to backend/.sqlx/query-b2c35e00474b8fa335d9485c1f98018dc033582642988ed7a3181989fef72fed.json
index 18c572b1fb..44dcca2457 100644
--- a/backend/.sqlx/query-44a12919fb154055f1142cc078ef131f8a0c9cdb37cfba6283a6718480b02a4b.json
+++ b/backend/.sqlx/query-b2c35e00474b8fa335d9485c1f98018dc033582642988ed7a3181989fef72fed.json
@@ -1,12 +1,12 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT value, is_secret, expires_at FROM variable WHERE path = $1",
+ "query": "SELECT value::text AS \"value!\", is_secret, expires_at FROM variable WHERE path = $1",
"describe": {
"columns": [
{
"ordinal": 0,
- "name": "value",
- "type_info": "Varchar"
+ "name": "value!",
+ "type_info": "Text"
},
{
"ordinal": 1,
@@ -25,10 +25,10 @@
]
},
"nullable": [
- false,
+ null,
false,
true
]
},
- "hash": "44a12919fb154055f1142cc078ef131f8a0c9cdb37cfba6283a6718480b02a4b"
+ "hash": "b2c35e00474b8fa335d9485c1f98018dc033582642988ed7a3181989fef72fed"
}
diff --git a/backend/.sqlx/query-b4e9a0266f4112467f534826c664c928d1e608ddd063d9d5ce3cea8ea62faca4.json b/backend/.sqlx/query-b4e9a0266f4112467f534826c664c928d1e608ddd063d9d5ce3cea8ea62faca4.json
new file mode 100644
index 0000000000..aee28fd8b6
--- /dev/null
+++ b/backend/.sqlx/query-b4e9a0266f4112467f534826c664c928d1e608ddd063d9d5ce3cea8ea62faca4.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE variable SET value_valid_until = '2029-12-31T00:00:00Z'\n WHERE workspace_id = 'wm-fork-expiry' AND path = 'u/admin/expiring'",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": []
+ },
+ "hash": "b4e9a0266f4112467f534826c664c928d1e608ddd063d9d5ce3cea8ea62faca4"
+}
diff --git a/backend/.sqlx/query-ade0696ec69bec2258d2e3ff86bfba6ed3b1d573c298f9d7fe8056ba7e32ed81.json b/backend/.sqlx/query-bc004ad060664cc75fc000229af16538576b978a1083365b5a98fc4ca7ea9576.json
similarity index 58%
rename from backend/.sqlx/query-ade0696ec69bec2258d2e3ff86bfba6ed3b1d573c298f9d7fe8056ba7e32ed81.json
rename to backend/.sqlx/query-bc004ad060664cc75fc000229af16538576b978a1083365b5a98fc4ca7ea9576.json
index 3fe61f6f8e..09fa4b0b11 100644
--- a/backend/.sqlx/query-ade0696ec69bec2258d2e3ff86bfba6ed3b1d573c298f9d7fe8056ba7e32ed81.json
+++ b/backend/.sqlx/query-bc004ad060664cc75fc000229af16538576b978a1083365b5a98fc4ca7ea9576.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT workspace_id, path, value FROM variable WHERE is_secret = true AND value IS NOT NULL AND value != ''",
+ "query": "SELECT workspace_id, path, value::text AS \"value!\" FROM variable WHERE is_secret = true AND value IS NOT NULL AND value != ''",
"describe": {
"columns": [
{
@@ -15,8 +15,8 @@
},
{
"ordinal": 2,
- "name": "value",
- "type_info": "Varchar"
+ "name": "value!",
+ "type_info": "Text"
}
],
"parameters": {
@@ -25,8 +25,8 @@
"nullable": [
false,
false,
- false
+ null
]
},
- "hash": "ade0696ec69bec2258d2e3ff86bfba6ed3b1d573c298f9d7fe8056ba7e32ed81"
+ "hash": "bc004ad060664cc75fc000229af16538576b978a1083365b5a98fc4ca7ea9576"
}
diff --git a/backend/.sqlx/query-bda9fc0c6e41d8afebee538e6997c6bad7843f93d0a5780632b02fa215aa404b.json b/backend/.sqlx/query-bda9fc0c6e41d8afebee538e6997c6bad7843f93d0a5780632b02fa215aa404b.json
deleted file mode 100644
index 02a979f32f..0000000000
--- a/backend/.sqlx/query-bda9fc0c6e41d8afebee538e6997c6bad7843f93d0a5780632b02fa215aa404b.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "SELECT path, value FROM variable\n WHERE path LIKE ('u/' || $1 || '/%')\n AND workspace_id = $2\n AND is_secret = true\n AND (value LIKE '$vault:%' OR value LIKE '$azure_kv:%')",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "path",
- "type_info": "Varchar"
- },
- {
- "ordinal": 1,
- "name": "value",
- "type_info": "Varchar"
- }
- ],
- "parameters": {
- "Left": [
- "Text",
- "Text"
- ]
- },
- "nullable": [
- false,
- false
- ]
- },
- "hash": "bda9fc0c6e41d8afebee538e6997c6bad7843f93d0a5780632b02fa215aa404b"
-}
diff --git a/backend/.sqlx/query-c0aff25f0cc3b71842b0ba9ae55b6bc5eca203bf02f46164db08580d128b860a.json b/backend/.sqlx/query-c0aff25f0cc3b71842b0ba9ae55b6bc5eca203bf02f46164db08580d128b860a.json
deleted file mode 100644
index 74a9d24135..0000000000
--- a/backend/.sqlx/query-c0aff25f0cc3b71842b0ba9ae55b6bc5eca203bf02f46164db08580d128b860a.json
+++ /dev/null
@@ -1,29 +0,0 @@
-{
- "db_name": "PostgreSQL",
- "query": "SELECT value, account FROM variable WHERE workspace_id = $1 AND path = $2",
- "describe": {
- "columns": [
- {
- "ordinal": 0,
- "name": "value",
- "type_info": "Varchar"
- },
- {
- "ordinal": 1,
- "name": "account",
- "type_info": "Int4"
- }
- ],
- "parameters": {
- "Left": [
- "Text",
- "Text"
- ]
- },
- "nullable": [
- false,
- true
- ]
- },
- "hash": "c0aff25f0cc3b71842b0ba9ae55b6bc5eca203bf02f46164db08580d128b860a"
-}
diff --git a/backend/.sqlx/query-cc2dbdc132fe751c7e82445aae5031d05da988837ce41d63a43d13c2018a57d3.json b/backend/.sqlx/query-cc2dbdc132fe751c7e82445aae5031d05da988837ce41d63a43d13c2018a57d3.json
new file mode 100644
index 0000000000..5abf2efb76
--- /dev/null
+++ b/backend/.sqlx/query-cc2dbdc132fe751c7e82445aae5031d05da988837ce41d63a43d13c2018a57d3.json
@@ -0,0 +1,71 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT v.workspace_id, v.path, v.description, v.value_valid_until AS \"value_valid_until!\",\n v.is_secret, v.expiration_dispatched_for, v.expiration_notified_at,\n ws.variable_expiration_handler->>'path' AS \"handler_path!\",\n (ws.variable_expiration_handler->'extra_args')::text::json\n AS \"extra_args: Json>\"\n FROM variable v\n JOIN workspace_settings ws ON ws.workspace_id = v.workspace_id\n JOIN workspace w ON w.id = v.workspace_id\n WHERE NOT w.deleted\n AND v.value_valid_until IS NOT NULL\n AND (v.value_valid_until IS DISTINCT FROM v.expiration_dispatched_for\n OR (v.value_valid_until > now()\n AND v.expiration_notified_at <= now() - make_interval(secs =>\n NULLIF((ws.variable_expiration_handler->>'remind_every_secs')::bigint, 0) - $2)))\n AND v.value_valid_until <= now() + make_interval(secs =>\n COALESCE((ws.variable_expiration_handler->>'notify_before_secs')::bigint, $1))\n AND ws.variable_expiration_handler->>'path' IS NOT NULL\n AND (NOT COALESCE((ws.variable_expiration_handler->>'muted_on_user_path')::boolean, false)\n OR v.path NOT LIKE 'u/%')",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "workspace_id",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 1,
+ "name": "path",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 2,
+ "name": "description",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 3,
+ "name": "value_valid_until!",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 4,
+ "name": "is_secret",
+ "type_info": "Bool"
+ },
+ {
+ "ordinal": 5,
+ "name": "expiration_dispatched_for",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 6,
+ "name": "expiration_notified_at",
+ "type_info": "Timestamptz"
+ },
+ {
+ "ordinal": 7,
+ "name": "handler_path!",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 8,
+ "name": "extra_args: Json>",
+ "type_info": "Json"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Int8",
+ "Int8"
+ ]
+ },
+ "nullable": [
+ false,
+ false,
+ false,
+ true,
+ false,
+ true,
+ true,
+ null,
+ null
+ ]
+ },
+ "hash": "cc2dbdc132fe751c7e82445aae5031d05da988837ce41d63a43d13c2018a57d3"
+}
diff --git a/backend/.sqlx/query-bc6ebef9d41aba232f115f95404922c6054df01fa7f38f5d15d6d4af6c726a3c.json b/backend/.sqlx/query-de0d78bf28a5b6457e5568dce9823a4c216b4be67b68241461b42326d9d65256.json
similarity index 74%
rename from backend/.sqlx/query-bc6ebef9d41aba232f115f95404922c6054df01fa7f38f5d15d6d4af6c726a3c.json
rename to backend/.sqlx/query-de0d78bf28a5b6457e5568dce9823a4c216b4be67b68241461b42326d9d65256.json
index d77682a4ea..e2145fc10a 100644
--- a/backend/.sqlx/query-bc6ebef9d41aba232f115f95404922c6054df01fa7f38f5d15d6d4af6c726a3c.json
+++ b/backend/.sqlx/query-de0d78bf28a5b6457e5568dce9823a4c216b4be67b68241461b42326d9d65256.json
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
- "query": "SELECT COUNT(*) FROM group_ WHERE name != 'all' AND name != 'error_handler' AND name != 'slack' AND name != 'wm_deployers'",
+ "query": "SELECT COUNT(*) FROM group_ WHERE name != 'all' AND name != 'error_handler' AND name != 'slack' AND name != 'wm_deployers' AND name != 'variable_expiration_handler'",
"describe": {
"columns": [
{
@@ -16,5 +16,5 @@
null
]
},
- "hash": "bc6ebef9d41aba232f115f95404922c6054df01fa7f38f5d15d6d4af6c726a3c"
+ "hash": "de0d78bf28a5b6457e5568dce9823a4c216b4be67b68241461b42326d9d65256"
}
diff --git a/backend/.sqlx/query-dec3b1a5a743bfcee0c6a1efdfc7398c1c11277e968af4acb9393ba2b340dd1e.json b/backend/.sqlx/query-dec3b1a5a743bfcee0c6a1efdfc7398c1c11277e968af4acb9393ba2b340dd1e.json
new file mode 100644
index 0000000000..6f1cc55c51
--- /dev/null
+++ b/backend/.sqlx/query-dec3b1a5a743bfcee0c6a1efdfc7398c1c11277e968af4acb9393ba2b340dd1e.json
@@ -0,0 +1,29 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "SELECT path, value::text AS \"value!\" FROM variable\n WHERE path LIKE ('u/' || $1 || '/%')\n AND workspace_id = $2\n AND is_secret = true\n AND (value LIKE '$vault:%' OR value LIKE '$azure_kv:%')",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "path",
+ "type_info": "Varchar"
+ },
+ {
+ "ordinal": 1,
+ "name": "value!",
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Text"
+ ]
+ },
+ "nullable": [
+ false,
+ null
+ ]
+ },
+ "hash": "dec3b1a5a743bfcee0c6a1efdfc7398c1c11277e968af4acb9393ba2b340dd1e"
+}
diff --git a/backend/.sqlx/query-e8eaa80631362dee4d696e41abf220cb6f8346d2bfcd901371b480a16a70109c.json b/backend/.sqlx/query-e8eaa80631362dee4d696e41abf220cb6f8346d2bfcd901371b480a16a70109c.json
new file mode 100644
index 0000000000..b3dd32073d
--- /dev/null
+++ b/backend/.sqlx/query-e8eaa80631362dee4d696e41abf220cb6f8346d2bfcd901371b480a16a70109c.json
@@ -0,0 +1,15 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE workspace_settings SET variable_expiration_handler = $1 WHERE workspace_id = $2",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": [
+ "Jsonb",
+ "Text"
+ ]
+ },
+ "nullable": []
+ },
+ "hash": "e8eaa80631362dee4d696e41abf220cb6f8346d2bfcd901371b480a16a70109c"
+}
diff --git a/backend/.sqlx/query-eac25b9ce5df40a896687d2ba3f9279396ed7213da4598034332bb0ba6ce271c.json b/backend/.sqlx/query-eac25b9ce5df40a896687d2ba3f9279396ed7213da4598034332bb0ba6ce271c.json
new file mode 100644
index 0000000000..736b176227
--- /dev/null
+++ b/backend/.sqlx/query-eac25b9ce5df40a896687d2ba3f9279396ed7213da4598034332bb0ba6ce271c.json
@@ -0,0 +1,22 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE workspace_settings SET variable_expiration_failed_at = now()\n WHERE workspace_id = $1 AND variable_expiration_failed_at IS NULL\n RETURNING 1",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "?column?",
+ "type_info": "Int4"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text"
+ ]
+ },
+ "nullable": [
+ null
+ ]
+ },
+ "hash": "eac25b9ce5df40a896687d2ba3f9279396ed7213da4598034332bb0ba6ce271c"
+}
diff --git a/backend/.sqlx/query-eacce0ccde20eddc4aded69c35d62122478424a36193254d5e00b8723ca28079.json b/backend/.sqlx/query-eacce0ccde20eddc4aded69c35d62122478424a36193254d5e00b8723ca28079.json
new file mode 100644
index 0000000000..f0ced3e76b
--- /dev/null
+++ b/backend/.sqlx/query-eacce0ccde20eddc4aded69c35d62122478424a36193254d5e00b8723ca28079.json
@@ -0,0 +1,20 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "UPDATE workspace_settings SET variable_expiration_failed_at = NULL\n WHERE variable_expiration_failed_at IS NOT NULL AND variable_expiration_handler IS NULL\n RETURNING workspace_id",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "workspace_id",
+ "type_info": "Varchar"
+ }
+ ],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "eacce0ccde20eddc4aded69c35d62122478424a36193254d5e00b8723ca28079"
+}
diff --git a/backend/.sqlx/query-ee8b92d51f220cac5a32f0edcb3c4effa302380d4b434d8c74677bfbf895333c.json b/backend/.sqlx/query-ee8b92d51f220cac5a32f0edcb3c4effa302380d4b434d8c74677bfbf895333c.json
new file mode 100644
index 0000000000..d101f5ec0a
--- /dev/null
+++ b/backend/.sqlx/query-ee8b92d51f220cac5a32f0edcb3c4effa302380d4b434d8c74677bfbf895333c.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "INSERT INTO workspace_diff\n (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)\n VALUES ('test-workspace', 'wm-fork-expiry', 'u/admin/expiring', 'variable', 0, 1, NULL)",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Left": []
+ },
+ "nullable": []
+ },
+ "hash": "ee8b92d51f220cac5a32f0edcb3c4effa302380d4b434d8c74677bfbf895333c"
+}
diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index 97626ab116..3ae4630fe5 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -1992,18 +1992,18 @@ dependencies = [
[[package]]
name = "bon"
-version = "3.10.2"
+version = "3.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "214f5df094ce551a10a30ffb9c70243d61f121a3d985a6495933e181dee6a7d2"
+checksum = "a05442cd7005532d0cfedec676b5b9333d2b4c7c3afabe2b1a9ce2742fbe2ee6"
dependencies = [
"bon-macros",
]
[[package]]
name = "bon-macros"
-version = "3.10.2"
+version = "3.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2706da6c749998cc555d04184909608956a69ad3f8ab1a076fbc867b4a3c3ce5"
+checksum = "93833e97ac7c46ba97045afcf247238c27057538fcf2f06344662ddd5f567be6"
dependencies = [
"darling 0.24.1",
"ident_case",
@@ -2366,9 +2366,9 @@ dependencies = [
[[package]]
name = "cc"
-version = "1.9.0"
+version = "1.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
+checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
dependencies = [
"find-msvc-tools",
"jobserver",
@@ -2695,9 +2695,9 @@ checksum = "3618cccc083bb987a415d85c02ca6c9994ea5b44731ec28b9ecf09658655fba9"
[[package]]
name = "const_format"
-version = "0.2.36"
+version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e"
+checksum = "a3decb4d6965448659923544a634a0571ba44cede4e11ef15a5a317126840454"
dependencies = [
"const_format_proc_macros",
"konst",
@@ -2705,9 +2705,9 @@ dependencies = [
[[package]]
name = "const_format_proc_macros"
-version = "0.2.34"
+version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744"
+checksum = "941b570269a642506f08cb5b688c04bfa6cfd41a26f8bcdc49db8fd00c0ab659"
dependencies = [
"proc-macro2",
"quote",
@@ -2964,9 +2964,9 @@ dependencies = [
[[package]]
name = "crypto-bigint"
-version = "0.7.5"
+version = "0.7.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271"
+checksum = "d726facc8b27e604391b232b970cdc19b5468c69f08b466b9734c759216c7415"
dependencies = [
"cpubits",
"ctutils",
@@ -3055,9 +3055,9 @@ dependencies = [
[[package]]
name = "ctutils"
-version = "0.4.3"
+version = "0.4.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
+checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0"
dependencies = [
"cmov",
"subtle",
@@ -5070,7 +5070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b805154de2e68f59874ec217ca36790dcffe500cd872c60fe509d28d0814a74d"
dependencies = [
"ed448",
- "elliptic-curve 0.14.1",
+ "elliptic-curve 0.14.2",
"hash2curve",
"rand_core 0.10.1",
"serdect 0.4.3",
@@ -5126,12 +5126,12 @@ dependencies = [
[[package]]
name = "elliptic-curve"
-version = "0.14.1"
+version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65"
+checksum = "0ad4496221fcfc21015fd8c0e26fcfab00504832dc0604f52acdd7bad2a8dc81"
dependencies = [
"base16ct 1.0.0",
- "crypto-bigint 0.7.5",
+ "crypto-bigint 0.7.8",
"crypto-common 0.2.2",
"ff 0.14.0",
"group 0.14.0",
@@ -5218,9 +5218,9 @@ checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "erased-serde"
-version = "0.4.10"
+version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
+checksum = "1e59b668157f32d3459d4bd634861a06ffa2503a461ca5f0916b9f0252605e66"
dependencies = [
"serde",
"serde_core",
@@ -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.1",
+ "elliptic-curve 0.14.2",
]
[[package]]
@@ -6745,7 +6745,7 @@ dependencies = [
"tokio",
"tokio-rustls 0.26.6",
"tower-service",
- "webpki-roots 1.0.10",
+ "webpki-roots 1.0.11",
]
[[package]]
@@ -10889,7 +10889,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
- "webpki-roots 1.0.10",
+ "webpki-roots 1.0.11",
]
[[package]]
@@ -12408,9 +12408,9 @@ checksum = "1b6709c7b6754dca1311b3c73e79fcce40dd414c782c66d88e8823030093b02b"
[[package]]
name = "sketches-ddsketch"
-version = "0.4.1"
+version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "513c3f5f732bfd6fbb187619c2dfe9d2f25f1a2976f01d575f0fd329d565df56"
+checksum = "e53e2f8d4e9c692f23a90208fad6a64862f413e286d533f1f649f160e2d5caee"
dependencies = [
"serde",
]
@@ -12423,9 +12423,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
-version = "1.16.3"
+version = "1.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
+checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
dependencies = [
"serde",
]
@@ -15052,9 +15052,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
-version = "1.28.0"
+version = "1.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
+checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -15369,9 +15369,9 @@ dependencies = [
[[package]]
name = "webpki-root-certs"
-version = "1.0.10"
+version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb"
+checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a"
dependencies = [
"rustls-pki-types",
]
@@ -15382,14 +15382,14 @@ version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
- "webpki-roots 1.0.10",
+ "webpki-roots 1.0.11",
]
[[package]]
name = "webpki-roots"
-version = "1.0.10"
+version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
+checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
dependencies = [
"rustls-pki-types",
]
@@ -15487,7 +15487,7 @@ dependencies = [
[[package]]
name = "windmill"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-nats",
@@ -15577,7 +15577,7 @@ dependencies = [
[[package]]
name = "windmill-ai"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"async-stream",
"async-trait",
@@ -15612,7 +15612,7 @@ dependencies = [
[[package]]
name = "windmill-alerting"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"axum",
"chrono",
@@ -15625,7 +15625,7 @@ dependencies = [
[[package]]
name = "windmill-api"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"argon2",
@@ -15768,7 +15768,7 @@ dependencies = [
[[package]]
name = "windmill-api-agent-workers"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"axum",
"chrono",
@@ -15792,7 +15792,7 @@ dependencies = [
[[package]]
name = "windmill-api-assets"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"axum",
"chrono",
@@ -15809,7 +15809,7 @@ dependencies = [
[[package]]
name = "windmill-api-auth"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"axum",
@@ -15835,7 +15835,7 @@ dependencies = [
[[package]]
name = "windmill-api-client"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"reqwest 0.12.28",
"serde",
@@ -15845,7 +15845,7 @@ dependencies = [
[[package]]
name = "windmill-api-configs"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"axum",
"chrono",
@@ -15862,7 +15862,7 @@ dependencies = [
[[package]]
name = "windmill-api-debug"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"axum",
"base64 0.22.1",
@@ -15884,7 +15884,7 @@ dependencies = [
[[package]]
name = "windmill-api-embeddings"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"axum",
@@ -15907,7 +15907,7 @@ dependencies = [
[[package]]
name = "windmill-api-flow-conversations"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"axum",
"chrono",
@@ -15923,7 +15923,7 @@ dependencies = [
[[package]]
name = "windmill-api-flows"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"axum",
"chrono",
@@ -15945,7 +15945,7 @@ dependencies = [
[[package]]
name = "windmill-api-groups"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"axum",
"chrono",
@@ -15967,7 +15967,7 @@ dependencies = [
[[package]]
name = "windmill-api-inputs"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"axum",
"chrono",
@@ -15981,7 +15981,7 @@ dependencies = [
[[package]]
name = "windmill-api-integration-tests"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-nats",
@@ -16017,7 +16017,7 @@ dependencies = [
[[package]]
name = "windmill-api-jobs"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"axum",
@@ -16043,7 +16043,7 @@ dependencies = [
[[package]]
name = "windmill-api-npm-proxy"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"axum",
"chrono",
@@ -16071,7 +16071,7 @@ dependencies = [
[[package]]
name = "windmill-api-openapi"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"axum",
@@ -16093,7 +16093,7 @@ dependencies = [
[[package]]
name = "windmill-api-schedule"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"axum",
"chrono",
@@ -16113,7 +16113,7 @@ dependencies = [
[[package]]
name = "windmill-api-scripts"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"axum",
@@ -16155,7 +16155,7 @@ dependencies = [
[[package]]
name = "windmill-api-settings"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"axum",
@@ -16185,7 +16185,7 @@ dependencies = [
[[package]]
name = "windmill-api-sse"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"lazy_static",
"serde",
@@ -16197,7 +16197,7 @@ dependencies = [
[[package]]
name = "windmill-api-users"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"argon2",
"axum",
@@ -16221,7 +16221,7 @@ dependencies = [
[[package]]
name = "windmill-api-workers"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"axum",
"chrono",
@@ -16236,7 +16236,7 @@ dependencies = [
[[package]]
name = "windmill-api-workspaces"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"axum",
"chrono",
@@ -16275,7 +16275,7 @@ dependencies = [
[[package]]
name = "windmill-audit"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"chrono",
"lazy_static",
@@ -16290,7 +16290,7 @@ dependencies = [
[[package]]
name = "windmill-autoscaling"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"axum",
@@ -16309,7 +16309,7 @@ dependencies = [
[[package]]
name = "windmill-common"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -16415,7 +16415,7 @@ dependencies = [
"url",
"urlencoding",
"uuid",
- "webpki-roots 1.0.10",
+ "webpki-roots 1.0.11",
"windmill-macros",
"windmill-parser",
"windmill-parser-py",
@@ -16427,7 +16427,7 @@ dependencies = [
[[package]]
name = "windmill-dep-map"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"chrono",
"futures",
@@ -16447,7 +16447,7 @@ dependencies = [
[[package]]
name = "windmill-git-sync"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"regex",
"serde",
@@ -16464,7 +16464,7 @@ dependencies = [
[[package]]
name = "windmill-indexer"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"astral-tokio-tar",
@@ -16491,7 +16491,7 @@ dependencies = [
[[package]]
name = "windmill-jseval"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"futures",
@@ -16508,7 +16508,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"itertools 0.14.0",
"lazy_static",
@@ -16524,7 +16524,7 @@ dependencies = [
[[package]]
name = "windmill-mcp"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16546,7 +16546,7 @@ dependencies = [
[[package]]
name = "windmill-native-triggers"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-trait",
@@ -16577,7 +16577,7 @@ dependencies = [
[[package]]
name = "windmill-oauth"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"arc-swap",
@@ -16602,7 +16602,7 @@ dependencies = [
[[package]]
name = "windmill-object-store"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-stream",
@@ -16638,7 +16638,7 @@ dependencies = [
[[package]]
name = "windmill-operator"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"futures",
@@ -16657,7 +16657,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"convert_case 0.6.0",
"serde",
@@ -16666,7 +16666,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16678,7 +16678,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16690,7 +16690,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"gosyn",
@@ -16702,7 +16702,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16714,7 +16714,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16726,7 +16726,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -16737,7 +16737,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16748,7 +16748,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -16760,7 +16760,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -16771,7 +16771,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16794,7 +16794,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"serde_json",
@@ -16806,7 +16806,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16820,7 +16820,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"convert_case 0.6.0",
@@ -16837,7 +16837,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16850,7 +16850,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"serde",
@@ -16862,7 +16862,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16880,7 +16880,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -16896,7 +16896,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -16913,7 +16913,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -16927,7 +16927,7 @@ dependencies = [
[[package]]
name = "windmill-queue"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -16966,7 +16966,7 @@ dependencies = [
[[package]]
name = "windmill-runtime-nativets"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"const_format",
@@ -17004,7 +17004,7 @@ dependencies = [
[[package]]
name = "windmill-sql-datatype-parser-wasm"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"getrandom 0.3.4",
"wasm-bindgen",
@@ -17015,7 +17015,7 @@ dependencies = [
[[package]]
name = "windmill-store"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -17050,7 +17050,7 @@ dependencies = [
[[package]]
name = "windmill-test-utils"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17074,7 +17074,7 @@ dependencies = [
[[package]]
name = "windmill-trigger"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17107,7 +17107,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-amqp"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17134,7 +17134,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-azure"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17167,7 +17167,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-email"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17187,7 +17187,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-gcp"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17221,7 +17221,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-http"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17258,7 +17258,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-kafka"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17281,7 +17281,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-mqtt"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17305,7 +17305,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-nats"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-nats",
@@ -17329,7 +17329,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-postgres"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17364,7 +17364,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-sqs"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17392,7 +17392,7 @@ dependencies = [
[[package]]
name = "windmill-trigger-websocket"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-trait",
@@ -17417,7 +17417,7 @@ dependencies = [
[[package]]
name = "windmill-types"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"bitflags 2.13.2",
@@ -17436,7 +17436,7 @@ dependencies = [
[[package]]
name = "windmill-worker"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-once-cell",
@@ -17554,7 +17554,7 @@ dependencies = [
[[package]]
name = "windmill-worker-volumes"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"bytes",
"futures",
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index d2d4678718..b42cac10f0 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "windmill"
-version = "1.847.0"
+version = "1.849.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.847.0"
+version = "1.849.0"
authors = ["Ruben Fiszel "]
edition = "2021"
diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt
index 96a620c43f..71a531c84a 100644
--- a/backend/ee-repo-ref.txt
+++ b/backend/ee-repo-ref.txt
@@ -1 +1 @@
-9a5ff8e4e562930d57d29e2e90f17f6b82ee96a5
+943c39b56522f7980385a750ba5e9ca5af4df5a8
diff --git a/backend/migrations/20260824180615_variable_value_valid_until.down.sql b/backend/migrations/20260824180615_variable_value_valid_until.down.sql
new file mode 100644
index 0000000000..963fd00f8e
--- /dev/null
+++ b/backend/migrations/20260824180615_variable_value_valid_until.down.sql
@@ -0,0 +1,9 @@
+ALTER TABLE variable DROP CONSTRAINT IF EXISTS variable_oauth_no_value_expiry;
+DROP INDEX IF EXISTS idx_variable_expiration_due;
+
+COMMENT ON COLUMN variable.expires_at IS NULL;
+
+ALTER TABLE variable
+ DROP COLUMN IF EXISTS value_valid_until,
+ DROP COLUMN IF EXISTS expiration_dispatched_for,
+ DROP COLUMN IF EXISTS expiration_notified_at;
diff --git a/backend/migrations/20260824180615_variable_value_valid_until.up.sql b/backend/migrations/20260824180615_variable_value_valid_until.up.sql
new file mode 100644
index 0000000000..308394d7e0
--- /dev/null
+++ b/backend/migrations/20260824180615_variable_value_valid_until.up.sql
@@ -0,0 +1,30 @@
+-- `value_valid_until` is when the value stored in the variable stops working; the row itself
+-- lives on. `expires_at` keeps its unrelated meaning — delete the row — so no existing writer
+-- changes meaning under a rolling deploy and no data is rewritten here.
+ALTER TABLE variable ADD COLUMN IF NOT EXISTS value_valid_until TIMESTAMP WITH TIME ZONE;
+
+COMMENT ON COLUMN variable.expires_at IS
+ 'Garbage collection deadline: the row is deleted once passed. Set by the ephemeral secret_arg mint. Not the value''s expiry — see value_valid_until.';
+COMMENT ON COLUMN variable.value_valid_until IS
+ 'When the value stops working. Drives the workspace variable expiration handler; never deletes the row.';
+
+-- Holds the `value_valid_until` that was dispatched, rather than a plain "was dispatched" flag,
+-- so that moving the date re-arms the handler by construction — no writer has to remember to
+-- clear anything.
+ALTER TABLE variable
+ ADD COLUMN IF NOT EXISTS expiration_dispatched_for TIMESTAMP WITH TIME ZONE;
+
+-- When the handler last ran for this variable, which paces its reminders.
+ALTER TABLE variable
+ ADD COLUMN IF NOT EXISTS expiration_notified_at TIMESTAMP WITH TIME ZONE;
+
+-- Serves the sweep's due predicate. Partial so it stays the size of the opted-in set rather
+-- than the whole variable table. Not partial on `expiration_dispatched_for`: reminders keep a
+-- dispatched variable due until it expires.
+CREATE INDEX IF NOT EXISTS idx_variable_expiration_due ON variable (value_valid_until)
+ WHERE value_valid_until IS NOT NULL;
+
+-- An OAuth-linked value is refreshed by Windmill itself, so a rotation date on it would only
+-- announce an expiry that never comes.
+ALTER TABLE variable ADD CONSTRAINT variable_oauth_no_value_expiry
+ CHECK (account IS NULL OR value_valid_until IS NULL);
diff --git a/backend/migrations/20260824180616_workspace_variable_expiration_handler.down.sql b/backend/migrations/20260824180616_workspace_variable_expiration_handler.down.sql
new file mode 100644
index 0000000000..92c048bd52
--- /dev/null
+++ b/backend/migrations/20260824180616_workspace_variable_expiration_handler.down.sql
@@ -0,0 +1,3 @@
+ALTER TABLE workspace_settings
+ DROP COLUMN IF EXISTS variable_expiration_handler,
+ DROP COLUMN IF EXISTS variable_expiration_failed_at;
diff --git a/backend/migrations/20260824180616_workspace_variable_expiration_handler.up.sql b/backend/migrations/20260824180616_workspace_variable_expiration_handler.up.sql
new file mode 100644
index 0000000000..8e6cb03938
--- /dev/null
+++ b/backend/migrations/20260824180616_workspace_variable_expiration_handler.up.sql
@@ -0,0 +1,6 @@
+-- Shape mirrors `error_handler` / `success_handler` as grouped by 20260124172000:
+-- {"path": ..., "extra_args": ..., "muted_on_user_path": ...}
+ALTER TABLE workspace_settings ADD COLUMN IF NOT EXISTS variable_expiration_handler JSONB;
+
+-- When the handler last failed to start; the sweep skips the workspace for a while after it.
+ALTER TABLE workspace_settings ADD COLUMN IF NOT EXISTS variable_expiration_failed_at TIMESTAMP WITH TIME ZONE;
diff --git a/backend/migrations/20260903161430_variable_expiration_handler_slack_perms.down.sql b/backend/migrations/20260903161430_variable_expiration_handler_slack_perms.down.sql
new file mode 100644
index 0000000000..40443fc999
--- /dev/null
+++ b/backend/migrations/20260903161430_variable_expiration_handler_slack_perms.down.sql
@@ -0,0 +1 @@
+UPDATE folder SET extra_perms = extra_perms - 'g/variable_expiration_handler' WHERE name = 'slack_bot';
diff --git a/backend/migrations/20260903161430_variable_expiration_handler_slack_perms.up.sql b/backend/migrations/20260903161430_variable_expiration_handler_slack_perms.up.sql
new file mode 100644
index 0000000000..25adde9d36
--- /dev/null
+++ b/backend/migrations/20260903161430_variable_expiration_handler_slack_perms.up.sql
@@ -0,0 +1,7 @@
+-- Windmill never created this group before this release, so one present now was made by a user,
+-- who would otherwise keep it as it gains read on the Slack bot token below.
+DELETE FROM usr_to_group WHERE group_ = 'variable_expiration_handler';
+UPDATE group_ SET extra_perms = '{}'::jsonb WHERE name = 'variable_expiration_handler';
+
+-- Backfill for slack_bot folders created before this grant (see create_slack_workspace_artifacts).
+UPDATE folder SET extra_perms = JSONB_SET(extra_perms, '{g/variable_expiration_handler}', 'false', true) WHERE name = 'slack_bot';
diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json
index 611e3f7c17..48957fee2f 100644
--- a/backend/oauth_connect.json
+++ b/backend/oauth_connect.json
@@ -337,12 +337,29 @@
"auth_url": "https://www.linkedin.com/oauth/v2/authorization",
"token_url": "https://www.linkedin.com/oauth/v2/accessToken",
"grant_types": ["authorization_code", "client_credentials"],
- "scopes": ["w_member_social", "r_liteprofile", "r_emailaddress"],
+ "scopes": ["openid", "profile", "email", "w_member_social"],
+ "scope_options": [
+ "openid",
+ "profile",
+ "email",
+ "w_member_social",
+ "r_member_social",
+ "w_organization_social",
+ "r_organization_social",
+ "r_organization_admin",
+ "rw_organization_admin",
+ "r_ads",
+ "r_basicprofile"
+ ],
"req_body_auth": true
},
"quickbooks": {
"auth_url": "https://appcenter.intuit.com/connect/oauth2",
"token_url": "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer",
+ "connect_fields": {
+ "realmId": "callback.realmId"
+ },
+ "resource_fields": ["realmId", "isSandBox"],
"scopes": ["com.intuit.quickbooks.accounting"]
},
"visma": {
@@ -468,15 +485,26 @@
"docusign": {
"auth_url": "https://account.docusign.com/oauth/auth",
"token_url": "https://account.docusign.com/oauth/token",
+ "userinfo_url": "https://account.docusign.com/oauth/userinfo",
+ "connect_fields": {
+ "account_id": "userinfo.accounts[is_default=true].account_id",
+ "base_uri": "userinfo.accounts[is_default=true].base_uri"
+ },
+ "resource_fields": ["account_id", "base_uri"],
"scopes": ["signature"],
"sandbox": {
"auth_url": "https://account-d.docusign.com/oauth/auth",
- "token_url": "https://account-d.docusign.com/oauth/token"
+ "token_url": "https://account-d.docusign.com/oauth/token",
+ "userinfo_url": "https://account-d.docusign.com/oauth/userinfo"
}
},
"salesforce": {
"auth_url": "https://login.salesforce.com/services/oauth2/authorize",
"token_url": "https://login.salesforce.com/services/oauth2/token",
+ "connect_fields": {
+ "instance_url": "token.instance_url"
+ },
+ "resource_fields": ["instance_url"],
"scopes": ["api", "refresh_token", "offline_access"],
"sandbox": {
"auth_url": "https://test.salesforce.com/services/oauth2/authorize",
diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock
index a73343f8b9..ba2d483531 100644
--- a/backend/parsers/windmill-parser-wasm/Cargo.lock
+++ b/backend/parsers/windmill-parser-wasm/Cargo.lock
@@ -6340,7 +6340,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windmill-common"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"aes-gcm",
"aho-corasick",
@@ -6430,7 +6430,7 @@ dependencies = [
[[package]]
name = "windmill-macros"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"proc-macro2",
"quote",
@@ -6442,7 +6442,7 @@ dependencies = [
[[package]]
name = "windmill-parser"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"convert_case",
"serde",
@@ -6451,7 +6451,7 @@ dependencies = [
[[package]]
name = "windmill-parser-bash"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6463,7 +6463,7 @@ dependencies = [
[[package]]
name = "windmill-parser-csharp"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6475,7 +6475,7 @@ dependencies = [
[[package]]
name = "windmill-parser-go"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"gosyn",
@@ -6487,7 +6487,7 @@ dependencies = [
[[package]]
name = "windmill-parser-graphql"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6499,7 +6499,7 @@ dependencies = [
[[package]]
name = "windmill-parser-java"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6511,7 +6511,7 @@ dependencies = [
[[package]]
name = "windmill-parser-nu"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"nu-parser",
@@ -6522,7 +6522,7 @@ dependencies = [
[[package]]
name = "windmill-parser-php"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6533,7 +6533,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"itertools 0.14.0",
@@ -6545,7 +6545,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-asset"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -6556,7 +6556,7 @@ dependencies = [
[[package]]
name = "windmill-parser-py-imports"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"async-recursion",
@@ -6579,7 +6579,7 @@ dependencies = [
[[package]]
name = "windmill-parser-r"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"serde_json",
@@ -6591,7 +6591,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ruby"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6605,7 +6605,7 @@ dependencies = [
[[package]]
name = "windmill-parser-rust"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"convert_case",
@@ -6622,7 +6622,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6635,7 +6635,7 @@ dependencies = [
[[package]]
name = "windmill-parser-sql-asset"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"serde",
@@ -6647,7 +6647,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6665,7 +6665,7 @@ dependencies = [
[[package]]
name = "windmill-parser-ts-asset"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"serde-wasm-bindgen",
@@ -6681,7 +6681,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wac"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"ruff_python_ast",
@@ -6698,7 +6698,7 @@ dependencies = [
[[package]]
name = "windmill-parser-wasm"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"getrandom 0.2.17",
@@ -6730,7 +6730,7 @@ dependencies = [
[[package]]
name = "windmill-parser-yaml"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"lazy_static",
@@ -6744,7 +6744,7 @@ dependencies = [
[[package]]
name = "windmill-types"
-version = "1.847.0"
+version = "1.849.0"
dependencies = [
"anyhow",
"bitflags",
diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml
index 6955752a98..0d0697c3fd 100644
--- a/backend/parsers/windmill-parser-wasm/Cargo.toml
+++ b/backend/parsers/windmill-parser-wasm/Cargo.toml
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
-version = "1.847.0"
+version = "1.849.0"
edition = "2021"
authors = ["Ruben Fiszel "]
diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs
index fbadfa4269..868e558f8d 100644
--- a/backend/src/monitor.rs
+++ b/backend/src/monitor.rs
@@ -129,6 +129,7 @@ use windmill_object_store::reload_object_store_setting;
use windmill_queue::{
add_completed_job_error, cancel_job, canceled_result, get_queued_job_v2,
schedule::{find_unarmed_schedules, rearm_schedule, RearmOutcome},
+ variable_expiration::dispatch_expiring_variables,
CanceledBy, MiniCompletedJob, SameWorkerPayload,
};
use windmill_store::resources::MAX_RESOURCE_VERSIONS;
@@ -4411,6 +4412,15 @@ pub async fn monitor_db(
}
};
+ // Run every 5 minutes (10 iterations * 30s)
+ let dispatch_expiring_variables_f = async {
+ if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(10) {
+ if let Some(db) = conn.as_sql() {
+ dispatch_expiring_variables(&db).await;
+ }
+ }
+ };
+
// run every hour (120 iterations * 30s = 3600s)
let cleanup_stale_server_heartbeats_f = async {
if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(120) {
@@ -4617,6 +4627,7 @@ pub async fn monitor_db(
native_triggers_sync_f,
cleanup_notify_events_f,
check_expiring_tokens_f,
+ dispatch_expiring_variables_f,
cleanup_stale_server_heartbeats_f,
manage_audit_partitions_f,
export_audit_logs_to_object_store_f,
diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt
index 2bf6b0188f..418108cd30 100644
--- a/backend/summarized_schema.txt
+++ b/backend/summarized_schema.txt
@@ -220,7 +220,7 @@ v2_job_runtime: id(uuid), ping(ts), memory_peak(int)
FK: (id) -> v2_job_queue(id)
v2_job_status: id(uuid), flow_status(jsonb), flow_leaf_jobs(jsonb), workflow_as_code_status(jsonb)
FK: (id) -> v2_job_queue(id)
-variable: workspace_id(char), path(char), value(char), is_secret(bool), description(char), extra_perms(jsonb), account(int), is_oauth(bool), expires_at(ts), labels(text[])
+variable: workspace_id(char), path(char), value(char), is_secret(bool), description(char), extra_perms(jsonb), account(int), is_oauth(bool), expires_at(ts), labels(text[]), value_valid_until(timestamptz), expiration_dispatched_for(timestamptz), expiration_notified_at(timestamptz)
FK: (workspace_id) -> workspace(id)
websocket_trigger: path(char), url(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), filters(jsonb[]), initial_messages(jsonb[]), url_runnable_args(jsonb), can_return_message(bool), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), can_return_error_result(bool), mode(trigger_mode), labels(text[])
windmill_migrations: name(text), created_at(ts)
@@ -244,7 +244,7 @@ workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_gr
FK: (workspace_id) -> workspace(id)
workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint), runnable_is_agent(bool)
FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id)
-workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool), remote_deploy_target(jsonb), remote_deploy_target_changed_at(ts)
+workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool), remote_deploy_target(jsonb), remote_deploy_target_changed_at(ts), variable_expiration_handler(jsonb), variable_expiration_failed_at(ts)
FK: (workspace_id) -> workspace(id)
zombie_job_counter: job_id(uuid), counter(int)
FK: (job_id) -> v2_job(id)
diff --git a/backend/tests/ai_agent_linked.rs b/backend/tests/ai_agent_linked.rs
new file mode 100644
index 0000000000..8b7f328213
--- /dev/null
+++ b/backend/tests/ai_agent_linked.rs
@@ -0,0 +1,55 @@
+use serde_json::json;
+use sqlx::{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,
+};
+
+/// Only writes of an agent are checked for operators, so a linked step must refuse a path whose
+/// resource is no longer an agent rather than run the `tools` it now holds.
+#[sqlx::test(fixtures("base"))]
+#[serial_test::serial]
+async fn linked_agent_step_refuses_a_resource_of_another_type(
+ db: Pool,
+) -> anyhow::Result<()> {
+ sqlx::query(
+ "INSERT INTO resource (workspace_id, path, value, resource_type)
+ VALUES ('test-workspace', 'u/test-user/agent', $1, 'object')",
+ )
+ .bind(
+ json!({"tools": [{"id": "t", "value": {"tool_type": "flowmodule",
+ "type": "rawscript", "language": "bun", "input_transforms": {},
+ "content": "export async function main() { return 1 }"}}]}),
+ )
+ .execute(&db)
+ .await?;
+
+ let server = ApiServer::start(db.clone()).await?;
+ let flow: FlowValue = serde_json::from_value(json!({"modules": [{"id": "agent", "value": {
+ "type": "aiagent", "tools": [], "agent": "$res:u/test-user/agent",
+ "input_transforms": {"user_message": {"type": "static", "value": "hi"}}
+ }}]}))?;
+ let id = RunJob::from(JobPayload::RawFlow {
+ value: flow,
+ path: Some("u/test-user/linked".into()),
+ restarted_from: None,
+ })
+ .push(&db)
+ .await;
+ let notifications = listen_for_completed_jobs(&db).await;
+ tokio::time::timeout(
+ Duration::from_secs(45),
+ in_test_worker(&db, notifications.find(&id), server.addr.port()),
+ )
+ .await?;
+ let job = completed_job(id, &db).await;
+ server.close().await?;
+ assert!(!job.success);
+ assert!(
+ format!("{:?}", job.result).contains("not an AI agent"),
+ "{:?}",
+ job.result
+ );
+ Ok(())
+}
diff --git a/backend/tests/app_access_mode_only.rs b/backend/tests/app_access_mode_only.rs
new file mode 100644
index 0000000000..b113e9de9a
--- /dev/null
+++ b/backend/tests/app_access_mode_only.rs
@@ -0,0 +1,311 @@
+//! `EditApp.execution_mode`: changing who may open an app without sending its policy.
+//!
+//! The change is applied to the deployed policy under the row lock a deploy takes, so it keeps
+//! every other field a concurrent deploy wrote. It is held to the rules a policy write is held
+//! to, and it is refused to a caller who could not keep the app's run-as user, since a policy
+//! write from them would rebind it.
+//!
+//! One test: the protection rules and the workspace roles are cached per workspace id across
+//! the process, and every test here uses `test-workspace`.
+//!
+//! Users from the `base` fixture:
+//! test-user (admin, token SECRET_TOKEN)
+//! test-user-2 (non-admin, token SECRET_TOKEN_2)
+//! test-user-3 (made a compose-only operator below, token SECRET_TOKEN_3)
+
+use reqwest::multipart;
+use serde_json::{json, Value};
+use sqlx::{Pool, Postgres};
+use windmill_common::workspaces::invalidate_protection_rules_cache;
+use windmill_test_utils::*;
+
+const ADMIN: &str = "SECRET_TOKEN";
+const USER: &str = "SECRET_TOKEN_2";
+const BUILDER: &str = "SECRET_TOKEN_3";
+
+fn client() -> reqwest::Client {
+ reqwest::Client::new()
+}
+
+async fn update(ws: &str, path: &str, token: &str, body: Value) -> anyhow::Result<(u16, String)> {
+ let resp = client()
+ .post(format!("{ws}/apps/update/{path}"))
+ .bearer_auth(token)
+ .json(&body)
+ .send()
+ .await?;
+ Ok((resp.status().as_u16(), resp.text().await?))
+}
+
+async fn create(ws: &str, token: &str, path: &str, policy: Value) -> anyhow::Result<()> {
+ let resp = client()
+ .post(format!("{ws}/apps/create"))
+ .bearer_auth(token)
+ .json(&json!({ "path": path, "summary": "", "value": {}, "policy": policy }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 201, "create {path}: {}", resp.text().await?);
+ Ok(())
+}
+
+/// An anonymous, sandboxed raw app with no runnables, as a builder may deploy one.
+async fn create_raw(ws: &str, token: &str, path: &str) -> anyhow::Result<()> {
+ let app = json!({
+ "path": path,
+ "summary": "",
+ "value": { "files": {}, "runnables": {} },
+ "policy": { "execution_mode": "anonymous", "sandbox": true, "triggerables_v2": {} },
+ });
+ let form = multipart::Form::new()
+ .part(
+ "app",
+ multipart::Part::text(app.to_string()).mime_str("application/json")?,
+ )
+ .part("js", multipart::Part::text("console.log('app')"));
+ let resp = client()
+ .post(format!("{ws}/apps/create_raw"))
+ .bearer_auth(token)
+ .multipart(form)
+ .send()
+ .await?;
+ assert_eq!(
+ resp.status(),
+ 201,
+ "create_raw {path}: {}",
+ resp.text().await?
+ );
+ Ok(())
+}
+
+async fn policy(db: &Pool, path: &str) -> anyhow::Result {
+ Ok(sqlx::query_scalar(
+ "SELECT policy FROM app WHERE path = $1 AND workspace_id = 'test-workspace'",
+ )
+ .bind(path)
+ .fetch_one(db)
+ .await?)
+}
+
+fn triggerable(key: &str) -> Value {
+ json!({ key: { "static_inputs": {}, "one_of_inputs": {} } })
+}
+
+#[sqlx::test(fixtures("base"))]
+async fn test_access_mode_only_update(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ invalidate_protection_rules_cache("test-workspace");
+ let server = ApiServer::start(db.clone()).await?;
+ let ws = format!(
+ "http://localhost:{}/api/w/test-workspace",
+ server.addr.port()
+ );
+
+ // ========================================
+ // 1. The mode alone changes; every other field of the deployed policy stays.
+ // ========================================
+ const SHARED: &str = "u/test-user-2/shared";
+ create(
+ &ws,
+ ADMIN,
+ SHARED,
+ json!({
+ "execution_mode": "publisher",
+ "triggerables_v2": triggerable("c:script/u/test-user/noop"),
+ "sandbox": true,
+ "allowed_s3_keys": [{ "s3_path": "a/b.txt" }],
+ }),
+ )
+ .await?;
+ let before = policy(&db, SHARED).await?;
+ assert_eq!(before["on_behalf_of"], "u/test-user");
+
+ let (status, body) =
+ update(&ws, SHARED, ADMIN, json!({ "execution_mode": "anonymous" })).await?;
+ assert_eq!(status, 200, "{body}");
+ let mut after = policy(&db, SHARED).await?;
+ assert_eq!(after["execution_mode"], "anonymous");
+ after["execution_mode"] = before["execution_mode"].clone();
+ assert_eq!(
+ after, before,
+ "a mode-only change must keep every other policy field"
+ );
+
+ // Sending a policy too is ambiguous about which mode wins.
+ let (status, _) = update(
+ &ws,
+ SHARED,
+ ADMIN,
+ json!({ "execution_mode": "publisher", "policy": { "execution_mode": "anonymous" } }),
+ )
+ .await?;
+ assert_eq!(status, 400);
+
+ // ========================================
+ // 2. Someone who cannot keep the run-as user cannot change the mode of an app that runs
+ // as someone else, even with write access to it.
+ // ========================================
+ let (status, body) =
+ update(&ws, SHARED, USER, json!({ "execution_mode": "publisher" })).await?;
+ assert_eq!(status, 403, "{body}");
+ assert!(body.contains("wm_deployers"), "{body}");
+ assert_eq!(policy(&db, SHARED).await?["execution_mode"], "anonymous");
+
+ // ========================================
+ // 3. A policy without a mode keeps the deployed one.
+ // ========================================
+ const OWN: &str = "u/test-user-2/own";
+ create(&ws, USER, OWN, json!({ "execution_mode": "publisher" })).await?;
+ sqlx::query(
+ "INSERT INTO draft (workspace_id, path, typ, value, email)
+ VALUES ('test-workspace', $1, 'app', '{}', 'test2@windmill.dev')",
+ )
+ .bind(OWN)
+ .execute(&db)
+ .await?;
+ let (status, body) = update(&ws, OWN, USER, json!({ "execution_mode": "anonymous" })).await?;
+ assert_eq!(status, 200, "{body}");
+ let drafts: i64 = sqlx::query_scalar(
+ "SELECT count(*) FROM draft WHERE workspace_id = 'test-workspace' AND path = $1",
+ )
+ .bind(OWN)
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(drafts, 1, "a mode-only change must keep the caller's draft");
+ let (status, body) = update(
+ &ws,
+ OWN,
+ USER,
+ json!({ "value": { "v": 2 }, "policy": { "triggerables_v2": triggerable("c:script/f/x/new") } }),
+ )
+ .await?;
+ assert_eq!(status, 200, "{body}");
+ let p = policy(&db, OWN).await?;
+ assert_eq!(p["execution_mode"], "anonymous");
+ assert!(
+ p["triggerables_v2"].get("c:script/f/x/new").is_some(),
+ "{p}"
+ );
+
+ // ========================================
+ // 4. A compose-only operator: an omitted mode inherits the deployed one unless it is Viewer.
+ // ========================================
+ sqlx::query("UPDATE usr SET operator = true WHERE workspace_id = 'test-workspace' AND username = 'test-user-3'")
+ .execute(&db)
+ .await?;
+ sqlx::query(
+ r#"UPDATE workspace_settings SET role_overrides = '{"operator": {"capabilities": {"apps": "compose"}}}'
+ WHERE workspace_id = 'test-workspace'"#,
+ )
+ .execute(&db)
+ .await?;
+ // In wm_deployers, so the refusal in the last step below holds for a builder who may keep
+ // another user's identity on deploy.
+ sqlx::query(
+ "INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ('test-workspace', 'wm_deployers', '', '{}')",
+ )
+ .execute(&db)
+ .await?;
+ sqlx::query(
+ "INSERT INTO usr_to_group (workspace_id, group_, usr) VALUES ('test-workspace', 'wm_deployers', 'test-user-3')",
+ )
+ .execute(&db)
+ .await?;
+ windmill_common::roles::invalidate_workspace_roles_cache("test-workspace");
+ windmill_common::roles::invalidate_member_role_cache("test-workspace", "test-user-3");
+
+ const BUILT: &str = "u/test-user-3/built";
+ create_raw(&ws, BUILDER, BUILT).await?;
+
+ let settings_only = json!({ "policy": { "sandbox": true, "triggerables_v2": {} } });
+ let (status, body) = update(&ws, BUILT, BUILDER, settings_only.clone()).await?;
+ assert_eq!(status, 200, "{body}");
+ assert_eq!(
+ policy(&db, BUILT).await?["execution_mode"],
+ "anonymous",
+ "a builder's policy without a mode must keep a non-Viewer deployed mode"
+ );
+
+ // Viewer stays refused to a builder, alone as in a policy.
+ let (status, body) = update(&ws, BUILT, BUILDER, json!({ "execution_mode": "viewer" })).await?;
+ assert_eq!(status, 403, "{body}");
+
+ // An admin may put the builder's app in Viewer; the builder's next policy without a mode
+ // then cannot inherit it.
+ let (status, body) = update(&ws, BUILT, ADMIN, json!({ "execution_mode": "viewer" })).await?;
+ assert_eq!(status, 200, "{body}");
+ let (status, body) = update(&ws, BUILT, BUILDER, settings_only).await?;
+ assert_eq!(status, 200, "{body}");
+ assert_eq!(policy(&db, BUILT).await?["execution_mode"], "publisher");
+
+ let (status, body) = update(
+ &ws,
+ BUILT,
+ BUILDER,
+ json!({ "execution_mode": "anonymous" }),
+ )
+ .await?;
+ assert_eq!(
+ status, 200,
+ "a builder may change the mode of its own app: {body}"
+ );
+ assert_eq!(policy(&db, BUILT).await?["execution_mode"], "anonymous");
+
+ // A builder's deploy would sandbox an app whose policy leaves it unset, which a mode-only
+ // change must not do.
+ sqlx::query(
+ "UPDATE app SET policy = policy - 'sandbox' WHERE path = $1 AND workspace_id = 'test-workspace'",
+ )
+ .bind(BUILT)
+ .execute(&db)
+ .await?;
+ let (status, body) = update(
+ &ws,
+ BUILT,
+ BUILDER,
+ json!({ "execution_mode": "publisher" }),
+ )
+ .await?;
+ assert_eq!(status, 403, "{body}");
+ let p = policy(&db, BUILT).await?;
+ assert!(p.get("sandbox").is_none(), "{p}");
+ assert_eq!(p["execution_mode"], "anonymous");
+
+ // Changing the mode alone skips the check of the triggerables against the builder's RLS, so
+ // it stays refused on an app that runs as someone else; deploying it is the way.
+ const ADMIN_RUN: &str = "u/test-user-3/admin_run";
+ create_raw(&ws, ADMIN, ADMIN_RUN).await?;
+ let (status, body) = update(
+ &ws,
+ ADMIN_RUN,
+ BUILDER,
+ json!({ "execution_mode": "publisher" }),
+ )
+ .await?;
+ assert_eq!(status, 403, "{body}");
+ assert!(body.contains("by deploying it"), "{body}");
+
+ // ========================================
+ // 5. Widening is gated by the protection rule; narrowing is not.
+ // ========================================
+ let resp = client()
+ .post(format!("{ws}/workspaces/protection_rules"))
+ .bearer_auth(ADMIN)
+ .json(&json!({
+ "name": "no-public-apps",
+ "rules": ["RestrictAnonymousAppDeployment"],
+ "bypass_users": [],
+ "bypass_groups": []
+ }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200, "{}", resp.text().await?);
+
+ let (status, body) = update(&ws, OWN, USER, json!({ "execution_mode": "publisher" })).await?;
+ assert_eq!(status, 200, "narrowing stays open: {body}");
+ let (status, body) = update(&ws, OWN, USER, json!({ "execution_mode": "anonymous" })).await?;
+ assert_eq!(status, 403, "{body}");
+ assert!(body.contains("no-public-apps"), "{body}");
+ assert_eq!(policy(&db, OWN).await?["execution_mode"], "publisher");
+
+ Ok(())
+}
diff --git a/backend/tests/app_run_mode_lock_strip.rs b/backend/tests/app_run_mode_lock_strip.rs
index 7a1bba75f6..77265f2bcd 100644
--- a/backend/tests/app_run_mode_lock_strip.rs
+++ b/backend/tests/app_run_mode_lock_strip.rs
@@ -110,10 +110,13 @@ async fn test_run_mode_strips_caller_lock_and_modules(db: Pool) -> any
.json(&json!({
// The args map is the other injection channel: an inline run is a
// `JobKind::Preview` job, so the worker/executors read `_MODULES` and
- // `_TEMP_SCRIPT_REFS` back out of the job args. Both must be stripped.
+ // `_TEMP_SCRIPT_REFS` back out of the job args. Both must be stripped —
+ // including the NUL spellings, which `push` stores under the plain name.
"args": {
"_MODULES": { "m.py": { "content": "print('evil')\n", "language": "python3" } },
- "_TEMP_SCRIPT_REFS": { "../evil": "deadbeef" }
+ "_MODULES\u{0}": { "m.py": { "content": "print('evil')\n", "language": "python3" } },
+ "_TEMP_SCRIPT_REFS": { "../evil": "deadbeef" },
+ "_TEMP_SCRIPT_REFS\u{0}": { "../evil": "deadbeef" }
},
"component": "comp",
"raw_code": inline_raw_code(Some(CALLER_HASH), true)
diff --git a/backend/tests/caller_modules_strip.rs b/backend/tests/caller_modules_strip.rs
index a65ca22d1b..bf4a096ee1 100644
--- a/backend/tests/caller_modules_strip.rs
+++ b/backend/tests/caller_modules_strip.rs
@@ -20,10 +20,16 @@ fn modules(v: &str) -> serde_json::Value {
/// Pushes `payload` with a caller `_MODULES` both in `args` and in `extra` (where webhook
/// query and headers land), and returns the stored `_MODULES`.
+///
+/// Every spelling `push` stores as `_MODULES` is sent, not just the exact name: it writes
+/// the args with their `\u0000` escapes removed, keys included, so `_MODULES\0` lands in
+/// the job as `_MODULES` unless the removal compares the name that will be stored.
async fn stored_modules(db: &Pool, payload: JobPayload) -> Option {
let caller: Box = serde_json::value::to_raw_value(&modules("caller")).unwrap();
- let args = HashMap::from([("_MODULES".to_string(), caller.clone())]);
- let extra = HashMap::from([("_MODULES".to_string(), caller)]);
+ let spellings =
+ || ["_MODULES", "_MODULES\0", "_MOD\0ULES"].map(|k| (k.to_string(), caller.clone()));
+ let args = HashMap::from(spellings());
+ let extra = HashMap::from(spellings());
let (id, tx) = windmill_queue::push(
db,
PushIsolationLevel::IsolatedRoot(db.clone()),
diff --git a/backend/tests/dependency_map.rs b/backend/tests/dependency_map.rs
index dad6c86aa3..e68828fe24 100644
--- a/backend/tests/dependency_map.rs
+++ b/backend/tests/dependency_map.rs
@@ -511,6 +511,7 @@ def main():
)
.unwrap(),
policy: None,
+ execution_mode: None,
deployment_message: None,
custom_path: None,
preserve_on_behalf_of: None,
diff --git a/backend/tests/git_sync_autopull_identity.rs b/backend/tests/git_sync_autopull_identity.rs
index 4e7562c3f3..bc76e4c1cb 100644
--- a/backend/tests/git_sync_autopull_identity.rs
+++ b/backend/tests/git_sync_autopull_identity.rs
@@ -192,3 +192,25 @@ async fn unstamped_fork_pull_runs_as_the_parents_first_admin(
assert_eq!(pull_identities(&db, FORK).await?, vec![identity("aaron")]);
Ok(())
}
+
+/// A workspace with no admin and no stamp runs its pulls as an instance superadmin, skipping
+/// sam, whose instance username a member holds, and the deactivated seeded superadmin.
+#[sqlx::test(fixtures("git_sync_autopull_identity"))]
+async fn unstamped_pull_without_admins_runs_as_a_superadmin(
+ db: Pool,
+) -> anyhow::Result<()> {
+ sqlx::query("UPDATE usr SET is_admin = false WHERE workspace_id = $1")
+ .bind(PARENT)
+ .execute(&db)
+ .await?;
+ sqlx::query("UPDATE password SET disabled = true WHERE email = 'admin@windmill.dev'")
+ .execute(&db)
+ .await?;
+ let mut repo = repo_enabled_by("alice@windmill.dev");
+ repo.auto_pull.as_mut().unwrap().enabled_by = None;
+
+ let job = reconcile_and_enqueue_pull(&db, PARENT, &repo, "main", "abc123", None).await?;
+ assert!(job.is_some(), "{}", recorded_pull_error(&db, PARENT).await?);
+ assert_eq!(pull_identities(&db, PARENT).await?, vec![identity("sue")]);
+ Ok(())
+}
diff --git a/backend/tests/reserved_control_args.rs b/backend/tests/reserved_control_args.rs
new file mode 100644
index 0000000000..a733546e51
--- /dev/null
+++ b/backend/tests/reserved_control_args.rs
@@ -0,0 +1,436 @@
+//! `_ENTRYPOINT_OVERRIDE`, `_MODULES` and `_TEMP_SCRIPT_REFS` steer *how* a runnable runs:
+//! which function the worker calls instead of `main`, what module files land in the job
+//! directory, where its workspace imports resolve from. They are legitimate for a caller
+//! running its own code under its own identity, and must not be settable by a third party
+//! whose payload runs under somebody else's: an HTTP route body or native trigger message
+//! (the job runs as the trigger's owner) and a public app viewer's inputs (as the
+//! publisher).
+//!
+//! `push` stores the args with their `\u0000` escapes removed, keys included, so every
+//! check also has to hold for a NUL-spelled key: `_ENTRYPOINT_OVERRIDE\0` is read back as
+//! `_ENTRYPOINT_OVERRIDE`. The same rename is what lets a viewer's `tenant\0` land as
+//! `tenant` over an app policy's pinned `tenant`.
+
+use std::collections::HashMap;
+
+use serde_json::{json, value::RawValue, Value};
+use sqlx::{Pool, Postgres};
+use windmill_common::{
+ jobs::JobPayload,
+ runnable_settings::{ConcurrencySettings, DebouncingSettings},
+ scripts::{ScriptHash, ScriptLang},
+ triggers::TriggerMetadata,
+};
+use windmill_queue::{PushArgs, PushArgsOwned, PushIsolationLevel};
+use windmill_test_utils::*;
+
+const SCRIPT_PATH: &str = "f/system/hello";
+const SCRIPT_HASH: i64 = 123412;
+/// Two top-level functions, as in the report: `main` authenticates, `apply` acts.
+const SCRIPT_CONTENT: &str = "export async function main(data: string) { return data }\n\
+ export async function apply(action: string) { return action }\n";
+
+fn client() -> reqwest::Client {
+ reqwest::Client::new()
+}
+
+fn authed(b: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder {
+ b.header("Authorization", format!("Bearer {}", token))
+}
+
+fn raw(v: Value) -> Box {
+ serde_json::value::to_raw_value(&v).unwrap()
+}
+
+fn modules() -> Value {
+ json!({ "bunfig.toml": { "content": "preload = [\"./x.ts\"]", "language": "bun" } })
+}
+
+/// Deploy `SCRIPT_PATH` (a non-null `lock` is what makes a script count as deployed).
+async fn deploy_script(db: &Pool) -> anyhow::Result<()> {
+ sqlx::query(
+ r#"INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by,
+ schema, summary, description, lock)
+ VALUES ('test-workspace', $1, $2, $3, 'bun', 'script', 'test-user', '{}', '', '',
+ E'{}\n//bun.lock\n')"#,
+ )
+ .bind(SCRIPT_HASH)
+ .bind(SCRIPT_PATH)
+ .bind(SCRIPT_CONTENT)
+ .execute(db)
+ .await?;
+ Ok(())
+}
+
+async fn job_args_and_entrypoint(
+ db: &Pool,
+ id: uuid::Uuid,
+) -> anyhow::Result<(Value, Option)> {
+ Ok(
+ sqlx::query_as("SELECT args, script_entrypoint_override FROM v2_job WHERE id = $1")
+ .bind(id)
+ .fetch_one(db)
+ .await?,
+ )
+}
+
+fn trigger_authed() -> windmill_api_auth::ApiAuthed {
+ windmill_api_auth::ApiAuthed {
+ email: "test@windmill.dev".to_string(),
+ username: "test-user".to_string(),
+ is_admin: true,
+ is_operator: false,
+ groups: vec![],
+ folders: vec![],
+ scopes: None,
+ username_override: None,
+ username_override_is_token_label: false,
+ is_session_token: false,
+ token_prefix: None,
+ read_only: false,
+ job_id: None,
+ credential_expiry: None,
+ }
+}
+
+/// `trigger_runnable_inner` is the one funnel every trigger reaches `push` through, so it
+/// stands in here for the HTTP route whose body keys become the job's top-level args. The
+/// sender is anonymous on a `none`/`custom_script` route; the job runs as the route owner.
+#[sqlx::test(fixtures("base"))]
+async fn trigger_args_lose_every_reserved_control_key(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ deploy_script(&db).await?;
+
+ let args = PushArgsOwned {
+ args: HashMap::from([
+ // Picks `apply` instead of `main`, skipping whatever `main` authenticates.
+ ("_ENTRYPOINT_OVERRIDE".to_string(), raw(json!("apply"))),
+ ("_ENTRYPOINT_OVERRIDE\u{0}".to_string(), raw(json!("apply"))),
+ ("_ENTRY\u{0}POINT_OVERRIDE".to_string(), raw(json!("apply"))),
+ ("_MODULES\u{0}".to_string(), raw(modules())),
+ (
+ "_TEMP_SCRIPT_REFS\u{0}".to_string(),
+ raw(json!({ "f/system/dep": "deadbeef" })),
+ ),
+ // An ordinary input must still get through, NUL-renamed or not.
+ ("data".to_string(), raw(json!("payload"))),
+ ("action\u{0}".to_string(), raw(json!("refund"))),
+ ]),
+ extra: None,
+ };
+
+ let (id, _, _, _, _) = windmill_trigger::trigger_helpers::trigger_runnable_inner(
+ &db,
+ None,
+ None,
+ trigger_authed(),
+ "test-workspace",
+ SCRIPT_PATH,
+ false,
+ args,
+ None,
+ None,
+ None,
+ "u/test-user/route".to_string(),
+ None,
+ TriggerMetadata::new(
+ Some("u/test-user/route".to_string()),
+ windmill_common::jobs::JobTriggerKind::Http,
+ ),
+ None,
+ )
+ .await?;
+
+ let (args, entrypoint) = job_args_and_entrypoint(&db, id).await?;
+ assert_eq!(
+ args,
+ json!({ "data": "payload", "action": "refund" }),
+ "a trigger payload may carry the runnable's inputs and nothing that steers how it runs"
+ );
+ assert_eq!(
+ entrypoint, None,
+ "a trigger payload must not pick the function the worker calls instead of `main`"
+ );
+ Ok(())
+}
+
+/// The counterpart: `_ENTRYPOINT_OVERRIDE` stays for a caller that runs the script under
+/// its own identity with `jobs:run` — the script editor's preprocessor test, dynamic-select
+/// helpers, the CLI and workflow-as-code all send it this way (GHSA-wxjq-w5pj-jqhx bounds
+/// what it may contain, not who may set it). Stripping it in `push` would break them.
+#[sqlx::test(fixtures("base"))]
+async fn an_authenticated_push_keeps_the_entrypoint_override(
+ db: Pool,
+) -> anyhow::Result<()> {
+ deploy_script(&db).await?;
+
+ // Both spellings: `push` normalizes the names to what it stores, so the NUL-spelled one
+ // selects the function exactly like the plain one instead of being silently different.
+ for key in ["_ENTRYPOINT_OVERRIDE", "_ENTRYPOINT_OVERRIDE\u{0}"] {
+ let args = HashMap::from([
+ (key.to_string(), raw(json!("apply"))),
+ ("action".to_string(), raw(json!("refund"))),
+ ]);
+ let (id, tx) = windmill_queue::push(
+ &db,
+ PushIsolationLevel::IsolatedRoot(db.clone()),
+ "test-workspace",
+ JobPayload::ScriptHash {
+ hash: ScriptHash(SCRIPT_HASH),
+ path: SCRIPT_PATH.to_string(),
+ cache_ttl: None,
+ cache_ignore_s3_path: None,
+ dedicated_worker: None,
+ language: ScriptLang::Bun,
+ priority: None,
+ apply_preprocessor: false,
+ concurrency_settings: ConcurrencySettings::default(),
+ debouncing_settings: DebouncingSettings::default(),
+ labels: None,
+ job_token_scopes: None,
+ },
+ PushArgs { args: &args, extra: None },
+ "test-user",
+ "test@windmill.dev",
+ "u/test-user".to_string(),
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ None,
+ false,
+ false,
+ None,
+ true,
+ None,
+ None,
+ None,
+ None,
+ None,
+ false,
+ None,
+ None,
+ None,
+ None,
+ )
+ .await?;
+ tx.commit().await?;
+ let (args, entrypoint) = job_args_and_entrypoint(&db, id).await?;
+ assert_eq!(
+ entrypoint.as_deref(),
+ Some("apply"),
+ "an authenticated caller running a script as itself may still pick the entrypoint \
+ (sent as {key:?})"
+ );
+ assert_eq!(
+ args,
+ json!({ "_ENTRYPOINT_OVERRIDE": "apply", "action": "refund" }),
+ "the stored name is the normalized one (sent as {key:?})"
+ );
+ }
+ Ok(())
+}
+
+/// Create a public (anonymous) app whose single component is the deployed script, with the
+/// given policy inputs, and return its path.
+async fn create_public_app(
+ ws: &str,
+ path: &str,
+ static_inputs: Value,
+ one_of_inputs: Value,
+) -> anyhow::Result<()> {
+ let resp = authed(client().post(format!("{ws}/apps/create")), "SECRET_TOKEN")
+ .json(&json!({
+ "path": path,
+ "summary": "",
+ "value": {},
+ "policy": {
+ "execution_mode": "anonymous",
+ "triggerables_v2": {
+ format!("comp:script/{SCRIPT_PATH}"): {
+ "static_inputs": static_inputs,
+ "one_of_inputs": one_of_inputs,
+ }
+ }
+ }
+ }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 201, "create app: {}", resp.text().await?);
+ Ok(())
+}
+
+/// A viewer of a public app runs the publisher's pinned script as the publisher: it may
+/// fill in the inputs the policy leaves free and nothing else.
+#[sqlx::test(fixtures("base"))]
+async fn public_app_viewer_cannot_steer_the_pinned_runnable(
+ db: Pool,
+) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let ws = format!(
+ "http://localhost:{}/api/w/test-workspace",
+ server.addr.port()
+ );
+ deploy_script(&db).await?;
+
+ let app_path = "u/test-user/reserved_args";
+ create_public_app(
+ &ws,
+ app_path,
+ json!({ "tenant": "acme" }),
+ json!({ "greeting": ["Hello,", "Bonjour,"] }),
+ )
+ .await?;
+
+ // No `Authorization` header at all: the anonymous visitor of a public app.
+ let run = |args: &'static str| {
+ client()
+ .post(format!("{ws}/apps_u/execute_component/{app_path}"))
+ .header("content-type", "application/json")
+ .body(format!(
+ r#"{{"component": "comp", "path": "script/{SCRIPT_PATH}", "args": {args}}}"#
+ ))
+ .send()
+ };
+
+ // A name spelled with a NUL is the same name once stored, so the policy's pinned value
+ // must still win and the one-of list must still be enforced.
+ let resp = run(r#"{"tenant\u0000": "globex", "greeting": "Hello,"}"#).await?;
+ let status = resp.status();
+ let body = resp.text().await?;
+ assert_eq!(status, 200, "run must be accepted: {body}");
+ let (args, _) = job_args_and_entrypoint(&db, uuid::Uuid::parse_str(body.trim())?).await?;
+ assert_eq!(
+ args["tenant"],
+ json!("acme"),
+ "a NUL-renamed arg must not override an input the policy pins: {args}"
+ );
+
+ let resp = run(r#"{"greeting\u0000": "anything"}"#).await?;
+ let status = resp.status();
+ let body = resp.text().await?;
+ assert_eq!(
+ status, 400,
+ "a NUL-renamed arg must still be held to the policy's one_of list: {body}"
+ );
+ assert!(
+ body.contains("must be one of"),
+ "expected the one_of rejection, got: {body}"
+ );
+
+ // The control keys, in both spellings, in one request.
+ let resp = run(
+ r#"{"_ENTRYPOINT_OVERRIDE": "apply", "_ENTRYPOINT_OVERRIDE\u0000": "apply",
+ "_MODULES": {"bunfig.toml": {"content": "x", "language": "bun"}},
+ "_MODULES\u0000": {"bunfig.toml": {"content": "x", "language": "bun"}},
+ "_TEMP_SCRIPT_REFS": {"f/system/dep": "deadbeef"},
+ "_TEMP_SCRIPT_REFS\u0000": {"f/system/dep": "deadbeef"},
+ "greeting": "Hello,"}"#,
+ )
+ .await?;
+ let status = resp.status();
+ let body = resp.text().await?;
+ assert_eq!(status, 200, "run must be accepted: {body}");
+ let (args, entrypoint) =
+ job_args_and_entrypoint(&db, uuid::Uuid::parse_str(body.trim())?).await?;
+ assert_eq!(
+ entrypoint, None,
+ "a viewer must not pick the function the worker calls instead of `main`"
+ );
+ assert_eq!(
+ args,
+ json!({ "tenant": "acme", "greeting": "Hello," }),
+ "only the policy's pinned input and the viewer's allowed one may reach the job: {args}"
+ );
+ Ok(())
+}
+
+/// A deployed flow's dynamic-select code has no deployed runnable, so it is queued as a
+/// `JobKind::Preview` job — the one kind on which the bun and python executors honor
+/// `_TEMP_SCRIPT_REFS` and resolve the code's workspace imports from the temporary script
+/// store, which any workspace member can write. The caller of this route only fills in that
+/// code's inputs (it may be an operator, who is refused `jobs/run/preview` outright), so the
+/// key must not reach the job, in either spelling.
+#[sqlx::test(fixtures("base"))]
+async fn flow_dynamic_select_strips_caller_control_args(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let ws = format!(
+ "http://localhost:{}/api/w/test-workspace",
+ server.addr.port()
+ );
+
+ // Someone else's flow: `test-user-2` below can run it, not edit it.
+ let flow_path = "u/test-user/dynselect_control_args";
+ let resp = authed(client().post(format!("{ws}/flows/create")), "SECRET_TOKEN")
+ .json(&json!({
+ "path": flow_path,
+ "summary": "",
+ "description": "",
+ "value": { "modules": [] },
+ "schema": {
+ "type": "object",
+ "properties": { "region": { "type": "object", "format": "dynselect-region" } },
+ "x-windmill-dyn-select-lang": "bun",
+ "x-windmill-dyn-select-code":
+ "import { regions } from \"/f/system/regions\"\n\
+ export async function region() { return regions() }\n"
+ }
+ }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 201, "create flow: {}", resp.text().await?);
+
+ // Read-only for the caller below: running the dyn-select code is all it may do.
+ sqlx::query(
+ "UPDATE flow SET extra_perms = '{\"u/test-user-2\": false}'
+ WHERE path = $1 AND workspace_id = 'test-workspace'",
+ )
+ .bind(flow_path)
+ .execute(&db)
+ .await?;
+
+ let refs = json!({ "f/system/regions": "deadbeef" });
+ let modules = json!({ "regions.ts": { "content": "export const v = 1", "language": "bun" } });
+ let resp = authed(
+ client().post(format!("{ws}/jobs/run/dynamic_select")),
+ "SECRET_TOKEN_2",
+ )
+ .json(&json!({
+ "entrypoint_function": "region",
+ "runnable_ref": { "source": "deployed", "path": flow_path, "runnable_kind": "flow" },
+ "args": {
+ "_TEMP_SCRIPT_REFS": refs,
+ "_TEMP_SCRIPT_REFS\u{0}": refs,
+ "_MODULES": modules,
+ "_MODULES\u{0}": modules,
+ "filterText": "eu"
+ }
+ }))
+ .send()
+ .await?;
+ let status = resp.status();
+ let body = resp.text().await?;
+ assert_eq!(status, 201, "dynamic select must be accepted: {body}");
+
+ let id = uuid::Uuid::parse_str(body.trim())?;
+ let kind: String = sqlx::query_scalar("SELECT kind::text FROM v2_job WHERE id = $1")
+ .bind(id)
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(
+ kind, "preview",
+ "the premise of this test: a deployed flow's dyn-select code runs as a preview job"
+ );
+ let (args, _) = job_args_and_entrypoint(&db, id).await?;
+ assert_eq!(
+ args,
+ json!({ "_ENTRYPOINT_OVERRIDE": "region", "filterText": "eu" }),
+ "only the caller's inputs and the server-set entrypoint may reach the job"
+ );
+ Ok(())
+}
diff --git a/backend/tests/variable_expiration.rs b/backend/tests/variable_expiration.rs
new file mode 100644
index 0000000000..94a4ed3f78
--- /dev/null
+++ b/backend/tests/variable_expiration.rs
@@ -0,0 +1,534 @@
+//! Tests for the variable expiration sweep. No worker: the sweep ends at the push, and the
+//! queued job is what the tests assert on.
+
+use sqlx::{Pool, Postgres};
+use windmill_queue::variable_expiration::dispatch_expiring_variables;
+use windmill_test_utils::{initialize_tracing, ApiServer};
+
+const WS: &str = "test-workspace";
+
+async fn configure_handler(db: &Pool, handler: &str) -> anyhow::Result<()> {
+ sqlx::query("UPDATE workspace_settings SET variable_expiration_handler = $2::jsonb WHERE workspace_id = $1")
+ .bind(WS)
+ .bind(handler)
+ .execute(db)
+ .await?;
+ Ok(())
+}
+
+/// Seed a deployed script the handler setting can point at.
+async fn seed_handler_script(db: &Pool, path: &str) -> anyhow::Result<()> {
+ sqlx::query(
+ r#"INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, language, lock)
+ VALUES ($1, $2, $3, '', '', 'export function main() {}', 'test-user', 'bun', '')"#,
+ )
+ .bind(WS)
+ .bind(path.bytes().fold(0i64, |h, b| h.wrapping_mul(31).wrapping_add(b as i64)))
+ .bind(path)
+ .execute(db)
+ .await?;
+ Ok(())
+}
+
+async fn seed_variable(db: &Pool, path: &str, expires_in: &str) -> anyhow::Result<()> {
+ sqlx::query(&format!(
+ r#"INSERT INTO variable (workspace_id, path, value, is_secret, description, value_valid_until)
+ VALUES ($1, $2, 'MUST_NOT_LEAK', true, 'a credential', now() + interval '{expires_in}')"#
+ ))
+ .bind(WS)
+ .bind(path)
+ .execute(db)
+ .await?;
+ Ok(())
+}
+
+async fn queued_handler_jobs(db: &Pool, path: &str) -> anyhow::Result {
+ Ok(
+ sqlx::query_scalar::<_, i64>("SELECT count(*) FROM v2_job WHERE runnable_path = $1")
+ .bind(path)
+ .fetch_one(db)
+ .await?,
+ )
+}
+
+/// The sweep dispatches inside the handler's `notify_before_secs`, leaves everything outside it
+/// alone, and does not fire twice for the same expiry.
+#[sqlx::test(fixtures("base"))]
+async fn test_dispatch_within_lead_time_once(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ seed_handler_script(&db, "u/test-user/expiry_handler").await?;
+ configure_handler(
+ &db,
+ r#"{"path": "script/u/test-user/expiry_handler", "notify_before_secs": 3600}"#,
+ )
+ .await?;
+
+ seed_variable(&db, "u/test-user/due", "30 minutes").await?;
+ seed_variable(&db, "u/test-user/far", "2 hours").await?;
+
+ dispatch_expiring_variables(&db).await;
+
+ assert_eq!(
+ queued_handler_jobs(&db, "u/test-user/expiry_handler").await?,
+ 1,
+ "exactly the variable inside the lead window should dispatch"
+ );
+
+ // Admins grant this group access to the secrets it rotates, so the job must run as it.
+ let (path, is_secret, permissioned_as, permissioned_as_email): (String, bool, String, String) =
+ sqlx::query_as(
+ "SELECT args->>'variable_path', (args->>'is_secret')::boolean, permissioned_as,
+ permissioned_as_email FROM v2_job WHERE runnable_path = $1",
+ )
+ .bind("u/test-user/expiry_handler")
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(path, "u/test-user/due");
+ assert!(is_secret);
+ assert_eq!(permissioned_as, "g/variable_expiration_handler");
+ assert_eq!(
+ permissioned_as_email,
+ "variable_expiration_handler@windmill.dev"
+ );
+
+ let args: String = sqlx::query_scalar("SELECT args::text FROM v2_job WHERE runnable_path = $1")
+ .bind("u/test-user/expiry_handler")
+ .fetch_one(&db)
+ .await?;
+ assert!(
+ !args.contains("MUST_NOT_LEAK"),
+ "handler args leaked the value: {args}"
+ );
+
+ // The claim is what makes concurrent replicas safe.
+ dispatch_expiring_variables(&db).await;
+ assert_eq!(
+ queued_handler_jobs(&db, "u/test-user/expiry_handler").await?,
+ 1,
+ "a dispatched variable must not fire again"
+ );
+
+ Ok(())
+}
+
+/// A variable's writer must not get the handler to resolve a reference as its own identity;
+/// the admin's `extra_args` still resolve.
+#[sqlx::test(fixtures("base"))]
+async fn test_variable_fields_do_not_resolve_in_handler(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ seed_handler_script(&db, "u/test-user/expiry_handler").await?;
+ configure_handler(
+ &db,
+ r#"{"path": "script/u/test-user/expiry_handler", "extra_args": {"slack": "$res:f/slack_bot/bot_token"}}"#,
+ )
+ .await?;
+ seed_variable(&db, "u/test-user/due", "30 minutes").await?;
+ sqlx::query(
+ "UPDATE variable SET description = '$var:u/test-user/other' WHERE path = 'u/test-user/due'",
+ )
+ .execute(&db)
+ .await?;
+
+ dispatch_expiring_variables(&db).await;
+
+ let (description, slack): (String, String) = sqlx::query_as(
+ "SELECT args->>'description', args->>'slack' FROM v2_job WHERE runnable_path = $1",
+ )
+ .bind("u/test-user/expiry_handler")
+ .fetch_one(&db)
+ .await?;
+ assert!(
+ !description.starts_with("$var:"),
+ "description still resolves: {description}"
+ );
+ assert_eq!(slack, "$res:f/slack_bot/bot_token");
+ Ok(())
+}
+
+/// `remind_every_secs` runs the handler again once the interval has elapsed, and not before.
+#[sqlx::test(fixtures("base"))]
+async fn test_remind_every_repeats_the_notice(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ seed_handler_script(&db, "u/test-user/expiry_handler").await?;
+ configure_handler(
+ &db,
+ r#"{"path": "script/u/test-user/expiry_handler", "remind_every_secs": 600}"#,
+ )
+ .await?;
+ seed_variable(&db, "u/test-user/due", "30 minutes").await?;
+
+ dispatch_expiring_variables(&db).await;
+ dispatch_expiring_variables(&db).await;
+ assert_eq!(
+ queued_handler_jobs(&db, "u/test-user/expiry_handler").await?,
+ 1,
+ "no reminder before the interval has elapsed"
+ );
+
+ // A sweep landing a few seconds short of the interval must still remind.
+ sqlx::query(
+ "UPDATE variable SET expiration_notified_at = now() - interval '9 minutes 50 seconds'",
+ )
+ .execute(&db)
+ .await?;
+ dispatch_expiring_variables(&db).await;
+ assert_eq!(
+ queued_handler_jobs(&db, "u/test-user/expiry_handler").await?,
+ 2
+ );
+
+ sqlx::query(
+ "UPDATE variable SET value_valid_until = now() - interval '1 minute',
+ expiration_dispatched_for = now() - interval '1 minute',
+ expiration_notified_at = now() - interval '11 minutes'",
+ )
+ .execute(&db)
+ .await?;
+ dispatch_expiring_variables(&db).await;
+ assert_eq!(
+ queued_handler_jobs(&db, "u/test-user/expiry_handler").await?,
+ 2
+ );
+ Ok(())
+}
+
+/// Moving `value_valid_until` re-arms the variable; re-writing the same date does not.
+#[sqlx::test(fixtures("base"))]
+async fn test_rearm_on_date_change(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let base_url = format!("http://localhost:{}", server.addr.port());
+ seed_handler_script(&db, "u/test-user/expiry_handler").await?;
+ configure_handler(&db, r#"{"path": "script/u/test-user/expiry_handler"}"#).await?;
+ seed_variable(&db, "u/test-user/due", "30 minutes").await?;
+
+ // Through the real endpoint: writing the column directly would assert nothing.
+ let update = async |body: serde_json::Value| -> anyhow::Result {
+ Ok(reqwest::Client::new()
+ .post(format!(
+ "{base_url}/api/w/{WS}/variables/update/u/test-user/due"
+ ))
+ .bearer_auth("SECRET_TOKEN")
+ .json(&body)
+ .send()
+ .await?
+ .status()
+ .as_u16())
+ };
+
+ dispatch_expiring_variables(&db).await;
+ assert_eq!(
+ queued_handler_jobs(&db, "u/test-user/expiry_handler").await?,
+ 1
+ );
+
+ let stored: chrono::DateTime = sqlx::query_scalar(
+ "SELECT value_valid_until FROM variable WHERE workspace_id = $1 AND path = $2",
+ )
+ .bind(WS)
+ .bind("u/test-user/due")
+ .fetch_one(&db)
+ .await?;
+
+ // An unchanged `wmill sync push` rewrites the same date.
+ assert_eq!(
+ update(serde_json::json!({ "value_valid_until": stored })).await?,
+ 200
+ );
+ dispatch_expiring_variables(&db).await;
+ assert_eq!(
+ queued_handler_jobs(&db, "u/test-user/expiry_handler").await?,
+ 1,
+ "an unchanged expiry must not re-arm"
+ );
+
+ assert_eq!(
+ update(serde_json::json!({ "description": "touched" })).await?,
+ 200
+ );
+ dispatch_expiring_variables(&db).await;
+ assert_eq!(
+ queued_handler_jobs(&db, "u/test-user/expiry_handler").await?,
+ 1,
+ "an update that omits value_valid_until must not re-arm"
+ );
+
+ assert_eq!(
+ update(serde_json::json!({ "value_valid_until": stored + chrono::Duration::minutes(5) }))
+ .await?,
+ 200
+ );
+ dispatch_expiring_variables(&db).await;
+ assert_eq!(
+ queued_handler_jobs(&db, "u/test-user/expiry_handler").await?,
+ 2,
+ "a moved expiry must re-arm the handler"
+ );
+
+ Ok(())
+}
+
+/// `muted_on_user_path` keeps personal-path variables out.
+#[sqlx::test(fixtures("base"))]
+async fn test_muted_user_paths(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ seed_handler_script(&db, "u/test-user/expiry_handler").await?;
+ seed_variable(&db, "u/test-user/personal", "30 minutes").await?;
+ seed_variable(&db, "f/test_folder/shared", "30 minutes").await?;
+ configure_handler(
+ &db,
+ r#"{"path": "script/u/test-user/expiry_handler", "muted_on_user_path": true}"#,
+ )
+ .await?;
+ dispatch_expiring_variables(&db).await;
+
+ let dispatched: Vec =
+ sqlx::query_scalar("SELECT args->>'variable_path' FROM v2_job WHERE runnable_path = $1")
+ .bind("u/test-user/expiry_handler")
+ .fetch_all(&db)
+ .await?;
+ assert_eq!(
+ dispatched,
+ vec!["f/test_folder/shared".to_string()],
+ "a muted personal path must not dispatch"
+ );
+
+ Ok(())
+}
+
+/// Broken workspaces' due rows must not hold back anyone else's first notice or reminder: every
+/// due row is tried each pass, and a broken handler only once per pass. The broken rows go in
+/// first, so a cap on the pass would cut off the working workspace.
+#[sqlx::test(fixtures("base"))]
+async fn test_broken_handlers_do_not_hold_back_others(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ sqlx::raw_sql(
+ r#"INSERT INTO workspace (id, name, owner) SELECT 'broken-' || i, 'broken-' || i, 'test-user' FROM generate_series(1, 100) i;
+ INSERT INTO workspace_key (workspace_id, kind, key) SELECT 'broken-' || i, 'cloud', 'k' FROM generate_series(1, 100) i;
+ INSERT INTO workspace_settings (workspace_id, variable_expiration_handler)
+ SELECT 'broken-' || i, '{"path": "script/u/test-user/does_not_exist"}'::jsonb FROM generate_series(1, 100) i;
+ INSERT INTO variable (workspace_id, path, value, is_secret, description, value_valid_until)
+ SELECT 'broken-' || i, 'f/creds/v' || j, 'v', false, '', now() - interval '1 day'
+ FROM generate_series(1, 100) i, generate_series(1, 2) j;"#,
+ )
+ .execute(&db)
+ .await?;
+ seed_handler_script(&db, "u/test-user/expiry_handler").await?;
+ configure_handler(
+ &db,
+ r#"{"path": "script/u/test-user/expiry_handler", "remind_every_secs": 60}"#,
+ )
+ .await?;
+ seed_variable(&db, "u/test-user/fresh", "30 minutes").await?;
+ sqlx::query(
+ "INSERT INTO variable (workspace_id, path, value, is_secret, description, value_valid_until,
+ expiration_dispatched_for, expiration_notified_at)
+ VALUES ($1, 'u/test-user/reminded', 'v', false, '', now() + interval '10 minutes',
+ now() + interval '10 minutes', now() - interval '2 minutes')",
+ )
+ .bind(WS)
+ .execute(&db)
+ .await?;
+
+ dispatch_expiring_variables(&db).await;
+
+ assert_eq!(
+ queued_handler_jobs(&db, "u/test-user/expiry_handler").await?,
+ 2,
+ "the first notice and the reminder must both go out"
+ );
+ assert_eq!(
+ queued_handler_jobs(&db, "u/test-user/does_not_exist").await?,
+ 0
+ );
+ Ok(())
+}
+
+/// A broken handler alerts once, however many passes it fails, runs on the first pass after
+/// it is deployed, and that resolves its alert.
+#[sqlx::test(fixtures("base"))]
+async fn test_deploying_the_handler_ends_the_failure(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ configure_handler(&db, r#"{"path": "script/u/test-user/redeployed_handler"}"#).await?;
+ seed_variable(&db, "u/test-user/due", "30 minutes").await?;
+ let alerts = || async {
+ Ok::<_, anyhow::Error>(sqlx::query_as::<_, (i64, i64)>(
+ "SELECT count(*), count(*) FILTER (WHERE NOT acknowledged) FROM alerts
+ WHERE alert_type = 'critical_error' AND resource = 'variable_expiration_handler:test-workspace'",
+ )
+ .fetch_one(&db)
+ .await?)
+ };
+
+ dispatch_expiring_variables(&db).await;
+ dispatch_expiring_variables(&db).await;
+ assert_eq!(
+ alerts().await?,
+ (1, 1),
+ "one alert per failure, not per pass"
+ );
+
+ seed_handler_script(&db, "u/test-user/redeployed_handler").await?;
+ dispatch_expiring_variables(&db).await;
+
+ assert_eq!(
+ queued_handler_jobs(&db, "u/test-user/redeployed_handler").await?,
+ 1,
+ "the deployed handler did not run on the next pass"
+ );
+ assert_eq!(alerts().await?, (1, 0), "starting again resolves the alert");
+ Ok(())
+}
+
+/// A handler script still waiting on its dependency job runs on the first pass after its lock
+/// lands, though nothing about the script changes but the lock.
+#[sqlx::test(fixtures("base"))]
+async fn test_handler_runs_once_its_lock_lands(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ configure_handler(
+ &db,
+ r#"{"path": "script/u/test-user/pending_lock_handler"}"#,
+ )
+ .await?;
+ seed_variable(&db, "u/test-user/due", "30 minutes").await?;
+ seed_handler_script(&db, "u/test-user/pending_lock_handler").await?;
+ sqlx::query("UPDATE script SET lock = NULL WHERE workspace_id = $1")
+ .bind(WS)
+ .execute(&db)
+ .await?;
+
+ dispatch_expiring_variables(&db).await;
+ assert_eq!(
+ queued_handler_jobs(&db, "u/test-user/pending_lock_handler").await?,
+ 0
+ );
+ sqlx::query("UPDATE script SET lock = '' WHERE workspace_id = $1")
+ .bind(WS)
+ .execute(&db)
+ .await?;
+ dispatch_expiring_variables(&db).await;
+
+ assert_eq!(
+ queued_handler_jobs(&db, "u/test-user/pending_lock_handler").await?,
+ 1,
+ "the handler did not run once its lock landed"
+ );
+ Ok(())
+}
+
+/// A removed handler has nothing left to retry, so the next pass ends its failure and resolves
+/// its alert.
+#[sqlx::test(fixtures("base"))]
+async fn test_removing_a_failing_handler_resolves_its_alert(
+ db: Pool,
+) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ sqlx::query(
+ "UPDATE workspace_settings SET variable_expiration_handler = NULL,
+ variable_expiration_failed_at = now() WHERE workspace_id = $1",
+ )
+ .bind(WS)
+ .execute(&db)
+ .await?;
+ sqlx::query(
+ "INSERT INTO alerts (alert_type, message, acknowledged, acknowledged_workspace, workspace_id, resource)
+ VALUES ('critical_error', 'failed', false, false, $1, 'variable_expiration_handler:' || $1)",
+ )
+ .bind(WS)
+ .execute(&db)
+ .await?;
+
+ dispatch_expiring_variables(&db).await;
+
+ let (failing, open_alerts): (bool, i64) = sqlx::query_as(
+ "SELECT ws.variable_expiration_failed_at IS NOT NULL,
+ (SELECT count(*) FROM alerts WHERE alert_type = 'critical_error' AND NOT acknowledged
+ AND workspace_id = $1)
+ FROM workspace_settings ws WHERE ws.workspace_id = $1",
+ )
+ .bind(WS)
+ .fetch_one(&db)
+ .await?;
+ assert!(!failing, "the removed handler's failure must end");
+ assert_eq!(
+ open_alerts, 0,
+ "the removed handler's alert must be resolved"
+ );
+ Ok(())
+}
+
+/// The handler endpoint: admin-only, existence-checked, and clearing by omission, which
+/// `wmill sync push` relies on to propagate an absent handler.
+#[sqlx::test(fixtures("base"))]
+async fn test_edit_handler_endpoint(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let base_url = format!("http://localhost:{}", server.addr.port());
+
+ let edit = async |token: &str, body: serde_json::Value| -> anyhow::Result {
+ Ok(reqwest::Client::new()
+ .post(format!(
+ "{base_url}/api/w/{WS}/workspaces/edit_variable_expiration_handler"
+ ))
+ .bearer_auth(token)
+ .json(&body)
+ .send()
+ .await?
+ .status()
+ .as_u16())
+ };
+ let stored = async || -> anyhow::Result> {
+ Ok(sqlx::query_scalar(
+ "SELECT variable_expiration_handler FROM workspace_settings WHERE workspace_id = $1",
+ )
+ .bind(WS)
+ .fetch_one(&db)
+ .await?)
+ };
+
+ seed_handler_script(&db, "u/test-user/expiry_handler").await?;
+ let handler = serde_json::json!({ "path": "script/u/test-user/expiry_handler" });
+ assert_eq!(edit("SECRET_TOKEN_2", handler.clone()).await?, 403);
+ assert!(stored().await?.is_none());
+
+ for path in [
+ "u/test-user/expiry_handler",
+ "script/u/test-user/does_not_exist",
+ ] {
+ assert_eq!(
+ edit("SECRET_TOKEN", serde_json::json!({ "path": path })).await?,
+ 400,
+ "{path}"
+ );
+ }
+
+ // A recorded failure is the sweep's to end, so the endpoint leaves it alone either way.
+ sqlx::query("UPDATE workspace_settings SET variable_expiration_failed_at = now() WHERE workspace_id = $1")
+ .bind(WS)
+ .execute(&db)
+ .await?;
+ let failing = || async {
+ Ok::<_, anyhow::Error>(sqlx::query_scalar::<_, bool>(
+ "SELECT variable_expiration_failed_at IS NOT NULL FROM workspace_settings WHERE workspace_id = $1",
+ )
+ .bind(WS)
+ .fetch_one(&db)
+ .await?)
+ };
+ assert_eq!(edit("SECRET_TOKEN", handler).await?, 200);
+ assert_eq!(
+ stored()
+ .await?
+ .and_then(|h| h["path"].as_str().map(String::from)),
+ Some("script/u/test-user/expiry_handler".to_string())
+ );
+ assert!(failing().await?, "saving the handler must keep its failure");
+
+ assert_eq!(edit("SECRET_TOKEN", serde_json::json!({})).await?, 200);
+ assert!(
+ stored().await?.is_none(),
+ "an omitted path clears the handler"
+ );
+
+ Ok(())
+}
diff --git a/backend/tests/workspace_export.rs b/backend/tests/workspace_export.rs
index 5894ca8394..792d41b487 100644
--- a/backend/tests/workspace_export.rs
+++ b/backend/tests/workspace_export.rs
@@ -288,6 +288,48 @@ async fn test_tarball_export_gates_values_on_item_scopes(db: Pool) ->
Ok(())
}
+/// The tarball drops variables by `expires_at`, never `value_valid_until`, and the exported
+/// entry carries the date, or a pull/push round-trip clears it.
+#[sqlx::test(fixtures("base"))]
+async fn test_tarball_export_excludes_only_gc_variables(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let base_url = format!("http://localhost:{}", server.addr.port());
+
+ sqlx::query(
+ r#"INSERT INTO variable (workspace_id, path, value, is_secret, description, expires_at, value_valid_until)
+ VALUES ('test-workspace', 'u/test-user/expiring', 'v', false, '', NULL, '2030-01-15T08:00:00Z'),
+ ('test-workspace', 'u/test-user/ephemeral', 'v', false, '', now() + interval '7 days', NULL)"#,
+ )
+ .execute(&db)
+ .await?;
+
+ let resp = reqwest::Client::new()
+ .get(format!(
+ "{base_url}/api/w/test-workspace/workspaces/tarball"
+ ))
+ .bearer_auth("SECRET_TOKEN")
+ .send()
+ .await?;
+ assert_eq!(resp.status().as_u16(), 200);
+ let body = String::from_utf8_lossy(&resp.bytes().await?).into_owned();
+
+ assert!(
+ body.contains("u/test-user/expiring.variable.json"),
+ "a variable with an expiry must still reach git sync"
+ );
+ assert!(
+ body.contains("2030-01-15T08:00:00Z"),
+ "the exported entry must carry the expiry back"
+ );
+ assert!(
+ !body.contains("u/test-user/ephemeral.variable.json"),
+ "a variable awaiting garbage collection must stay out of the export"
+ );
+
+ Ok(())
+}
+
/// `settings.json` carries the admin-managed integration config that `get_settings`
/// is admin-only for (the webhook URL, ai_config, git_sync, handler extra_args), so
/// `include_settings` takes the same admin check as `get_settings` rather than
diff --git a/backend/windmill-ai/src/ai_providers.rs b/backend/windmill-ai/src/ai_providers.rs
index 311c8d3eb7..489a965ef1 100644
--- a/backend/windmill-ai/src/ai_providers.rs
+++ b/backend/windmill-ai/src/ai_providers.rs
@@ -213,7 +213,9 @@ impl AIProvider {
AIProvider::Groq => Ok("https://api.groq.com/openai/v1".to_string()),
AIProvider::OpenRouter => Ok("https://openrouter.ai/api/v1".to_string()),
AIProvider::TogetherAI => Ok("https://api.together.xyz/v1".to_string()),
- AIProvider::Anthropic => Ok("https://api.anthropic.com/v1".to_string()),
+ AIProvider::Anthropic => {
+ Ok(crate::providers::anthropic::ANTHROPIC_API_BASE_URL.to_string())
+ }
AIProvider::Mistral => Ok("https://api.mistral.ai/v1".to_string()),
AIProvider::TypeSafe => Ok(TYPESAFE_BASE_URL.to_string()),
AIProvider::Cloudflare => Err(Error::BadRequest(
diff --git a/backend/windmill-ai/src/credentials.rs b/backend/windmill-ai/src/credentials.rs
index 5a779938ed..f5489c5c30 100644
--- a/backend/windmill-ai/src/credentials.rs
+++ b/backend/windmill-ai/src/credentials.rs
@@ -32,6 +32,21 @@ pub struct ProviderCredentials {
/// A role trust policy that conditions on `sub` has to admit both, or only
/// one of the copilot and the AI agent step will be able to assume the role.
pub oidc_role_arn: Option,
+ /// Anthropic Workload Identity Federation settings, from an `anthropic_federated`
+ /// resource. The API proxy exchanges an OIDC token minted for the requesting
+ /// user for a short-lived access token, which then goes out as `access_token`.
+ pub anthropic_federation: Option,
pub platform: AIPlatform,
pub custom_headers: HashMap,
}
+
+/// The identifiers a Claude Console federation rule needs in a token exchange.
+/// None of them is a secret: what authorises the exchange is the OIDC token.
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub struct AnthropicFederation {
+ pub federation_rule_id: String,
+ pub organization_id: String,
+ pub service_account_id: String,
+ /// Required by Anthropic only when the rule is enabled in several workspaces.
+ pub workspace_id: Option,
+}
diff --git a/backend/windmill-ai/src/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs
index 7acd2db939..2a3417bc62 100644
--- a/backend/windmill-ai/src/providers/anthropic.rs
+++ b/backend/windmill-ai/src/providers/anthropic.rs
@@ -2,6 +2,7 @@ use super::{anthropic_model_rejects_sampling_params, REASONING_OFF_SENTINEL};
use crate::{
ai_google::parse_data_url,
ai_providers::{AIPlatform, AIProvider, DISABLE_ANTHROPIC_PROMPT_CACHING},
+ credentials::AnthropicFederation,
image_handler::prepare_messages_for_api,
proxy::{
add_user_to_body, common_outbound_headers, credential_header, ProxyBuildArgs, ProxyRequest,
@@ -837,6 +838,169 @@ impl QueryBuilder for AnthropicQueryBuilder {
}
}
+pub const ANTHROPIC_API_BASE_URL: &str = "https://api.anthropic.com/v1";
+
+/// Audience of the OIDC token Windmill presents in an Anthropic token exchange,
+/// which a federation rule can pin with its `audience` matcher.
+pub const ANTHROPIC_OIDC_AUDIENCE: &str = "https://api.anthropic.com";
+
+/// Lifetime of that OIDC token. A federation issuer refuses a token that lives
+/// longer than its maximum (one hour by default), and Anthropic caps the access
+/// token at twice the assertion's remaining life, so 30 minutes still lets a rule
+/// mint its default one-hour token.
+pub const ANTHROPIC_OIDC_TOKEN_LIFETIME_SECS: i64 = 1800;
+
+/// Exchange again this long before the access token expires, so a long streamed
+/// response that starts on a reused token cannot outlive it. Capped at half the
+/// token's lifetime: a rule may mint tokens for as little as a minute, and a
+/// margin longer than that would exchange again on every request.
+const FEDERATED_TOKEN_REFRESH_MARGIN: std::time::Duration = std::time::Duration::from_secs(300);
+
+/// A short-lived Anthropic access token minted by a federated token exchange.
+#[derive(Clone, Debug)]
+pub struct FederatedAccessToken {
+ pub access_token: String,
+ refresh_at: std::time::SystemTime,
+}
+
+impl FederatedAccessToken {
+ fn new(access_token: String, lifetime: std::time::Duration) -> Self {
+ // Anthropic mints tokens for at most a day; the clamp keeps an out-of-range
+ // `expires_in` from overflowing the clock arithmetic.
+ let lifetime = lifetime.min(std::time::Duration::from_secs(86400));
+ let margin = FEDERATED_TOKEN_REFRESH_MARGIN.min(lifetime / 2);
+ Self { access_token, refresh_at: std::time::SystemTime::now() + lifetime - margin }
+ }
+
+ pub fn is_fresh(&self) -> bool {
+ std::time::SystemTime::now() < self.refresh_at
+ }
+}
+
+#[derive(Serialize)]
+struct FederatedTokenRequest<'a> {
+ grant_type: &'static str,
+ assertion: &'a str,
+ federation_rule_id: &'a str,
+ organization_id: &'a str,
+ service_account_id: &'a str,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ workspace_id: Option<&'a str>,
+}
+
+#[derive(Deserialize)]
+struct FederatedTokenResponse {
+ access_token: String,
+ expires_in: u64,
+}
+
+/// Exchange an OIDC token for an Anthropic access token at `POST /v1/oauth/token`
+/// (RFC 7523 `jwt-bearer` grant). `client` must already be pinned to the
+/// validated address of `base_url`.
+pub async fn exchange_federated_token(
+ client: &reqwest::Client,
+ base_url: &str,
+ federation: &AnthropicFederation,
+ assertion: &str,
+) -> Result {
+ let exchanged = request_federated_token(client, base_url, federation, assertion).await;
+ windmill_common::feature_usage::log_feature_usage(
+ "anthropic_federation",
+ "token_exchange",
+ if exchanged.is_ok() { "ok" } else { "failed" },
+ );
+ exchanged
+}
+
+async fn request_federated_token(
+ client: &reqwest::Client,
+ base_url: &str,
+ federation: &AnthropicFederation,
+ assertion: &str,
+) -> Result {
+ let url = AIProvider::build_anthropic_api_url(base_url, "oauth/token");
+ let response = client
+ .post(url)
+ .header("anthropic-version", ANTHROPIC_VERSION_STANDARD)
+ .json(&FederatedTokenRequest {
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
+ assertion,
+ federation_rule_id: &federation.federation_rule_id,
+ organization_id: &federation.organization_id,
+ service_account_id: &federation.service_account_id,
+ workspace_id: federation.workspace_id.as_deref(),
+ })
+ .send()
+ .await
+ .map_err(|e| Error::internal_err(format!("Anthropic token exchange failed: {e}")))?;
+
+ let status = response.status();
+ if !status.is_success() {
+ let body = response.text().await.unwrap_or_default();
+ // Anthropic answers every denied assertion with the same opaque 401; the
+ // reason is only recorded in the Console's authentication history.
+ let hint = if status == reqwest::StatusCode::UNAUTHORIZED {
+ " The reason the exchange was denied is in the authentication history of the Claude Console's Workload identity page."
+ } else {
+ ""
+ };
+ return Err(Error::BadRequest(format!(
+ "Anthropic token exchange failed with status {status}: {body}.{hint}"
+ )));
+ }
+
+ let token = response
+ .json::()
+ .await
+ .map_err(|e| {
+ Error::internal_err(format!(
+ "Failed to parse the Anthropic token exchange response: {e}"
+ ))
+ })?;
+ Ok(FederatedAccessToken::new(
+ token.access_token,
+ std::time::Duration::from_secs(token.expires_in),
+ ))
+}
+
+/// The access token of a federated resource for a job, reusing `cached` while it
+/// is still fresh, so the agent loop exchanges once per job rather than per turn.
+/// Returns `None` for a resource that does not federate.
+pub async fn refresh_federated_access_token<'a>(
+ credentials: &crate::credentials::ProviderCredentials,
+ cached: &'a mut Option,
+ client: &AuthedClient,
+) -> Result, Error> {
+ let Some(federation) = credentials.anthropic_federation.as_ref() else {
+ return Ok(None);
+ };
+ if !cached.as_ref().is_some_and(FederatedAccessToken::is_fresh) {
+ // A worker holds no OIDC signing key, so it asks the API to mint the token for this job.
+ let assertion = client
+ .get_id_token_expiring_in(
+ ANTHROPIC_OIDC_AUDIENCE,
+ Some(ANTHROPIC_OIDC_TOKEN_LIFETIME_SECS),
+ )
+ .await
+ .map_err(|e| Error::ExecutionErr(format!("Failed to get an OIDC token: {e}")))?;
+ let http_client = crate::utils::pinned_ai_client_for(&credentials.base_url).await?;
+ *cached = Some(
+ exchange_federated_token(&http_client, &credentials.base_url, federation, &assertion)
+ .await?,
+ );
+ }
+ Ok(cached.as_ref().map(|token| token.access_token.as_str()))
+}
+
+/// Headers authenticating a request with a federated access token, which Anthropic
+/// takes as a bearer token in place of `x-api-key`.
+pub fn federated_auth_headers(access_token: &str) -> Vec<(&'static str, String)> {
+ vec![
+ ("Authorization", format!("Bearer {access_token}")),
+ ("anthropic-version", ANTHROPIC_VERSION_STANDARD.to_string()),
+ ]
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -859,6 +1023,7 @@ mod tests {
aws_secret_access_key: None,
aws_session_token: None,
oidc_role_arn: None,
+ anthropic_federation: None,
platform,
custom_headers: HashMap::new(),
}
diff --git a/backend/windmill-ai/src/providers/google_ai.rs b/backend/windmill-ai/src/providers/google_ai.rs
index bbf45e5e57..06a404e5a4 100644
--- a/backend/windmill-ai/src/providers/google_ai.rs
+++ b/backend/windmill-ai/src/providers/google_ai.rs
@@ -805,6 +805,7 @@ mod tests {
aws_secret_access_key: None,
aws_session_token: None,
oidc_role_arn: None,
+ anthropic_federation: None,
platform,
custom_headers: HashMap::new(),
}
diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs
index 82dceaa02f..ffddd33d3a 100644
--- a/backend/windmill-ai/src/providers/mod.rs
+++ b/backend/windmill-ai/src/providers/mod.rs
@@ -370,6 +370,7 @@ mod parity_tests {
aws_secret_access_key: None,
aws_session_token: None,
oidc_role_arn: None,
+ anthropic_federation: None,
platform: kase.platform.clone(),
custom_headers: HashMap::new(),
}
diff --git a/backend/windmill-ai/src/proxy.rs b/backend/windmill-ai/src/proxy.rs
index ca6ee1b59b..66285437e4 100644
--- a/backend/windmill-ai/src/proxy.rs
+++ b/backend/windmill-ai/src/proxy.rs
@@ -285,6 +285,7 @@ mod tests {
aws_secret_access_key: None,
aws_session_token: None,
oidc_role_arn: None,
+ anthropic_federation: None,
platform: AIPlatform::Standard,
custom_headers: HashMap::new(),
}
diff --git a/backend/windmill-ai/src/types.rs b/backend/windmill-ai/src/types.rs
index 59733e352c..3950576aed 100644
--- a/backend/windmill-ai/src/types.rs
+++ b/backend/windmill-ai/src/types.rs
@@ -19,7 +19,7 @@ pub struct McpToolSource {
use crate::{
ai_google::sanitize_schema_for_google,
ai_providers::{empty_string_as_none, AIProvider},
- credentials::ProviderCredentials,
+ credentials::{AnthropicFederation, ProviderCredentials},
};
use windmill_common::{db::DB, error::Error, flow_status::AgentAction, flows::FlowModule};
use windmill_parser::Typ;
@@ -270,6 +270,16 @@ pub struct ProviderResource {
/// credentials exchange this crate does not perform.
#[serde(default, deserialize_with = "empty_string_as_none")]
pub token_url: Option,
+ /// Set only by `anthropic_federated` resources, which authenticate through
+ /// Workload Identity Federation with an OIDC token minted for the job.
+ #[serde(default, deserialize_with = "empty_string_as_none")]
+ pub federation_rule_id: Option,
+ #[serde(default, deserialize_with = "empty_string_as_none")]
+ pub organization_id: Option,
+ #[serde(default, deserialize_with = "empty_string_as_none")]
+ pub service_account_id: Option,
+ #[serde(default, deserialize_with = "empty_string_as_none")]
+ pub workspace_id: Option,
/// The API token of a `cloudflare` resource, which names it `token` rather than `api_key`.
#[serde(default, deserialize_with = "empty_string_as_none")]
pub token: Option,
@@ -323,8 +333,13 @@ impl ProviderWithResource {
/// Callers must only pass resources that were already authorized for the
/// current job/workspace; this helper does not perform access checks.
pub async fn to_provider_credentials(&self, db: &DB) -> Result {
+ let anthropic_federation = self.anthropic_federation()?;
let base_url = if self.kind == AIProvider::AWSBedrock {
String::new()
+ } else if anthropic_federation.is_some() {
+ // The exchanged token is the job's identity, so it only ever goes to
+ // Anthropic: a resource cannot point it at another host.
+ crate::providers::anthropic::ANTHROPIC_API_BASE_URL.to_string()
} else {
self.get_base_url(db).await?
};
@@ -348,11 +363,40 @@ impl ProviderWithResource {
aws_secret_access_key: self.resource.aws_secret_access_key.clone(),
aws_session_token: self.resource.aws_session_token.clone(),
oidc_role_arn: self.resource.oidc_role_arn.clone(),
+ anthropic_federation,
platform: self.resource.platform.clone(),
custom_headers: self.resource.headers.clone(),
})
}
+ /// The federation settings of an `anthropic_federated` resource, which only
+ /// `federation_rule_id` identifies; the other required fields are checked here.
+ fn anthropic_federation(&self) -> Result, Error> {
+ let resource = &self.resource;
+ let Some(federation_rule_id) = resource.federation_rule_id.clone() else {
+ return Ok(None);
+ };
+ if self.kind != AIProvider::Anthropic {
+ return Err(Error::ExecutionErr(format!(
+ "An anthropic_federated resource can only be used with the Anthropic provider, not {:?}",
+ self.kind
+ )));
+ }
+ let required = |value: &Option, name: &str| {
+ value.clone().ok_or_else(|| {
+ Error::ExecutionErr(format!(
+ "The anthropic_federated resource needs {name} to be set"
+ ))
+ })
+ };
+ Ok(Some(AnthropicFederation {
+ federation_rule_id,
+ organization_id: required(&resource.organization_id, "organization_id")?,
+ service_account_id: required(&resource.service_account_id, "service_account_id")?,
+ workspace_id: resource.workspace_id.clone(),
+ }))
+ }
+
#[cfg(feature = "bedrock")]
pub fn get_region(&self) -> Option<&str> {
self.resource.region.as_deref()
@@ -983,6 +1027,47 @@ pub struct S3ObjectWithType {
mod tests {
use super::*;
+ #[test]
+ fn a_federated_resource_resolves_its_federation_and_names_a_missing_field() {
+ let provider = |kind: &str, resource: &str| -> ProviderWithResource {
+ serde_json::from_str(&format!(
+ r#"{{"kind":"{kind}","model":"m","resource":{resource}}}"#
+ ))
+ .unwrap()
+ };
+ let federated = r#"{"federation_rule_id":"fdrl_x","organization_id":"org","service_account_id":"svac_x","workspace_id":""}"#;
+
+ assert_eq!(
+ provider("anthropic", federated)
+ .anthropic_federation()
+ .unwrap(),
+ Some(AnthropicFederation {
+ federation_rule_id: "fdrl_x".to_string(),
+ organization_id: "org".to_string(),
+ service_account_id: "svac_x".to_string(),
+ workspace_id: None,
+ })
+ );
+ let incomplete = provider(
+ "anthropic",
+ r#"{"federation_rule_id":"fdrl_x","organization_id":"org"}"#,
+ );
+ assert!(incomplete
+ .anthropic_federation()
+ .unwrap_err()
+ .to_string()
+ .contains("service_account_id"));
+ assert!(provider("openai", federated)
+ .anthropic_federation()
+ .is_err());
+ assert_eq!(
+ provider("anthropic", r#"{"api_key":"k","organization_id":"org"}"#)
+ .anthropic_federation()
+ .unwrap(),
+ None
+ );
+ }
+
/// The schema is part of the prompt prefix the provider caches on, so its keys must
/// serialize in the same order on every request.
#[test]
diff --git a/backend/windmill-api-auth/src/composed.rs b/backend/windmill-api-auth/src/composed.rs
new file mode 100644
index 0000000000..e9d79240dd
--- /dev/null
+++ b/backend/windmill-api-auth/src/composed.rs
@@ -0,0 +1,171 @@
+//! Authorizing what a restricted role composes: the references a value-only walk
+//! (`check_flow_is_composition_only`) collects, checked against the caller's own permissions.
+
+use sqlx::{Postgres, Transaction};
+use windmill_common::{
+ error::{self, Error},
+ flows::FlowValue,
+ roles::AuthorLevel,
+ workspaces::{check_operator_can_build, BuilderKind},
+ DB,
+};
+
+use crate::{get_scope_tags, is_super_admin_authed, ApiAuthed};
+
+/// `validate_operator_composed_flow` on a transaction the caller already holds, so a write that
+/// has one open does not wait on a second connection. It must have been begun as the caller
+/// (`UserDB::begin`): its row-level security is the check.
+pub async fn authorize_composed_flow(
+ value: &FlowValue,
+ flow_tag: &Option,
+ authed: &ApiAuthed,
+ db: &DB,
+ tx: &mut Transaction<'_, Postgres>,
+ w_id: &str,
+) -> error::Result<()> {
+ let mut refs = windmill_common::flows::check_flow_is_composition_only(value)?;
+
+ // A builder links only an agent it can read, and binds only the tool inputs it leaves open.
+ for agent in &refs.agents {
+ let path = &agent.path;
+ let value = sqlx::query_scalar!(
+ "SELECT value AS \"value: sqlx::types::Json\"
+ FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'",
+ w_id,
+ path,
+ )
+ .fetch_optional(&mut **tx)
+ .await?
+ .ok_or_else(|| {
+ Error::PermissionDenied(format!(
+ "Agent {path} does not exist or is not readable by you"
+ ))
+ })?;
+ if agent.bound_inputs.is_empty() {
+ continue;
+ }
+ let tools: Vec = value
+ .and_then(|v| v.0.get("tools").cloned())
+ .map(serde_json::from_value)
+ .transpose()
+ .map_err(|e| Error::BadRequest(format!("Invalid tools in agent {path}: {e}")))?
+ .unwrap_or_default();
+ windmill_common::flows::check_bound_agent_inputs_are_open(
+ path,
+ &tools,
+ &agent.bound_inputs,
+ )?;
+ }
+
+ // A tag is how a step picks the worker group it runs on: unauthorized, a builder could route
+ // a job onto a privileged one.
+ refs.tags.extend(flow_tag.clone().filter(|t| !t.is_empty()));
+ if !refs.tags.is_empty() {
+ // Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags.
+ let is_super_admin = is_super_admin_authed(db, authed).await?;
+ for tag in &refs.tags {
+ windmill_common::jobs::check_tag_available_for_workspace_internal(
+ db,
+ w_id,
+ tag,
+ None,
+ std::future::ready(w_id.to_string()),
+ is_super_admin,
+ get_scope_tags(authed),
+ )
+ .await?;
+ }
+ }
+
+ if refs.runnables.is_empty() && refs.pinned_scripts.is_empty() {
+ return Ok(());
+ }
+ // A flow can step through the same script thirty times; this runs on every write, preview and
+ // dependency job.
+ refs.runnables.sort();
+ refs.runnables.dedup();
+ refs.pinned_scripts
+ .sort_by_key(|(path, hash)| (path.clone(), hash.0));
+ refs.pinned_scripts
+ .dedup_by_key(|(path, hash)| (path.clone(), hash.0));
+ // The worker resolves a step's path with the root DB handle and runs it as that runnable's
+ // `on_behalf_of`, so composing an unreadable path would run code the builder cannot see. RLS
+ // on this transaction is the check.
+ for (is_flow, path) in &refs.runnables {
+ let readable = if *is_flow {
+ sqlx::query_scalar!(
+ "SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2)",
+ w_id,
+ path,
+ )
+ } else {
+ sqlx::query_scalar!(
+ "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2)",
+ w_id,
+ path,
+ )
+ }
+ .fetch_one(&mut **tx)
+ .await?
+ .unwrap_or(false);
+ if !readable {
+ return Err(Error::PermissionDenied(format!(
+ "{} {path} does not exist or is not readable by you",
+ if *is_flow { "Flow" } else { "Script" }
+ )));
+ }
+ }
+ // A pinned step is dispatched by its hash alone, ignoring the path beside it, so a readable
+ // path paired with another script's hash would still run that other script.
+ for (path, hash) in &refs.pinned_scripts {
+ let exists = sqlx::query_scalar!(
+ "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2 AND hash = $3)",
+ w_id,
+ path,
+ hash.0,
+ )
+ .fetch_one(&mut **tx)
+ .await?
+ .unwrap_or(false);
+ if !exists {
+ return Err(Error::PermissionDenied(format!(
+ "Version {hash} is not a readable version of {path}"
+ )));
+ }
+ }
+ Ok(())
+}
+
+/// What a restricted role must pass to store an `ai_agent` resource, deployed or as a draft. Its
+/// tools run in the agent's own runs and in every flow step linking it, so it is held to the rules
+/// of a composed flow, under the same capability. `tx` is the write's own, begun as the caller.
+pub async fn validate_operator_agent(
+ value: &serde_json::Value,
+ authed: &ApiAuthed,
+ db: &DB,
+ tx: &mut Transaction<'_, Postgres>,
+ w_id: &str,
+) -> error::Result<()> {
+ if check_operator_can_build(db, w_id, authed, BuilderKind::Flows, "write AI agents").await?
+ == AuthorLevel::Full
+ {
+ return Ok(());
+ }
+ let tools = value
+ .get("tools")
+ .cloned()
+ .unwrap_or_else(|| serde_json::json!([]));
+ let flow: FlowValue = serde_json::from_value(serde_json::json!({"modules": [{
+ "id": "agent",
+ "value": {"type": "aiagent", "input_transforms": {}, "tools": tools}
+ }]}))
+ .map_err(|e| Error::BadRequest(format!("Invalid agent tools: {e}")))?;
+ authorize_composed_flow(&flow, &None, authed, db, tx, w_id)
+ .await
+ .map_err(|e| match e {
+ Error::PermissionDenied(msg) => {
+ Error::PermissionDenied(msg.replace("edit this flow", "edit this agent"))
+ }
+ e => e,
+ })
+}
diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs
index bfbd4470c8..82a40377a0 100644
--- a/backend/windmill-api-auth/src/lib.rs
+++ b/backend/windmill-api-auth/src/lib.rs
@@ -7,6 +7,7 @@
*/
pub mod auth;
+pub mod composed;
#[cfg(feature = "private")]
pub mod ee;
pub mod ee_oss;
diff --git a/backend/windmill-api-configs/src/lib.rs b/backend/windmill-api-configs/src/lib.rs
index cfa74bd2f0..8336888088 100644
--- a/backend/windmill-api-configs/src/lib.rs
+++ b/backend/windmill-api-configs/src/lib.rs
@@ -35,6 +35,7 @@ pub fn global_service() -> Router {
"/list_autoscaling_events/{worker_group}",
get(list_autoscaling_events),
)
+ .route("/test_autoscaling/{worker_group}", post(test_autoscaling))
.route(
"/native_kubernetes_autoscaling_healthcheck",
get(native_kubernetes_autoscaling_healthcheck),
@@ -419,6 +420,48 @@ async fn native_kubernetes_autoscaling_healthcheck() -> Result<(), error::Error>
))
}
+#[derive(Serialize)]
+struct TestAutoscalingResponse {
+ job_id: Option,
+}
+
+#[cfg(all(feature = "enterprise", feature = "private"))]
+async fn test_autoscaling(
+ authed: ApiAuthed,
+ Extension(db): Extension,
+ Path(worker_group): Path,
+ Json(test): Json,
+) -> error::JsonResult {
+ require_devops_role(&db, &authed).await?;
+ audit_log(
+ &db,
+ &authed,
+ "worker_config.test_autoscaling",
+ ActionKind::Execute,
+ "global",
+ Some(&worker_group),
+ None,
+ )
+ .await?;
+ let job_id = windmill_autoscaling::autoscaling_ee::test_autoscaling(
+ &db,
+ &worker_group,
+ test,
+ &authed.email,
+ )
+ .await?;
+ Ok(Json(TestAutoscalingResponse {
+ job_id: job_id.map(|id| id.to_string()),
+ }))
+}
+
+#[cfg(not(all(feature = "enterprise", feature = "private")))]
+async fn test_autoscaling() -> error::JsonResult {
+ Err(error::Error::BadRequest(
+ "Autoscaling available only in the enterprise version".to_string(),
+ ))
+}
+
async fn list_available_python_versions() -> error::JsonResult> {
#[cfg(not(all(feature = "python", feature = "run_inline")))]
return Err(error::Error::BadRequest(
diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs
index 3ef3de7a33..5d4268a93c 100644
--- a/backend/windmill-api-flows/src/flows.rs
+++ b/backend/windmill-api-flows/src/flows.rs
@@ -16,8 +16,8 @@ use axum::{
};
use windmill_api_auth::{
auth::{list_tokens_internal, TruncatedTokenWithEmail},
- build_scope_path_predicate, check_scopes, get_scope_tags, maybe_refresh_folders,
- require_owner_of_path, ApiAuthed,
+ build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path,
+ ApiAuthed,
};
use windmill_common::roles::AuthorLevel;
use windmill_common::workspaces::{
@@ -134,6 +134,17 @@ async fn list_search_flows(
Ok(Json(rows))
}
+/// `WHERE` condition matching one label, directly on the flow or inherited from its folder.
+///
+/// The template has two `?`, so both are filled in a single `binds` pass. Chaining `bind`
+/// would not work: `bind` replaces the first `?` of the string *as it is at that moment*,
+/// so the second call would find the `?` that the first call inserted as part of the bound
+/// value and substitute there, leaving the value partly outside its quotes.
+fn label_condition(label: &str) -> String {
+ "(o.labels @> ARRAY[?] OR folder_labels(o.workspace_id, o.path) @> ARRAY[?])"
+ .binds(&[&label.trim()])
+}
+
async fn list_flows(
authed: ApiAuthed,
Extension(user_db): Extension,
@@ -217,11 +228,7 @@ async fn list_flows(
}
if let Some(label) = &lq.label {
for l in label.split(',') {
- sqlb.and_where(
- "(o.labels @> ARRAY[?] OR folder_labels(o.workspace_id, o.path) @> ARRAY[?])"
- .bind(&l.trim())
- .bind(&l.trim()),
- );
+ sqlb.and_where(label_condition(l));
}
}
@@ -643,85 +650,11 @@ pub async fn validate_operator_composed_flow(
user_db: &UserDB,
w_id: &str,
) -> error::Result<()> {
- let mut refs = windmill_common::flows::check_flow_is_composition_only(value)?;
-
- // A tag is how a step picks the worker group it runs on: unauthorized, a builder could route
- // a job onto a privileged one.
- refs.tags.extend(flow_tag.clone().filter(|t| !t.is_empty()));
- if !refs.tags.is_empty() {
- // Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags.
- let is_super_admin = windmill_api_auth::is_super_admin_authed(db, authed).await?;
- for tag in &refs.tags {
- windmill_common::jobs::check_tag_available_for_workspace_internal(
- db,
- w_id,
- tag,
- None,
- std::future::ready(w_id.to_string()),
- is_super_admin,
- get_scope_tags(authed),
- )
- .await?;
- }
- }
-
- if refs.runnables.is_empty() && refs.pinned_scripts.is_empty() {
- return Ok(());
- }
- // A flow can step through the same script thirty times; this runs on every write, preview and
- // dependency job.
- refs.runnables.sort();
- refs.runnables.dedup();
- refs.pinned_scripts
- .sort_by_key(|(path, hash)| (path.clone(), hash.0));
- refs.pinned_scripts
- .dedup_by_key(|(path, hash)| (path.clone(), hash.0));
- // The worker resolves a step's path with the root DB handle and runs it as that runnable's
- // `on_behalf_of`, so composing an unreadable path would run code the builder cannot see. RLS
- // on this transaction is the check.
let mut tx = user_db.clone().begin(authed).await?;
- for (is_flow, path) in &refs.runnables {
- let readable = if *is_flow {
- sqlx::query_scalar!(
- "SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2)",
- w_id,
- path,
- )
- } else {
- sqlx::query_scalar!(
- "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2)",
- w_id,
- path,
- )
- }
- .fetch_one(&mut *tx)
- .await?
- .unwrap_or(false);
- if !readable {
- return Err(Error::PermissionDenied(format!(
- "{} {path} does not exist or is not readable by you",
- if *is_flow { "Flow" } else { "Script" }
- )));
- }
- }
- // A pinned step is dispatched by its hash alone, ignoring the path beside it, so a readable
- // path paired with another script's hash would still run that other script.
- for (path, hash) in &refs.pinned_scripts {
- let exists = sqlx::query_scalar!(
- "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2 AND hash = $3)",
- w_id,
- path,
- hash.0,
- )
- .fetch_one(&mut *tx)
- .await?
- .unwrap_or(false);
- if !exists {
- return Err(Error::PermissionDenied(format!(
- "Version {hash} is not a readable version of {path}"
- )));
- }
- }
+ windmill_api_auth::composed::authorize_composed_flow(
+ value, flow_tag, authed, db, &mut tx, w_id,
+ )
+ .await?;
tx.commit().await?;
Ok(())
}
@@ -2358,6 +2291,31 @@ mod tests {
const SECOND: Duration = Duration::from_secs(1);
+ #[test]
+ fn label_condition_fills_both_placeholders_in_one_pass() {
+ // Ordinary label: unchanged behaviour, the value appears quoted in both positions.
+ assert_eq!(
+ super::label_condition(" finance "),
+ "(o.labels @> ARRAY['finance'] OR folder_labels(o.workspace_id, o.path) @> ARRAY['finance'])"
+ );
+
+ // A `?` in the label must stay inside the quoted literal in both positions and must
+ // not leave a placeholder behind. With chained `bind`s this produced
+ // `ARRAY['a'a?b'b'] ... ARRAY[?]`, i.e. label text outside the quotes.
+ let cond = super::label_condition("a?b");
+ assert_eq!(
+ cond,
+ "(o.labels @> ARRAY['a?b'] OR folder_labels(o.workspace_id, o.path) @> ARRAY['a?b'])"
+ );
+ assert!(!cond.contains("ARRAY[?]"));
+
+ // Single quotes keep being escaped by doubling.
+ assert_eq!(
+ super::label_condition("a'?b"),
+ "(o.labels @> ARRAY['a''?b'] OR folder_labels(o.workspace_id, o.path) @> ARRAY['a''?b'])"
+ );
+ }
+
#[test]
fn flowmodule_serde() {
let fv = FlowValue {
diff --git a/backend/windmill-api-groups/src/groups.rs b/backend/windmill-api-groups/src/groups.rs
index 860e486a75..0d3b5e360d 100644
--- a/backend/windmill-api-groups/src/groups.rs
+++ b/backend/windmill-api-groups/src/groups.rs
@@ -218,12 +218,13 @@ pub async fn require_is_owner(
}
async fn _check_nb_of_groups(db: &DB) -> Result<()> {
- let nb_groups = sqlx::query_scalar!("SELECT COUNT(*) FROM group_ WHERE name != 'all' AND name != 'error_handler' AND name != 'slack' AND name != 'wm_deployers'",)
+ // Groups that back workspace settings do not count toward the limit.
+ let nb_groups = sqlx::query_scalar!("SELECT COUNT(*) FROM group_ WHERE name != 'all' AND name != 'error_handler' AND name != 'slack' AND name != 'wm_deployers' AND name != 'variable_expiration_handler'",)
.fetch_one(db)
.await?;
if nb_groups.unwrap_or(0) >= 3 {
return Err(Error::BadRequest(
- "You have reached the maximum number of groups (3 outside of native groups 'all', 'slack', 'error_handler' and 'wm_deployers') without an enterprise license"
+ "You have reached the maximum number of groups (3 outside of native groups 'all', 'slack', 'error_handler', 'wm_deployers' and 'variable_expiration_handler') without an enterprise license"
.to_string(),
));
}
diff --git a/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs b/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs
index b9c4e60800..6bed27eca5 100644
--- a/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs
+++ b/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs
@@ -325,5 +325,155 @@ async fn test_operator_builder_flows_boundary(db: Pool) -> anyhow::Res
resp.text().await?
);
+ check_builder_agents(&db, &api, &c).await
+}
+
+/// An agent is a resource an operator may otherwise write, and its stored tools run as they are,
+/// so only the flow right opens it, and every write of one, drafts included, is checked like a
+/// composed flow. Part of the boundary test: the right is read through a process-global cache
+/// keyed by workspace id, which a second test in this binary would race.
+async fn check_builder_agents(
+ db: &Pool,
+ api: &str,
+ c: &reqwest::Client,
+) -> anyhow::Result<()> {
+ let tool = |mut value: serde_json::Value| {
+ value["tool_type"] = json!("flowmodule");
+ value["input_transforms"] = json!({});
+ json!({"tools": [{"id": "t", "summary": "t", "value": value}]})
+ };
+ let composed = tool(json!({"type": "script", "path": "u/operator/some_script"}));
+ let inline = tool(json!({"type": "rawscript", "content": "echo x", "language": "bash"}));
+ let agent = |path: &str, value: &serde_json::Value| json!({"path": path, "resource_type": "ai_agent", "value": value});
+
+ set_builder(db, false).await?;
+ let resp = c
+ .post(format!("{api}/resources/create"))
+ .json(&agent("u/operator/a1", &composed))
+ .send()
+ .await?;
+ assert_eq!(
+ resp.status(),
+ 403,
+ "an operator without the builder right must not write an agent"
+ );
+
+ set_builder(db, true).await?;
+ let resp = c
+ .post(format!("{api}/resources/create"))
+ .json(&agent("u/operator/a1", &inline))
+ .send()
+ .await?;
+ assert_eq!(
+ resp.status(),
+ 403,
+ "a builder must not save an agent with an inline tool"
+ );
+ let resp = c
+ .post(format!("{api}/resources/create"))
+ .json(&agent("u/operator/a1", &composed))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 201, "{}", resp.text().await?);
+ let resp = c
+ .post(format!("{api}/resources/update_value/u/operator/a1"))
+ .json(&json!({"value": inline}))
+ .send()
+ .await?;
+ assert_eq!(
+ resp.status(),
+ 403,
+ "a builder must not swap an agent's tools for inline code"
+ );
+ // A draft of a deployed agent may leave its type to the deployed row.
+ let resp = c
+ .post(format!("{api}/drafts/update/resource/u/operator/a1"))
+ .json(&json!({"value": {"args": inline, "description": ""}, "force": true}))
+ .send()
+ .await?;
+ assert_eq!(
+ resp.status(),
+ 403,
+ "a builder must not draft an agent with an inline tool"
+ );
+ // Deploying a draft over an agent keeps the row's type, whatever type the draft names.
+ let resp = c
+ .post(format!("{api}/drafts/update/resource/u/operator/a1"))
+ .json(&json!({"value": {"args": inline, "description": "", "resource_type": "object"},
+ "force": true}))
+ .send()
+ .await?;
+ assert_eq!(
+ resp.status(),
+ 403,
+ "a builder must not draft inline tools over an agent under another type"
+ );
+
+ let linking = |path: &str, agent: &str| {
+ json!({"path": path, "summary": "", "description": "", "schema": {},
+ "value": {"modules": [{"id": "a", "value": {"type": "aiagent",
+ "input_transforms": {}, "tools": [], "agent": format!("$res:{agent}")}}]}})
+ };
+ let resp = c
+ .post(format!("{api}/flows/create"))
+ .json(&linking("u/operator/agent_f1", "u/operator/a1"))
+ .send()
+ .await?;
+ assert!(
+ resp.status().is_success(),
+ "a builder must be able to link a readable agent: {}",
+ resp.text().await?
+ );
+ let resp = c
+ .post(format!("{api}/flows/create"))
+ .json(&linking("u/operator/agent_f2", "u/alice/agent"))
+ .send()
+ .await?;
+ assert_eq!(
+ resp.status(),
+ 403,
+ "a builder must not link an agent it cannot read"
+ );
+
+ // A developer's agent may run inline code, and linking it runs it as authored. A step may bind
+ // the inputs the agent leaves to the model, never one its author set.
+ let mut dev_agent = inline.clone();
+ dev_agent["tools"][0]["value"]["input_transforms"] = json!({
+ "set": {"type": "static", "value": "fixed"}, "open": {"type": "ai"}});
+ sqlx::query(
+ "INSERT INTO resource (workspace_id, path, value, resource_type, created_by)
+ VALUES ($1, 'u/operator/dev_agent', $2, 'ai_agent', 'alice')",
+ )
+ .bind(WS)
+ .bind(&dev_agent)
+ .execute(db)
+ .await?;
+ let binding = |path: &str, input: &str| {
+ let mut flow = linking(path, "u/operator/dev_agent");
+ flow["value"]["modules"][0]["value"]["tool_inputs"] =
+ json!({"t": {input: {"type": "static", "value": "bound"}}});
+ flow
+ };
+ let resp = c
+ .post(format!("{api}/flows/create"))
+ .json(&binding("u/operator/agent_f4", "open"))
+ .send()
+ .await?;
+ assert!(
+ resp.status().is_success(),
+ "a builder must be able to bind an input the agent leaves open: {}",
+ resp.text().await?
+ );
+ let resp = c
+ .post(format!("{api}/flows/create"))
+ .json(&binding("u/operator/agent_f3", "set"))
+ .send()
+ .await?;
+ assert_eq!(
+ resp.status(),
+ 403,
+ "a builder must not override an input the agent's author set"
+ );
+
Ok(())
}
diff --git a/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs b/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs
index cfbeae7a69..dbc5f36e01 100644
--- a/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs
+++ b/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs
@@ -406,3 +406,62 @@ async fn test_operators_draft_resources_and_variables(db: Pool) -> any
Ok(())
}
+
+/// The two endpoints that make the API server run `pg_dump`/`psql` against a database a request
+/// names are closed to a role that cannot run code.
+///
+/// Both polarities on each route: a 403 alone would equally be a route that does not exist or one
+/// refusing the source for some other reason, and the admin's 400 shows the gate is about the role
+/// rather than the request. The source is deliberately unresolvable, so the gate is the only thing
+/// that can answer 403 and no database is ever reached.
+#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))]
+#[serial]
+async fn test_dumping_a_database_needs_the_code_right(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ windmill_common::roles::invalidate_all_roles_cache();
+ let server = ApiServer::start(db.clone()).await?;
+ let port = server.addr.port();
+ let api = format!("http://localhost:{port}/api/w/{WS}");
+
+ let cases = [
+ (
+ "workspaces/export_pg_schema",
+ json!({"source": "u/operator/not-a-source"}),
+ "export a database schema",
+ ),
+ (
+ "workspaces/import_pg_database",
+ json!({"source": "u/operator/not-a-source", "target": "u/operator/not-a-source",
+ "fork_behavior": "schema_only"}),
+ "import a database",
+ ),
+ ];
+
+ for (route, body, action) in cases {
+ let resp = operator_client()
+ .post(format!("{api}/{route}"))
+ .json(&body)
+ .send()
+ .await?;
+ let status = resp.status();
+ let text = resp.text().await?;
+ assert_eq!(status, 403, "{route}: {text}");
+ assert!(
+ text.contains(&format!("Your role cannot {action} in this workspace")),
+ "{route} refused for another reason: {text}"
+ );
+
+ let resp = reqwest::Client::new()
+ .post(format!("{api}/{route}"))
+ .header("Authorization", "Bearer SECRET_TOKEN")
+ .json(&body)
+ .send()
+ .await?;
+ let status = resp.status();
+ let text = resp.text().await?;
+ assert_eq!(status, 400, "{route}: {text}");
+ assert!(text.contains("Invalid source format"), "{route}: {text}");
+ }
+
+ Ok(())
+}
diff --git a/backend/windmill-api-integration-tests/tests/variables.rs b/backend/windmill-api-integration-tests/tests/variables.rs
index 7332383936..adb9f29c36 100644
--- a/backend/windmill-api-integration-tests/tests/variables.rs
+++ b/backend/windmill-api-integration-tests/tests/variables.rs
@@ -339,6 +339,74 @@ async fn test_variables_are_cached(db: Pool) -> anyhow::Result<()> {
Ok(())
}
+/// A draft-only row is built field by field, so a forgotten field is dropped silently.
+#[sqlx::test(migrations = "../migrations", fixtures("base"))]
+async fn test_draft_only_row_carries_value_valid_until(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let port = server.addr.port();
+
+ authed(client().post(format!(
+ "http://localhost:{port}/api/w/test-workspace/drafts/update/variable/u/test-user/draft_var"
+ )))
+ .json(&json!({
+ "value": {
+ "path": "u/test-user/draft_var",
+ "variable": { "value": "x", "is_secret": true, "description": "" },
+ "wsSpecific": false,
+ "value_valid_until": "2027-03-15T08:00:00Z"
+ }
+ }))
+ .send()
+ .await?;
+
+ let resp = authed(client().get(format!(
+ "http://localhost:{port}/api/w/test-workspace/variables/list?include_draft_only=true"
+ )))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 200);
+ let rows = resp.json::>().await?;
+
+ let row = rows
+ .iter()
+ .find(|v| v["path"] == "u/test-user/draft_var")
+ .expect("draft-only row is listed");
+ assert_eq!(row["draft_only"], true);
+ assert_eq!(row["value_valid_until"], "2027-03-15T08:00:00Z");
+
+ Ok(())
+}
+
+/// An expiry on an OAuth-linked variable is refused with a readable error, not a raw
+/// constraint violation.
+#[sqlx::test(migrations = "../migrations", fixtures("base"))]
+async fn test_oauth_variable_refuses_value_expiry(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+ let server = ApiServer::start(db.clone()).await?;
+ let port = server.addr.port();
+
+ sqlx::query(
+ "WITH a AS (INSERT INTO account (workspace_id, expires_at, refresh_token, client)
+ VALUES ('test-workspace', now() + interval '1 hour', 'r', 'github') RETURNING id)
+ INSERT INTO variable (workspace_id, path, value, is_secret, description, account, is_oauth)
+ SELECT 'test-workspace', 'u/test-user/oauth_var', 't', false, '', id, true FROM a",
+ )
+ .execute(&db)
+ .await?;
+
+ let resp = authed(client().post(format!(
+ "http://localhost:{port}/api/w/test-workspace/variables/update/u/test-user/oauth_var"
+ )))
+ .json(&json!({ "value_valid_until": "2027-03-15T08:00:00Z" }))
+ .send()
+ .await?;
+ assert_eq!(resp.status(), 400);
+ assert!(resp.text().await?.contains("OAuth"));
+
+ Ok(())
+}
+
/// Reading a variable linked to an OAuth account refreshes through it and returns the fresh token,
/// so the variable endpoints must not let a caller choose the link.
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
diff --git a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs
index bef3b5dd78..830a29e8c2 100644
--- a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs
+++ b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs
@@ -2770,3 +2770,83 @@ async fn test_compare_secret_variables_by_value(db: Pool) -> anyhow::R
Ok(())
}
+
+/// `compare_two_variables` hand-lists its columns, so a forgotten one hides that difference
+/// from the fork diff, and it can never be promoted.
+#[sqlx::test(migrations = "../migrations", fixtures("base"))]
+async fn test_compare_variables_detects_value_valid_until(db: Pool) -> anyhow::Result<()> {
+ initialize_tracing().await;
+
+ let server = ApiServer::start(db.clone()).await?;
+ let port = server.addr.port();
+ let base_url = format!("http://localhost:{port}/api");
+ let admin = windmill_api_client::create_client(
+ &format!("http://localhost:{port}"),
+ "SECRET_TOKEN".to_string(),
+ );
+
+ sqlx::query!(
+ "INSERT INTO variable (workspace_id, path, value, is_secret, description, value_valid_until)
+ VALUES ('test-workspace', 'u/admin/expiring', 'v', false, 'same', '2027-01-01T00:00:00Z')"
+ )
+ .execute(&db)
+ .await?;
+
+ let resp = admin
+ .client()
+ .post(&format!(
+ "{base_url}/w/test-workspace/workspaces/create_fork"
+ ))
+ .json(&json!({ "id": "wm-fork-expiry", "name": "Expiry Fork" }))
+ .send()
+ .await?;
+ assert!(
+ resp.status().is_success(),
+ "fork creation failed: {}",
+ resp.status()
+ );
+
+ // Before the UPDATE below overwrites it: the fork must inherit the date from the source.
+ let inherited: Option = sqlx::query_scalar(
+ "SELECT to_char(value_valid_until, 'YYYY-MM-DD') FROM variable
+ WHERE workspace_id = 'wm-fork-expiry' AND path = 'u/admin/expiring'",
+ )
+ .fetch_one(&db)
+ .await?;
+ assert_eq!(inherited.as_deref(), Some("2027-01-01"));
+
+ // Only the expiry differs.
+ sqlx::query!(
+ "UPDATE variable SET value_valid_until = '2029-12-31T00:00:00Z'
+ WHERE workspace_id = 'wm-fork-expiry' AND path = 'u/admin/expiring'"
+ )
+ .execute(&db)
+ .await?;
+
+ // Forces the endpoint to re-evaluate the pair rather than trust a cached verdict.
+ sqlx::query!(
+ "INSERT INTO workspace_diff
+ (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)
+ VALUES ('test-workspace', 'wm-fork-expiry', 'u/admin/expiring', 'variable', 0, 1, NULL)"
+ )
+ .execute(&db)
+ .await?;
+
+ let comparison = admin
+ .client()
+ .get(&format!(
+ "{base_url}/w/test-workspace/workspaces/compare/wm-fork-expiry"
+ ))
+ .send()
+ .await?
+ .json::()
+ .await?;
+
+ assert_eq!(
+ comparison["summary"]["variables_changed"].as_u64(),
+ Some(1),
+ "an expiry-only change must show as deployable: {comparison}"
+ );
+
+ Ok(())
+}
diff --git a/backend/windmill-api-jobs/src/query.rs b/backend/windmill-api-jobs/src/query.rs
index 96d8ca3698..ebfc0c3725 100644
--- a/backend/windmill-api-jobs/src/query.rs
+++ b/backend/windmill-api-jobs/src/query.rs
@@ -268,9 +268,10 @@ pub fn filter_list_queue_query(
if let Some(bf) = &lq.broad_filter {
let pat = format!("%{}%", escape_ilike_pattern(bf));
+ // `binds` fills every `?` in one pass; chained `bind`s would re-scan a `?` in the bound value
sqlb.and_where(
"(runnable_path ILIKE ? OR v2_job.tag ILIKE ? OR trigger ILIKE ? OR trigger_kind::text ILIKE ?)"
- .bind(&pat).bind(&pat).bind(&pat).bind(&pat)
+ .binds(&[&pat]),
);
}
@@ -490,7 +491,7 @@ pub fn filter_list_completed_query(
}
if let Some(dt) = &lq.created_or_started_after {
let ts = dt.to_rfc3339();
- sqlb.and_where("(created_at >= ? OR started_at >= ?)".bind(&ts).bind(&ts));
+ sqlb.and_where("(created_at >= ? OR started_at >= ?)".binds(&[&ts]));
}
if let Some(dt) = &lq.created_before {
@@ -591,10 +592,11 @@ pub fn filter_list_completed_query(
if let Some(bf) = &lq.broad_filter {
let pat = format!("%{}%", escape_ilike_pattern(bf));
+ // `binds` fills every `?` in one pass; chained `bind`s would re-scan a `?` in the bound value
sqlb.and_where(
"(runnable_path ILIKE ? OR v2_job.tag ILIKE ? OR trigger ILIKE ? OR trigger_kind::text ILIKE ? \
OR EXISTS (SELECT 1 FROM unnest(v2_job.labels) lbl WHERE lbl ILIKE ?))"
- .bind(&pat).bind(&pat).bind(&pat).bind(&pat).bind(&pat)
+ .binds(&[&pat]),
);
}
@@ -945,6 +947,20 @@ mod tests {
assert!(sql.contains("'schedule'"));
}
+ #[test]
+ fn test_queue_broad_filter_with_question_mark() {
+ let lq = ListQueueQuery { broad_filter: Some("a?b".to_string()), ..empty_queue_query() };
+ let sqlb = filter_list_queue_query(
+ SqlBuilder::select_from("v2_job_queue").clone(),
+ &lq,
+ "ws",
+ false,
+ );
+ let sql = build_sql(sqlb);
+ assert_eq!(sql.matches("ILIKE '%a?b%'").count(), 4);
+ assert!(!sql.contains("ILIKE ?"));
+ }
+
// --- Completed query tests ---
#[test]
@@ -1124,4 +1140,19 @@ mod tests {
let sql = build_sql(sqlb);
assert!(sql.contains("result @>"));
}
+
+ #[test]
+ fn test_completed_broad_filter_with_question_mark() {
+ let lq =
+ ListCompletedQuery { broad_filter: Some("a?b".to_string()), ..empty_completed_query() };
+ let sqlb = filter_list_completed_query(
+ SqlBuilder::select_from("v2_job_completed").clone(),
+ &lq,
+ "ws",
+ false,
+ );
+ let sql = build_sql(sqlb);
+ assert_eq!(sql.matches("ILIKE '%a?b%'").count(), 5);
+ assert!(!sql.contains("ILIKE ?"));
+ }
}
diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs
index 787742d18d..e2fa7da8fe 100644
--- a/backend/windmill-api-schedule/src/lib.rs
+++ b/backend/windmill-api-schedule/src/lib.rs
@@ -894,17 +894,18 @@ async fn list_schedule(
}
if let Some(broad_filter) = &lsq.broad_filter {
let pat = format!("%{}%", escape_ilike_pattern(broad_filter));
+ // `binds` fills every `?` in one pass; chained `bind`s would re-scan a `?` in the bound value
sqlb.and_where(
"(path ILIKE ? OR script_path ILIKE ? OR description ILIKE ? OR summary ILIKE ? OR schedule ILIKE ?)"
- .bind(&pat).bind(&pat).bind(&pat).bind(&pat).bind(&pat)
+ .binds(&[&pat]),
);
}
if let Some(label) = &lsq.label {
for l in label.split(',') {
+ // `binds` fills every `?` in one pass; chained `bind`s would re-scan a `?` in the bound value
sqlb.and_where(
"(labels @> ARRAY[?] OR folder_labels(workspace_id, path) @> ARRAY[?])"
- .bind(&l.trim())
- .bind(&l.trim()),
+ .binds(&[&l.trim()]),
);
}
}
diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs
index e6dcfe8155..4e8516b8ed 100644
--- a/backend/windmill-api-scripts/src/scripts.rs
+++ b/backend/windmill-api-scripts/src/scripts.rs
@@ -309,10 +309,10 @@ async fn list_scripts(
}
if let Some(label) = &lq.label {
for l in label.split(',') {
+ // `binds` fills every `?` in one pass; chained `bind`s would re-scan a `?` in the bound value
sqlb.and_where(
"(o.labels @> ARRAY[?] OR folder_labels(o.workspace_id, o.path) @> ARRAY[?])"
- .bind(&l.trim())
- .bind(&l.trim()),
+ .binds(&[&l.trim()]),
);
}
}
diff --git a/backend/windmill-api-workspaces/src/datatable_clone.rs b/backend/windmill-api-workspaces/src/datatable_clone.rs
index 1e6c6b55c2..067a248aa7 100644
--- a/backend/windmill-api-workspaces/src/datatable_clone.rs
+++ b/backend/windmill-api-workspaces/src/datatable_clone.rs
@@ -104,7 +104,7 @@ pub(crate) async fn connection_snapshot(
continue;
}
let row: Option<(String, bool)> = sqlx::query_as(
- "SELECT value, is_secret FROM variable WHERE workspace_id = $1 AND path = $2",
+ "SELECT value::text, is_secret FROM variable WHERE workspace_id = $1 AND path = $2",
)
.bind(w_id)
.bind(var)
diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs
index a779c240cc..26d147301d 100644
--- a/backend/windmill-api-workspaces/src/workspaces.rs
+++ b/backend/windmill-api-workspaces/src/workspaces.rs
@@ -127,6 +127,10 @@ pub fn workspaced_service() -> Router {
.route("/billable_seats", get(get_billable_seats))
.route("/edit_error_handler", post(edit_error_handler))
.route("/edit_success_handler", post(edit_success_handler))
+ .route(
+ "/edit_variable_expiration_handler",
+ post(edit_variable_expiration_handler),
+ )
.route(
"/edit_large_file_storage_config",
post(edit_large_file_storage_config),
@@ -342,6 +346,8 @@ pub struct WorkspaceSettings {
#[serde(skip_serializing_if = "Option::is_none")]
pub success_handler: Option,
#[serde(skip_serializing_if = "Option::is_none")]
+ pub variable_expiration_handler: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
pub public_app_execution_limit_per_minute: Option,
pub error_handler_fallback_to_instance_alerts: bool,
/// Whether this workspace admits guest sessions (`ExecutionMode::Guest`). An app's
@@ -1203,6 +1209,7 @@ async fn get_settings(
auto_invite,
error_handler,
success_handler,
+ variable_expiration_handler,
public_app_execution_limit_per_minute,
error_handler_fallback_to_instance_alerts,
guest_access_enabled,
@@ -3359,6 +3366,10 @@ pub(crate) async fn resolve_pg_source_checked_with_kind(
let pg: PgDatabase = serde_json::from_value(db_resource)
.map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?;
+ // Checked here rather than at each caller: every source and target of a dump, restore or copy
+ // comes through this function, and `validate_dbname` only ever reaches a target. The check is
+ // deliberately the narrow one — see `validate_dbname_not_option_like`.
+ windmill_common::validate_dbname_not_option_like(&pg.dbname)?;
Ok((pg, managed_kind))
}
@@ -3448,21 +3459,20 @@ pub(crate) struct PgDumpOptions<'a> {
pub(crate) no_acl: bool,
}
-/// Run pg_dump against a PgDatabase, writing output to a temp file on disk.
-/// Returns a DumpFile handle; the file is deleted when the handle is dropped.
-pub(crate) async fn pg_dump_database(
+/// A pg_dump invocation against `pg_db` writing to `dump_path`, carrying the connection settings
+/// the CLI reads from the environment. The returned file, when there is one, holds the root
+/// certificate the command points at and must outlive it.
+fn pg_dump_command(
pg_db: &PgDatabase,
opts: PgDumpOptions<'_>,
-) -> Result {
- let dump_file = DumpFile::new()?;
-
+ dump_path: &std::path::Path,
+) -> Result<(tokio::process::Command, Option)> {
let host = &pg_db.host;
let port = pg_db.port.unwrap_or(5432).to_string();
let user = pg_db.login_name();
- let dbname = &pg_db.dbname;
let mut cmd = tokio::process::Command::new("pg_dump");
- cmd.arg("--format=plain").arg("--file").arg(&dump_file.path);
+ cmd.arg("--format=plain").arg("--file").arg(dump_path);
if opts.schema_only {
cmd.arg("--schema-only");
}
@@ -3480,13 +3490,31 @@ pub(crate) async fn pg_dump_database(
.arg("--port")
.arg(&port)
.arg("--username")
- .arg(user)
- .arg(dbname);
+ .arg(user);
+
+ // The name comes from a resource, so it never goes on the command line: as the positional
+ // argument a name starting with `-` is read as an option of pg_dump (`--file`,
+ // `--restrict-key`), and as `--dbname` a name holding `=` is expanded as a libpq connection
+ // string overriding the host, port and user set above. libpq takes the environment variable
+ // as a plain database name, neither parsed as an option nor expanded.
+ cmd.env("PGDATABASE", &pg_db.dbname);
if let Some(ref password) = pg_db.password {
cmd.env("PGPASSWORD", password);
}
- let _root_cert = apply_pg_tls_env(&mut cmd, pg_db)?;
+ let root_cert = apply_pg_tls_env(&mut cmd, pg_db)?;
+ Ok((cmd, root_cert))
+}
+
+/// Run pg_dump against a PgDatabase, writing output to a temp file on disk.
+/// Returns a DumpFile handle; the file is deleted when the handle is dropped.
+pub(crate) async fn pg_dump_database(
+ pg_db: &PgDatabase,
+ opts: PgDumpOptions<'_>,
+) -> Result {
+ let dump_file = DumpFile::new()?;
+
+ let (mut cmd, _root_cert) = pg_dump_command(pg_db, opts, &dump_file.path)?;
let output = cmd
.output()
@@ -3612,12 +3640,14 @@ fn psql_command(pg_db: &PgDatabase) -> Result<(tokio::process::Command, Option PgDatabase {
+ PgDatabase {
+ host: "127.0.0.1".to_string(),
+ user: None,
+ password: None,
+ port: Some(1),
+ sslmode: None,
+ dbname: dbname.to_string(),
+ root_certificate_pem: None,
+ accept_invalid_certs: None,
+ use_iam_auth: None,
+ region: None,
+ options: None,
+ }
+ }
+
+ fn pgdatabase_of(cmd: &std::process::Command) -> Option {
+ cmd.get_envs()
+ .find(|(k, _)| *k == "PGDATABASE")
+ .and_then(|(_, v)| v.map(|v| v.to_os_string()))
+ }
+
+ #[test]
+ fn pg_dump_takes_the_dbname_from_the_environment() {
+ let dump_file = DumpFile::new().unwrap();
+ let name = "--file=/tmp/written_by_the_caller";
+ let (cmd, _root_cert) =
+ pg_dump_command(&pg_db(name), PgDumpOptions::default(), &dump_file.path).unwrap();
+ let cmd = cmd.as_std();
+ assert!(
+ cmd.get_args().all(|arg| arg != name),
+ "the dbname is still in pg_dump's argument vector"
+ );
+ assert_eq!(pgdatabase_of(cmd).as_deref(), Some(name.as_ref()));
+ // The only `--file` pg_dump is given is still Windmill's own.
+ assert_eq!(
+ cmd.get_args()
+ .filter(|arg| arg.to_string_lossy().starts_with("--file"))
+ .count(),
+ 1
+ );
+ }
+
+ #[test]
+ fn psql_takes_the_dbname_from_the_environment() {
+ let name = "host=elsewhere";
+ let (cmd, _root_cert) = psql_command(&pg_db(name)).unwrap();
+ let cmd = cmd.as_std();
+ assert!(
+ cmd.get_args().all(|arg| arg != name),
+ "the dbname is still in psql's argument vector"
+ );
+ assert_eq!(pgdatabase_of(cmd).as_deref(), Some(name.as_ref()));
+ }
+
+ /// The same, observed on the real binary: an option-shaped name must reach the connection
+ /// attempt rather than pg_dump's option parser. Skipped where pg_dump is not installed —
+ /// which is why the assertion also requires that pg_dump ran at all (our `pg_dump failed:`
+ /// prefix) instead of only that the option message is absent.
+ #[tokio::test]
+ async fn pg_dump_does_not_read_an_option_shaped_dbname_as_an_option() {
+ if std::process::Command::new("pg_dump")
+ .arg("--version")
+ .output()
+ .is_err()
+ {
+ eprintln!("skipped: pg_dump is not installed");
+ return;
+ }
+ let Err(err) = pg_dump_database(&pg_db("--no-such-option"), PgDumpOptions::default()).await
+ else {
+ panic!("pg_dump reached a server on 127.0.0.1:1");
+ };
+ let err = err.to_string();
+ assert!(
+ err.contains("pg_dump failed:"),
+ "pg_dump did not run: {err}"
+ );
+ assert!(!err.contains("unrecognized option"), "{err}");
+ }
+}
+
/// GUC names the server backing `pg_db` knows about.
///
/// Asked through psql rather than a tokio-postgres connection so the lookup reaches
@@ -4054,6 +4175,25 @@ fn ensure_copied_without_roles(governing: &GoverningDatatable) -> Result<()> {
Ok(())
}
+/// Gate for the endpoints that make the API server run `pg_dump`/`psql` against a database a
+/// request names.
+///
+/// Those two processes are children of the API server, outside any job sandbox and holding its
+/// environment, and the dump they exchange is a `psql` script assembled from strings the source
+/// server returns — so reaching them is close enough to running code on the server that the role
+/// which may not run code at all must not reach them either. `code` is the capability the data
+/// table migration endpoints next door already ask for (`datatable_migrations::ensure_not_operator`),
+/// and not admin: the database manager offers an export and an import of a user's own Postgres
+/// resource to every author, who can run the same `pg_dump` in a job anyway.
+async fn ensure_can_dump_databases(
+ db: &DB,
+ w_id: &str,
+ authed: &ApiAuthed,
+ action: &str,
+) -> Result<()> {
+ windmill_common::workspaces::check_role_can_code(db, w_id, authed, action).await
+}
+
/// Import (pg_dump/pg_import) from source to target
async fn import_pg_database(
authed: ApiAuthed,
@@ -4062,6 +4202,8 @@ async fn import_pg_database(
Path(w_id): Path,
Json(req): Json,
) -> Result {
+ ensure_can_dump_databases(&db, &w_id, &authed, "import a database").await?;
+
if req.fork_behavior == DataTableForkBehavior::KeepOriginal {
return Ok("No action needed for KeepOriginal behavior".to_string());
}
@@ -4178,6 +4320,8 @@ async fn export_pg_schema(
Path(w_id): Path,
Json(req): Json,
) -> Result {
+ ensure_can_dump_databases(&db, &w_id, &authed, "export a database schema").await?;
+
let pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?;
let dump_file = pg_dump_database(
&pg,
@@ -6253,6 +6397,157 @@ async fn edit_error_handler(
Ok(format!("Edit error_handler for workspace {}", &w_id))
}
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct EditVariableExpirationHandler {
+ pub path: Option,
+ pub extra_args: Option,
+ #[serde(default)]
+ pub muted_on_user_path: bool,
+ pub notify_before_secs: Option,
+ pub remind_every_secs: Option,
+}
+
+async fn edit_variable_expiration_handler(
+ authed: ApiAuthed,
+ Extension(db): Extension,
+ Path(w_id): Path,
+ Json(es): Json,
+) -> Result {
+ require_admin(authed.is_admin, &authed.username)?;
+
+ let mut tx = db.begin().await?;
+
+ // Created with no access: an admin grants it write access to each variable it rotates.
+ sqlx::query_as!(
+ Group,
+ "INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING",
+ w_id,
+ "variable_expiration_handler",
+ "The group the variable expiration handler acts on behalf of",
+ serde_json::json!({username_to_permissioned_as(&authed.username): true})
+ )
+ .execute(&mut *tx)
+ .await?;
+
+ if let Some(path) = &es.path {
+ if es
+ .extra_args
+ .as_ref()
+ .is_some_and(|extra_args| !extra_args.is_object())
+ {
+ return Err(Error::BadRequest(
+ "Field `extra_args` expected to be JSON object".to_string(),
+ ));
+ }
+
+ // Any hub script is left unchecked, so saving never needs the hub.
+ let exists = if let Some(p) = path.strip_prefix("script/") {
+ p.starts_with("hub/")
+ || sqlx::query_scalar!(
+ "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2 AND archived = false AND deleted = false)",
+ w_id,
+ p
+ )
+ .fetch_one(&mut *tx)
+ .await?
+ .unwrap_or(false)
+ } else if let Some(p) = path.strip_prefix("flow/") {
+ sqlx::query_scalar!(
+ "SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2 AND archived = false)",
+ w_id,
+ p
+ )
+ .fetch_one(&mut *tx)
+ .await?
+ .unwrap_or(false)
+ } else {
+ return Err(Error::BadRequest(format!(
+ "Field `path` must start with script/ or flow/ (got {path})"
+ )));
+ };
+ if !exists {
+ return Err(Error::BadRequest(format!(
+ "Variable expiration handler '{path}' not found. The handler must be an existing, non-archived script or flow."
+ )));
+ }
+
+ // Persisted even when false, like `error_handler`'s.
+ let mut variable_expiration_handler = serde_json::json!({
+ "path": path,
+ "muted_on_user_path": es.muted_on_user_path,
+ });
+ if let Some(extra_args) = &es.extra_args {
+ variable_expiration_handler["extra_args"] = extra_args.clone();
+ }
+ if let Some(notify_before_secs) = es.notify_before_secs {
+ variable_expiration_handler["notify_before_secs"] = notify_before_secs.into();
+ }
+ if let Some(remind_every_secs) = es.remind_every_secs {
+ variable_expiration_handler["remind_every_secs"] = remind_every_secs.into();
+ }
+
+ // A recorded failure is the sweep's: it ends at the handler's next start.
+ sqlx::query!(
+ "UPDATE workspace_settings SET variable_expiration_handler = $1 WHERE workspace_id = $2",
+ variable_expiration_handler,
+ &w_id
+ )
+ .execute(&mut *tx)
+ .await?;
+ } else {
+ sqlx::query!(
+ "UPDATE workspace_settings SET variable_expiration_handler = NULL WHERE workspace_id = $1",
+ &w_id
+ )
+ .execute(&mut *tx)
+ .await?;
+ }
+
+ audit_log(
+ &mut *tx,
+ &authed,
+ "workspaces.edit_variable_expiration_handler",
+ ActionKind::Update,
+ &w_id,
+ Some(&authed.email),
+ Some([("variable_expiration_handler", &format!("{:?}", es.path)[..])].into()),
+ )
+ .await?;
+ tx.commit().await?;
+
+ handle_deployment_metadata(
+ &authed.email,
+ &authed.username,
+ &db,
+ &w_id,
+ windmill_git_sync::DeployedObject::Settings {
+ setting_type: "variable_expiration_handler".to_string(),
+ },
+ Some("Variable expiration handler configuration updated".to_string()),
+ false,
+ None,
+ )
+ .await?;
+
+ windmill_common::feature_usage::log_feature_usage(
+ "variable_expiration",
+ "handler_configured",
+ // By slug, not hub id, so a bumped hub version still counts as the built-in.
+ match es.path.as_deref() {
+ None => "cleared",
+ Some(p) if p.ends_with("/variable-expiration-handler-slack") => "slack",
+ Some(p) if p.ends_with("/variable-expiration-handler-teams") => "teams",
+ Some(_) => "custom",
+ },
+ );
+
+ Ok(format!(
+ "Edit variable_expiration_handler for workspace {}",
+ &w_id
+ ))
+}
+
async fn edit_success_handler(
authed: ApiAuthed,
Extension(db): Extension,
@@ -6524,7 +6819,7 @@ async fn set_encryption_key(
);
let all_variables = sqlx::query!(
- "SELECT path, value, is_secret FROM variable WHERE workspace_id = $1",
+ "SELECT path, value::text AS \"value!\", is_secret FROM variable WHERE workspace_id = $1",
w_id
)
.fetch_all(&mut *tx)
@@ -7925,9 +8220,10 @@ async fn clone_variables(
source_workspace_id: &str,
target_workspace_id: &str,
) -> Result<()> {
+ // Dispatch markers are not copied, so the fork re-arms its own handler.
sqlx::query!(
- "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at)
- SELECT $2, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at
+ "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at, value_valid_until)
+ SELECT $2, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at, value_valid_until
FROM variable
WHERE workspace_id = $1",
source_workspace_id,
@@ -14464,7 +14760,7 @@ async fn compare_two_variables(
// Get variable from each workspace
let source_variable = sqlx::query!(
- "SELECT value, is_secret, description
+ "SELECT value::text AS \"value!\", is_secret, description, value_valid_until
FROM variable
WHERE workspace_id = $1 AND path = $2",
source_workspace_id,
@@ -14474,7 +14770,7 @@ async fn compare_two_variables(
.await?;
let target_variable = sqlx::query!(
- "SELECT value, is_secret, description
+ "SELECT value::text AS \"value!\", is_secret, description, value_valid_until
FROM variable
WHERE workspace_id = $1 AND path = $2",
fork_workspace_id,
@@ -14487,7 +14783,10 @@ async fn compare_two_variables(
// Check metadata differences
if let (Some(source), Some(target)) = (&source_variable, &target_variable) {
- if source.is_secret != target.is_secret || source.description != target.description {
+ if source.is_secret != target.is_secret
+ || source.description != target.description
+ || source.value_valid_until != target.value_valid_until
+ {
has_changes = true;
} else if source.value != target.value
|| (source.is_secret && is_external_stored_value(&source.value))
@@ -15316,6 +15615,7 @@ pub enum SettingsEdit {
InstanceGroups,
ErrorHandler,
SuccessHandler,
+ VariableExpirationHandler,
LargeFileStorage,
GitSync,
DefaultScripts,
@@ -15368,6 +15668,9 @@ pub async fn apply_settings_edit(
InstanceGroups => edit_instance_groups(a, d, p, settings_edit_body(edit, body)?).await?,
ErrorHandler => edit_error_handler(a, d, p, settings_edit_body(edit, body)?).await?,
SuccessHandler => edit_success_handler(a, d, p, settings_edit_body(edit, body)?).await?,
+ VariableExpirationHandler => {
+ edit_variable_expiration_handler(a, d, p, settings_edit_body(edit, body)?).await?
+ }
LargeFileStorage => {
edit_large_file_storage_config(a, d, p, settings_edit_body(edit, body)?).await?
}
diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs
index 64aa5c6a08..aea887624d 100644
--- a/backend/windmill-api-workspaces/src/workspaces_extra.rs
+++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs
@@ -135,7 +135,7 @@ pub(crate) async fn change_workspace_id(
// Duplicate workspace settings (keep copy in old workspace for reference)
info!("Duplicating workspace_settings table");
sqlx::query!(
- "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2",
+ "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler FROM workspace_settings WHERE workspace_id = $2",
&rw.new_id,
&old_id
)
diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml
index e4d9c709a5..3ba85557b7 100644
--- a/backend/windmill-api/openapi.yaml
+++ b/backend/windmill-api/openapi.yaml
@@ -1,7 +1,7 @@
openapi: "3.0.3"
info:
- version: 1.847.0
+ version: 1.849.0
title: Windmill API
contact:
@@ -4366,6 +4366,8 @@ paths:
$ref: "#/components/schemas/ErrorHandlerConfig"
success_handler:
$ref: "#/components/schemas/SuccessHandlerConfig"
+ variable_expiration_handler:
+ $ref: "#/components/schemas/VariableExpirationHandlerConfig"
large_file_storage:
$ref: "#/components/schemas/LargeFileStorage"
ducklake:
@@ -5432,6 +5434,29 @@ paths:
schema:
type: string
+ /w/{workspace}/workspaces/edit_variable_expiration_handler:
+ post:
+ summary: edit variable expiration handler
+ operationId: editVariableExpirationHandler
+ tags:
+ - workspace
+ parameters:
+ - $ref: "#/components/parameters/WorkspaceId"
+ requestBody:
+ description: WorkspaceVariableExpirationHandler
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/EditVariableExpirationHandler"
+ responses:
+ "200":
+ description: status
+ content:
+ text/plain:
+ schema:
+ type: string
+
/w/{workspace}/workspaces/edit_success_handler:
post:
summary: edit success handler
@@ -8837,6 +8862,11 @@ paths:
type: string
state:
type: string
+ params:
+ type: object
+ description: The callback URL's other query params, which the provider's `connect_fields` may read (QuickBooks' `realmId`)
+ additionalProperties:
+ type: string
required:
- code
- state
@@ -15791,6 +15821,10 @@ paths:
value: {}
policy:
$ref: "#/components/schemas/Policy"
+ execution_mode:
+ type: string
+ enum: [viewer, publisher, guest, anonymous]
+ description: "Changes who may open the app and leaves the rest of the deployed policy, its run-as user included, as it is. Applied under the same lock as a deploy, so neither overwrites the other. Cannot be sent with `policy`, which states its own mode. When the app runs as another user, refused to a caller who is neither an admin nor in 'wm_deployers', and to an operator with builder rights even in 'wm_deployers'; they change the mode by deploying."
deployment_message:
type: string
custom_path:
@@ -24699,6 +24733,56 @@ paths:
items:
$ref: "#/components/schemas/AutoscalingEvent"
+ /configs/test_autoscaling/{worker_group}:
+ post:
+ summary: Run an autoscaling integration once for a worker group, without recording an autoscaling event
+ operationId: testAutoscaling
+ tags:
+ - config
+ parameters:
+ - name: worker_group
+ in: path
+ required: true
+ schema:
+ type: string
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ desired_workers:
+ type: integer
+ minimum: 0
+ integration:
+ description: The integration to run, as in the worker group's autoscaling config
+ type: object
+ properties:
+ type:
+ type: string
+ enum: [script, kubernetes, dryrun]
+ path:
+ type: string
+ tag:
+ type: string
+ required:
+ - type
+ required:
+ - desired_workers
+ - integration
+ responses:
+ "200":
+ description: The job queued for a script integration
+ content:
+ application/json:
+ schema:
+ type: object
+ properties:
+ job_id:
+ type: string
+ nullable: true
+
/configs/native_kubernetes_autoscaling_healthcheck:
get:
summary: Check Kubernetes autoscaling health for a worker group
@@ -29832,6 +29916,45 @@ components:
type: boolean
default: false
+ EditVariableExpirationHandler:
+ type: object
+ description: >
+ Request body for editing the workspace variable expiration handler. Omit
+ `path` (or send null) to unset the handler.
+ properties:
+ path:
+ type: string
+ nullable: true
+ description: Path to the variable expiration handler script or flow
+ extra_args:
+ $ref: "#/components/schemas/ScriptArgs"
+ muted_on_user_path:
+ type: boolean
+ description: Do not run the handler for variables on a personal (u/) path
+ notify_before_secs:
+ type: integer
+ minimum: 0
+ description: How long before a variable's value expires the handler runs. 1 day when omitted.
+ remind_every_secs:
+ type: integer
+ minimum: 0
+ description: Run the handler again at this interval until the value expires. No reminders when omitted or 0.
+
+ VariableExpirationHandlerConfig:
+ type: object
+ description: Workspace variable expiration handler configuration
+ properties:
+ path:
+ type: string
+ extra_args:
+ $ref: "#/components/schemas/ScriptArgs"
+ muted_on_user_path:
+ type: boolean
+ notify_before_secs:
+ type: integer
+ remind_every_secs:
+ type: integer
+
EditSuccessHandler:
description: Request body for editing the workspace success handler. Accepts both new grouped format and legacy flat format for backward compatibility.
oneOf:
@@ -32613,6 +32736,10 @@ components:
expires_at:
type: string
format: date-time
+ value_valid_until:
+ type: string
+ nullable: true
+ format: date-time
labels:
type: array
items:
@@ -32689,7 +32816,18 @@ components:
description: Whether the variable is an OAuth variable
expires_at:
type: string
- description: The expiration date of the variable
+ description: >
+ When to garbage-collect the variable. Used for ephemeral secrets;
+ the variable is deleted once this is reached. Not the value's expiry
+ — see value_valid_until.
+ format: date-time
+ value_valid_until:
+ type: string
+ description: >
+ When the value stored in this variable stops working. Triggers the
+ workspace's variable expiration handler ahead of it, by the handler's
+ `notify_before_secs` (1 day by default); never causes the variable
+ to be deleted.
format: date-time
labels:
type: array
@@ -32718,6 +32856,14 @@ components:
description:
type: string
description: The new description of the variable
+ value_valid_until:
+ type: string
+ nullable: true
+ description: >
+ When the value stored in this variable stops working. Omit to leave
+ it unchanged; send null to clear it. Changing it re-arms the
+ workspace's variable expiration handler.
+ format: date-time
labels:
type: array
items:
@@ -32825,6 +32971,7 @@ components:
- "workspaces.edit_webhook"
- "workspaces.edit_copilot_config"
- "workspaces.edit_error_handler"
+ - "workspaces.edit_variable_expiration_handler"
- "workspaces.create"
- "workspaces.update"
- "workspaces.archive"
@@ -37934,6 +38081,13 @@ components:
type: string
grant_type:
type: string
+ resource_fields:
+ type: object
+ additionalProperties: {}
+ description: >
+ Resource fields the provider's `connect_fields` picked on connect
+ (Salesforce's instance_url, Docusign's account_id and base_uri,
+ QuickBooks' realmId), for the connect dialog to prefill.
required:
- access_token
diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs
index 8de5e16c11..213a41b4eb 100644
--- a/backend/windmill-api/src/ai.rs
+++ b/backend/windmill-api/src/ai.rs
@@ -24,7 +24,10 @@ use windmill_ai::ai_providers::{
empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel,
};
use windmill_ai::ai_types::{validate_token_limit, CONTEXT_WINDOWS, MAX_MODEL_RATE, OUTPUT_LIMITS};
-use windmill_ai::credentials::ProviderCredentials;
+use windmill_ai::credentials::{AnthropicFederation, ProviderCredentials};
+use windmill_ai::providers::anthropic::{
+ exchange_federated_token, FederatedAccessToken, ANTHROPIC_API_BASE_URL,
+};
#[cfg(feature = "bedrock")]
use windmill_ai::providers::bedrock::{
handle_bedrock_proxy, BedrockProxyResponse, BedrockProxyResponseBody,
@@ -119,6 +122,10 @@ lazy_static::lazy_static! {
pub static ref AI_REQUEST_CACHE: Cache<(String, AIProvider), ExpiringProviderCredentials> = Cache::new(500);
+ /// Anthropic access tokens from a federated exchange, keyed by (workspace,
+ /// federation settings, user email). Like BEDROCK_ASSUMED_ROLE_CACHE, an entry
+ /// is never handed to another user than the one the OIDC token named.
+ static ref ANTHROPIC_FEDERATED_TOKEN_CACHE: Cache<(String, AnthropicFederation, String), FederatedAccessToken> = Cache::new(500);
}
#[cfg(feature = "bedrock")]
@@ -132,6 +139,8 @@ lazy_static::lazy_static! {
pub(crate) fn invalidate_ai_request_cache_for_workspace(workspace_id: &str) {
AI_REQUEST_CACHE.retain(|(cached_workspace_id, _), _| cached_workspace_id != workspace_id);
+ ANTHROPIC_FEDERATED_TOKEN_CACHE
+ .retain(|(cached_workspace_id, _, _), _| cached_workspace_id != workspace_id);
// Dropped alongside the credentials that named the role, so that changing a
// workspace's AI settings does not leave a session assumed under the old ones
// in play until STS expires it.
@@ -247,10 +256,33 @@ struct OAuthTokens {
access_token: String,
}
+/// An `anthropic_federated` resource: the Claude Console federation rule,
+/// organization, service account and (for a rule enabled in several workspaces)
+/// workspace to exchange the requesting user's OIDC token against.
+///
+/// It has no base URL on purpose. The exchanged token is the identity of whoever
+/// uses the resource, not of whoever wrote it, so a configurable host would let
+/// the resource's author collect other users' tokens.
+///
+/// Only `federation_rule_id` decides that a value is one: the other fields are
+/// optional here and checked afterwards, so a resource missing one is reported
+/// rather than read as a standard resource with no key.
+#[derive(Deserialize, Debug)]
+struct AIAnthropicFederatedResource {
+ federation_rule_id: String,
+ #[serde(default)]
+ organization_id: String,
+ #[serde(default)]
+ service_account_id: String,
+ #[serde(default, deserialize_with = "empty_string_as_none")]
+ workspace_id: Option,
+}
+
#[derive(Deserialize, Debug)]
#[serde(untagged)]
enum AIResource {
OAuth(AIOAuthResource),
+ AnthropicFederated(AIAnthropicFederatedResource),
Standard(AIStandardResource),
}
@@ -345,10 +377,50 @@ async fn resolve_provider_credentials(
aws_secret_access_key,
aws_session_token,
oidc_role_arn,
+ anthropic_federation: None,
platform: resource.platform,
custom_headers: resource.headers,
})
}
+ AIResource::AnthropicFederated(resource) => {
+ if *provider != AIProvider::Anthropic {
+ return Err(Error::BadRequest(format!(
+ "An anthropic_federated resource can only be used for the Anthropic provider, not {provider:?}"
+ )));
+ }
+ let required = [
+ ("federation_rule_id", &resource.federation_rule_id),
+ ("organization_id", &resource.organization_id),
+ ("service_account_id", &resource.service_account_id),
+ ];
+ if let Some((field, _)) = required.iter().find(|(_, value)| value.is_empty()) {
+ return Err(Error::BadRequest(format!(
+ "The anthropic_federated resource needs {field} to be set"
+ )));
+ }
+
+ Ok(ProviderCredentials {
+ provider: provider.clone(),
+ base_url: ANTHROPIC_API_BASE_URL.to_string(),
+ api_key: None,
+ access_token: None,
+ organization_id: None,
+ user: None,
+ region: None,
+ aws_access_key_id: None,
+ aws_secret_access_key: None,
+ aws_session_token: None,
+ oidc_role_arn: None,
+ anthropic_federation: Some(AnthropicFederation {
+ federation_rule_id: resource.federation_rule_id,
+ organization_id: resource.organization_id,
+ service_account_id: resource.service_account_id,
+ workspace_id: resource.workspace_id,
+ }),
+ platform: AIPlatform::Standard,
+ custom_headers: HashMap::new(),
+ })
+ }
AIResource::OAuth(resource) => {
let user = if let Some(user) = resource.user.clone() {
Some(resolve_var(user, db, w_id, user_db.as_ref(), authed).await?)
@@ -370,6 +442,7 @@ async fn resolve_provider_credentials(
aws_secret_access_key: None,
aws_session_token: None,
oidc_role_arn: None,
+ anthropic_federation: None,
platform: AIPlatform::Standard,
custom_headers: HashMap::new(),
})
@@ -522,6 +595,73 @@ async fn assume_bedrock_role(
}
}
+/// Exchange an OIDC token minted for the requesting user for an Anthropic access
+/// token, in place, when the resource is set up for Workload Identity Federation.
+///
+/// Runs after the workspace credential cache for the same reason the Bedrock
+/// exchange does: the token's `sub` names the user, and is what a federation rule
+/// matches and the Claude Console's authentication history records.
+async fn resolve_anthropic_federated_credentials(
+ db: &DB,
+ w_id: &str,
+ authed: &ApiAuthed,
+ credentials: &mut ProviderCredentials,
+) -> Result<()> {
+ let Some(federation) = credentials.anthropic_federation.as_ref() else {
+ return Ok(());
+ };
+
+ let cache_key = (w_id.to_string(), federation.clone(), authed.email.clone());
+ let token = match ANTHROPIC_FEDERATED_TOKEN_CACHE
+ .get(&cache_key)
+ .filter(FederatedAccessToken::is_fresh)
+ {
+ Some(cached) => cached,
+ None => {
+ let assertion = mint_anthropic_oidc_token(db, w_id, authed).await?;
+ let client = pinned_ai_client_for(&credentials.base_url).await?;
+ let token =
+ exchange_federated_token(&client, &credentials.base_url, federation, &assertion)
+ .await?;
+ ANTHROPIC_FEDERATED_TOKEN_CACHE.insert(cache_key, token.clone());
+ token
+ }
+ };
+
+ credentials.access_token = Some(token.access_token);
+ Ok(())
+}
+
+async fn mint_anthropic_oidc_token(db: &DB, w_id: &str, authed: &ApiAuthed) -> Result {
+ #[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))]
+ {
+ use windmill_ai::providers::anthropic::{
+ ANTHROPIC_OIDC_AUDIENCE, ANTHROPIC_OIDC_TOKEN_LIFETIME_SECS,
+ };
+ use windmill_common::oidc_oss::{generate_id_token, OidcCaller, WorkspaceClaim};
+
+ let id_token = generate_id_token(
+ OidcCaller::Anthropic,
+ Some(db),
+ WorkspaceClaim { workspace: w_id.to_string() },
+ ANTHROPIC_OIDC_AUDIENCE,
+ format!("{}::{}", authed.email, w_id),
+ Some(authed.email.clone()),
+ Some(ANTHROPIC_OIDC_TOKEN_LIFETIME_SECS),
+ )
+ .await?;
+ Ok(id_token.to_string())
+ }
+ #[cfg(not(all(feature = "enterprise", feature = "openidconnect", feature = "private")))]
+ {
+ let _ = (db, w_id, authed);
+ Err(Error::BadRequest(
+ "Anthropic Workload Identity Federation through Windmill's OIDC provider requires an enterprise license"
+ .to_string(),
+ ))
+ }
+}
+
#[derive(Clone, Debug)]
pub struct ExpiringProviderCredentials {
credentials: ProviderCredentials,
@@ -1129,6 +1269,7 @@ async fn global_proxy(
aws_secret_access_key: None,
aws_session_token: None,
oidc_role_arn: None,
+ anthropic_federation: None,
platform: AIPlatform::Standard,
custom_headers: HashMap::new(),
};
@@ -1435,6 +1576,7 @@ async fn proxy(
#[cfg(feature = "bedrock")]
resolve_bedrock_oidc_credentials(&db, &w_id, &authed, &mut credentials).await?;
+ resolve_anthropic_federated_credentials(&db, &w_id, &authed, &mut credentials).await?;
// Free tier: pin the model and clamp max_tokens server-side before forwarding,
// since the request body is otherwise client-controlled.
@@ -1643,11 +1785,41 @@ mod tests {
aws_secret_access_key: None,
aws_session_token: None,
oidc_role_arn: None,
+ anthropic_federation: None,
platform: AIPlatform::Standard,
custom_headers: HashMap::new(),
}
}
+ #[test]
+ fn only_a_federation_rule_makes_a_resource_anthropic_federated() {
+ let federated = serde_json::json!({
+ "federation_rule_id": "fdrl_x",
+ "organization_id": "00000000-0000-0000-0000-000000000000",
+ "service_account_id": "svac_x",
+ "workspace_id": ""
+ });
+ assert!(matches!(
+ serde_json::from_value::(federated),
+ Ok(AIResource::AnthropicFederated(
+ AIAnthropicFederatedResource { workspace_id: None, .. }
+ ))
+ ));
+
+ let incomplete = serde_json::json!({"federation_rule_id": "fdrl_x"});
+ assert!(matches!(
+ serde_json::from_value::(incomplete),
+ Ok(AIResource::AnthropicFederated(_))
+ ));
+
+ // `organization_id` is also the OpenAI organization of a standard resource.
+ let standard = serde_json::json!({"api_key": "k", "organization_id": "org-x"});
+ assert!(matches!(
+ serde_json::from_value::(standard),
+ Ok(AIResource::Standard(_))
+ ));
+ }
+
#[test]
fn ai_standard_resource_ignores_legacy_enable_1m_context_keys() {
// Resources created before the field was removed still carry the legacy key
diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs
index bc0124b685..cdc66496c0 100644
--- a/backend/windmill-api/src/apps.rs
+++ b/backend/windmill-api/src/apps.rs
@@ -66,8 +66,8 @@ use windmill_common::{
db::{DbWithOptAuthed, UserDB},
error::{to_anyhow, Error, JsonResult, Result},
jobs::{
- get_payload_tag_from_prefixed_path, resolve_delete_after_secs, schedule_job_deletion,
- JobPayload, JobTriggerKind, RawCode,
+ get_payload_tag_from_prefixed_path, normalize_nul_in_args, resolve_delete_after_secs,
+ sanitize_untrusted_args, schedule_job_deletion, JobPayload, JobTriggerKind, RawCode,
},
scripts::ScriptLang,
triggers::TriggerMetadata,
@@ -602,6 +602,11 @@ pub struct EditApp {
pub summary: Option,
pub value: Option>>,
pub policy: Option,
+ /// Changes only who may open the app: applied to the deployed policy under the row lock a
+ /// deploy takes, so it neither reverts a concurrent deploy's policy nor is reverted by it.
+ /// Exclusive with `policy`, which states its own mode.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub execution_mode: Option,
pub deployment_message: Option,
pub custom_path: Option,
pub preserve_on_behalf_of: Option,
@@ -735,10 +740,10 @@ async fn list_apps(
if let Some(label) = &lq.label {
for l in label.split(',') {
+ // `binds` fills every `?` in one pass; chained `bind`s would re-scan a `?` in the bound value
sqlb.and_where(
"(app.labels @> ARRAY[?] OR folder_labels(app.workspace_id, app.path) @> ARRAY[?])"
- .bind(&l.trim())
- .bind(&l.trim()),
+ .binds(&[&l.trim()]),
);
}
}
@@ -2366,18 +2371,12 @@ fn check_operator_composed_app(
// Viewer mode would make the triggerables collected above non-exhaustive (see the docs), and
// the deploy panel shows it as members-only, so an admin deploying a draft would not see it.
- // Pin an omitted mode on deploy: `update_app_internal` resolves it to the deployed app's, so a
- // redeploy over an admin's viewer-mode app would otherwise inherit `Viewer`.
- match policy.stated_execution_mode() {
- Some(ExecutionMode::Viewer) => {
- return Err(Error::PermissionDenied(format!(
- "Operators with builder rights cannot {verb} an app that runs as its viewer"
- )))
- }
- None if write == BuilderAppWrite::Deploy => {
- policy.set_execution_mode(ExecutionMode::Publisher)
- }
- _ => {}
+ // An omitted mode is resolved by the deploy: create's default, or on update the deployed mode,
+ // which `update_app_internal` turns into `Publisher` for a builder when it is `Viewer`.
+ if policy.stated_execution_mode() == Some(ExecutionMode::Viewer) {
+ return Err(Error::PermissionDenied(format!(
+ "Operators with builder rights cannot {verb} an app that runs as its viewer"
+ )));
}
referenced.sort();
@@ -2473,15 +2472,7 @@ pub(crate) async fn validate_operator_composed_app(
// docs/operator-builder-rights.md); a draft keeps whatever it names, which an admin's
// one-click draft deploy preserves without showing it.
if let (BuilderAppWrite::Draft, Some(policy)) = (write, policy.as_deref()) {
- let names_another = policy
- .on_behalf_of
- .as_deref()
- .is_some_and(|p| p != username_to_permissioned_as(&authed.username))
- || policy
- .on_behalf_of_email
- .as_deref()
- .is_some_and(|e| e != authed.email);
- if names_another {
+ if policy_runs_as_another(policy, authed) {
return Err(Error::PermissionDenied(
"Operators with builder rights cannot save an app that runs as someone else"
.to_string(),
@@ -2492,6 +2483,17 @@ pub(crate) async fn validate_operator_composed_app(
require_runnables_readable(authed, db, user_db, w_id, referenced).await
}
+fn policy_runs_as_another(policy: &Policy, authed: &ApiAuthed) -> bool {
+ policy
+ .on_behalf_of
+ .as_deref()
+ .is_some_and(|p| p != username_to_permissioned_as(&authed.username))
+ || policy
+ .on_behalf_of_email
+ .as_deref()
+ .is_some_and(|e| e != authed.email)
+}
+
async fn require_runnables_readable(
authed: &ApiAuthed,
db: &DB,
@@ -3809,10 +3811,22 @@ async fn update_app_internal<'a>(
}
}
- if caller_capabilities(&db, w_id, &authed).await?.apps == AuthorLevel::Compose {
+ if ns.policy.is_some() && ns.execution_mode.is_some() {
+ return Err(Error::BadRequest(
+ "Send execution_mode either inside the policy or alone, not both".to_string(),
+ ));
+ }
+ // The policy a mode-only change writes is only known under the row lock, so a builder's
+ // check on it runs there.
+ let mode_only = ns.policy.is_none() && ns.value.is_none() && ns.execution_mode.is_some();
+
+ let composes_only = caller_capabilities(&db, w_id, &authed).await?.apps == AuthorLevel::Compose;
+ if composes_only {
// Restoring a full-code version always sends allow_kind_change. A builder converts
// nothing, so drop it and let the locked kind check below refuse an actual conversion.
ns.allow_kind_change = None;
+ }
+ if composes_only && !mode_only {
validate_operator_composed_app(
&authed,
&db,
@@ -3890,6 +3904,7 @@ async fn update_app_internal<'a>(
|| ns.summary.is_some()
|| ns.custom_path.is_some()
|| ns.labels.is_some()
+ || ns.execution_mode.is_some()
// A source deploy may send nothing but the value, and its runnables are
// what the derived grants describe: skipping the UPDATE here would keep
// the grants keyed to the sources this deploy just replaced.
@@ -3907,7 +3922,12 @@ async fn update_app_internal<'a>(
// that carries no policy keeps the deployed mode, read under the row
// lock so a policy update landing alongside cannot slip a guest app
// onto a path it cannot be scoped to.
- let mode = match ns.policy.as_ref().and_then(|p| p.stated_execution_mode()) {
+ let stated = ns
+ .policy
+ .as_ref()
+ .and_then(|p| p.stated_execution_mode())
+ .or(ns.execution_mode);
+ let mode = match stated {
Some(mode) => mode,
None => sqlx::query_scalar::<_, Option>(
"SELECT policy->>'execution_mode' FROM app
@@ -3987,12 +4007,13 @@ async fn update_app_internal<'a>(
// no policy at all, in which case the rest of the deployed one carries
// over. Either way a policy is about to be written wholesale.
let caller_sent_policy = ns.policy.is_some();
- if caller_sent_policy || derived_triggerables.is_some() {
+ if caller_sent_policy || derived_triggerables.is_some() || ns.execution_mode.is_some() {
// One locked read serving everything below: the mode an omitted one
// inherits, the already-anonymous check, and the policy a source
- // deploy carries over. FOR UPDATE holds the row until this
- // transaction's UPDATE commits, so a policy edit landing between the
- // read and the write cannot be clobbered by this stale snapshot.
+ // deploy or a mode-only change carries over. FOR UPDATE holds the row
+ // until this transaction's UPDATE commits, so a policy edit landing
+ // between the read and the write cannot be clobbered by this stale
+ // snapshot.
let deployed = sqlx::query_scalar!(
"SELECT policy FROM app WHERE path = $1 AND workspace_id = $2 FOR UPDATE",
path,
@@ -4006,30 +4027,81 @@ async fn update_app_internal<'a>(
let mut npolicy = match ns.policy.take() {
Some(npolicy) => npolicy,
+ None if deployed.is_none() => {
+ return Err(Error::NotFound(format!("App {path} not found")))
+ }
// Carrying the deployed policy forward is only safe if all of it
// survived the round trip; a partial one would silently drop
// `on_behalf_of`, sandboxing or S3 rules.
None => deployed_policy.clone().ok_or_else(|| {
- Error::internal_err(format!(
- "app {path} has no readable policy to deploy its new sources under"
- ))
+ Error::internal_err(format!("app {path} has no readable policy to update"))
})?,
};
if let Some(triggerables) = derived_triggerables {
npolicy.triggerables = None;
npolicy.triggerables_v2 = Some(triggerables);
}
+ if let Some(mode) = ns.execution_mode {
+ // A policy write from someone who cannot keep the run-as user rebinds it
+ // to them, so the mode alone must not let them open an app running as
+ // someone else to a new audience. A builder who may keep it is refused too:
+ // its deploy checks the triggerables against its own RLS, which needs a
+ // second pool connection and so cannot run under this lock.
+ if policy_runs_as_another(&npolicy, &authed)
+ && (composes_only || !windmill_common::can_preserve_on_behalf_of(&authed))
+ {
+ let runs_as = npolicy
+ .on_behalf_of_email
+ .as_deref()
+ .or(npolicy.on_behalf_of.as_deref())
+ .unwrap_or("another user");
+ return Err(Error::PermissionDenied(if composes_only {
+ format!(
+ "app {path} runs as {runs_as}: change who can open it by deploying it"
+ )
+ } else {
+ format!(
+ "app {path} runs as {runs_as}: only admins and members of \
+ wm_deployers can change who can open it without deploying it, and a \
+ deploy from you makes it run as you"
+ )
+ }));
+ }
+ npolicy.set_execution_mode(mode);
+ if composes_only {
+ // The check below sandboxes an app whose policy leaves `sandbox` unset; a
+ // mode-only change keeps every other field, so that is left to a deploy.
+ if npolicy.sandbox != Some(true) {
+ return Err(Error::PermissionDenied(format!(
+ "app {path} is not sandboxed: change who can open it by deploying it"
+ )));
+ }
+ check_operator_composed_app(
+ BuilderAppWrite::Deploy,
+ raw_app,
+ None,
+ Some(&mut npolicy),
+ )?;
+ }
+ }
validate_frontend_sdk_scopes(&npolicy)?;
// The policy is written wholesale, so one that states no mode would
// otherwise re-permission the app to the create-time default: a
// `viewer` app would start running on the publisher's identity. Keep
- // the deployed mode instead, and make the stored policy state it.
+ // the deployed mode instead, and make the stored policy state it. A
+ // builder may not deploy `Viewer` (see `check_operator_composed_app`),
+ // so a builder's redeploy over a viewer-mode app makes it `Publisher`.
if npolicy.stated_execution_mode().is_none() {
+ let deployed_mode = deployed_policy
+ .as_ref()
+ .map(|d| d.execution_mode())
+ .unwrap_or_default();
npolicy.set_execution_mode(
- deployed_policy
- .as_ref()
- .map(|d| d.execution_mode())
- .unwrap_or_default(),
+ if composes_only && deployed_mode == ExecutionMode::Viewer {
+ ExecutionMode::Publisher
+ } else {
+ deployed_mode
+ },
);
}
refuse_unscopable_guest_app(
@@ -4155,7 +4227,8 @@ async fn update_app_internal<'a>(
// CLI / git-sync deploys ask us to preserve any existing user draft at this
// path instead of wiping it as part of the deploy. Only wipe the deployer's
// own draft (plus the legacy NULL-email row) — see create_app_internal.
- if !ns.skip_draft_deletion.unwrap_or(false) {
+ // A mode-only change publishes nothing from the draft, so it keeps it.
+ if !ns.skip_draft_deletion.unwrap_or(false) && !mode_only {
sqlx::query!(
"DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app') \
AND (email = $3 OR email IS NULL)",
@@ -4890,11 +4963,15 @@ async fn execute_component(
let resolved_delete_secs =
resolve_delete_after_secs(None, policy_triggerables.delete_after_secs);
- // `_TEMP_SCRIPT_REFS` is a server-injected control key (into `extra`) that the
- // worker reads back for a `Preview` job, which an inline run is. A caller
- // supplying it in `args` would redirect relative-import resolution, unpinned, as
- // the app identity. Drop it; the legitimate value rides in `extra`.
- payload.args.remove("_TEMP_SCRIPT_REFS");
+ // The caller here is a viewer of the app, down to an anonymous visitor of a public one,
+ // and the job runs as the app's identity on code the policy pins. So the viewer fills in
+ // the runnable's inputs and nothing else: not the function called instead of `main`
+ // (which would reach helpers the app never exposes, as the publisher), not the module
+ // files written into the job directory, and not `_TEMP_SCRIPT_REFS`, which would
+ // redirect relative-import resolution, unpinned, as the app identity. The legitimate
+ // `_TEMP_SCRIPT_REFS` is server-injected into `extra` further below, which this leaves
+ // alone — as it does the policy's own `static_inputs`, which the publisher writes.
+ sanitize_untrusted_args(&mut payload.args);
// An agent runnable takes the message and its files, plus optionally the conversation they
// belong to, which names the agent's memory rather than an input of its run. Without it the
@@ -6567,6 +6644,16 @@ async fn build_args(
let mut job_id: Option = None;
let mut safe_args = HashMap::>::new();
+ // Every check below matches an argument by name against the policy the publisher wrote,
+ // and `push` stores the args with their `\u0000` escapes removed, names included. So
+ // compare the names that will actually be stored: otherwise a viewer's `tenant\0` is
+ // never recognized as the `tenant` the policy pins in `static_inputs` (and is stored as
+ // `tenant`, winning over the pinned value), and `greeting\0` is never recognized as the
+ // `greeting` restricted by `one_of_inputs`, so neither list is enforced. Values are
+ // normalized along with the names, so the checks that look at a value see what the job
+ // will receive.
+ normalize_nul_in_args(&mut args);
+
// Before anything reads `args`: a context input the runnable declares is never taken
// from the request, or a viewer could name someone else as themselves.
for (k, prop) in ctx_inputs.into_iter().flatten() {
diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs
index 6605a3ba92..e2f8406bd4 100644
--- a/backend/windmill-api/src/drafts.rs
+++ b/backend/windmill-api/src/drafts.rs
@@ -393,6 +393,7 @@ async fn validate_composed_draft(
user_db: &UserDB,
w_id: &str,
kind: UserDraftItemKind,
+ path: &str,
value: &str,
) -> Result<()> {
let caps = windmill_common::roles::caller_capabilities(db, w_id, authed).await?;
@@ -420,6 +421,41 @@ async fn validate_composed_draft(
.await?;
}
+ // An agent draft is held to the rules of its deploy, as a flow draft is: a developer who
+ // tests it in the agent editor runs its tools as themselves.
+ if caps.flows != AuthorLevel::Full && kind == UserDraftItemKind::Resource {
+ #[derive(Deserialize)]
+ struct ResourceDraft {
+ #[serde(default)]
+ args: serde_json::Value,
+ resource_type: Option,
+ }
+ let draft: ResourceDraft = serde_json::from_str(&strip_json_nul(value))
+ .map_err(|e| Error::BadRequest(format!("Invalid resource draft: {e}")))?;
+ // Whatever type the draft names, deploying it over a deployed agent keeps the row's type.
+ let is_agent = draft.resource_type.as_deref() == Some("ai_agent")
+ || sqlx::query_scalar!(
+ "SELECT EXISTS(SELECT 1 FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent')",
+ w_id,
+ path,
+ )
+ .fetch_one(db)
+ .await?
+ .unwrap_or(false);
+ if is_agent {
+ let mut tx = user_db.clone().begin(authed).await?;
+ windmill_api_auth::composed::validate_operator_agent(
+ &draft.args,
+ authed,
+ db,
+ &mut tx,
+ w_id,
+ )
+ .await?;
+ tx.commit().await?;
+ }
+ }
+
// A raw-app draft carries the app value and its policy side by side.
if caps.apps == AuthorLevel::Compose && kind == UserDraftItemKind::RawApp {
let stripped = strip_json_nul(value);
@@ -535,7 +571,7 @@ async fn update_draft(
}
if let Some(value) = &req.value {
- validate_composed_draft(&authed, &db, &user_db, &w_id, kind, value.0.get()).await?;
+ validate_composed_draft(&authed, &db, &user_db, &w_id, kind, path, value.0.get()).await?;
}
let applied = if let Some(value) = &req.value {
@@ -1116,8 +1152,16 @@ async fn transfer_drafts(
// The copy is a save in the target, so a role that only composes there is held to the
// rules a direct save would apply. Checked on the locked rows, the ones copied.
for row in &source_rows {
- validate_composed_draft(target_authed, &db, &user_db, target, row.typ, &row.value)
- .await?;
+ validate_composed_draft(
+ target_authed,
+ &db,
+ &user_db,
+ target,
+ row.typ,
+ &row.path,
+ &row.value,
+ )
+ .await?;
}
if req.replace_existing {
sqlx::query!(
diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs
index c5dd374c7a..bf3d7ea24c 100644
--- a/backend/windmill-api/src/jobs.rs
+++ b/backend/windmill-api/src/jobs.rs
@@ -36,7 +36,7 @@ use windmill_common::error::JsonResult;
use windmill_common::flow_status::{JobResult, RestartedFrom};
use windmill_common::jobs::{
format_completed_job_result, format_result, is_safe_log_file_path, is_valid_entrypoint_name,
- DynamicInput, ENTRYPOINT_OVERRIDE,
+ sanitize_untrusted_args, DynamicInput, ENTRYPOINT_OVERRIDE,
};
#[cfg(feature = "run_inline")]
use windmill_common::jobs::{
@@ -10488,7 +10488,7 @@ async fn run_dynamic_select(
Extension(user_db): Extension,
Path(w_id): Path,
Query(run_query): Query,
- Json(request): Json,
+ Json(mut request): Json,
) -> error::Result {
#[cfg(feature = "enterprise")]
check_license_key_valid().await?;
@@ -10510,6 +10510,20 @@ async fn run_dynamic_select(
)));
}
+ // The dyn-select code is the runnable's, not necessarily the caller's: a deployed flow
+ // has no deployed runnable for it, so it runs as a `JobKind::Preview` job, and the
+ // executors honor `_TEMP_SCRIPT_REFS` on every preview — it resolves the code's
+ // workspace imports from the temporary script store, which any workspace member can
+ // write. A caller of this route (an operator, for a deployed flow) only fills in that
+ // code's inputs; the function to call is taken from `entrypoint_function`, validated
+ // above and inserted server-side below, never from the args. So the reserved control
+ // keys are dropped here, in the spellings `push` would store them under. Nothing
+ // server-side puts them in `args`: the request has no `temp_script_refs` field, and the
+ // run form does not send them.
+ if let Some(args) = request.args.as_mut() {
+ sanitize_untrusted_args(args);
+ }
+
// Deployed scripts keep their normal deployed-run path (a `script` job, resolved below by
// push_script_job_by_path_into_queue). Deployed flows and inline snippets have no deployed
// runnable, so they run their dyn-select code as a `preview`; the flow carries its path and
diff --git a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs
index 8ff3e9f5c2..9add0792ee 100644
--- a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs
+++ b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs
@@ -184,7 +184,12 @@ pub fn all_tools() -> Vec {
},
"expires_at": {
"type": "string",
- "description": "The expiration date of the variable",
+ "description": "When to garbage-collect the variable. Used for ephemeral secrets; the variable is deleted once this is reached. Not the value's expiry — see value_valid_until.\n",
+ "format": "date-time"
+ },
+ "value_valid_until": {
+ "type": "string",
+ "description": "When the value stored in this variable stops working. Triggers the workspace's variable expiration handler ahead of it, by the handler's `notify_before_secs` (1 day by default); never causes the variable to be deleted.\n",
"format": "date-time"
},
"labels": {
@@ -272,6 +277,12 @@ pub fn all_tools() -> Vec {
"type": "string",
"description": "The new description of the variable"
},
+ "value_valid_until": {
+ "type": "string",
+ "nullable": true,
+ "description": "When the value stored in this variable stops working. Omit to leave it unchanged; send null to clear it. Changing it re-arms the workspace's variable expiration handler.\n",
+ "format": "date-time"
+ },
"labels": {
"type": "array",
"items": {
diff --git a/backend/windmill-api/src/mcp/session/deploy.rs b/backend/windmill-api/src/mcp/session/deploy.rs
index e508a16174..ba65488c75 100644
--- a/backend/windmill-api/src/mcp/session/deploy.rs
+++ b/backend/windmill-api/src/mcp/session/deploy.rs
@@ -268,11 +268,16 @@ async fn deploy_variable(
|| draft.get("is_oauth") == Some(&Value::Bool(true))
|| non_null(draft.get("account")).is_some();
let keep_stored = protected && stages_no_value;
- let body = if keep_stored {
+ let mut body = if keep_stored {
without(draft, &["value"])
} else {
draft.clone()
};
+ // The draft holds the full variable, so an absent expiry was cleared, while the update
+ // endpoint reads an absent one as unchanged.
+ if let Some(body) = body.as_object_mut() {
+ body.entry("value_valid_until").or_insert(Value::Null);
+ }
ctx.post(
&ctx.ws(&format!("variables/update/{}", path_enc(path))),
body,
diff --git a/backend/windmill-api/src/mcp/session/gen/instructions.json b/backend/windmill-api/src/mcp/session/gen/instructions.json
index 796bdb7ffd..9cdb920c99 100644
--- a/backend/windmill-api/src/mcp/session/gen/instructions.json
+++ b/backend/windmill-api/src/mcp/session/gen/instructions.json
@@ -225,13 +225,13 @@
"script.bunnative": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (bunnative)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# TypeScript (Bun Native)\n\nNative TypeScript execution. Native scripts are Bun scripts that run on the native worker — a lightweight V8 isolate that exposes `fetch` and the JavaScript standard library — and can be heavily parallelized. Every script MUST start with `//native` on its first line so Windmill routes it to the native worker; without it the exact same script runs on the regular Bun worker. You may import npm packages and other Windmill scripts (e.g. `./helper.ts`) — imports are resolved and bundled just like a regular Bun script — as long as everything (your code and its dependencies) relies only on `fetch` and the standard library. Libraries that need Node/Bun runtime APIs (filesystem, `node:*` modules, child processes, native addons) will not work on the native worker; use the regular `bun` language for those.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\n//native\nexport async function main(param1: string, param2: number) {\n // Your code here\n return { result: param1, count: param2 };\n}\n```\n\nDo not call the main function.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nUse the `RT` namespace for resource types:\n\n```typescript\n//native\nexport async function main(stripe: RT.Stripe) {\n // stripe contains API key and config from the resource\n}\n```\n\nOnly use resource types if you need them to satisfy the instructions. Always use the RT namespace.\n\n## Imports\n\n**The constraint is the runtime, not the import list.** You may import npm packages and relative Windmill scripts; they are resolved and bundled exactly like a regular Bun script. But the native worker only provides `fetch` and the JavaScript standard library, so any imported code must work using only those. Anything requiring Node/Bun built-ins (`node:fs`, `child_process`, the `Bun` API, native modules) belongs in a regular `bun` script instead. Use the globally available `fetch` for HTTP:\n\n```typescript\n//native\nexport async function main(url: string) {\n const response = await fetch(url);\n return await response.json();\n}\n```\n\n## Pure computation: `//no_network`\n\nA native script that only transforms its inputs can declare `//no_network` in its leading comment block (right after `//native`). The runtime then refuses every connection the script attempts: `fetch` to any host, the Windmill API (so `windmill-client` calls fail too), raw sockets and unix sockets, and `WM_TOKEN` is not set. Without `//native` it means something else: the script must run in an nsjail sandbox (`//sandbox`, or instance-wide sandboxing), which then loses its network, and it fails to run otherwise. Use it only when the script needs no external data beyond its arguments:\n\n```typescript\n//native\n//no_network\nexport async function main(items: { price: number; qty: number }[]) {\n return items.reduce((sum, i) => sum + i.price * i.qty, 0);\n}\n```\n\n## Windmill Client\n\n`windmill-client` works on the native worker (its calls go over `fetch`), so use it as the **preferred way to talk to Windmill** — reading resources/variables/states, running scripts and flows, and the S3 helpers below (`loadS3File`, `loadS3FileStream`, `writeS3File`, `S3Object`). It handles auth, the workspace, and the base URL for you. Reserve raw `fetch` for calling *external* HTTP APIs that aren't Windmill.\n\nThe full `windmill-client` API reference (every exported function and its signature) is included below — consult it for the exact method instead of hand-rolling a `fetch` against the Windmill API.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\n//native\ntype Event = {\n kind:\n | \"webhook\"\n | \"http\"\n | \"websocket\"\n | \"kafka\"\n | \"email\"\n | \"nats\"\n | \"postgres\"\n | \"sqs\"\n | \"mqtt\"\n | \"amqp\"\n | \"gcp\"\n | \"azure\";\n body: any;\n headers: Record;\n query: Record;\n};\n\nexport async function preprocessor(event: Event) {\n return {\n param1: event.body.field1,\n param2: event.query.id,\n };\n}\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations. The `wmill.S3Object` type covers both the `s3://storage/key` URI form (`s3:///key` for the workspace default storage) and the `{ s3, storage? }` record form — always use it instead of redefining your own.\n\n### Receiving an S3Object as a script parameter\n\n```typescript\n//native\nimport * as wmill from \"windmill-client\";\n\nexport async function main(file: wmill.S3Object) {\n const content = await wmill.loadS3File(file);\n // ...\n}\n```\n\n### S3 operations\n\n```typescript\n//native\nimport * as wmill from \"windmill-client\";\n\n// Load file content from S3\nconst content: Uint8Array = await wmill.loadS3File(s3object);\n\n// Load file as stream\nconst blob: Blob = await wmill.loadS3FileStream(s3object);\n\n// Write file to S3\nconst result: wmill.S3Object = await wmill.writeS3File(\n s3object, // Target path (or undefined to auto-generate)\n fileContent, // string or Blob\n s3ResourcePath // Optional: specific S3 resource to use\n);\n```\n\n\n# TypeScript SDK (windmill-client)\n\nImport: import * as wmill from 'windmill-client'\n\nThe client configures itself from the job's environment — base URL, token and credentials mode\nare all set before your code runs, so there is nothing to initialize and no reason to read\nWM_TOKEN or BASE_INTERNAL_URL and build an API URL yourself. Reconstructing that by hand only\nreintroduces details the client already handles. Call the SDK for anything Windmill, and use raw\nHTTP for third-party APIs.\n\nThe helpers below are the surface to prefer. For an endpoint none of them covers, import the\ngenerated service classes (JobService, ScriptService, ...) from 'windmill-client' — they are not\nlisted here but they do exist. What does not exist is a helper name you guessed at: if it is\nneither listed below nor a service method, do not call it.\n\nTo know who is running the script, read the contextual variables rather than calling the API:\n`process.env.WM_END_USER_EMAIL || process.env.WM_EMAIL`. WM_END_USER_EMAIL is the app viewer when\nthe run was triggered from an app and empty otherwise (both variables are always defined), WM_EMAIL\nis the user the job is permissioned as. WM_USERNAME is the matching username.\n\nworkerHasInternalServer(): boolean\n\n/**\n * Initialize the Windmill client with authentication token and base URL\n * @param token - Authentication token (defaults to WM_TOKEN env variable)\n * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable)\n */\nsetClient(token?: string, baseUrl?: string): void\n\n/**\n * Create a client configuration from env variables\n * @returns client configuration\n */\ngetWorkspace(): string\n\n/**\n * Get a resource value by path\n * @param path path of the resource, default to internal state path\n * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error\n * @returns resource value\n */\nasync getResource(path?: string, undefinedIfEmpty?: boolean): Promise\n\n/**\n * Get the true root job id\n * @param jobId job id to get the root job id from (default to current job)\n * @returns root job id\n */\nasync getRootJobId(jobId?: string): Promise\n\n/**\n * Run a script synchronously by its path and wait for the result\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Run a script synchronously by its hash and wait for the result\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Append a text to the result stream\n * @param text text to append to the result stream\n */\nappendToResultStream(text: string): void\n\n/**\n * Stream to the result stream\n * @param stream stream to stream to the result stream\n */\nasync streamResult(stream: AsyncIterable): Promise\n\n/**\n * Run a flow synchronously by its path and wait for the result\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Flow execution result\n */\nasync runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Wait for a job to complete and return its result\n * @param jobId - ID of the job to wait for\n * @param verbose - Enable verbose logging\n * @returns Job result when completed\n */\nasync waitJob(jobId: string, verbose: boolean = false): Promise\n\n/**\n * Get the result of a completed job\n * @param jobId - ID of the completed job\n * @returns Job result\n */\nasync getResult(jobId: string): Promise\n\n/**\n * Get the result of a job if completed, or its current status\n * @param jobId - ID of the job\n * @returns Object with started, completed, success, and result properties\n */\nasync getResultMaybe(jobId: string): Promise\n\n/**\n * Cancel a queued or running job by ID.\n * @param jobId - UUID of the job to cancel\n * @param reason - Optional reason for cancellation\n * @returns Response message from the cancel endpoint\n */\nasync cancelJob(jobId: string, reason: string | undefined = undefined): Promise\n\n/**\n * Run a script asynchronously by its path\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise\n\n/**\n * Run a script asynchronously by its hash\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise\n\n/**\n * Run a flow asynchronously by its path\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job)\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runFlowAsync(path: string | null, args: Record | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true, tag: string | null = null): Promise\n\n/**\n * Resolve a resource value in case the default value was picked because the input payload was undefined\n * @param obj resource value or path of the resource under the format `$res:path`\n * @returns resource value\n */\nasync resolveDefaultResource(obj: any): Promise\n\n/**\n * Get the state file path from environment variables\n * @returns State path string\n */\ngetStatePath(): string\n\n/**\n * Set a resource value by path\n * @param path path of the resource to set, default to state path\n * @param value new value of the resource to set\n * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type\n */\nasync setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise\n\n/**\n * Set the state\n * @param state state to set\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync setState(state: any, path?: string): Promise\n\n/**\n * Set the progress\n * Progress cannot go back and limited to 0% to 99% range\n * @param percent Progress to set in %\n * @param jobId? Job to set progress for\n */\nasync setProgress(percent: number, jobId?: any): Promise\n\n/**\n * Get the progress\n * @param jobId? Job to get progress from\n * @returns Optional clamped between 0 and 100 progress value\n */\nasync getProgress(jobId?: any): Promise\n\n/**\n * Whether the worker running this job has received its shutdown signal.\n * A draining worker waits for its current job to finish before exiting, so a\n * long-running script can poll this and return early to let the worker be replaced.\n * @param jobId? Job to check, defaults to the current job\n */\nasync workerIsDraining(jobId?: string): Promise\n\n/**\n * Set a flow user state\n * @param key key of the state\n * @param value value of the state\n */\nasync setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get a flow user state\n * @param path path of the variable\n */\nasync getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get the state shared across executions\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync getState(path?: string): Promise\n\n/**\n * Get a variable by path\n * @param path path of the variable\n * @returns variable value\n */\nasync getVariable(path: string): Promise\n\n/**\n * Set a variable by path, create if not exist\n * @param path path of the variable\n * @param value value of the variable\n * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false)\n * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: \"\")\n */\nasync setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise\n\n/**\n * Build a PostgreSQL connection URL from a database resource\n * @param path - Path to the database resource\n * @returns PostgreSQL connection URL string\n */\nasync databaseUrlFromResource(path: string): Promise\n\nasync polarsConnectionSettings(s3_resource_path: string | undefined): Promise\n\nasync duckdbConnectionSettings(s3_resource_path: string | undefined): Promise\n\n/**\n * Get S3 client settings from a resource or workspace default\n * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)\n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n * @returns S3 client configuration settings\n */\nasync denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContent = await wmill.loadS3FileContent(inputFile)\n * // if the file is a raw text file, it can be decoded and printed directly:\n * const text = new TextDecoder().decode(fileContentStream)\n * console.log(text);\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContentBlob = await wmill.loadS3FileStream(inputFile)\n * // if the content is plain text, the blob can be read directly:\n * console.log(await fileContentBlob.text());\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * const s3object = await writeS3File(s3Object, \"Hello Windmill!\")\n * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')\n * console.log(fileContentAsUtf8Str)\n * ```\n * \n * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var)\n */\nasync writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Permanently delete a file from S3 by key.\n * \n * ```typescript\n * await wmill.deleteS3File({ s3: \"path/to/file.txt\" })\n * ```\n * \n * @param s3object - S3 object identifying the file to delete (must have `s3` set)\n * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var)\n */\nasync deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise\n\n/**\n * Sign S3 objects to be used by anonymous users in public apps\n * @param s3objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 objects\n */\nasync signS3Objects(s3objects: S3Object[], { expirySecs }: { expirySecs?: number } = {}): Promise\n\n/**\n * Sign S3 object to be used by anonymous users in public apps\n * @param s3object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 object\n */\nasync signS3Object(s3object: S3Object, { expirySecs }: { expirySecs?: number } = {}): Promise\n\n/**\n * Generate a presigned public URL for an array of S3 objects.\n * If an S3 object is not signed yet, it will be signed first.\n * @param s3Objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns list of signed public URLs\n */\nasync getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise\n\n/**\n * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.\n * @param s3Object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed public URL\n */\nasync getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise\n\n/**\n * Get URLs needed for resuming a flow after this step\n * @param approver approver name\n * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.\n * This allows pre-approvals that can be consumed by any later suspend step in the same flow.\n * @returns approval page UI URL, resume and cancel API URLs for resuming the flow\n */\nasync getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only)\n * @param audience audience of the token\n * @param expiresIn Optional number of seconds until the token expires\n * @returns jwt token\n */\nasync getIdToken(audience: string, expiresIn?: number): Promise\n\n/**\n * Convert a base64-encoded string to Uint8Array\n * @param data - Base64-encoded string\n * @returns Decoded Uint8Array\n */\nbase64ToUint8Array(data: string): Uint8Array\n\n/**\n * Convert a Uint8Array to base64-encoded string\n * @param arrayBuffer - Uint8Array to encode\n * @returns Base64-encoded string\n */\nuint8ArrayToBase64(arrayBuffer: Uint8Array): string\n\n/**\n * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Slack approval request.\n * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill.\n * @param {string} options.channelId - The Slack channel ID where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Slack approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * @param {string} [options.resumeButtonText] - Optional text for the resume button.\n * @param {string} [options.cancelButtonText] - Optional text for the cancel button.\n * \n * @returns {Promise} Resolves when the Slack approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getSlackApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveSlackApproval({\n * slackResourcePath: \"/u/alex/my_slack_resource\",\n * channelId: \"admins-slack-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * resumeButtonText: \"Resume\",\n * cancelButtonText: \"Cancel\",\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise\n\n/**\n * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Teams approval request.\n * @param {string} options.teamName - The Teams team name where the approval request will be sent.\n * @param {string} options.channelName - The Teams channel name where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Teams approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * \n * @returns {Promise} Resolves when the Teams approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveTeamsApproval({\n * teamName: \"admins-teams\",\n * channelName: \"admins-teams-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise\n\nsetWorkflowCtx(ctx: WorkflowCtx | null): void\n\nasync sleep(seconds: number): Promise\n\n/**\n * Execute `fn` inline and checkpoint the result. On replay the cached value is\n * returned without re-executing `fn`.\n * \n * `fn`'s result is encoded as JSON and decoded back before it is returned, so\n * the round that runs the body sees the same types every replay sees: a `Date`\n * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.\n * \n * Called from inside a task body, `fn` runs in place and nothing is\n * checkpointed: a task is a single job and is never replayed.\n */\nasync step(name: string, fn: () => T | Promise,): Promise>>\n\n/**\n * Create a task that dispatches to a separate Windmill script.\n * \n * @example\n * const extract = taskScript(\"f/data/extract\");\n * // inside workflow: await extract({ url: \"https://...\" })\n */\ntaskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Create a task that dispatches to a separate Windmill flow.\n * \n * @example\n * const pipeline = taskFlow(\"f/etl/pipeline\");\n * // inside workflow: await pipeline({ input: data })\n */\ntaskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Mark an async function as a workflow-as-code entry point.\n * \n * The function must be **deterministic**: given the same inputs it must call\n * tasks in the same order on every replay. Branching on task results is fine\n * (results are replayed from checkpoint), but branching on external state\n * (current time, random values, external API calls) must use `step()` to\n * checkpoint the value so replays see the same result.\n */\nworkflow(fn: (...args: any[]) => Promise): void\n\n/**\n * Suspend the workflow and wait for an external approval.\n * \n * Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that\n * resume exactly this approval — route them through your own channel. Without a\n * key the steps are named `approval`, `approval_2`, ...\n * \n * `skin: \"minimal\"` shows approvers only the request (form and approve/reject)\n * instead of the detailed page with the workflow's details. `description` is\n * shown above the form: a string, or a rich value such as `{ markdown: \"...\" }`.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * const { value, approver } = await waitForApproval({ key: \"manager\", timeout: 3600 });\n */\nwaitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: \"detailed\" | \"minimal\"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>\n\n/**\n * Resume/cancel/approval-page URLs bound to one `waitForApproval` step.\n * \n * Unlike `getResumeUrls()`, which signs a random nonce, these address the very\n * `resume_job` record the step's built-in approval buttons use, so they are\n * stable across replays and safe to embed in a custom notification.\n * \n * `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique\n * within a workflow; reusing one throws rather than silently renaming it. The URL\n * only resumes while that step is awaiting approval; used at any other moment it is\n * rejected rather than banking a row a different approval would consume. Send it\n * ahead of time — approvers just cannot act before the workflow reaches the step.\n * \n * `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's\n * approval page, which acts on whichever approval is pending when it is used.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * await waitForApproval({ key: \"manager\" });\n */\nasync getApprovalUrls(stepKey: string = \"approval\", approver?: string): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Process items in parallel with optional concurrency control.\n * \n * Each item is processed by calling `fn(item)`, which should be a task().\n * Items are dispatched in batches of `concurrency` (default: all at once).\n * \n * @example\n * const process = task(async (item: string) => { ... });\n * const results = await parallel(items, process, { concurrency: 5 });\n */\nasync parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise\n\n/**\n * Commit Kafka offsets for a trigger with auto_commit disabled.\n * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path)\n * @param topic - Kafka topic name (from event.topic)\n * @param partition - Partition number (from event.partition)\n * @param offset - Message offset to commit (from event.offset)\n */\nasync commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise\n\n/**\n * Parse an S3 object from URI string or record format\n * @param s3Object - S3 object as URI string (`s3://storage/key`, `s3:///key`\n * for the default storage) or record. Any other string throws rather than\n * falling back to an auto-generated key: an auto key is requested by\n * omitting the object, and a fallback would silently misplace the upload\n * on any typo.\n * @returns S3 object record with storage and s3 key\n */\nparseS3Object(s3Object: S3Object): S3ObjectRecord\n\n/**\n * Create a SQL template function for PostgreSQL/datatable queries\n * @param name - Database/datatable name (default: \"main\")\n * @param opts.role - Connect as this data table role instead of the data table's default one.\n * Only meaningful on a data table under roles, and only for a role you are a tenant of.\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.datatable()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}::int\n * `.fetch()\n * @example\n * // Read through a restricted role\n * let sql = wmill.datatable(\"main\", { role: \"analytics\" })\n */\ndatatable(name: string = \"main\", opts?: DatatableOptions): DatatableSqlTemplateFunction\n\n/**\n * Create a SQL template function for DuckDB/ducklake queries\n * @param name - DuckDB database name, optionally with a schema as `name:schema` (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.ducklake()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}\n * `.fetch()\n * @example\n * // Target a specific schema within the ducklake\n * let sql = wmill.ducklake(\"my_lake:analytics\")\n */\nducklake(name: string = \"main\"): SqlTemplateFunction\n\n/**\n * Idempotently materialize `selectSql` into a ducklake table for one\n * partition (or the whole table when `partition` is omitted) — the client-side\n * equivalent of the `// materialize` engine.\n * With `uniqueKey` it upserts the slice (delete-by-key + insert); otherwise it\n * replaces it (whole table → `CREATE OR REPLACE`; partition → delete + insert).\n * Safe to re-run for the same partition (backfill / failure-recovery).\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.upsertPartition({ table, selectSql, partition }).execute()`.\n */\nupsertPartition(opts: DucklakeMaterializeOptions): SqlStatement\n\n/**\n * INSERT-only materialization (no dedup/replace) for append-only tables.\n * Re-running the same partition duplicates rows — use only for immutable\n * event-log sources.\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.appendPartition({ table, selectSql, partition }).execute()`.\n */\nappendPartition(opts: Omit,): SqlStatement\n",
"script.dbt": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (dbt)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n",
"flow": "# Global draft flow instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Never invent a folder.\n- `write_flow` mirrors flow mode's `set_flow_json`: pass `path`, optional `summary`, optional `description`, required `modules`, and optional `schema`, `preprocessor_module`, `failure_module`, `groups`, and `notes`. `summary` and `description` are top-level flow metadata (not part of the compact value `patch_flow_json` edits); the flow-structure arguments are JSON strings, matching the tool schema descriptions. When overwriting an existing flow, top-level flow settings (see below) are preserved from the current flow — use `patch_flow_json` to change them.\n- `read_workspace_item` returns a compact flow `value` object with `modules`, `schema`, `preprocessor_module`, `failure_module`, `groups`, `notes`, and any top-level flow settings that are set.\n- Top-level flow settings appear as top-level keys of the compact flow value and can be added, edited, or removed with `patch_flow_json`: same_worker, preserve_step_tags, concurrent_limit, concurrency_key, concurrency_time_window_s, debounce_delay_s, debounce_key, debounce_args_to_accumulate, max_total_debouncing_time, max_total_debounces_amount, skip_expr, cache_ttl, cache_ignore_s3_path, delete_after_secs, flow_env, priority, early_return, chat_input_enabled. For example, `chat_input_enabled: true` marks a flow as chat-style (flow-as-chat); keep it intact when restructuring such a flow.\n- `modules` contains normal sequential modules. Use top-level `preprocessor_module` and `failure_module` for special modules; do not put `preprocessor` or `failure` in `modules`.\n- Every module needs a stable unique `id` and a useful `summary` when the schema supports it.\n- Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed.\n- When writing rawscript module code, call `get_instructions` with `subject: \"script\"` and the rawscript language first.\n\n## Groups and notes\n\n- Add `groups` to any non-trivial flow without being asked; \"Organizing Flows: Groups and Notes\" in the reference below gives the fields and the rules the editor enforces. Pass `null` to `write_flow` to clear them, and the same for `notes`.\n- With `patch_flow_json`, edit `groups` and `notes` the same way as any other field — they appear as top-level keys in the compact flow value.\n\n## Compact view: how rawscript bodies surface in tool I/O\n\n- `read_workspace_item` and `patch_flow_json` operate on a **compact view** of the flow: every rawscript module's `value.content` is replaced with the placeholder `\"inline_script.\"` so inline script bodies don't bloat tool I/O. Schema, groups, notes, preprocessor_module and failure_module are all shown in this view.\n- Inline rawscript content is **not** part of the JSON `patch_flow_json` sees. Edits to inline bodies happen via dedicated tools:\n - `read_flow_module_code(path, module_id)` — returns the raw inline script content for one module.\n - `set_flow_module_code(path, module_id, code)` — overwrites that module's inline script content; saves to the draft.\n- Use `patch_flow_json` for *structural* edits: module ids, paths, input_transforms, branch arrangement, summaries, preprocessor/failure swaps, schema/groups/notes. Use `set_flow_module_code` for changes inside a specific rawscript body.\n- When **adding a new rawscript module** with `patch_flow_json`, set its `content` to the placeholder `\"inline_script.\"` (matching the compact view) — never real code — then **immediately fill the body** with `set_flow_module_code(path, module_id, code)`. The module is saved with an empty body until you do; the tool result lists the module ids still awaiting code. A placeholder that names anything other than the module's own id or an existing rawscript module is rejected.\n- `write_flow` is for full overwrites / create-from-scratch. Its `modules`, `preprocessor_module`, and `failure_module` arguments are **non-compact** flow modules: inline each rawscript body directly in `content` by default. But if a body is long or quote/backslash-heavy enough that escaping it into the JSON string is error-prone — or if a `write_flow` call comes back with a JSON parse error — create that module with **empty content** (`\"content\": \"\"`) and fill it with `set_flow_module_code(path, module_id, code)`, whose `code` is a plain argument with no nested escaping. `write_flow` reports which modules still have empty bodies so you know what to fill.\n - When overwriting an **existing** flow, set `\"content\": \"inline_script.\"` on any rawscript module whose code you are not changing — the placeholder resolves to that module's current body, so you never re-send (or re-read) unchanged code. Placeholders that match no existing rawscript module and are not the module's own id are rejected.\n\n\n# Windmill flow authoring reference\n\n# Windmill Flow Building Guide\n\n## OpenFlow Schema\n\nThe OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions.\n\n## Reserved Module IDs\n\n- `failure` - Reserved for failure handler module\n- `preprocessor` - Reserved for preprocessor module\n- `Input` - Reserved for flow input reference\n\n## Hard Structural Rules\n\nThese are strict Windmill schema rules. Follow them exactly.\n\n- `value.modules` is only for normal sequential steps\n- `value.preprocessor_module` and `value.failure_module` are special top-level fields inside `value`, not entries in `value.modules`\n- If a flow needs a preprocessor, create `value.preprocessor_module` with `id: preprocessor`\n- If a flow needs a failure handler, create `value.failure_module` with `id: failure`\n- Do NOT create regular modules inside `value.modules` named `preprocessor` or `failure`\n- `preprocessor_module` and `failure_module` only support `script` or `rawscript`\n- `preprocessor_module` runs before normal modules and cannot reference `results.*`\n- `failure_module` can use the `error` object with `error.message`, `error.step_id`, `error.name`, and `error.stack`\n- A flow whose `failure_module` runs still ends as failed, unless the handler returns an object with `recover: true`: the flow then ends as a success. This also holds when the failing step is inside a loop, a branch or a subflow. `recover` never changes which steps run; whether a loop or branch carries on past a failure is still decided by its own settings (`skip_failures`, `continue_on_error`)\n\nCorrect shape:\n\n```yaml\nvalue:\n preprocessor_module:\n id: preprocessor\n value:\n type: rawscript\n ...\n failure_module:\n id: failure\n value:\n type: rawscript\n ...\n modules:\n - id: process_event\n value:\n type: rawscript\n ...\n```\n\nIncorrect shape:\n\n```yaml\nvalue:\n modules:\n - id: preprocessor\n ...\n - id: process_event\n ...\n - id: failure\n ...\n```\n\n## Module ID Rules\n\n- Must be unique across the entire flow\n- Use underscores, not spaces (e.g., `fetch_data` not `fetch data`)\n- Use descriptive names that reflect the step's purpose\n\n## AI Agent Modules\n\nAn `aiagent` module runs an LLM that can call tools. Each entry of `value.tools` is a module-shaped\nobject with an extra `value.tool_type`: `flowmodule` for a script/flow tool, `mcp` for an MCP server\ntool, `websearch` for web search.\n\n```json\n{\n \"id\": \"support_agent\",\n \"summary\": \"AI agent for customer support\",\n \"value\": {\n \"type\": \"aiagent\",\n \"input_transforms\": {\n \"provider\": {\n \"type\": \"static\",\n \"value\": { \"kind\": \"openai\", \"resource\": \"$res:f/ai_providers/openai\", \"model\": \"gpt-4o\" }\n },\n \"output_type\": { \"type\": \"static\", \"value\": \"text\" },\n \"user_message\": { \"type\": \"javascript\", \"expr\": \"flow_input.query\" },\n \"system_prompt\": { \"type\": \"static\", \"value\": \"You are a helpful assistant.\" }\n },\n \"tools\": [\n {\n \"id\": \"search_docs\",\n \"summary\": \"search_documentation\",\n \"description\": \"Search the product documentation. Use it whenever the user asks how a feature works.\",\n \"value\": {\n \"tool_type\": \"flowmodule\",\n \"type\": \"rawscript\",\n \"language\": \"bun\",\n \"content\": \"export async function main(query: string) { return ['doc1', 'doc2']; }\",\n \"input_transforms\": { \"query\": { \"type\": \"static\", \"value\": \"\" } }\n }\n }\n ]\n }\n}\n```\n\n- `provider` is an object, not a bare resource string: `{ \"kind\": ,\n \"resource\": \"$res:\", \"model\": }`. Required unless the module links to a saved\n agent through `value.agent`. Static is right for a flow run from a form; a chat flow wires its\n fields to flow inputs instead — see below\n\n### Chat-Mode Flows\n\nA flow with `value.chat_input_enabled: true` is run from a chat instead of a form: the composer\nsends one message per turn and renders the conversation. It needs a required `user_message` string\ninput, read by the agent. Any other flow input the composer does not edit itself is asked for\nunder Configure inputs.\n\n**A static `provider` gives a chat that cannot change its model.** Feed it from flow inputs\ninstead, either way round: one input carrying the whole object (`\"expr\": \"flow_input.model_config\"`)\nmakes every field editable, or wire it field by field to fix some and expose others. A field the\nchat can write becomes a control in the composer — a provider picker, a model list, a thinking\ncontrol — and a field left static is fixed, with no control drawn for it. `kind` is the one\nexception: the composer writes it only together with `resource`, since a provider is picked as a\npair, so a `kind` input wired on its own stays askable under Configure inputs and nothing the run\nneeds becomes unreachable.\n\n```json\n{\n \"id\": \"chat_agent\",\n \"value\": {\n \"type\": \"aiagent\",\n \"input_transforms\": {\n \"provider\": {\n \"type\": \"javascript\",\n \"expr\": \"({ kind: 'anthropic', resource: '$res:f/ai/claude', model: flow_input.model, reasoning_effort: flow_input.thinking })\"\n },\n \"user_message\": { \"type\": \"javascript\", \"expr\": \"flow_input.user_message\" },\n \"user_attachments\": { \"type\": \"javascript\", \"expr\": \"flow_input.files\" },\n \"memory\": { \"type\": \"static\", \"value\": { \"kind\": \"compaction\" } },\n \"streaming\": { \"type\": \"static\", \"value\": true },\n \"output_type\": { \"type\": \"static\", \"value\": \"text\" }\n },\n \"tools\": []\n }\n}\n```\n\n- Wiring field by field means one object literal whose values are literals or bare `flow_input.x`\n references. A spread, a call or a computed key leaves the composer unable to tell which input\n feeds which field, so it offers no control at all — a bare `flow_input.x` for the whole object\n is read instead as that one input carrying every field\n- A step linked to a saved agent (`agent` set) has no `provider` of its own. It exposes the same\n two controls through its `model` and `reasoning_effort` inputs, which replace what the agent\n saved: `\"model\": { \"type\": \"javascript\", \"expr\": \"flow_input.model\" }`. An empty model keeps the\n saved one, an empty effort sends none, and the provider resource always stays the agent's\n- `memory` is what lets the agent see earlier turns; without it every message starts from nothing\n- `streaming` on makes the answer and its thinking appear token by token instead of all at once\n- `user_attachments` points at a flow input typed as an array of s3 objects\n (`{ \"type\": \"array\", \"items\": { \"type\": \"object\", \"resourceType\": \"s3object\" } }`), so files\n sent with a message reach the agent\n- Running one needs a `memory_id` **query parameter** — not a flow argument — naming the\n conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat\n supplies it itself; a run driven any other way has to pass it or the server refuses the job\n\n### Tool Naming Rules\n\nThese rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` tool's\n`summary` is a plain label (`Web Search`), and an `mcp` tool exposes the MCP server's own tool\nnames, so neither is name-checked at all — leave those summaries as they are.\n\n- A flowmodule tool's `summary` is the **name the agent calls it by**, not a human label. Put the\n human-readable explanation in `description`\n- `summary` must match `^[a-zA-Z0-9_]+$`: letters, numbers and underscores only. No spaces, dashes,\n dots or accents — `search_documentation`, never `Search documentation`\n- Always set `summary`. It must be unique among that agent's tools, and must not be one of the\n reserved ids (`do`, `bg`, `ctx`, `state`, `if`, `else`, `for`, `delete`, `while`, `new`, `in`,\n `failure`, `preprocessor`, `as`, `Input`, `Result`, `Trigger`)\n- A tool name outside that character set fails any run that offers the tool to the agent, with `Invalid tool name`.\n The flow write tools refuse such a name.\n- Tool `id` follows the same rules as any module ID — unique across the flow, underscores not spaces\n- `description` is optional free text telling the agent when and how to call the tool. Set it\n whenever the name alone does not make that obvious; it overrides the description derived from the\n underlying script\n\n## AI Decision Modules\n\nAn `aidecision` module asks a decision model (TypeSafe's Jev, Cloudflare's Clef or OpenAI's Decisions API) typed questions about a `state` and\nanswers each with calibrated probabilities instead of text. Prefer it over an `aiagent` when the step\nis a judgment (classify, route, score or a yes/no check) that needs no tools and no free text: it\nis faster, cheaper, and its answers have a fixed shape.\n\n```json\n{\n \"id\": \"triage\",\n \"summary\": \"Classify the ticket\",\n \"value\": {\n \"type\": \"aidecision\",\n \"input_transforms\": {\n \"provider\": {\n \"type\": \"static\",\n \"value\": { \"kind\": \"typesafe\", \"resource\": \"$res:f/ai/typesafe\", \"model\": \"jev-latest\" }\n },\n \"state\": { \"type\": \"javascript\", \"expr\": \"flow_input.message\" },\n \"questions\": {\n \"type\": \"static\",\n \"value\": {\n \"intent\": {\n \"type\": \"choice\",\n \"instructions\": \"What does the customer want?\",\n \"criteria\": { \"refund\": \"Money back\", \"bug\": \"Something is broken\", \"other\": \"Anything else\" }\n },\n \"urgency\": {\n \"type\": \"score\",\n \"instructions\": \"How urgent is the ticket?\",\n \"criteria\": [\"Can wait\", \"This week\", \"Today\", \"Right now\"]\n },\n \"angry\": { \"type\": \"noul\", \"instructions\": \"Is the customer angry?\" }\n }\n }\n }\n }\n}\n```\n\n- `provider.kind` is `typesafe` (`model` `jev-latest` unless a version is pinned), `cloudflare`\n (`model` `clef`, or `clef-flash` for faster answers) or `openai` (`model` `gpt-6-luna`, never a\n chat model); the resource is of that same type\n- `state` is the content to evaluate: usually a text, such as the message to classify. To combine\n several values, pass an object with descriptive keys holding only what the questions need\n (`({ message: flow_input.message, plan: results.get_account.plan })`); an array of strings also works\n- `questions` maps each question name to `{ type, instructions, criteria }`:\n - `choice`: `criteria` maps each option to its description (up to 255 options). The answer has\n `choice`, `probabilities` and `confidence`\n - `score`: `criteria` is an ordered array of 2 to 10 level descriptions. The answer has `score`,\n `legend`, `probabilities` and `confidence`\n - `noul`: yes/no, `criteria` optionally `{ \"true\": ..., \"false\": ... }` descriptions. The answer has\n `noul`, the probability of yes from 0 to 1\n- The result is `{ output, model, usage }` with `output` keyed by question name, so a later step\n reads `results.triage.output.intent.choice`, `results.triage.output.urgency.score` or\n `results.triage.output.angry.noul > 0.7`\n- On `openai` the same `questions` are translated for its Decisions API and the answers read the\n same way (`choice`, `score`, `noul`), except that `probabilities` is a list of\n `{ value, probability }` (plus `label` on a score) and a score has no `legend`. A question OpenAI\n refuses to answer fails the step. A `state` that is\n an array of user messages (`[{ role: \"user\", content: [{ type: \"input_text\", text }, { type: \"input_image\", image_url: \"data:image/png;base64,...\" }] }]`)\n is sent as is, which is how an image is evaluated; any other object or array is sent as JSON text\n\n### Branching on the Answers\n\nTo route on the answers, put a `branchone` right after the decision, one branch per option, with\neach `expr` reading the decision by id:\n\n```json\n{\n \"id\": \"route\",\n \"value\": {\n \"type\": \"branchone\",\n \"branches\": [\n { \"summary\": \"refund\", \"expr\": \"results.triage.output.intent.choice === 'refund'\", \"modules\": [...] },\n { \"summary\": \"bug\", \"expr\": \"results.triage.output.intent.choice === 'bug'\", \"modules\": [...] }\n ],\n \"default\": [...]\n }\n}\n```\n\nFor a yes/no question, one branch with `\"expr\": \"results.triage.output.angry.noul >= 0.5\"` (tune the\nthreshold as needed) and the `default` as the \"no\" path.\n\n### As an Agent Tool\n\nAn `aidecision` can be a `flowmodule` tool of an `aiagent` (`\"tool_type\": \"flowmodule\", \"type\":\n\"aidecision\"`): set `state` to `{ \"type\": \"ai\" }` so the calling model supplies it, and only `output`\ngoes back to the model. The Tool Naming Rules apply to its `summary`. Only a flow's own agent step\ncan call one, not an agent used as a tool.\n\n## Common Mistakes to Avoid\n\n- Missing `input_transforms` - Rawscript parameters won't receive values without them\n- Referencing future steps - `results.step_id` only works for steps that execute before the current one\n- Duplicate module IDs - Each module ID must be unique in the flow\n- AI agent flowmodule tool names with spaces - `summary` is the tool name and only accepts letters, numbers and underscores\n\n## Data Flow Between Steps\n\n- `flow_input.property` - Access flow input parameters\n- `results.step_id` - Access output from a previous step only when that step result is in scope\n- `results.step_id.property` - Access specific property from a previous step output only when that step result is in scope\n- `flow_input.iter.value` - Current iteration value inside a `forloopflow`; in a `whileloopflow` it is just the iteration index (a plain number, same as `flow_input.iter.index`)\n- `flow_input.iter.index` - Current loop index when inside a loop (`forloopflow` or `whileloopflow`)\n- After a suspend step (approval), the next step's javascript expressions also get:\n - `resume` - the last resume payload (the resume form values); with `continue_on_disapprove_timeout`, a disapproval or timeout yields `{ error: { name: \"SuspendedDisapproved\" | \"SuspendedTimedOut\", message } }` instead\n - `resumes` - every resume payload, in arrival order\n - `approvers` - a plain array of strings, one per resume: for a resume through the `resume`/`cancel` URL from `getResumeUrls(approver)`, the `approver` passed there if any; otherwise (including the `approvalPage` link) the resuming user's username, else `\"anonymous\"`, e.g. `approvers[0]`. It is NOT the `{ resume_id, approver }` objects stored in the flow status, so `approvers[0].approver` is always undefined\n\n## Loop Structure Rules\n\n- A `forloopflow` runs its `modules` once per element of `iterator`, a javascript expression returning an array (e.g. `results.get_items`); `parallel: true` runs the iterations concurrently, and `skip_failures: true` carries on past a failed iteration\n- For `whileloopflow`, break the loop with a module-level `stop_after_if`: on the loop module itself, or on an inner step (required when that step carries state via its own `results` — see below)\n- `stop_after_if` is always a sibling of `id` and `value` on a flow module — never a direct key of the loop's `value` object\n- `stop_after_all_iters_if` is for checks after the whole loop finishes, not the normal per-iteration break condition\n- `stop_after_if` is evaluated after each iteration: on the loop module, `result` is that iteration's result (what its last step returned); on an inner step, it is that step's result\n- `flow_input.iter.value` in a `whileloopflow` is just the iteration index (same number as `flow_input.iter.index`) — it never carries state, so `flow_input.iter.value.` is always undefined and a loop whose stop condition depends on it never terminates\n- To carry state across iterations, a step reads its own previous-iteration result via `results.` with a first-iteration fallback (e.g. `results.b ?? flow_input.start`) — but then the loop's `stop_after_if` MUST sit on that inner step, not on the loop module: a body that is exactly one plain step with the stop condition on the loop module runs on a fast path where `results.