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

This commit is contained in:
Guilhem Lemouel
2026-10-26 10:23:17 -07:00
325 changed files with 13640 additions and 3030 deletions
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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
+3 -1
View File
@@ -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
+2 -1
View File
@@ -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:
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "1.847.0"
".": "1.849.0"
}
+1 -1
View File
@@ -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
+57
View File
@@ -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)
+5 -2
View File
@@ -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
+13
View File
@@ -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.
+112 -141
View File
@@ -3,10 +3,10 @@
</p>
<p align=center>
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.
<p align=center>
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.
</p>
<p align="center">
@@ -34,7 +34,7 @@ Scripts are turned into sharable UIs automatically, and can be composed together
<a href="https://app.windmill.dev">Try it</a> - <a href="https://www.windmill.dev/">Website</a> - <a href="https://www.windmill.dev/docs/intro/">Docs</a> - <a href="https://discord.gg/V7PM2YHsPB">Discord</a> - <a href="https://hub.windmill.dev">Hub</a> - <a href="./CONTRIBUTING.md">Contributing</a>
</p>
# 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
<p align="center">
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.
<img src="./imgs/diagram.svg">
</p>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./imgs/architecture-dark.svg">
<img alt="Windmill architecture: business teams and engineers build through stateless API servers into workspaces, where changes move from dev and forks to prod after review. PostgreSQL holds the job queue and state. Worker groups for dev and prod, native workers and remote agent workers pull jobs by tag and reach your databases, APIs and AI model providers." src="./imgs/architecture-light.svg">
</picture>
## 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 <sales@windmill.dev>. 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 <sales@windmill.dev>.
- 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 <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.
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 <https://app.windmill.dev> 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=<YOUR_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=<YOUR_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
<a href="https://github.com/windmill-labs/windmill/graphs/contributors">
<img src="https://contrib.rocks/image?repo=windmill-labs/windmill" />
</a>
To run Windmill from source, see [frontend/README_DEV.md](./frontend/README_DEV.md).
## Copyright
+9
View File
@@ -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: <n>`: 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.
@@ -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,
+310 -16
View File
@@ -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<string, unknown>
description?: string
/** A database's tables, as `get_db_schema` reads them: schema, table, then column. */
dbSchema?: Record<string, Record<string, Record<string, BenchmarkDbColumn>>>
}
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<string, unknown>
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/<user>` or `g/<group>`: `true` grants write. */
extra_perms?: Record<string, boolean>
}
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<string, unknown>
}
type BenchmarkCompletedJob = CompletedJob & { type: 'CompletedJob' }
type BenchmarkQueuedJob = QueuedJob & { type: 'QueuedJob' }
const benchmarkWorkspaces = new Set<string>()
const benchmarkWorkspaceRunnables = new Map<string, BenchmarkWorkspaceRunnables>()
// 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<string, { workspace: string; job: BenchmarkCompletedJob }>()
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<string, Record<string, unknown>>()
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<BenchmarkWorkspaceResource['dbSchema']> } | 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<string, unknown> }).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<string, unknown> {
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<string, unknown>; 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) =============
/**
@@ -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<any>(
'$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<any>('$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<string, unknown>) =>
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)
+650
View File
@@ -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
+2
View File
@@ -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<EvalCase[]> {
assistantExpect: entry.assistantExpect,
judgeChecklist: entry.judgeChecklist,
skipJudge: entry.skipJudge,
judgeAssistantText: entry.judgeAssistantText,
runtime: entry.runtime,
}));
}
+13
View File
@@ -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",
+6 -3
View File
@@ -245,15 +245,18 @@ async function runCaseAttempts<TInitial, TExpected, TActual>(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,
});
+8 -1
View File
@@ -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;
}
@@ -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<unknown>()\n\treturn (\n\t\t<div>\n\t\t\t<h1>Ops console</h1>\n\t\t\t<input value={domain} onChange={(e) => setDomain(e.target.value)} />\n\t\t\t<button onClick={async () => setResult(await backend.check_ssl({ domain }))}>Check SSL</button>\n\t\t\t<pre>{JSON.stringify(result, null, 2)}</pre>\n\t\t</div>\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"
]
}
]
}
}
@@ -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"
}
}
]
}
}
@@ -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" }
]
}
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "SELECT value AS \"value: sqlx::types::Json<serde_json::Value>\"\n FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "value: sqlx::types::Json<serde_json::Value>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
true
]
},
"hash": "56bb398d53c1c54b27fe3b61b866c752ea54871db71a8859311e18bf6b6da82c"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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<Box<RawValue>>\"\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<Box<RawValue>>",
"type_info": "Json"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
false,
false,
false,
true,
false,
true,
true,
null,
null
]
},
"hash": "cc2dbdc132fe751c7e82445aae5031d05da988837ce41d63a43d13c2018a57d3"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+114 -114
View File
@@ -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",
+2 -2
View File
@@ -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 <ruben@windmill.dev>"]
edition = "2021"
+1 -1
View File
@@ -1 +1 @@
9a5ff8e4e562930d57d29e2e90f17f6b82ee96a5
943c39b56522f7980385a750ba5e9ca5af4df5a8
@@ -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;
@@ -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);
@@ -0,0 +1,3 @@
ALTER TABLE workspace_settings
DROP COLUMN IF EXISTS variable_expiration_handler,
DROP COLUMN IF EXISTS variable_expiration_failed_at;
@@ -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;
@@ -0,0 +1 @@
UPDATE folder SET extra_perms = extra_perms - 'g/variable_expiration_handler' WHERE name = 'slack_bot';
@@ -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';
+30 -2
View File
@@ -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",
+24 -24
View File
@@ -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",
@@ -12,7 +12,7 @@ resolver = "2"
members = ["."]
[workspace.package]
version = "1.847.0"
version = "1.849.0"
edition = "2021"
authors = ["Ruben Fiszel <ruben@windmill.dev>"]
+11
View File
@@ -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,
+2 -2
View File
@@ -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)
+55
View File
@@ -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<Postgres>,
) -> 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(())
}
+311
View File
@@ -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<Postgres>, path: &str) -> anyhow::Result<Value> {
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<Postgres>) -> 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(())
}
+5 -2
View File
@@ -110,10 +110,13 @@ async fn test_run_mode_strips_caller_lock_and_modules(db: Pool<Postgres>) -> 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)
+8 -2
View File
@@ -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<Postgres>, payload: JobPayload) -> Option<serde_json::Value> {
let caller: Box<RawValue> = 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()),
+1
View File
@@ -511,6 +511,7 @@ def main():
)
.unwrap(),
policy: None,
execution_mode: None,
deployment_message: None,
custom_path: None,
preserve_on_behalf_of: None,
@@ -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<Postgres>,
) -> 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(())
}
+436
View File
@@ -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<RawValue> {
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<Postgres>) -> 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<empty>')"#,
)
.bind(SCRIPT_HASH)
.bind(SCRIPT_PATH)
.bind(SCRIPT_CONTENT)
.execute(db)
.await?;
Ok(())
}
async fn job_args_and_entrypoint(
db: &Pool<Postgres>,
id: uuid::Uuid,
) -> anyhow::Result<(Value, Option<String>)> {
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<Postgres>) -> 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<Postgres>,
) -> 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<Postgres>,
) -> 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<Postgres>) -> 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(())
}
+534
View File
@@ -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<Postgres>, 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<Postgres>, 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<Postgres>, 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<Postgres>, path: &str) -> anyhow::Result<i64> {
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<Postgres>) -> 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<Postgres>) -> 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<Postgres>) -> 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<Postgres>) -> 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<u16> {
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<chrono::Utc> = 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<Postgres>) -> 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<String> =
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<Postgres>) -> 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<Postgres>) -> 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<Postgres>) -> 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<Postgres>,
) -> 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<Postgres>) -> 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<u16> {
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<Option<serde_json::Value>> {
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(())
}
+42
View File
@@ -288,6 +288,48 @@ async fn test_tarball_export_gates_values_on_item_scopes(db: Pool<Postgres>) ->
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<Postgres>) -> 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
+3 -1
View File
@@ -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(
+15
View File
@@ -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<String>,
/// 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<AnthropicFederation>,
pub platform: AIPlatform,
pub custom_headers: HashMap<String, String>,
}
/// 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<String>,
}
@@ -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<FederatedAccessToken, Error> {
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<FederatedAccessToken, Error> {
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::<FederatedTokenResponse>()
.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<FederatedAccessToken>,
client: &AuthedClient,
) -> Result<Option<&'a str>, 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(),
}
@@ -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(),
}
+1
View File
@@ -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(),
}
+1
View File
@@ -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(),
}
+86 -1
View File
@@ -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<String>,
/// 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<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub organization_id: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub service_account_id: Option<String>,
#[serde(default, deserialize_with = "empty_string_as_none")]
pub workspace_id: Option<String>,
/// 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<String>,
@@ -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<ProviderCredentials, Error> {
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<Option<AnthropicFederation>, 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<String>, 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]
+171
View File
@@ -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<String>,
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<serde_json::Value>\"
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<windmill_common::flows::AgentTool> = 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,
})
}
+1
View File
@@ -7,6 +7,7 @@
*/
pub mod auth;
pub mod composed;
#[cfg(feature = "private")]
pub mod ee;
pub mod ee_oss;
+43
View File
@@ -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<String>,
}
#[cfg(all(feature = "enterprise", feature = "private"))]
async fn test_autoscaling(
authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(worker_group): Path<String>,
Json(test): Json<windmill_autoscaling::autoscaling_ee::TestAutoscaling>,
) -> error::JsonResult<TestAutoscalingResponse> {
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<TestAutoscalingResponse> {
Err(error::Error::BadRequest(
"Autoscaling available only in the enterprise version".to_string(),
))
}
async fn list_available_python_versions() -> error::JsonResult<Vec<String>> {
#[cfg(not(all(feature = "python", feature = "run_inline")))]
return Err(error::Error::BadRequest(
+43 -85
View File
@@ -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<UserDB>,
@@ -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 {
+3 -2
View File
@@ -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(),
));
}
@@ -325,5 +325,155 @@ async fn test_operator_builder_flows_boundary(db: Pool<Postgres>) -> 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<Postgres>,
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(())
}
@@ -406,3 +406,62 @@ async fn test_operators_draft_resources_and_variables(db: Pool<Postgres>) -> 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<Postgres>) -> 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(())
}
@@ -339,6 +339,74 @@ async fn test_variables_are_cached(db: Pool<Postgres>) -> 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<Postgres>) -> 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::<Vec<serde_json::Value>>().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<Postgres>) -> 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"))]
@@ -2770,3 +2770,83 @@ async fn test_compare_secret_variables_by_value(db: Pool<Postgres>) -> 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<Postgres>) -> 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<String> = 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::<serde_json::Value>()
.await?;
assert_eq!(
comparison["summary"]["variables_changed"].as_u64(),
Some(1),
"an expiry-only change must show as deployable: {comparison}"
);
Ok(())
}
+34 -3
View File
@@ -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 ?"));
}
}

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