diff --git a/.github/workflows/codex-pr-review.yml b/.github/workflows/codex-pr-review.yml index 2d70ad7f7a..e1628729d8 100644 --- a/.github/workflows/codex-pr-review.yml +++ b/.github/workflows/codex-pr-review.yml @@ -42,12 +42,14 @@ jobs: # skips auto-review for every private member. # Dependabot-triggered runs get a separate secret scope, so this job has no API # key and cannot review. workflow_call stays open for an explicit request. + # `[skip-ai-review]` in the PR body opts out of the automatic run only. if: | github.event_name == 'workflow_call' || ( github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false && - github.event.pull_request.user.login != 'dependabot[bot]' + github.event.pull_request.user.login != 'dependabot[bot]' && + !contains(github.event.pull_request.body, '[skip-ai-review]') ) permissions: contents: read diff --git a/.github/workflows/pi-pr-review.yml b/.github/workflows/pi-pr-review.yml index a18dc28ff2..4b57db7b78 100644 --- a/.github/workflows/pi-pr-review.yml +++ b/.github/workflows/pi-pr-review.yml @@ -40,12 +40,14 @@ jobs: # skips auto-review for every private member. # Dependabot-triggered runs get a separate secret scope, so this job has no API # key and cannot review. workflow_call stays open for an explicit request. + # `[skip-ai-review]` in the PR body opts out of the automatic run only. if: | github.event_name == 'workflow_call' || ( github.event.pull_request.draft == false && github.event.pull_request.head.repo.fork == false && - github.event.pull_request.user.login != 'dependabot[bot]' + github.event.pull_request.user.login != 'dependabot[bot]' && + !contains(github.event.pull_request.body, '[skip-ai-review]') ) permissions: contents: read diff --git a/.github/workflows/pr-ready-review.yml b/.github/workflows/pr-ready-review.yml index 90714cba53..4b88273cad 100644 --- a/.github/workflows/pr-ready-review.yml +++ b/.github/workflows/pr-ready-review.yml @@ -39,12 +39,14 @@ jobs: # skips auto-review for every private member. # Dependabot-triggered runs get a separate secret scope, so this job has no API # key and cannot review. workflow_call stays open for an explicit request. + # `[skip-ai-review]` in the PR body opts out of the automatic run only. if: | github.event_name == 'workflow_call' || ( (github.event.pull_request.draft == false || github.event.pull_request.ready_for_review == true) && github.event.pull_request.head.repo.fork == false && - github.event.pull_request.user.login != 'dependabot[bot]' + github.event.pull_request.user.login != 'dependabot[bot]' && + !contains(github.event.pull_request.body, '[skip-ai-review]') ) permissions: contents: read diff --git a/.github/workflows/weekly-pr-summary.yml b/.github/workflows/weekly-pr-summary.yml index 9d1f0631fb..14551e9943 100644 --- a/.github/workflows/weekly-pr-summary.yml +++ b/.github/workflows/weekly-pr-summary.yml @@ -39,9 +39,10 @@ jobs: - This gives you the exact 7-day window (store these in variables for use in commands) 2. **Fetch ONLY Merged PRs from Past Week**: - - Command: `gh pr list --repo ${{ github.repository }} --state merged --search "merged:>=$CUTOFF_DATE" --limit 100 --json number,title,author,mergedAt,url` + - Command: `gh pr list --repo ${{ github.repository }} --state merged --search "merged:>=$CUTOFF_DATE" --limit 100 --json number,title,author,mergedAt,url,body --jq '[.[] | select((.body // "") | contains("[skip-ai-review]") | not) | del(.body)]'` - This returns ONLY PRs that were merged in the last 7 days - The --search flag filters by merge date using GitHub's search syntax + - The --jq filter drops PRs whose body contains `[skip-ai-review]`; never fetch or mention those PRs - **FILTER OUT** any PRs with titles starting with "chore: release" or "chore(release)" 3. **Categorize PRs**: Group PRs into three categories by analyzing titles and labels: diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ef65812b73..9edb1a8e87 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "1.847.0" + ".": "1.849.0" } diff --git a/AGENTS.md b/AGENTS.md index c2aae7159d..835c8a4edb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,7 @@ Open-source platform for internal tools, workflows, API integrations, background enforced rather than cosmetic, and why a right that is granted-unless-withdrawn needs `Option` fields and a jsonb merge rather than a serde default - **Operator builder rights**: `docs/operator-builder-rights.md` — the workspace settings that let - operators compose flows and full-code apps, what the composition check must cover, which + operators compose flows, AI agents and full-code apps, what the composition check must cover, which raw-app endpoint stays closed on purpose, and why either right costs a full seat - **Auth surface**: `docs/auth-surface.md` — credential precedence, session/cache invalidation scope, which token labels email their owner at expiry, how OAuth login matches `login_type`, and diff --git a/CHANGELOG.md b/CHANGELOG.md index 12e61b56bc..d0fccb9912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,62 @@ # Changelog +## [1.849.0](https://github.com/windmill-labs/windmill/compare/v1.848.3...v1.849.0) (2026-10-25) + + +### Features + +* per-user Anthropic Workload Identity Federation for Windmill AI ([#11998](https://github.com/windmill-labs/windmill/issues/11998)) ([6c8fd6c](https://github.com/windmill-labs/windmill/commit/6c8fd6cfe2057f2822230bdf9ad2058f537df996)) + +## [1.848.3](https://github.com/windmill-labs/windmill/compare/v1.848.2...v1.848.3) (2026-10-25) + + +### Bug Fixes + +* pin node to 24.22.1 in the worker image for CVE-2026-111497 ([#11995](https://github.com/windmill-labs/windmill/issues/11995)) ([0e79e93](https://github.com/windmill-labs/windmill/commit/0e79e934b6342150ee31de08a19400de482b1969)) + +## [1.848.2](https://github.com/windmill-labs/windmill/compare/v1.848.1...v1.848.2) (2026-10-24) + + +### Bug Fixes + +* tighten file handling in deno/bun, stricter codebase tarball,executor isolation ([#11990](https://github.com/windmill-labs/windmill/issues/11990)) ([ccf8892](https://github.com/windmill-labs/windmill/commit/ccf889269b8897be707cde639b2531d522c286c9)) + +## [1.848.1](https://github.com/windmill-labs/windmill/compare/v1.848.0...v1.848.1) (2026-10-23) + + +### Bug Fixes + +* restore workspace pg_dump changes reverted by 812ff6ea5e ([#11988](https://github.com/windmill-labs/windmill/issues/11988)) ([45d3c95](https://github.com/windmill-labs/windmill/commit/45d3c951bb8827c67daaaf75ed52f3c3e1215911)) +* use linkedin openid connect default scopes ([#11953](https://github.com/windmill-labs/windmill/issues/11953)) ([001313a](https://github.com/windmill-labs/windmill/commit/001313aa242c6de053a9deb0c6cdb64580ce1cee)) + +## [1.848.0](https://github.com/windmill-labs/windmill/compare/v1.847.0...v1.848.0) (2026-10-23) + + +### Features + +* add a use/plan/build mode picker and read-only prod sessions ([#11940](https://github.com/windmill-labs/windmill/issues/11940)) ([d6c2b2c](https://github.com/windmill-labs/windmill/commit/d6c2b2c68344bb95939701c418116b2e7d4715a3)) +* composer pills that teach Windmill from the workspace ([#11889](https://github.com/windmill-labs/windmill/issues/11889)) ([ebeea51](https://github.com/windmill-labs/windmill/commit/ebeea514926f60295f3f11f516f4ccda3448f829)) +* fill docusign, salesforce and quickbooks fields on connect ([#11960](https://github.com/windmill-labs/windmill/issues/11960)) ([11f2a4c](https://github.com/windmill-labs/windmill/commit/11f2a4c7ffac980036f839759ae93da6ff3f22e4)) +* group home row menus and open them at their final height ([#11949](https://github.com/windmill-labs/windmill/issues/11949)) ([91bf9b6](https://github.com/windmill-labs/windmill/commit/91bf9b67a74afb419659719949484f1cfd5338c0)) +* let flow builder operators build AI agents ([#11898](https://github.com/windmill-labs/windmill/issues/11898)) ([e5dedd1](https://github.com/windmill-labs/windmill/commit/e5dedd147e1db0c4f36796ba292188bcdb0efc5a)) +* manage and create folders from the home tree view ([#11943](https://github.com/windmill-labs/windmill/issues/11943)) ([da443d2](https://github.com/windmill-labs/windmill/commit/da443d2fb263ecaf90320809b1bb2fc8a4f65b20)) +* run a workspace handler when a variable's value expires ([#10837](https://github.com/windmill-labs/windmill/issues/10837)) ([7a29abc](https://github.com/windmill-labs/windmill/commit/7a29abc7eec491c35217445c1c08486ff030a22c)) + + +### Bug Fixes + +* change an app's access mode under the deploy row lock ([#11965](https://github.com/windmill-labs/windmill/issues/11965)) ([725b15f](https://github.com/windmill-labs/windmill/commit/725b15f546cf20c2e5908acffb105d73fe907558)) +* fork a raw app into the raw app builder, in its session when there ([#11978](https://github.com/windmill-labs/windmill/issues/11978)) ([3d6a2bc](https://github.com/windmill-labs/windmill/commit/3d6a2bcef2e284d64690db04db32a2326a4b0670)) +* home's acting-on follows the workspace home switches to ([#11946](https://github.com/windmill-labs/windmill/issues/11946)) ([18c6936](https://github.com/windmill-labs/windmill/commit/18c69360c9a8e9a9fe4c7c1d9e825a2b7be403a9)) +* keep a staged flow path across reloads and follow moves in session tabs ([#11967](https://github.com/windmill-labs/windmill/issues/11967)) ([6b56656](https://github.com/windmill-labs/windmill/commit/6b56656bd650f2db5fbd74e23b55b926c6f69dd7)) +* keep the log indexer starting when a stored day chunk is broken ([#11979](https://github.com/windmill-labs/windmill/issues/11979)) ([79fe8ab](https://github.com/windmill-labs/windmill/commit/79fe8ab03f6f052bf7fe763b6f8a0a23fe922611)) +* list usr columns explicitly in whoami's user lookup ([#11970](https://github.com/windmill-labs/windmill/issues/11970)) ([fb193d7](https://github.com/windmill-labs/windmill/commit/fb193d7a816ae97fa8fc60608270296e159dfd3c)) +* make the autoscaling Test scaling button run the integration ([#11972](https://github.com/windmill-labs/windmill/issues/11972)) ([72c6b10](https://github.com/windmill-labs/windmill/commit/72c6b10821cfa35ff3deb3a3ecc729ed75a9dbc6)) +* return variable.value as text so widening it changes no result type ([#11976](https://github.com/windmill-labs/windmill/issues/11976)) ([f6f3a0a](https://github.com/windmill-labs/windmill/commit/f6f3a0a1f24cd9ea0d78207d6848147ebd73bea0)) +* run unstamped git auto-pulls as a superadmin when no admin ([#11973](https://github.com/windmill-labs/windmill/issues/11973)) ([c89a827](https://github.com/windmill-labs/windmill/commit/c89a82750ed0a461d8d4f1502f019bc0da4ed58c)) +* send app editor inspector picks to a new AI session as a chip ([#11966](https://github.com/windmill-labs/windmill/issues/11966)) ([28d2556](https://github.com/windmill-labs/windmill/commit/28d2556c941f66cad353eca3809f43e8d8cf3c05)) +* share settings.yaml's git sync block between a fork and its parent ([#11974](https://github.com/windmill-labs/windmill/issues/11974)) ([fe38a1d](https://github.com/windmill-labs/windmill/commit/fe38a1d3763a11f8b0bc4b07d61824d3a7b51850)) + ## [1.847.0](https://github.com/windmill-labs/windmill/compare/v1.846.1...v1.847.0) (2026-10-22) diff --git a/Dockerfile b/Dockerfile index ad3a79a2b6..93862fe0b9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -144,6 +144,9 @@ ARG KUBECTL_VERSION=1.37.1 ARG HELM_VERSION=3.21.4 # NOTE: If changing, also change go version in workspace dependencies template at WorkspaceDependenciesEditor.svelte ARG GO_VERSION=1.27.1 +# Pinned to the patch: an unpinned install is cached by the image build and stops picking up +# security releases. +ARG NODE_VERSION=24.22.1 ARG APP=/usr/src/app ARG WITH_POWERSHELL=true ARG WITH_KUBECTL=true @@ -252,8 +255,8 @@ RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_r RUN UV_CACHE_DIR=/tmp/build_cache/uv UV_PYTHON_INSTALL_DIR=/tmp/build_cache/py_runtime uv python install $NEW_INSTANCE_PY --compile-bytecode -RUN curl -sL https://deb.nodesource.com/setup_24.x | bash - -RUN apt-get -y update && apt-get install -y --no-install-recommends curl procps nodejs awscli && apt-get clean \ +RUN curl -sL https://deb.nodesource.com/setup_${NODE_VERSION%%.*}.x | bash - +RUN apt-get -y update && apt-get install -y --no-install-recommends curl procps nodejs=${NODE_VERSION}-1nodesource1 awscli && apt-get clean \ && rm -rf /var/lib/apt/lists/* # The npm bundled with nodejs is replaced by a pinned one: it is what `//npm` scripts install with diff --git a/LICENSE b/LICENSE index aea6c58c6f..7e682e7dd1 100644 --- a/LICENSE +++ b/LICENSE @@ -30,5 +30,18 @@ quotas set in the software and a right to distribute the community edition as is but not to sell, resell, serve as a managed service, modify or wrap under any form without an explicit agreement. +To re-expose directly any Windmill parts to your users (see +https://www.windmill.dev/docs/misc/white_labelling) as a feature of your +product, with the exception of iframed public Windmill "apps", or to build a +feature on top of "Windmill Community Edition" that you sell commercially or +embed in a distributable product or binary, you must get a commercial license. +Contact us at sales@windmill.dev if you have any questions. To do the same from +the binary compiled from the source code in this repository without the +"enterprise" feature flag, you must comply with the AGPLv3 license terms and +conditions or get a commercial license from Windmill Labs, Inc. + +To use Windmill "Community Edition" as is internally in your organization, or to +use its APIs as is, you do NOT need a commercial license. + All third party components incorporated into the Windmill Software are licensed under the original license provided by the owner of the applicable component. diff --git a/README.md b/README.md index 597a9142b2..df877e2c0f 100644 --- a/README.md +++ b/README.md @@ -3,10 +3,10 @@

-Open-source developer platform for internal code: APIs, background jobs, workflows and UIs. Self-hostable alternative to Retool, Pipedream, Superblocks and a simplified Temporal with autogenerated UIs and custom UIs to trigger workflows and scripts as internal apps. +Open-source, self-hostable code-first platform for workflows, apps and AI agents, running on the fastest self-hostable orchestrator. Engineers write code in 20+ languages and sync it with Git. Business teams build on the same platform with AI, while review, promotion between environments and isolated workers keep engineers in control of what ships. Self-hostable alternative to Lovable, Retool, n8n and a simplified Temporal.

-Scripts are turned into sharable UIs automatically, and can be composed together into flows or used into richer apps built with low-code. Supported languages: Python, TypeScript, Go, Bash, SQL, GraphQL, PowerShell, Rust, and more. +Scripts are turned into sharable UIs, APIs and cron jobs automatically, and can be composed together into flows, data pipelines, AI agents or full-code React and Svelte apps. Supported languages: Python, TypeScript, Go, Bash, SQL, GraphQL, PowerShell, Rust, and more.

@@ -34,7 +34,7 @@ Scripts are turned into sharable UIs automatically, and can be composed together Try it - Website - Docs - Discord - Hub - Contributing

-# Windmill - Developer platform for APIs, background jobs, workflows and UIs +# Windmill - Code-first platform for workflows, apps and AI agents Windmill is fully open-sourced (AGPLv3) and Windmill Labs offers dedicated instances and commercial support and licenses. @@ -42,10 +42,12 @@ Windmill is fully open-sourced (AGPLv3) and Windmill Labs offers dedicated insta https://github.com/user-attachments/assets/d80de1d9-64de-4d89-aacd-6df23fa81fc4 -- [Windmill - Developer platform for APIs, background jobs, workflows and UIs](#windmill---developer-platform-for-apis-background-jobs-workflows-and-uis) +- [Windmill - Code-first platform for workflows, apps and AI agents](#windmill---code-first-platform-for-workflows-apps-and-ai-agents) + - [Build with AI, ship with review](#build-with-ai-ship-with-review) - [Main Concepts](#main-concepts) - - [Show me some actual script code](#show-me-some-actual-script-code) - - [Local Development](#local-development) + - [What the code looks like](#what-the-code-looks-like) + - [Data: data tables, DuckDB and DuckLake](#data-data-tables-duckdb-and-ducklake) + - [Develop locally or in the browser](#develop-locally-or-in-the-browser) - [Stack](#stack) - [Fastest Self-Hostable Workflow Engine](#fastest-self-hostable-workflow-engine) - [Security](#security) @@ -59,13 +61,21 @@ https://github.com/user-attachments/assets/d80de1d9-64de-4d89-aacd-6df23fa81fc4 - [License](#license) - [Integrations](#integrations) - [Environment Variables](#environment-variables) - - [Run a local dev setup](#run-a-local-dev-setup) - - [Frontend only](#frontend-only) - - [Backend + Frontend](#backend--frontend) - [Contributing](#contributing) - - [Contributors](#contributors) - [Copyright](#copyright) +## Build with AI, ship with review + +Business teams and engineers build on the same Windmill instance. What the AI builds is an ordinary script, flow or app, and goes through the same review as code written by engineers: + +- [AI sessions](https://www.windmill.dev/docs/core_concepts/ai_sessions) build scripts, flows and apps with AI in a workspace fork, with a live preview and a diff to review before deploying. +- [Workspace roles](https://www.windmill.dev/docs/core_concepts/roles_and_permissions#workspace-roles) decide what each team can author, and [folders](https://www.windmill.dev/docs/core_concepts/groups_and_folders) scope the resources, skills and MCP servers its AI can reach. +- [Protection rulesets](https://www.windmill.dev/docs/core_concepts/protection_rulesets) block direct deployment, so changes reach production only after an engineer reviews and deploys them. +- [Dev workspaces](https://www.windmill.dev/docs/advanced/dev_workspaces) keep their own credentials, and promote to prod through Compare & Deploy or a Git pull request. +- [Worker groups](https://www.windmill.dev/docs/core_concepts/worker_groups) run AI-built code on its own workers, or on a [separate instance](https://www.windmill.dev/docs/core_concepts/staging_prod#deploy-to-a-workspace-on-another-instance). + +See [let business teams build with AI safely](https://www.windmill.dev/docs/advanced/ai_guardrails) for the full setup. Engineers keep writing code locally with the [CLI](https://www.windmill.dev/docs/advanced/cli), the [VS Code extension](https://www.windmill.dev/docs/cli_local_dev/vscode-extension) and their coding agent, synced with [Git](https://www.windmill.dev/docs/advanced/git_sync). + ## Main Concepts 1. Define a minimal and generic script in Python, TypeScript, Go or Bash that solves a specific task. The code can be defined in the provided Web IDE or synchronized with your own GitHub repo (e.g. through VS Code extension): [provided Web IDE](https://www.windmill.dev/docs/code_editor) or [synchronized with your own GitHub repo](https://www.windmill.dev/docs/advanced/cli/sync) (e.g. through [VS Code](https://www.windmill.dev/docs/cli_local_dev/vscode-extension) extension): @@ -90,54 +100,93 @@ Scripts and flows can be triggered by [schedules](https://www.windmill.dev/docs/ Build your entire infra on top of Windmill! -## Show me some actual script code +## What the code looks like + +A script is a plain function. Its typed arguments become a generated form, an HTTP endpoint and a CLI command: ```typescript -//import any dependency from npm import * as wmill from "windmill-client"; -import * as cowsay from "cowsay@1.5.0"; - -// fill the type, or use the +Resource type to get a type-safe reference to a resource -type Postgresql = { - host: string; - port: number; - user: string; - dbname: string; - sslmode: string; - password: string; -}; +import dayjs from "dayjs"; // any npm package, resolved and pinned automatically export async function main( - a: number, - b: "my" | "enum", - c: Postgresql, - d = "inferred type string from default arg", - e = { nested: "object" } - //f: wmill.Base64 + customerId: string, + plan: "starter" | "pro", // a union of strings becomes a dropdown + trialDays = 14 // defaults infer the type ) { - const email = process.env["WM_EMAIL"]; - // variables are permissioned and by path - let variable = await wmill.getVariable("f/company-folder/my_secret"); - const lastTimeRun = await wmill.getState(); - // logs are printed and always inspectable - console.log(cowsay.say({ text: "hello " + email + " " + lastTimeRun })); - await wmill.setState(Date.now()); + const trialEnd = dayjs().add(trialDays, "day").toISOString(); - // return is serialized as JSON - return { foo: d, variable }; + // a data table: the workspace's Postgres, queried without a connection string + const sql = wmill.datatable(); + await sql`INSERT INTO trials (customer_id, plan, trial_end) VALUES (${customerId}, ${plan}, ${trialEnd})`.fetch(); + + // secrets are permissioned by path + const slackToken = await wmill.getVariable("f/sales/slack_token"); + await fetch("https://slack.com/api/chat.postMessage", { + method: "POST", + headers: { Authorization: `Bearer ${slackToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ channel: "#sales", text: `New ${plan} trial: ${customerId}` }), + }); + + console.log(`Trial started for ${customerId}`); // logs stream live in the UI + return { customerId, plan, trialEnd }; // returned as JSON } ``` -## Local Development +Workflows are code too. Each task runs as its own job with its own logs, and waiting for an approval releases the worker: -Windmill supports multiple ways to develop locally and sync with your instance: +```typescript +import { task, waitForApproval, workflow } from "windmill-client"; + +async function plan(env: string) { + return { env, changes: 3 }; +} + +async function apply(plan: { env: string; changes: number }) { + return `applied ${plan.changes} changes to ${plan.env}`; +} + +export const main = workflow(async (env: string) => { + const changes = await task(plan)(env); + const approval = await waitForApproval({ timeout: 3600 }); + if (!approval.approved) { + return `rejected by ${approval.approver}`; + } + return await task(apply)(changes); +}); +``` + +Flows can also be built in the visual [flow editor](https://www.windmill.dev/docs/flows/flow_editor) or as YAML, with scripts like the first one as steps. See [workflows as code](https://www.windmill.dev/docs/core_concepts/workflows_as_code). + +## Data: data tables, DuckDB and DuckLake + +- [Data tables](https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables) give each workspace managed Postgres tables, queried with type-checked SQL from TypeScript, Python and DuckDB scripts without exposing a connection string. +- [DuckDB](https://www.windmill.dev/docs/integrations/duckdb) scripts query S3 and Azure Blob files (CSV, Parquet, JSON), data tables, Postgres, MySQL and BigQuery in SQL, on your workers. +- [DuckLake](https://www.windmill.dev/docs/core_concepts/persistent_storage/ducklake) stores large datasets as lakehouse tables in your workspace object storage, queried in plain SQL. +- [Pipelines](https://www.windmill.dev/docs/core_concepts/pipelines) (alpha) wire scripts by the data they read and write instead of a hand-drawn DAG. This script runs whenever `events` is materialized, and materializes its own table: + +```sql +-- pipeline +-- on ducklake://main/events +-- materialize ducklake://main/events_by_kind +ATTACH 'ducklake://main' AS dl; +SELECT kind, count(*) AS n +FROM dl.events +GROUP BY kind; +``` + +Existing [dbt projects](https://www.windmill.dev/docs/getting_started/scripts_quickstart/dbt) run unmodified as scripts. See the [pipelines quickstart](https://www.windmill.dev/docs/getting_started/pipeline_quickstart). + +## Develop locally or in the browser + +Engineers work from their editor and coding agent, business teams build with AI in the browser, and both land in the same workspace: | Tool | Description | |------|-------------| -| **[CLI](https://www.windmill.dev/docs/advanced/cli)** | Sync scripts from local files or GitHub, run scripts/flows from the command line | -| **[VS Code Extension](https://www.windmill.dev/docs/cli_local_dev/vscode-extension)** | Edit and test scripts & flows directly from VS Code / Cursor with full IDE support | -| **[Git Sync](https://www.windmill.dev/docs/advanced/git_sync)** | Two-way sync between Windmill and your Git repository | -| **[Claude Code](https://www.windmill.dev/docs/core_concepts/ai_generation)** | AI-assisted development with Claude for scripts, flows, and apps | +| **[CLI](https://www.windmill.dev/docs/advanced/cli)** | Sync a workspace with local files and Git, run scripts and flows from the command line. `wmill init` generates `AGENTS.md` and [skill files](https://www.windmill.dev/docs/misc/guides/local_dev_with_ai) for Claude Code, Cursor and other AI tools | +| **[VS Code extension](https://www.windmill.dev/docs/cli_local_dev/vscode-extension)** | Edit and test scripts and flows from VS Code or Cursor with full IDE support | +| **[Git sync](https://www.windmill.dev/docs/advanced/git_sync)** | Two-way sync between a workspace and your Git repository | +| **[MCP server](https://www.windmill.dev/docs/core_concepts/mcp)** | Let coding agents and AI clients run scripts, manage resources and monitor jobs in a workspace | +| **[AI sessions](https://www.windmill.dev/docs/core_concepts/ai_sessions)** | The same agent loop in the browser, with nothing to install: plan mode, skills and MCP servers, a live preview, and a diff to review before deploying, in a fork of the workspace | https://github.com/user-attachments/assets/c541c326-e9ae-4602-a09a-1989aaded1e9 @@ -185,11 +234,12 @@ back to the database is ~50ms. A typical lightweight deno job will take around ## Architecture -

+Stateless API servers and workers share a PostgreSQL database that holds the job queue and all workspace state (remote agent workers go through the API servers instead). Workers pull jobs by tag, so each worker group can run in its own network, for example dev workers that only reach dev systems and prod workers that only run reviewed code. - - -

+ + + 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. + ## How to self-host @@ -230,15 +280,11 @@ Configure OAuth and SSO (Google Workspace, Microsoft/Azure, Okta) directly from ### License -The Community Edition is free to use internally. For commercial redistribution or managed services, contact . See [LICENSE](./LICENSE) and [Pricing](https://www.windmill.dev/pricing) for details. +- Using the Community Edition as is, internally or through its APIs, is free and needs no commercial license, within the limits and quotas set in the software. +- Selling, reselling or serving the Community Edition as a managed service, modifying or wrapping it, or re-exposing it to your users as a feature of your product (iframed public apps excepted) requires a commercial license: contact . +- The binary built from this repository without the `enterprise` feature flag is open source under [AGPLv3](./LICENSE-AGPL): the same uses are then allowed under the AGPLv3 terms, or with a commercial license. The API clients and the OpenAPI spec are [Apache 2.0](./LICENSE-APACHE). -The "Community Edition" of Windmill available in the docker images hosted under ghcr.io/windmill-labs/windmill and the github binary releases contains the files under the AGPLv3 and Apache 2 sources but also includes proprietary and non-public code and features which are not open source and under the following terms: Windmill Labs, Inc. grants a right to use all the features of the "Community Edition" for free without restrictions other than the limits and quotas set in the software and a right to distribute the community edition as is but not to sell, resell, serve Windmill as a managed service, modify or wrap under any form without an explicit agreement. - -The binary compilable from source code in this repository without the "enterprise" feature flag is open-source under the [LICENSE-AGPLv3](https://github.com/windmill-labs/windmill/blob/main/LICENSE-AGPL) License terms and conditions. - -To [re-expose directly any Windmill parts to your users](https://www.windmill.dev/docs/misc/white_labelling) as a feature of your product, with the exception of iframed public Windmill "apps", or to build a feature on top of "Windmill Community Edition" that you sell commercially or embed in a distributable product or binary, you must get a commercial license. Contact us at if you have any questions. To do the same from the binary compiled from the source code in this repository without the "enterprise" feature flag, you must comply with the AGPLv3 license terms and conditions or get a commercial license from Windmill Labs, Inc. - -To use Windmill "Community Edition" as is internally in your organization, or to use its APIs as is, you do NOT need a commercial license. +See [LICENSE](./LICENSE) for the full terms and [Pricing](https://www.windmill.dev/pricing) for plans. ### Integrations @@ -249,88 +295,17 @@ On self-hosted instances, you might want to import all the approved resource typ ## Environment Variables -| Environment Variable name | Default | Description | Api Server/Worker/All | -| ----------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | -| DATABASE_URL | | The Postgres database url. | All | -| WORKER_GROUP | default | The worker group the worker belongs to and get its configuration pulled from | Worker | -| MODE | standalone | The mode if the binary. Possible values: standalone, worker, server, agent | All | -| METRICS_ADDR | None | (ee only) The socket addr at which to expose Prometheus metrics at the /metrics path. Set to "true" to expose it on port 8001 | All | -| JSON_FMT | false | Output the logs in json format instead of logfmt | All | -| BASE_URL | http://localhost:8000 | The base url that is exposed publicly to access your instance. Is overriden by the instance settings if any. | Server | -| ZOMBIE_JOB_TIMEOUT | 30 | The timeout after which a job is considered to be zombie if the worker did not send pings about processing the job (every server check for zombie jobs every 30s) | Server | -| RESTART_ZOMBIE_JOBS | true | If true then a zombie job is restarted (in-place with the same uuid and some logs), if false the zombie job is failed | Server | -| NATIVE_MODE | false | Enable native mode: sets NUM_WORKERS=8, rejects non-native jobs (nativets, postgresql, mysql, etc.) | Worker | -| SLEEP_QUEUE | 50 | The number of ms to sleep in between the last check for new jobs in the DB. It is multiplied by NUM_WORKERS such that in average, for one worker instance, there is one pull every SLEEP_QUEUE ms. | Worker | -| KEEP_JOB_DIR | false | Keep the job directory after the job is done. Useful for debugging. | Worker | -| EXIT_AFTER_N_JOBS | None | Exit the worker process after it has executed that many jobs, so that a supervisor restarts it and no process runs more than that many, bar the steps of a same-worker flow it has started, which it always finishes (set it to 1 for a process per job; jobs handed to a dedicated worker, and the worker's own init and periodic scripts, do not count). Not counting the init and periodic scripts means they run again on every restart: an init script's runtime is added to the latency of every batch of that many jobs, and a periodic script fires once per process start whatever its interval says. The worker's shell in the workers page also starts backed off rather than after the two minutes it otherwise takes, since a process due to be recycled cannot count on living that long: the first command of a session can wait up to 15s, later ones are immediate. For deployments that isolate executions by process lifetime rather than with nsjail; note that a container restart resets the process, not the container filesystem, so caches and `/tmp` survive it. The worker name is then derived from the hostname instead of being random, so the restarted worker keeps its row in the workers list (an agent worker keeps the row but restarts its job count). Use one worker per process: workers of one process share its environment, so the first to reach the limit shuts the others down too. | Worker | -| WORKER_SUFFIX | None | Pins the last part of the worker name, which is otherwise random, so that a restarted worker keeps its row in the workers list. Only needed when several worker processes of the same worker group run on one host, since the name is derived from the hostname: give each of them a distinct value, as two processes sharing one must never happen. At most 64 letters, digits and underscores; anything else is refused at startup. | Worker | -| LICENSE_KEY (EE only) | None | License key checked at startup for the Enterprise Edition of Windmill | Worker | -| SLACK_SIGNING_SECRET | None | The signing secret of your Slack app. See [Slack documentation](https://api.slack.com/authentication/verifying-requests-from-slack) | Server | -| COOKIE_DOMAIN | None | The domain of the cookie. If not set, the cookie will be set by the browser based on the full origin | Server | -| DENO_PATH | /usr/bin/deno | The path to the deno binary. | Worker | -| PYTHON_PATH | | The path to the python binary if wanting to not have it managed by uv. | Worker | -| GO_PATH | /usr/bin/go | The path to the go binary. | Worker | -| GOPRIVATE | | The GOPRIVATE env variable to use private go modules | Worker | -| GOPROXY | | The GOPROXY env variable to use | Worker | -| NETRC | | The netrc content to use a private go registry | Worker | -| PY_CONCURRENT_DOWNLOADS | 20 | Sets the maximum number of in-flight concurrent python downloads that windmill will perform at any given time. | Worker | -| PATH | None | The path environment variable, usually inherited | Worker | -| HOME | None | The home directory to use for Go and Bash , usually inherited | Worker | -| DATABASE_CONNECTIONS | 50 (Server)/3 (Worker) | The max number of connections in the database connection pool | All | -| SUPERADMIN_SECRET | None | A token that would let the caller act as a virtual superadmin superadmin@windmill.dev | Server | -| TIMEOUT_WAIT_RESULT | 20 | The number of seconds to wait before timeout on the 'run_wait_result' endpoint | Worker | -| QUEUE_LIMIT_WAIT_RESULT | None | The number of max jobs in the queue before rejecting immediately the request in 'run_wait_result' endpoint. Takes precedence on the query arg. If none is specified, there are no limit. | Worker | -| DENO_AUTH_TOKENS | None | Custom DENO_AUTH_TOKENS to pass to worker to allow the use of private modules | Worker | -| DISABLE_RESPONSE_LOGS | false | Disable response logs | Server | -| CREATE_WORKSPACE_REQUIRE_SUPERADMIN | true | If true, only superadmins can create new workspaces | Server | -| MIN_FREE_DISK_SPACE_MB | 15000 | Minimum amount of free space on worker. Sends critical alert if worker has less free space. | Worker | -| RUN_UPDATE_CA_CERTIFICATE_AT_START | false | If true, runs CA certificate update command at startup before other initialization | All | -| RUN_UPDATE_CA_CERTIFICATE_PATH | /usr/sbin/update-ca-certificates | Path to the CA certificate update command/script to run when RUN_UPDATE_CA_CERTIFICATE_AT_START is true | All | -| GOOGLE_APPLICATION_CREDENTIALS | None | (ee only) Credentials file for GCP Pub/Sub triggers that authenticate as the instance rather than through a `gcloud` resource (workspace admins only). Application default credentials also resolve the gcloud well-known file and the GCE metadata server. Workload Identity Federation files work with the `file`, `url` and `aws` credential sources; the `executable` source is not supported. | Server | +The variables most deployments set: -## Run a local dev setup +| Environment Variable name | Default | Description | Api Server/Worker/All | +| ------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ | --------------------- | +| DATABASE_URL | | The Postgres database url. | All | +| MODE | standalone | The mode of the binary: standalone, server, worker, agent (EE), indexer (EE image, full-text search), mcp | All | +| WORKER_GROUP | default | The worker group the worker belongs to and gets its configuration pulled from | Worker | +| BASE_URL | http://localhost:8000 | The base url that is exposed publicly to access your instance. Is overriden by the instance settings if any. | Server | +| LICENSE_KEY (EE only) | None | License key checked at startup for the Enterprise Edition of Windmill | Worker | -We recommend using [Nix](./frontend/README_DEV.md#nix). See [./frontend/README_DEV.md](./frontend/README_DEV.md) for all options. - -### Frontend only - -Uses the backend of with local frontend (hot-reload): - -```bash -cd frontend -npm install -npm run generate-backend-client # or generate-backend-client-mac on Mac -npm run dev -``` - -Windmill available at `http://localhost/` - -### Backend + Frontend - -See the [./frontend/README_DEV.md](./frontend/README_DEV.md) file for all -running options. - -1. Start a local Postgres database using for instance the `start-dev-db.sh` script which will make a database available at `postgres://postgres:changeme@localhost:5432/windmill` - Then run the migrations using the following command: - ``` - cargo install sqlx-cli - env DATABASE_URL= sqlx migrate run - ``` - This will also avoid compile time issue with sqlx's `query!` macro. -2. (optional, linux only) Install [nsjail](https://github.com/google/nsjail) and have it accessible in - your PATH -3. Install bun and python3 (+ any languages you want to use), have the bins at `/usr/bin/bun` and - `/usr/local/bin/python3` or set the corresponding environment variables. -4. (optional) Install the [lld linker](https://lld.llvm.org/) -5. Go to `frontend/`: - 1. `npm install`, `npm run generate-backend-client` then `REMOTE=http://localhost:8000 npm run dev` - 2. You might need to set some extra heap space for the node runtime - `export NODE_OPTIONS="--max-old-space-size=4096"` - 3. Create an empty `frontend/build` folder using `mkdir frontend/build` -6. Go to `backend/`: - 1. `env DATABASE_URL= RUST_LOG=info cargo run` - 2. You can specify any feature flag you want to enable, for example `cargo run --features python` to enable the python executor. -7. Windmill should be available at `http://localhost:3000` +All other variables (database pool, queue, runtimes, private registries, certificates, zombie jobs and more) are documented in the [environment variables reference](https://www.windmill.dev/docs/core_concepts/environment_variables). ## Contributing @@ -338,11 +313,7 @@ At this time, we are not seeking outside contribution. Bug reports and feature r welcome, and small, trivially-verified PRs that fix a problem are still accepted. See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full policy. -## Contributors - - - - +To run Windmill from source, see [frontend/README_DEV.md](./frontend/README_DEV.md). ## Copyright diff --git a/ai_evals/README.md b/ai_evals/README.md index d458e019d0..67592daee4 100644 --- a/ai_evals/README.md +++ b/ai_evals/README.md @@ -195,6 +195,15 @@ profile: operatorSettings: { builder_apps: true } # also builder_flows, manage_schedules, manage_triggers ``` +A role that builds nothing (no builder rights, `manage_schedules` and `manage_triggers` both +`false`) holds the session in Use, as the chat does in production: the toolset is the +read-only one and the prompt carries the session section that names the role as the reason. + +`judgeAssistantText: true` hands the judge what the assistant said alongside the mode's +output. Use it when the deliverable is the answer itself, such as a refusal and where to go +instead: the `global` judge otherwise reads only the drafts, and a substring check cannot tell +"you can't create it yourself" from "create it yourself". + `toolExpect.toolCallArgs` entries support `sharedByAtLeast: `: at least `n` recorded calls to that tool must carry the same non-blank string in the field. Use it for calls that have to share an identifier, like two test runs of one chat conversation. diff --git a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts index e70dbfcfe5..c648848387 100644 --- a/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts +++ b/ai_evals/adapters/frontend/core/global/globalEvalRunner.ts @@ -5,9 +5,17 @@ import type { ChatCompletionMessageParam } from "openai/resources/chat/completio import type { AIProvider, OperatorSettings } from "$lib/gen/types.gen"; import { globalToolsFor, - prepareGlobalSystemMessage, prepareGlobalUserMessage, } from "../../../../../frontend/src/lib/components/copilot/chat/global/core"; +import { assembleGlobalSystemMessage } from "../../../../../frontend/src/lib/components/copilot/chat/global/globalAssembly"; +import { + canBuild, + USE_MODE_ACCESS, +} from "../../../../../frontend/src/lib/components/copilot/chat/sessionCapabilities"; +import { + FULL_CAPABILITIES, + legacyOperatorCapabilities, +} from "../../../../../frontend/src/lib/roleCapabilities"; import { clearGlobalDrafts, getGlobalDraft, @@ -155,15 +163,24 @@ export async function runGlobalEval( options.workspaceFixtures ?? {}, ); seedLiveEditorDrafts(workspaceRoot, options.liveEditorDrafts ?? []); - // No deploy rules in the mock workspace, so they always pass. - const access = + // No deploy rules in the mock workspace, so they always pass. The role is read as a server + // that predates workspace roles describes it, from `operator_settings`. + const roleAccess = options.profile && capabilitiesForRole({ isAdmin: false, - operator: options.profile.operator, deployRulesPass: true, - operatorSettings: options.profile.operatorSettings as OperatorSettings, + capabilities: options.profile.operator + ? legacyOperatorCapabilities( + options.profile.operatorSettings as OperatorSettings, + ) + : FULL_CAPABILITIES, }); + // A session whose role builds nothing is held in Use, as the chat manager holds it: the + // read-only toolset, and the session section that names the role as the reason. + const heldInUse = + !!options.sessionChat && !!roleAccess && !canBuild(roleAccess); + const access = heldInUse ? USE_MODE_ACCESS : roleAccess; seedOperatorSettings(workspaceRoot, options.profile); // Declared out here only so `finally` can reach it; a malformed fixture throws while // building it, and everything seeded above still has to be torn down. @@ -179,7 +196,7 @@ export async function runGlobalEval( ); } // Production resolves a profile for session chats only; elsewhere every tool ships. - if (!options.sessionChat && access) { + if (!options.sessionChat && roleAccess) { throw new Error( "This case sets runtime.profile, which only a session chat applies — set runtime.sessionChat: true on the case.", ); @@ -203,10 +220,13 @@ export async function runGlobalEval( : undefined; // Pass the seeded identity straight to the prompt builder rather than mutating // the process-global `userStore`, so concurrent cases never race on it. - const baseSystemMessage = prepareGlobalSystemMessage(undefined, { + const baseSystemMessage = assembleGlobalSystemMessage(undefined, { user: options.user, previewTools: options.sessionChat ?? false, access, + ...(heldInUse && { + sessionContext: { workspaceId: workspaceRoot, useMode: "role" as const }, + }), }); const helpers = { ...evalArtifacts.helpers, diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index 69398555f0..1de463690b 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -11,8 +11,13 @@ import type { ListableApp, ListableResource, ListableVariable, + Folder, + Group, + QueuedJob, Resource, - Script + Schedule, + Script, + WorkerPing } from '../../../frontend/src/lib/gen' import type { DataMetric, @@ -97,6 +102,14 @@ export interface BenchmarkWorkspaceResource { resource_type: string value?: Record description?: string + /** A database's tables, as `get_db_schema` reads them: schema, table, then column. */ + dbSchema?: Record>> +} + +export interface BenchmarkDbColumn { + type: string + required: boolean + default?: string } /** A resource type the instance holds. */ @@ -117,6 +130,48 @@ export interface BenchmarkWorkspaceJob { logs?: string args?: Record result?: unknown + /** Defaults to `completed`. `suspended` is a running flow parked on an approval step. */ + state?: 'completed' | 'queued' | 'running' | 'suspended' + tag?: string + schedulePath?: string + /** When the run was created, relative to the case start. Unset = now. */ + createdMinutesAgo?: number + /** Completed runs only: how long the run took. */ + durationMs?: number +} + +/** A worker of the instance, as `list_workers` reports it. */ +export interface BenchmarkWorkspaceWorker { + worker: string + worker_group: string + custom_tags?: string[] + lastPingSecondsAgo?: number + jobs_executed?: number +} + +/** A folder of the benchmark workspace, as `read_workspace_item` reads it. */ +export interface BenchmarkWorkspaceFolder { + name: string + summary?: string + owners?: string[] + /** Who else may use the folder, as `u/` or `g/`: `true` grants write. */ + extra_perms?: Record +} + +export interface BenchmarkWorkspaceGroup { + name: string + summary?: string + members?: string[] +} + +export interface BenchmarkWorkspaceSchedule { + path: string + /** Six-field cron. */ + schedule: string + script_path: string + is_flow?: boolean + enabled?: boolean + summary?: string } export interface BenchmarkWorkspaceRunnables { @@ -133,15 +188,28 @@ export interface BenchmarkWorkspaceRunnables { ducklakes?: string[] dataMetrics?: DataMetric[] jobs?: BenchmarkWorkspaceJob[] + /** The instance's workers. Unset falls through to the real backend's; `[]` is none. */ + workers?: BenchmarkWorkspaceWorker[] + schedules?: BenchmarkWorkspaceSchedule[] + folders?: BenchmarkWorkspaceFolder[] + groups?: BenchmarkWorkspaceGroup[] + /** The workspace settings document (`get_workspace_settings` reads it). */ + workspaceSettings?: Record } type BenchmarkCompletedJob = CompletedJob & { type: 'CompletedJob' } +type BenchmarkQueuedJob = QueuedJob & { type: 'QueuedJob' } const benchmarkWorkspaces = new Set() const benchmarkWorkspaceRunnables = new Map() // Keyed by `${workspace}::${jobId}` so concurrent attempts (or distinct cases) // can seed the same fixed job id without clobbering each other's entry. -const benchmarkJobs = new Map() +const benchmarkJobs = new Map< + string, + { workspace: string; job: BenchmarkCompletedJob | BenchmarkQueuedJob } +>() +// What set_workspace_settings applied, per workspace, so get_workspace_settings reads it back. +const benchmarkWorkspaceSettings = new Map>() function benchmarkJobKey(workspace: string, jobId: string): string { return `${workspace}::${jobId}` @@ -152,11 +220,12 @@ export function resetBenchmarkMockBackend(): void { benchmarkWorkspaceRunnables.clear() benchmarkJobs.clear() benchmarkDrafts.clear() + benchmarkWorkspaceSettings.clear() } // Stand-in for FolderService.createFolder so the global create_folder tool runs in -// memory instead of mutating the real backend. Folders aren't otherwise modelled -// (no folder-listing in evals), so this just echoes the created name. +// memory instead of mutating the real backend. It echoes the name without adding the +// folder to the fixture folders that `listBenchmarkFolders` reads. export function createBenchmarkFolder(_workspace: string, name: string): string { return name } @@ -180,6 +249,14 @@ export function registerBenchmarkWorkspaceRunnables( }) // Seed any fixture jobs so list_runs / get_run have data to return. for (const seed of runnables.jobs ?? []) { + const createdAt = + seed.createdMinutesAgo === undefined + ? undefined + : new Date(Date.now() - seed.createdMinutesAgo * 60_000).toISOString() + if (seed.state && seed.state !== 'completed') { + createBenchmarkQueuedJob(workspace, seed, createdAt) + continue + } createBenchmarkCompletedJob({ workspace, id: seed.id, @@ -190,14 +267,24 @@ export function registerBenchmarkWorkspaceRunnables( label: seed.label, logs: seed.logs, args: seed.args, - result: seed.result + result: seed.result, + tag: seed.tag, + schedulePath: seed.schedulePath, + createdAt, + durationMs: seed.durationMs }) } + if (runnables.workspaceSettings) { + benchmarkWorkspaceSettings.set(workspace, structuredClone(runnables.workspaceSettings)) + } else { + benchmarkWorkspaceSettings.delete(workspace) + } } export function unregisterBenchmarkWorkspace(workspace: string): void { benchmarkWorkspaces.delete(workspace) benchmarkWorkspaceRunnables.delete(workspace) + benchmarkWorkspaceSettings.delete(workspace) clearBenchmarkDrafts(workspace) for (const [jobId, entry] of benchmarkJobs.entries()) { if (entry.workspace === workspace) { @@ -389,6 +476,15 @@ export function queryBenchmarkResourceTypes( .slice(0, limit) } +/** The tables a fixture seeds for a database resource; `null` when it seeds none. */ +export function getBenchmarkDbSchemaSeed( + workspace: string, + path: string +): { resourceType: string; schema: NonNullable } | null { + const seed = benchmarkWorkspaceRunnables.get(workspace)?.resources?.find((r) => r.path === path) + return seed?.dbSchema ? { resourceType: seed.resource_type, schema: seed.dbSchema } : null +} + /** A seeded resource with its value, as `ResourceService.getResource` returns it. Covers both * seed kinds, so it agrees with `existsResource` and `listResource` — both of those report AI * providers too, and a case that lists resources and then reads one by path would otherwise get @@ -481,18 +577,24 @@ export function createBenchmarkCompletedJob(input: { id?: string createdBy?: string label?: string + tag?: string + schedulePath?: string + createdAt?: string + durationMs?: number }): string { const jobId = input.id ?? `benchmark-job-${randomUUID()}` - const now = new Date().toISOString() + const createdAt = input.createdAt ?? new Date().toISOString() + const durationMs = input.durationMs ?? 0 const job: BenchmarkCompletedJob = { type: 'CompletedJob', id: jobId, workspace_id: input.workspace, created_by: input.createdBy ?? 'ai-evals', - created_at: now, - started_at: now, - completed_at: now, - duration_ms: 0, + created_at: createdAt, + started_at: createdAt, + completed_at: new Date(Date.parse(createdAt) + durationMs).toISOString(), + duration_ms: durationMs, + schedule_path: input.schedulePath, success: input.success ?? true, script_path: input.scriptPath, script_hash: input.scriptHash, @@ -506,7 +608,7 @@ export function createBenchmarkCompletedJob(input: { is_skipped: false, email: 'ai-evals@local', visible_to_owner: true, - tag: 'benchmark', + tag: input.tag ?? 'benchmark', labels: input.label ? [input.label] : undefined } @@ -517,17 +619,58 @@ export function createBenchmarkCompletedJob(input: { return jobId } +/** A run still in the queue: waiting for a worker, running, or a flow parked on an + * approval step (running with a suspend outstanding, as the backend reports it). */ +function createBenchmarkQueuedJob( + workspace: string, + seed: BenchmarkWorkspaceJob, + createdAt = new Date().toISOString() +): string { + const jobId = seed.id ?? `benchmark-job-${randomUUID()}` + const started = seed.state === 'running' || seed.state === 'suspended' + const job: BenchmarkQueuedJob = { + type: 'QueuedJob', + id: jobId, + workspace_id: workspace, + created_by: seed.createdBy ?? 'ai-evals', + created_at: createdAt, + scheduled_for: createdAt, + started_at: started ? createdAt : undefined, + running: started, + suspend: seed.state === 'suspended' ? 1 : undefined, + script_path: seed.scriptPath, + schedule_path: seed.schedulePath, + args: seed.args, + logs: seed.logs, + canceled: false, + job_kind: seed.jobKind ?? 'script', + permissioned_as: 'u/ai-evals', + is_flow_step: false, + email: 'ai-evals@local', + visible_to_owner: true, + tag: seed.tag ?? 'benchmark' + } + benchmarkJobs.set(benchmarkJobKey(workspace, jobId), { workspace, job }) + return jobId +} + export function getBenchmarkCompletedJob( workspace: string, jobId: string ): BenchmarkCompletedJob | null { const entry = benchmarkJobs.get(benchmarkJobKey(workspace, jobId)) - if (!entry) { + if (!entry || entry.job.type !== 'CompletedJob') { return null } return structuredClone(entry.job) } +/** Any seeded run, finished or not, as `JobService.getJob` returns it. */ +export function getBenchmarkJob(workspace: string, jobId: string): Job | null { + const entry = benchmarkJobs.get(benchmarkJobKey(workspace, jobId)) + return entry ? (structuredClone(entry.job) as Job) : null +} + /** * List seeded/recorded jobs for a benchmark workspace, most recent first — * the shape `JobService.listJobs` returns. Returns `null` for a non-benchmark @@ -550,7 +693,7 @@ export function listBenchmarkJobs(workspace: string): Job[] | null { * "not found" error for an unknown id, matching the backend 404. */ export function getBenchmarkJobLogs(workspace: string, jobId: string): string { - const job = getBenchmarkCompletedJob(workspace, jobId) + const job = getBenchmarkJob(workspace, jobId) if (!job) { throw new Error(`Job Logs not found for "${jobId}"`) } @@ -562,7 +705,7 @@ export function getBenchmarkJobLogs(workspace: string, jobId: string): string { * tree. Fixture jobs are single runs with no steps, so only the root entry. */ export function getBenchmarkFlowAllResults(workspace: string, jobId: string) { - const job = getBenchmarkCompletedJob(workspace, jobId) + const job = getBenchmarkJob(workspace, jobId) if (!job) { throw new Error(`Job "${jobId}" not found in benchmark workspace`) } @@ -575,8 +718,9 @@ export function getBenchmarkFlowAllResults(workspace: string, jobId: string) { depth: 0, sibling_index: 1, sibling_count: 1, - status: job.success ? 'success' : 'failure', - success: job.success + ...('success' in job + ? { status: job.success ? 'success' : 'failure', success: job.success } + : { status: job.suspend ? 'suspended' : job.running ? 'running' : 'queued' }) } ], truncated: false, @@ -584,6 +728,156 @@ export function getBenchmarkFlowAllResults(workspace: string, jobId: string) { } } +// ============= Workers, schedules ============= + +/** Mirror `WorkerService.listWorkers`; `null` when no case seeded workers. Workers name no + * workspace, so this answers from whichever case seeded them: run cases whose worker seeds + * differ one at a time. */ +export function listBenchmarkWorkers(): WorkerPing[] | null { + let workers: BenchmarkWorkspaceWorker[] | undefined + for (const workspace of benchmarkWorkspaces) { + workers ??= benchmarkWorkspaceRunnables.get(workspace)?.workers + } + if (!workers) return null + return workers.map((w) => ({ + worker: w.worker, + worker_instance: `${w.worker}-instance`, + worker_group: w.worker_group, + custom_tags: w.custom_tags, + last_ping: w.lastPingSecondsAgo ?? 2, + started_at: BENCHMARK_TIMESTAMP, + ip: '10.0.0.1', + jobs_executed: w.jobs_executed ?? 0, + wm_version: 'benchmark' + })) +} + +function buildBenchmarkSchedule(workspace: string, s: BenchmarkWorkspaceSchedule): Schedule { + return { + path: s.path, + edited_by: 'benchmark', + edited_at: BENCHMARK_TIMESTAMP, + schedule: s.schedule, + timezone: 'Etc/UTC', + enabled: s.enabled ?? true, + script_path: s.script_path, + is_flow: s.is_flow ?? false, + extra_perms: {}, + email: 'ai-evals@local', + summary: s.summary, + workspace_id: workspace + } as unknown as Schedule +} + +export function listBenchmarkSchedules(workspace: string): Schedule[] { + return (benchmarkWorkspaceRunnables.get(workspace)?.schedules ?? []).map((s) => + buildBenchmarkSchedule(workspace, s) + ) +} + +export function getBenchmarkSchedule(workspace: string, path: string): Schedule | null { + const s = benchmarkWorkspaceRunnables.get(workspace)?.schedules?.find((e) => e.path === path) + return s ? buildBenchmarkSchedule(workspace, s) : null +} + +// ============= Folders, groups, workspace settings ============= + +function buildBenchmarkFolder(workspace: string, f: BenchmarkWorkspaceFolder): Folder { + return { + workspace_id: workspace, + name: f.name, + summary: f.summary, + owners: f.owners ?? [], + extra_perms: f.extra_perms ?? {} + } as Folder +} + +export function listBenchmarkFolders(workspace: string): Folder[] { + return (benchmarkWorkspaceRunnables.get(workspace)?.folders ?? []).map((f) => + buildBenchmarkFolder(workspace, f) + ) +} + +export function getBenchmarkFolder(workspace: string, name: string): Folder | null { + const f = benchmarkWorkspaceRunnables.get(workspace)?.folders?.find((e) => e.name === name) + return f ? buildBenchmarkFolder(workspace, f) : null +} + +export function listBenchmarkGroups(workspace: string): Group[] { + return (benchmarkWorkspaceRunnables.get(workspace)?.groups ?? []).flatMap((g) => { + const group = getBenchmarkGroup(workspace, g.name) + return group ? [group] : [] + }) +} + +export function getBenchmarkGroup(workspace: string, name: string): Group | null { + const g = benchmarkWorkspaceRunnables.get(workspace)?.groups?.find((e) => e.name === name) + return g + ? ({ + workspace_id: workspace, + name: g.name, + summary: g.summary, + members: g.members ?? [], + extra_perms: {} + } as Group) + : null +} + +const DATA_TABLE_DATABASE_TYPES = ['postgresql', 'instance', 'external_instance'] + +/** Refuse a data table entry the backend's `DataTableDatabase` would not deserialize. */ +function assertBenchmarkDataTables(value: unknown): void { + if (value === undefined || value === null) return + const datatables = (value as { datatables?: Record }).datatables ?? {} + for (const [name, entry] of Object.entries(datatables)) { + const { database, reference } = (entry ?? {}) as { + database?: { resource_type?: unknown; resource_path?: unknown } + reference?: unknown + } + // A fork's pointer at its parent's entry owns no database. + if (reference && !database) continue + if (!DATA_TABLE_DATABASE_TYPES.includes(String(database?.resource_type))) { + throw new Error( + `datatable.datatables.${name}.database.resource_type must be one of ${DATA_TABLE_DATABASE_TYPES.join(', ')}` + ) + } + if (typeof database?.resource_path !== 'string' || database.resource_path === '') { + throw new Error(`datatable.datatables.${name}.database.resource_path is required`) + } + } +} + +/** Mirror `WorkspaceService.getWorkspaceSettingsDocument`; an unseeded workspace has an + * empty document. */ +export function getBenchmarkWorkspaceSettingsDocument(workspace: string): Record { + return structuredClone(benchmarkWorkspaceSettings.get(workspace) ?? {}) +} + +/** Mirror `WorkspaceService.applyWorkspaceSettingsDocument` for a partial document: each + * top-level key replaces the stored one, `null` clears it. */ +export function applyBenchmarkWorkspaceSettingsDocument(input: { + workspace: string + requestBody: { document: Record; dry_run?: boolean } +}): { + changes: Array<{ key: string; before: unknown; after: unknown }> + applied: boolean +} { + assertBenchmarkDataTables(input.requestBody.document.datatable) + const current = benchmarkWorkspaceSettings.get(input.workspace) ?? {} + const changes = Object.entries(input.requestBody.document) + .filter(([key, after]) => JSON.stringify(current[key] ?? null) !== JSON.stringify(after ?? null)) + .map(([key, after]) => ({ key, before: current[key] ?? null, after: after ?? null })) + if (!input.requestBody.dry_run) { + const next = { ...current } + for (const { key, after } of changes) { + if (after === null) delete next[key] + else next[key] = structuredClone(after) + } + benchmarkWorkspaceSettings.set(input.workspace, next) + } + return { changes, applied: !input.requestBody.dry_run } +} + // ============= Drafts (per-user, DB-backed in production) ============= /** diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index b24ccbe5e4..754de224ac 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -85,12 +85,52 @@ vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ vi.mock('$lib/components/vscode', () => ({})) +// get_db_schema reads a database's tables by running a query job; a benchmark database +// answers with the tables its fixture seeds instead. +vi.mock('$lib/components/apps/components/display/dbtable/metadata', async () => { + const actual = await vi.importActual( + '$lib/components/apps/components/display/dbtable/metadata' + ) + const { getBenchmarkDbSchemaSeed } = await import('./mockBackend') + const { stringifySchema } = await import('$lib/components/copilot/lib') + const { resourceTypeToLang } = await import( + '$lib/components/apps/components/display/dbtable/utils' + ) + return { + ...actual, + getDbSchemas: async ( + resourceType: string, + resourcePath: string, + workspace: string | undefined, + ...rest: unknown[] + ) => { + const seed = workspace ? getBenchmarkDbSchemaSeed(workspace, resourcePath) : null + if (!seed) return actual.getDbSchemas(resourceType, resourcePath, workspace, ...rest) + const dbSchema = { + lang: resourceTypeToLang(seed.resourceType), + schema: seed.schema, + publicOnly: !!seed.schema.public + } + return { ...dbSchema, stringified: stringifySchema(dbSchema) } + } + } +}) + vi.mock('$lib/gen', async () => { const actual = await vi.importActual('$lib/gen') const { getBenchmarkAppByPath, - getBenchmarkCompletedJob, + getBenchmarkJob, getBenchmarkCompletedJobResultMaybe, + listBenchmarkWorkers, + listBenchmarkSchedules, + getBenchmarkSchedule, + listBenchmarkFolders, + getBenchmarkFolder, + getBenchmarkGroup, + listBenchmarkGroups, + getBenchmarkWorkspaceSettingsDocument, + applyBenchmarkWorkspaceSettingsDocument, getBenchmarkDatatableSchema, getBenchmarkDatatableMigrationsStatus, createBenchmarkDatatableMigration, @@ -175,7 +215,29 @@ vi.mock('$lib/gen', async () => { createFolder: async (data: { workspace: string; requestBody: { name: string } }) => hasBenchmarkWorkspace(data.workspace) ? createBenchmarkFolder(data.workspace, data.requestBody.name) - : actual.FolderService.createFolder(data) + : actual.FolderService.createFolder(data), + listFolders: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? listBenchmarkFolders(data.workspace) + : actual.FolderService.listFolders(data), + getFolder: async (data: { workspace: string; name: string }) => { + if (!hasBenchmarkWorkspace(data.workspace)) return actual.FolderService.getFolder(data) + const folder = getBenchmarkFolder(data.workspace, data.name) + if (!folder) throw new Error(`Folder "${data.name}" not found in benchmark workspace`) + return folder + } + }), + GroupService: wrapService(actual.GroupService, { + listGroups: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? listBenchmarkGroups(data.workspace) + : actual.GroupService.listGroups(data), + getGroup: async (data: { workspace: string; name: string }) => { + if (!hasBenchmarkWorkspace(data.workspace)) return actual.GroupService.getGroup(data) + const group = getBenchmarkGroup(data.workspace, data.name) + if (!group) throw new Error(`Group "${data.name}" not found in benchmark workspace`) + return group + } }), ScriptService: wrapService(actual.ScriptService, { listScripts: async (data: { workspace: string }) => @@ -357,7 +419,7 @@ vi.mock('$lib/gen', async () => { : actual.JobService.runFlowByPath(data), getJob: async (data: { workspace: string; id: string }) => { if (hasBenchmarkWorkspace(data.workspace)) { - const job = getBenchmarkCompletedJob(data.workspace, data.id) + const job = getBenchmarkJob(data.workspace, data.id) if (!job) { throw new Error(`Job "${data.id}" not found in benchmark workspace`) } @@ -383,6 +445,14 @@ vi.mock('$lib/gen', async () => { : actual.JobService.getFlowAllResults(data) }), WorkspaceService: wrapService(actual.WorkspaceService, { + getWorkspaceSettingsDocument: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? getBenchmarkWorkspaceSettingsDocument(data.workspace) + : actual.WorkspaceService.getWorkspaceSettingsDocument(data), + applyWorkspaceSettingsDocument: async (data: any) => + hasBenchmarkWorkspace(data.workspace) + ? applyBenchmarkWorkspaceSettingsDocument(data) + : actual.WorkspaceService.applyWorkspaceSettingsDocument(data), getCopilotInfo: async (data: { workspace: string }) => hasBenchmarkWorkspace(data.workspace) ? (getBenchmarkAiConfig(data.workspace) ?? {}) @@ -436,13 +506,21 @@ vi.mock('$lib/gen', async () => { ? { metrics: listBenchmarkDataMetrics(data.workspace) ?? [] } : actual.DataMetricService.listDataMetrics(data) }), + WorkerService: wrapService(actual.WorkerService, { + listWorkers: async (data: Record) => + listBenchmarkWorkers() ?? actual.WorkerService.listWorkers(data) + }), ScheduleService: wrapService(actual.ScheduleService, { existsSchedule: async (data: { workspace: string; path: string }) => hasBenchmarkWorkspace(data.workspace) ? false : actual.ScheduleService.existsSchedule(data), listSchedules: async (data: { workspace: string }) => - hasBenchmarkWorkspace(data.workspace) ? [] : actual.ScheduleService.listSchedules(data), + hasBenchmarkWorkspace(data.workspace) + ? listBenchmarkSchedules(data.workspace) + : actual.ScheduleService.listSchedules(data), getSchedule: async (data: { workspace: string; path: string }) => { if (hasBenchmarkWorkspace(data.workspace)) { + const schedule = getBenchmarkSchedule(data.workspace, data.path) + if (schedule) return schedule throw new Error(`Schedule "${data.path}" not found in benchmark workspace`) } return actual.ScheduleService.getSchedule(data) diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 136f125e59..f0df4ae6e3 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -3231,6 +3231,33 @@ - - admin skipJudge: true +# A role with schedules and triggers withdrawn builds nothing, which holds the session in Use. +# Its user cannot create a script by hand either, so the way forward is an admin, not steps. +- id: global-use-role-script-goes-to-admin + prompt: |- + Write a Python script that checks whether https://status.example.com is up and returns + true or false. + initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json + runtime: + maxTurns: 6 + sessionChat: true + profile: + operator: true + operatorSettings: + manage_schedules: false + manage_triggers: false + validate: + draftCountExactly: 0 + assistantExpect: + requiredMentionsAnyOf: + - - admin + judgeAssistantText: true + judgeChecklist: + - the reply puts the refusal on the user's role in this workspace, not only on this session or its mode + - the reply suggests asking a workspace admin, who can create the script or give the role more rights + - the reply does not tell or invite the user to create the script themselves with the role they have now, with steps or with code offered for them to paste in; code offered for an admin to add, or saying they could once an admin grants their role more, is fine + - the reply does not suggest switching this session to Use & build or Plan; saying a role with more rights could build is fine + # An agent from the Agents page is an `ai_agent` resource. Asked about one by name, the model # has to read it as a resource rather than look for a flow or script of that name. - id: global-agent-read-as-resource @@ -3283,3 +3310,626 @@ - Agents page - agent editor skipJudge: true + +# --- Composer suggestions --- +# The prompts the pills under Home's composer put in the box +# (frontend/src/lib/components/copilot/chat/starter/composerSuggestions.ts), word for word. +# A pill is only worth offering if the session chat can act on its prompt, so each one is +# pinned here against a workspace where the prompt has something to find. + +- id: global-composer-run-queue + prompt: |- + Is anything stuck in this workspace's queue? Which jobs are waiting or suspended, and why? + initial: ai_evals/fixtures/frontend/global/initial/composer_incidents.json + runtime: + maxTurns: 10 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - list_runs + - list_workers + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - gpu-renderer + - - refund_approval + - approval + - approve + skipJudge: true + judgeChecklist: + - finds the 12 gpu_render runs queued on the gpu-renderer tag that no connected worker serves + - finds the refund_approval flow suspended on its approval step, and says approving it unblocks it + +- id: global-composer-worker-health + prompt: |- + Are the workers healthy and keeping up with the queue? Compare how long jobs wait before they start with how busy each worker group is. + initial: ai_evals/fixtures/frontend/global/initial/composer_incidents.json + runtime: + maxTurns: 10 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - list_workers + - list_runs + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - gpu-renderer + skipJudge: true + judgeChecklist: + - reads the connected workers and the queued runs instead of guessing + - points out that the gpu-renderer runs wait because no worker serves that tag + +- id: global-composer-schedules + prompt: |- + Do any schedules in this workspace keep failing? If so, which ones and why? + initial: ai_evals/fixtures/frontend/global/initial/composer_incidents.json + runtime: + maxTurns: 10 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - list_runs + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - charge_cards_every_minute + - - insufficient funds + - declined + skipJudge: true + judgeChecklist: + - names f/billing/charge_cards_every_minute as the failing schedule, not import_orders_hourly + - gives the cause from its runs' logs (card declined, insufficient funds) + +- id: global-composer-recent-runs + prompt: |- + Look at the recent runs in this workspace: did any fail, and why? Is anything getting slower? + initial: ai_evals/fixtures/frontend/global/initial/composer_incidents.json + runtime: + maxTurns: 8 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - list_runs + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - charge_cards + - - insufficient funds + - declined + skipJudge: true + judgeChecklist: + - names charge_cards as the failing script and gives the cause from its logs + - does not invent a slowdown the run durations do not show + +# The empty-workspace examples, sent as the composer sends them. The workspace holds nothing, +# so the model has to stage what it needs; one answer covers a question about the Slack setup. +- id: global-composer-example-daily-metrics + prompt: |- + Every weekday at 9am, query postgres for yesterday's signups and revenue and post a summary to the #metrics Slack channel + initial: ai_evals/fixtures/frontend/global/initial/user_admin_empty.json + runtime: + maxTurns: 16 + sessionChat: true + userAnswers: + - Use placeholders for the postgres and Slack connections, I'll fill them in later + validate: + draftCountAtLeast: 1 + toolExpect: + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - stages a script or flow that queries postgres for the previous day's signups and revenue + - posts the summary to the #metrics Slack channel + - runs it on a schedule at 9am on weekdays only + +- id: global-composer-example-refund-approval + prompt: |- + Build a flow that asks for approval in Slack before issuing a refund in Stripe, and only refunds if someone approves + initial: ai_evals/fixtures/frontend/global/initial/user_admin_empty.json + runtime: + maxTurns: 16 + sessionChat: true + userAnswers: + - Use placeholders for the Stripe and Slack connections, I'll fill them in later + validate: + requiredDrafts: + - type: flow + valueIncludes: + - suspend + toolExpect: + requiredToolsUsed: + - write_flow + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - posts an approval request to Slack before any refund is issued + - suspends the flow on an approval step and continues only once someone approves + - issues the refund through Stripe only on approval, never when it is rejected + +- id: global-composer-example-ticket-triage + prompt: |- + When a support ticket arrives by webhook, classify its urgency with an LLM and post the urgent ones to the #support Slack channel + initial: ai_evals/fixtures/frontend/global/initial/user_admin_empty.json + runtime: + maxTurns: 16 + sessionChat: true + userAnswers: + - Use placeholders for the LLM and Slack connections, I'll fill them in later + validate: + draftCountAtLeast: 1 + toolExpect: + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - stages a script or flow that takes the ticket as the input a webhook delivers + - classifies the ticket's urgency with an LLM call; an `aidecision` step (Windmill's built-in LLM decision, with `choice` or `noul` questions) counts + - posts only the urgent tickets to the #support Slack channel + +# The pills that name what an established workspace holds: a teammate's flow, a starred +# script, a connected database and channel. +- id: global-composer-walk-through-flow + prompt: |- + Walk me through what the flow f/billing/charge_customers does, step by step. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 6 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - read_workspace_item + forbiddenToolsUsed: + - write_flow + - patch_flow_json + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - due today + - invoices due + - fetch_due + - - receipt + skipJudge: true + judgeChecklist: + - reads the flow and describes its three steps in order + +- id: global-composer-run-favorite + prompt: |- + Run the script f/ops/check_ssl_expiry for me. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 8 + sessionChat: true + userAnswers: + - windmill.dev + validate: + draftCountExactly: 0 + # The script needs a domain the prompt does not give. Opening the run form for the user to + # fill and asking for the domain in chat are both right, so neither is pinned; what is pinned + # is that the deployed script is read and nothing is previewed or written. + toolExpect: + requiredToolsUsed: + - read_workspace_item + forbiddenToolsUsed: + - test_run_script + - write_script + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - domain + # Running produces no draft, and the judge cannot observe runs; validate via tool use. + skipJudge: true + judgeChecklist: + - reads the deployed script and either opens its run form or asks for the domain it needs + +- id: global-composer-daily-summary + prompt: |- + Every weekday at 9am, summarize yesterday's activity in the Postgres database f/data/warehouse and post it to Slack with the resource f/team/slack + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 16 + sessionChat: true + userAnswers: + - Post it in #metrics, with new signups, new invoices and what they add up to + validate: + draftCountAtLeast: 1 + toolExpect: + # "Yesterday's activity" means whatever the tables hold, so they are read first. + requiredToolsUsed: + - get_db_schema + forbiddenToolsUsed: + - deploy_workspace_item + - delete_workspace_item + judgeChecklist: + - queries the warehouse's real tables (signups, invoices) for the previous day through f/data/warehouse + - posts the summary to Slack through the resource f/team/slack; a channel parameter with a default is fine, since the prompt names none + - runs on a schedule at 9am on weekdays only + +# Pills that teach the piece a workspace is missing. +- id: global-composer-setup-datatable + prompt: |- + This workspace has no data table yet. Walk me through setting one up in the workspace settings, then show me how a script writes to it. + initial: ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json + runtime: + maxTurns: 20 + sessionChat: true + userAnswers: + - Back it with the Postgres resource f/data/warehouse and call it main + # Opening the data table settings for the admin and writing them after a confirmation are + # both a way through; either way the script example is what teaches the concept. + toolExpect: + requiredToolsAnyOf: + - - open_page + - set_workspace_settings + forbiddenToolsUsed: + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - datatable( + - datatable() + skipJudge: true + judgeChecklist: + - shows where data tables are set up and a script writing to one with wmill.datatable() + +- id: global-composer-store-data + prompt: |- + How should my scripts store data in Windmill? Explain data tables and show me how a script reads and writes one. + initial: ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json + runtime: + maxTurns: 8 + sessionChat: true + # The pill is for developers: an admin is offered "Set up a data table" instead. + profile: + operator: false + toolExpect: + forbiddenToolsUsed: + - set_workspace_settings + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - datatable( + - datatable() + skipJudge: true + judgeChecklist: + - explains data tables and shows a script reading and writing one with wmill.datatable() + +- id: global-composer-connect-slack + prompt: |- + Help me connect Slack to this workspace so my scripts can post messages. + initial: ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json + runtime: + maxTurns: 12 + sessionChat: true + userAnswers: + - Use a bot token, I will paste it myself + # A Slack resource whose token is a secret variable, or the workspace's own Slack connection + # in settings: both connect Slack, so either path is accepted. + toolExpect: + requiredToolsAnyOf: + - - write_resource + - open_page + forbiddenToolsUsed: + - deploy_workspace_item + skipJudge: true + judgeChecklist: + - stages a Slack resource with its token in a secret variable, or opens the workspace's Slack settings + +- id: global-composer-store-secret + prompt: |- + I have an API key my scripts need. Store it as a secret and show me how a script reads it with wmill.getVariable. + initial: ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json + runtime: + maxTurns: 10 + sessionChat: true + userAnswers: + - It is for the Stripe API; I will paste the value myself + validate: + requiredDrafts: + - type: variable + valueIncludes: + - '"is_secret": true' + toolExpect: + forbiddenToolsUsed: + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - getVariable + - get_variable + - "$var:" + judgeChecklist: + - stages a secret variable with a placeholder value instead of inventing the key + - any example script reads the variable rather than embedding the key; an example given only in the chat is checked separately, so do not score its absence here + +# Pills that name what an established workspace holds, read from composer_connected_workspace. +- id: global-composer-explore-datatable + prompt: |- + List the tables in the data table main and tell me what each one holds. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 6 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsAnyOf: + - - list_datatables + - get_datatable_table_schema + assistantExpect: + requiredMentionsAnyOf: + - - feedback + - - onboarding_tasks + skipJudge: true + judgeChecklist: + - names the feedback and onboarding_tasks tables and what each holds + +- id: global-composer-app-on-datatable + prompt: |- + Build a full-code app to browse and edit the rows of a table in the data table main. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 30 + sessionChat: true + userAnswers: + - The feedback table + validate: + requiredDrafts: + - type: app + toolExpect: + requiredToolsUsed: + - init_app + forbiddenToolsUsed: + - deploy_workspace_item + judgeChecklist: + - builds a full-code app whose backend reads the rows of a table in the main data table through wmill.datatable(); `sql.raw` is the documented way to inline a table or column name + - lets the user edit a row and saves the change back to the table + +- id: global-composer-add-app-page + prompt: |- + Add a page to the app f/ops/console, and ask me what it should show. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 24 + sessionChat: true + userAnswers: + - A page listing the latest feedback from the main data table + # The pill asks the chat to ask: in chat or through askUserQuestion. A question in chat ends + # the run before the page is written, so the page itself is not pinned. + toolExpect: + requiredToolsAnyOf: + - - read_app_file + - read_workspace_item + forbiddenToolsUsed: + - init_app + - deploy_workspace_item + skipJudge: true + judgeChecklist: + - reads the app, then asks what the new page should show before writing it + +- id: global-composer-walk-through-agent + prompt: |- + Walk me through what the AI agent f/support/triage_agent does and which tools it can use. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 6 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - read_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - billing + - - no tools + - any tools + - no tool + skipJudge: true + judgeChecklist: + - explains that the agent labels tickets as billing, bug or question and has no tools + +- id: global-composer-channel-in-script + prompt: |- + Show me how a script uses the resource f/team/slack to post a Slack message, with a short example. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 8 + sessionChat: true + toolExpect: + forbiddenToolsUsed: + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - chat.postMessage + skipJudge: true + judgeChecklist: + - shows a script that takes the f/team/slack resource and posts a message with chat.postMessage + +- id: global-composer-postgres-trigger + prompt: |- + Run a script whenever a row is inserted into a table of the Postgres database f/data/warehouse, and explain how Postgres triggers work. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 20 + sessionChat: true + userAnswers: + - The signups table + validate: + requiredDrafts: + - type: trigger + triggerKind: postgres + toolExpect: + forbiddenToolsUsed: + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - publication + - replication + judgeChecklist: + - stages a Postgres trigger on f/data/warehouse that fires on inserts, limited to one table rather than every table + - the triggered script takes the inserted row as its input + +- id: global-composer-http-endpoint + prompt: |- + Expose the script f/ops/check_ssl_expiry as an HTTP API endpoint that requires a Windmill token, and show me how to call it. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 12 + sessionChat: true + # "Expose" asks for a live route, so deploying it, behind the user's confirmation, is right. + validate: + requiredDrafts: + - type: trigger + triggerKind: http + valueIncludes: + - f/ops/check_ssl_expiry + assistantExpect: + requiredMentionsAnyOf: + - - curl + - fetch( + judgeChecklist: + - stages an HTTP route that runs f/ops/check_ssl_expiry and passes it the domain + - the route requires a Windmill token (authentication_method windmill), so it is not public + - how to call it is explained in the chat, which is checked separately; do not score it + +- id: global-composer-schedule-script + prompt: |- + Run the script f/ops/check_ssl_expiry every weekday at 9am. Ask me for any input it needs, and explain how schedules work. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 12 + sessionChat: true + userAnswers: + - windmill.dev + # The script needs a domain the prompt does not give, so the chat reads the script and asks + # for it: in chat, which ends the run, or with askUserQuestion, which goes on to the schedule. + toolExpect: + requiredToolsUsed: + - read_workspace_item + forbiddenToolsUsed: + - deploy_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - domain + - - cron + - schedule + skipJudge: true + judgeChecklist: + - reads the script, asks for the domain it needs, and explains how schedules work + +- id: global-composer-webhook + prompt: |- + How do I start the flow f/billing/charge_customers from another app with its webhook? Show me the URL and an example request. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 8 + sessionChat: true + validate: + draftCountExactly: 0 + assistantExpect: + requiredMentionsAnyOf: + - - jobs/run + - - token + - Authorization + skipJudge: true + judgeChecklist: + - gives the flow's webhook URL and an example request with a token + +- id: global-composer-who-can-see + prompt: |- + Who can see and edit the flow f/billing/charge_customers, and why? + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 8 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - read_workspace_item + assistantExpect: + requiredMentionsAnyOf: + - - finance + - - bob + skipJudge: true + judgeChecklist: + - explains that access comes from the billing folder - its owner ada, bob with write, the finance group with read + +- id: global-composer-operator-what-can-i-run + prompt: |- + Which scripts and flows can I run in this workspace, and what does each one do? + initial: ai_evals/fixtures/frontend/global/initial/operator_ops_scripts.json + runtime: + maxTurns: 6 + sessionChat: true + profile: + operator: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - list_workspace_items + assistantExpect: + requiredMentionsAnyOf: + - - list_open_orders + - List open orders + skipJudge: true + judgeChecklist: + - lists the runnable scripts and flows with what each does + +- id: global-composer-hub-scripts + prompt: |- + Find scripts on the Windmill Hub for Slack that I could use with the resource f/team/slack. + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 8 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsAnyOf: + - - search_hub_scripts + - get_hub_integration + skipJudge: true + judgeChecklist: + - lists Slack scripts from the Hub that take a Slack resource + +- id: global-composer-mcp + prompt: |- + How do I connect Claude to this workspace with MCP so it can run my scripts and flows? + initial: ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json + runtime: + maxTurns: 8 + sessionChat: true + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsAnyOf: + - - search_docs + - read_docs_page + assistantExpect: + requiredMentionsAnyOf: + - - token + - MCP URL + skipJudge: true + judgeChecklist: + - explains the MCP setup from the docs: the MCP URL with a token, added to Claude diff --git a/ai_evals/core/cases.ts b/ai_evals/core/cases.ts index c745f36a15..0e9e400a74 100644 --- a/ai_evals/core/cases.ts +++ b/ai_evals/core/cases.ts @@ -24,6 +24,7 @@ interface RawEvalCase { assistantExpect?: EvalCase["assistantExpect"]; judgeChecklist?: string[]; skipJudge?: boolean; + judgeAssistantText?: boolean; runtime?: EvalCaseRuntimeSpec; } export function getRepoRoot(): string { @@ -54,6 +55,7 @@ export async function loadCases(mode: EvalMode): Promise { assistantExpect: entry.assistantExpect, judgeChecklist: entry.judgeChecklist, skipJudge: entry.skipJudge, + judgeAssistantText: entry.judgeAssistantText, runtime: entry.runtime, })); } diff --git a/ai_evals/core/models.ts b/ai_evals/core/models.ts index cf131337fd..3c469c4fd3 100644 --- a/ai_evals/core/models.ts +++ b/ai_evals/core/models.ts @@ -82,6 +82,19 @@ export const EVAL_MODELS: EvalModelSpec[] = [ model: "opus", }, }, + { + id: "sonnet-5", + label: "Claude Sonnet 5", + aliases: ["sonnet-5", "claude-sonnet-5"], + frontend: { + provider: "anthropic", + model: "claude-sonnet-5", + }, + cli: { + runtime: "claude-code", + model: "claude-sonnet-5", + }, + }, { id: "sonnet-5.5", label: "Claude Sonnet 5.5", diff --git a/ai_evals/core/runSuite.ts b/ai_evals/core/runSuite.ts index c2d80e5d6e..831cac1f05 100644 --- a/ai_evals/core/runSuite.ts +++ b/ai_evals/core/runSuite.ts @@ -245,15 +245,18 @@ async function runCaseAttempts(input: { input.judgeModel !== null && !input.evalCase.skipJudge ) { + const output = input.modeRunner.prepareJudgeActual + ? input.modeRunner.prepareJudgeActual(run.actual) + : run.actual; const judge = await judgeOutput({ mode: input.modeRunner.mode, prompt: input.evalCase.prompt, checklist: input.evalCase.judgeChecklist, initial, expected: input.modeRunner.mode === "cli" ? undefined : expected, - actual: input.modeRunner.prepareJudgeActual - ? input.modeRunner.prepareJudgeActual(run.actual) - : run.actual, + actual: input.evalCase.judgeAssistantText + ? { assistantReply: run.assistantText, output } + : output, model: input.judgeModel, }); diff --git a/ai_evals/core/types.ts b/ai_evals/core/types.ts index 192a0c7ac7..b13f6264ac 100644 --- a/ai_evals/core/types.ts +++ b/ai_evals/core/types.ts @@ -41,7 +41,8 @@ export interface EvalCaseRuntimeSpec { // refused until the model hands over a plan with exit_plan_mode. planMode?: boolean; // Global session chats: the user's rights in the workspace, as production resolves them for - // a session from a non-admin role. Unset means full access. + // a session from a non-admin role. Unset means full access. A role that builds nothing holds + // the session in Use, with the read-only toolset and the session section that says why. profile?: EvalCaseProfile; } @@ -273,6 +274,12 @@ export interface EvalCase { assistantExpect?: AssistantValidationSpec; judgeChecklist?: string[]; skipJudge?: boolean; + /** + * The judge also reads what the assistant said, for a case whose deliverable is the answer + * itself: the `global` judge otherwise sees only the drafts. Needs a mode whose runner + * reports `assistantText`. + */ + judgeAssistantText?: boolean; runtime?: EvalCaseRuntimeSpec; } diff --git a/ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json b/ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json new file mode 100644 index 0000000000..db30ec00e8 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/composer_connected_workspace.json @@ -0,0 +1,313 @@ +{ + "user": { + "username": "admin", + "is_admin": true + }, + "workspace": { + "flows": [ + { + "path": "f/billing/charge_customers", + "summary": "Charge customers with due invoices", + "description": "Every night, charges each customer whose invoice is due and emails a receipt.", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "dry_run": { + "type": "boolean", + "default": false + } + }, + "required": [] + }, + "value": { + "modules": [ + { + "id": "fetch_due", + "summary": "Fetch invoices due today from the warehouse", + "value": { + "type": "rawscript", + "language": "postgresql", + "content": "-- database f/data/warehouse\nSELECT id, customer_id, amount_cents FROM invoices WHERE due_date = current_date AND paid = false;", + "input_transforms": { + "database": { + "type": "static", + "value": "$res:f/data/warehouse" + } + } + } + }, + { + "id": "charge", + "summary": "Charge each invoice", + "value": { + "type": "forloopflow", + "iterator": { + "type": "javascript", + "expr": "results.fetch_due" + }, + "skip_failures": false, + "modules": [ + { + "id": "charge_one", + "summary": "Charge one invoice through the payment API", + "value": { + "type": "rawscript", + "language": "bun", + "content": "export async function main(invoice: { id: number; amount_cents: number }) {\n const res = await fetch('https://payments.example.com/charge', { method: 'POST', body: JSON.stringify(invoice) })\n if (!res.ok) throw new Error(`charge failed for invoice ${invoice.id}`)\n return await res.json()\n}\n", + "input_transforms": { + "invoice": { + "type": "javascript", + "expr": "flow_input.iter.value" + } + } + } + } + ] + } + }, + { + "id": "email_receipts", + "summary": "Email a receipt to each charged customer", + "value": { + "type": "rawscript", + "language": "bun", + "content": "// Each charge is what the payment API answered, which carries the customer to email.\nexport async function main(charges: { id: string; customer_id: number }[]) {\n for (const charge of charges) {\n const res = await fetch('https://mail.example.com/receipts', { method: 'POST', body: JSON.stringify(charge) })\n if (!res.ok) throw new Error(`receipt failed for charge ${charge.id}`)\n }\n return { sent: charges.length }\n}\n", + "input_transforms": { + "charges": { + "type": "javascript", + "expr": "results.charge" + } + } + } + } + ] + } + } + ], + "scripts": [ + { + "path": "f/ops/check_ssl_expiry", + "summary": "Check SSL certificate expiry", + "language": "bun", + "schema": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "domain": { + "type": "string", + "description": "The domain whose certificate to check" + } + }, + "required": [ + "domain" + ] + }, + "content": "export async function main(domain: string) {\n const res = await fetch(`https://ssl-check.example.com/${domain}`)\n return await res.json()\n}\n" + } + ], + "resources": [ + { + "path": "f/data/warehouse", + "resource_type": "postgresql", + "description": "Production warehouse", + "value": { + "host": "db.internal", + "port": 5432, + "dbname": "warehouse", + "user": "windmill", + "password": "$var:f/data/warehouse_password" + }, + "dbSchema": { + "public": { + "customers": { + "id": { + "type": "integer", + "required": true + }, + "email": { + "type": "text", + "required": true + }, + "plan": { + "type": "text", + "required": false + }, + "created_at": { + "type": "timestamp", + "required": true, + "default": "now()" + } + }, + "invoices": { + "id": { + "type": "integer", + "required": true + }, + "customer_id": { + "type": "integer", + "required": true + }, + "amount_cents": { + "type": "integer", + "required": true + }, + "due_date": { + "type": "date", + "required": true + }, + "paid": { + "type": "boolean", + "required": true, + "default": "false" + }, + "created_at": { + "type": "timestamp", + "required": true, + "default": "now()" + } + }, + "signups": { + "id": { + "type": "integer", + "required": true + }, + "email": { + "type": "text", + "required": true + }, + "source": { + "type": "text", + "required": false + }, + "created_at": { + "type": "timestamp", + "required": true, + "default": "now()" + } + } + } + } + }, + { + "path": "f/team/slack", + "resource_type": "slack", + "description": "Team Slack bot", + "value": { + "token": "$var:f/team/slack_token" + } + }, + { + "path": "f/support/triage_agent", + "resource_type": "ai_agent", + "description": "Labels incoming support tickets", + "value": { + "provider": { + "kind": "anthropic", + "model": "claude-sonnet-5", + "resource": "" + }, + "output_type": "text", + "system_prompt": "Read the support ticket and answer with one label: billing, bug or question.", + "memory": { + "kind": "off" + }, + "tools": [] + } + } + ], + "apps": [ + { + "path": "f/ops/console", + "summary": "Ops console", + "value": { + "files": { + "/App.tsx": "import { useState } from 'react'\nimport { backend } from './wmill'\n\nexport default function App() {\n\tconst [domain, setDomain] = useState('')\n\tconst [result, setResult] = useState()\n\treturn (\n\t\t
\n\t\t\t

Ops console

\n\t\t\t setDomain(e.target.value)} />\n\t\t\t\n\t\t\t
{JSON.stringify(result, null, 2)}
\n\t\t
\n\t)\n}\n" + }, + "runnables": { + "check_ssl": { + "type": "path", + "runType": "script", + "path": "f/ops/check_ssl_expiry", + "fields": {} + } + } + } + } + ], + "datatables": [ + { + "datatable_name": "main", + "schemas": { + "public": { + "feedback": { + "columns": { + "id": "int4", + "customer_email": "text", + "rating": "int4", + "comment": "text", + "created_at": "timestamptz" + }, + "rows": [ + { + "id": 1, + "customer_email": "ada@example.com", + "rating": 5, + "comment": "Fast support", + "created_at": "2026-10-01T09:00:00Z" + } + ] + }, + "onboarding_tasks": { + "columns": { + "id": "int4", + "customer_email": "text", + "task": "text", + "done": "bool" + }, + "rows": [ + { + "id": 1, + "customer_email": "ada@example.com", + "task": "Connect billing", + "done": false + } + ] + } + } + } + } + ], + "folders": [ + { + "name": "billing", + "summary": "Billing jobs", + "owners": [ + "u/ada" + ], + "extra_perms": { + "g/finance": false, + "u/bob": true + } + }, + { + "name": "ops", + "summary": "Operations", + "owners": [ + "u/admin" + ], + "extra_perms": {} + } + ], + "groups": [ + { + "name": "finance", + "summary": "Finance team", + "members": [ + "carol", + "dan" + ] + } + ] + } +} diff --git a/ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json b/ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json new file mode 100644 index 0000000000..b719ba0751 --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/composer_fresh_workspace.json @@ -0,0 +1,22 @@ +{ + "user": { + "username": "admin", + "is_admin": true + }, + "workspace": { + "resources": [ + { + "path": "f/data/warehouse", + "resource_type": "postgresql", + "description": "Production warehouse", + "value": { + "host": "db.internal", + "port": 5432, + "dbname": "warehouse", + "user": "windmill", + "password": "$var:f/data/warehouse_password" + } + } + ] + } +} diff --git a/ai_evals/fixtures/frontend/global/initial/composer_incidents.json b/ai_evals/fixtures/frontend/global/initial/composer_incidents.json new file mode 100644 index 0000000000..4799b895ea --- /dev/null +++ b/ai_evals/fixtures/frontend/global/initial/composer_incidents.json @@ -0,0 +1,83 @@ +{ + "workspace": { + "scripts": [ + { + "path": "f/billing/charge_cards", + "summary": "Charge cards", + "language": "bash", + "content": "echo \"charging card for customer $1\"\ncurl -sf https://payments.example.com/charge || { echo \"card declined: insufficient funds\" >&2; exit 1; }\n" + }, + { + "path": "f/sales/import_orders", + "summary": "Import orders", + "language": "bash", + "content": "echo \"imported $(date +%s) orders\"\n" + }, + { + "path": "f/media/gpu_render", + "summary": "GPU render", + "language": "bash", + "content": "echo \"rendering frame\"\n" + } + ], + "flows": [ + { + "path": "f/billing/refund_approval", + "summary": "Refund approval", + "value": { + "modules": [ + { + "id": "a", + "summary": "Ask a manager to approve the refund", + "value": { "type": "rawscript", "language": "bash", "content": "echo \"refund requested\"", "input_transforms": {} }, + "suspend": { "required_events": 1, "timeout": 86400 } + }, + { + "id": "b", + "summary": "Issue the refund", + "value": { "type": "rawscript", "language": "bash", "content": "echo \"refunded\"", "input_transforms": {} } + } + ] + } + } + ], + "schedules": [ + { "path": "f/billing/charge_cards_every_minute", "schedule": "0 * * * * *", "script_path": "f/billing/charge_cards", "summary": "Charge pending cards every minute" }, + { "path": "f/sales/import_orders_hourly", "schedule": "0 0 * * * *", "script_path": "f/sales/import_orders", "summary": "Import orders every hour" } + ], + "workers": [ + { + "worker": "wk-default-7f3a2", + "worker_group": "default", + "custom_tags": ["bash", "python3", "bun", "deno", "go", "flow", "dependency"], + "lastPingSecondsAgo": 3, + "jobs_executed": 214 + } + ], + "jobs": [ + { "id": "01930000-0000-7000-8000-00000000c001", "scriptPath": "f/billing/charge_cards", "schedulePath": "f/billing/charge_cards_every_minute", "createdBy": "schedule", "success": false, "createdMinutesAgo": 1, "durationMs": 41, "tag": "bash", "logs": "charging card for customer 4412\ncard declined: insufficient funds\nexit code 1" }, + { "id": "01930000-0000-7000-8000-00000000c002", "scriptPath": "f/billing/charge_cards", "schedulePath": "f/billing/charge_cards_every_minute", "createdBy": "schedule", "success": false, "createdMinutesAgo": 2, "durationMs": 38, "tag": "bash", "logs": "charging card for customer 4412\ncard declined: insufficient funds\nexit code 1" }, + { "id": "01930000-0000-7000-8000-00000000c003", "scriptPath": "f/billing/charge_cards", "schedulePath": "f/billing/charge_cards_every_minute", "createdBy": "schedule", "success": false, "createdMinutesAgo": 3, "durationMs": 44, "tag": "bash", "logs": "charging card for customer 4412\ncard declined: insufficient funds\nexit code 1" }, + { "id": "01930000-0000-7000-8000-00000000c004", "scriptPath": "f/billing/charge_cards", "schedulePath": "f/billing/charge_cards_every_minute", "createdBy": "schedule", "success": false, "createdMinutesAgo": 4, "durationMs": 40, "tag": "bash", "logs": "charging card for customer 4412\ncard declined: insufficient funds\nexit code 1" }, + { "id": "01930000-0000-7000-8000-00000000c005", "scriptPath": "f/billing/charge_cards", "createdBy": "alice", "success": false, "createdMinutesAgo": 30, "durationMs": 52, "tag": "bash", "logs": "charging card for customer 4412\ncard declined: insufficient funds\nexit code 1" }, + { "id": "01930000-0000-7000-8000-00000000c006", "scriptPath": "f/billing/charge_cards", "createdBy": "alice", "success": false, "createdMinutesAgo": 31, "durationMs": 47, "tag": "bash", "logs": "charging card for customer 4412\ncard declined: insufficient funds\nexit code 1" }, + { "id": "01930000-0000-7000-8000-00000000d001", "scriptPath": "f/sales/import_orders", "schedulePath": "f/sales/import_orders_hourly", "createdBy": "schedule", "success": true, "createdMinutesAgo": 10, "durationMs": 820, "tag": "bash", "logs": "imported 112 orders" }, + { "id": "01930000-0000-7000-8000-00000000d002", "scriptPath": "f/sales/import_orders", "schedulePath": "f/sales/import_orders_hourly", "createdBy": "schedule", "success": true, "createdMinutesAgo": 70, "durationMs": 790, "tag": "bash", "logs": "imported 98 orders" }, + { "id": "01930000-0000-7000-8000-00000000d003", "scriptPath": "f/sales/import_orders", "schedulePath": "f/sales/import_orders_hourly", "createdBy": "schedule", "success": true, "createdMinutesAgo": 130, "durationMs": 805, "tag": "bash", "logs": "imported 104 orders" }, + { "id": "01930000-0000-7000-8000-00000000d004", "scriptPath": "f/sales/import_orders", "createdBy": "bob", "success": true, "createdMinutesAgo": 45, "durationMs": 811, "tag": "bash", "logs": "imported 3 orders" }, + { "id": "01930000-0000-7000-8000-00000000e001", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 22, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e002", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 22, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e003", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 22, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e004", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 21, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e005", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 21, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e006", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 21, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e007", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 20, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e008", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 20, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e009", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 20, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e010", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 19, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e011", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 19, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000e012", "scriptPath": "f/media/gpu_render", "createdBy": "alice", "state": "queued", "createdMinutesAgo": 19, "tag": "gpu-renderer" }, + { "id": "01930000-0000-7000-8000-00000000f001", "scriptPath": "f/billing/refund_approval", "jobKind": "flow", "createdBy": "bob", "state": "suspended", "createdMinutesAgo": 40, "tag": "flow", "logs": "refund requested" } + ] + } +} diff --git a/backend/.sqlx/query-020c031c3de6c85577e30421ada9d39a5a47ca1b6cf3dbfd6988aa0694d7364c.json b/backend/.sqlx/query-020c031c3de6c85577e30421ada9d39a5a47ca1b6cf3dbfd6988aa0694d7364c.json deleted file mode 100644 index e8edd87bda..0000000000 --- a/backend/.sqlx/query-020c031c3de6c85577e30421ada9d39a5a47ca1b6cf3dbfd6988aa0694d7364c.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT value FROM variable WHERE path = $1 AND workspace_id = $2 AND is_secret = true", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "value", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "020c031c3de6c85577e30421ada9d39a5a47ca1b6cf3dbfd6988aa0694d7364c" -} diff --git a/backend/.sqlx/query-7a92837ad2a1181580c1e0d059fd6bc0ece84d3c315635de52bd2b364c238a71.json b/backend/.sqlx/query-08af141e847f9bb6a4c26ccad3bd84d2a06b9e55557757d407787078af1ecad7.json similarity index 62% rename from backend/.sqlx/query-7a92837ad2a1181580c1e0d059fd6bc0ece84d3c315635de52bd2b364c238a71.json rename to backend/.sqlx/query-08af141e847f9bb6a4c26ccad3bd84d2a06b9e55557757d407787078af1ecad7.json index 331816c897..4031b62a9e 100644 --- a/backend/.sqlx/query-7a92837ad2a1181580c1e0d059fd6bc0ece84d3c315635de52bd2b364c238a71.json +++ b/backend/.sqlx/query-08af141e847f9bb6a4c26ccad3bd84d2a06b9e55557757d407787078af1ecad7.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "SELECT value, account, (now() > account.expires_at) as is_expired, is_secret, path from variable\n LEFT JOIN account ON variable.account = account.id WHERE variable.path = $1 AND variable.workspace_id = $2", + "query": "SELECT value::text AS \"value!\", account, (now() > account.expires_at) as is_expired, is_secret, path from variable\n LEFT JOIN account ON variable.account = account.id WHERE variable.path = $1 AND variable.workspace_id = $2", "describe": { "columns": [ { "ordinal": 0, - "name": "value", - "type_info": "Varchar" + "name": "value!", + "type_info": "Text" }, { "ordinal": 1, @@ -36,12 +36,12 @@ ] }, "nullable": [ - false, + null, true, null, false, false ] }, - "hash": "7a92837ad2a1181580c1e0d059fd6bc0ece84d3c315635de52bd2b364c238a71" + "hash": "08af141e847f9bb6a4c26ccad3bd84d2a06b9e55557757d407787078af1ecad7" } diff --git a/backend/.sqlx/query-146f0e42ada3068a5cdae0ffdbb54b63f8c06c9143b16ce399170c1b5a6b911e.json b/backend/.sqlx/query-1cf81ee935e6e820463820519e3a402d5290b494dac690c9a11f121eeefeab8e.json similarity index 56% rename from backend/.sqlx/query-146f0e42ada3068a5cdae0ffdbb54b63f8c06c9143b16ce399170c1b5a6b911e.json rename to backend/.sqlx/query-1cf81ee935e6e820463820519e3a402d5290b494dac690c9a11f121eeefeab8e.json index 9fd6daded5..da40d27d9f 100644 --- a/backend/.sqlx/query-146f0e42ada3068a5cdae0ffdbb54b63f8c06c9143b16ce399170c1b5a6b911e.json +++ b/backend/.sqlx/query-1cf81ee935e6e820463820519e3a402d5290b494dac690c9a11f121eeefeab8e.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "SELECT value, is_secret FROM variable WHERE path = $1 AND workspace_id = $2", + "query": "SELECT value::text AS \"value!\", is_secret FROM variable WHERE path = $1 AND workspace_id = $2", "describe": { "columns": [ { "ordinal": 0, - "name": "value", - "type_info": "Varchar" + "name": "value!", + "type_info": "Text" }, { "ordinal": 1, @@ -21,9 +21,9 @@ ] }, "nullable": [ - false, + null, false ] }, - "hash": "146f0e42ada3068a5cdae0ffdbb54b63f8c06c9143b16ce399170c1b5a6b911e" + "hash": "1cf81ee935e6e820463820519e3a402d5290b494dac690c9a11f121eeefeab8e" } diff --git a/backend/.sqlx/query-2c0ab7571e1a7c4290315bc3efccb4db9e0c9aee05596a594f81975a0cdb74d1.json b/backend/.sqlx/query-2c0ab7571e1a7c4290315bc3efccb4db9e0c9aee05596a594f81975a0cdb74d1.json deleted file mode 100644 index 9c4b3eb842..0000000000 --- a/backend/.sqlx/query-2c0ab7571e1a7c4290315bc3efccb4db9e0c9aee05596a594f81975a0cdb74d1.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT value FROM variable WHERE workspace_id = $1 AND path = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "value", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "2c0ab7571e1a7c4290315bc3efccb4db9e0c9aee05596a594f81975a0cdb74d1" -} diff --git a/backend/.sqlx/query-2d5ff8fc102ae0c452c1f9cd5cd30fd0a3b4e6e746c659da1767edafd139d45e.json b/backend/.sqlx/query-30f69fd4baef17716d26cc59bf6369d6768972478def50010f680103d7c4281b.json similarity index 50% rename from backend/.sqlx/query-2d5ff8fc102ae0c452c1f9cd5cd30fd0a3b4e6e746c659da1767edafd139d45e.json rename to backend/.sqlx/query-30f69fd4baef17716d26cc59bf6369d6768972478def50010f680103d7c4281b.json index 2939d5ac88..3933b5e6a2 100644 --- a/backend/.sqlx/query-2d5ff8fc102ae0c452c1f9cd5cd30fd0a3b4e6e746c659da1767edafd139d45e.json +++ b/backend/.sqlx/query-30f69fd4baef17716d26cc59bf6369d6768972478def50010f680103d7c4281b.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "SELECT value, is_secret, description\n FROM variable\n WHERE workspace_id = $1 AND path = $2", + "query": "SELECT value::text AS \"value!\", is_secret, description, value_valid_until\n FROM variable\n WHERE workspace_id = $1 AND path = $2", "describe": { "columns": [ { "ordinal": 0, - "name": "value", - "type_info": "Varchar" + "name": "value!", + "type_info": "Text" }, { "ordinal": 1, @@ -17,6 +17,11 @@ "ordinal": 2, "name": "description", "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "value_valid_until", + "type_info": "Timestamptz" } ], "parameters": { @@ -26,10 +31,11 @@ ] }, "nullable": [ + null, false, false, - false + true ] }, - "hash": "2d5ff8fc102ae0c452c1f9cd5cd30fd0a3b4e6e746c659da1767edafd139d45e" + "hash": "30f69fd4baef17716d26cc59bf6369d6768972478def50010f680103d7c4281b" } diff --git a/backend/.sqlx/query-32c7dba63931c9bd35f82f145d095bcfb8f9400809ade1b173bac05d61bfe77a.json b/backend/.sqlx/query-32c7dba63931c9bd35f82f145d095bcfb8f9400809ade1b173bac05d61bfe77a.json new file mode 100644 index 0000000000..0e2bcbd955 --- /dev/null +++ b/backend/.sqlx/query-32c7dba63931c9bd35f82f145d095bcfb8f9400809ade1b173bac05d61bfe77a.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO variable\n (workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, value_valid_until, labels, edited_by)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Varchar", + "Varchar", + "Bool", + "Varchar", + "Int4", + "Bool", + "Timestamptz", + "Timestamptz", + "TextArray", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "32c7dba63931c9bd35f82f145d095bcfb8f9400809ade1b173bac05d61bfe77a" +} diff --git a/backend/.sqlx/query-b656f38e5f1d6a0799767a775b8550027cf7a3bb202269e8dda92d31bc8789dd.json b/backend/.sqlx/query-3420c007c141c5d9e3748c18de03d4b51216ba05e2951ce045ff58112c1db361.json similarity index 63% rename from backend/.sqlx/query-b656f38e5f1d6a0799767a775b8550027cf7a3bb202269e8dda92d31bc8789dd.json rename to backend/.sqlx/query-3420c007c141c5d9e3748c18de03d4b51216ba05e2951ce045ff58112c1db361.json index 6ad2fc0ae1..b7b2516d40 100644 --- a/backend/.sqlx/query-b656f38e5f1d6a0799767a775b8550027cf7a3bb202269e8dda92d31bc8789dd.json +++ b/backend/.sqlx/query-3420c007c141c5d9e3748c18de03d4b51216ba05e2951ce045ff58112c1db361.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT path, value, is_secret FROM variable WHERE workspace_id = $1", + "query": "SELECT path, value::text AS \"value!\", is_secret FROM variable WHERE workspace_id = $1", "describe": { "columns": [ { @@ -10,8 +10,8 @@ }, { "ordinal": 1, - "name": "value", - "type_info": "Varchar" + "name": "value!", + "type_info": "Text" }, { "ordinal": 2, @@ -26,9 +26,9 @@ }, "nullable": [ false, - false, + null, false ] }, - "hash": "b656f38e5f1d6a0799767a775b8550027cf7a3bb202269e8dda92d31bc8789dd" + "hash": "3420c007c141c5d9e3748c18de03d4b51216ba05e2951ce045ff58112c1db361" } diff --git a/backend/.sqlx/query-71b410dbc6cdd0252de2f8646395903c9fa60a5435fa73c587da2b913203cf18.json b/backend/.sqlx/query-36121d10cb03d45fc14aacd08750fb944a54c1172ffa2b990ba157e676f66f51.json similarity index 50% rename from backend/.sqlx/query-71b410dbc6cdd0252de2f8646395903c9fa60a5435fa73c587da2b913203cf18.json rename to backend/.sqlx/query-36121d10cb03d45fc14aacd08750fb944a54c1172ffa2b990ba157e676f66f51.json index d74d5e6ba3..8d7e440538 100644 --- a/backend/.sqlx/query-71b410dbc6cdd0252de2f8646395903c9fa60a5435fa73c587da2b913203cf18.json +++ b/backend/.sqlx/query-36121d10cb03d45fc14aacd08750fb944a54c1172ffa2b990ba157e676f66f51.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "SELECT v.value, a.id AS account_id, a.refresh_token\n FROM variable v\n JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account\n WHERE v.workspace_id = $1 AND v.path = $2\n AND a.client = $3 AND a.is_workspace_integration = true", + "query": "SELECT v.value::text AS \"value!\", a.id AS account_id, a.refresh_token\n FROM variable v\n JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account\n WHERE v.workspace_id = $1 AND v.path = $2\n AND a.client = $3 AND a.is_workspace_integration = true", "describe": { "columns": [ { "ordinal": 0, - "name": "value", - "type_info": "Varchar" + "name": "value!", + "type_info": "Text" }, { "ordinal": 1, @@ -27,10 +27,10 @@ ] }, "nullable": [ - false, + null, false, false ] }, - "hash": "71b410dbc6cdd0252de2f8646395903c9fa60a5435fa73c587da2b913203cf18" + "hash": "36121d10cb03d45fc14aacd08750fb944a54c1172ffa2b990ba157e676f66f51" } diff --git a/backend/.sqlx/query-37275b26b092ed3ae1a11b78da5227a40a49d9435107e71033740f11cc8d2417.json b/backend/.sqlx/query-37275b26b092ed3ae1a11b78da5227a40a49d9435107e71033740f11cc8d2417.json deleted file mode 100644 index 7560df9dd2..0000000000 --- a/backend/.sqlx/query-37275b26b092ed3ae1a11b78da5227a40a49d9435107e71033740f11cc8d2417.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "37275b26b092ed3ae1a11b78da5227a40a49d9435107e71033740f11cc8d2417" -} diff --git a/backend/.sqlx/query-3c951e76bd0d86a488372e54fdfe4f5a2923cff642905c6410267be2eab24853.json b/backend/.sqlx/query-3c951e76bd0d86a488372e54fdfe4f5a2923cff642905c6410267be2eab24853.json new file mode 100644 index 0000000000..a41e413243 --- /dev/null +++ b/backend/.sqlx/query-3c951e76bd0d86a488372e54fdfe4f5a2923cff642905c6410267be2eab24853.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2 AND archived = false AND deleted = false)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "3c951e76bd0d86a488372e54fdfe4f5a2923cff642905c6410267be2eab24853" +} diff --git a/backend/.sqlx/query-40790b7f44b40b0c64bbabd3bc6c984000e12f33bc1cea460e8f5dd55ea59eec.json b/backend/.sqlx/query-40790b7f44b40b0c64bbabd3bc6c984000e12f33bc1cea460e8f5dd55ea59eec.json new file mode 100644 index 0000000000..ab8bc4e241 --- /dev/null +++ b/backend/.sqlx/query-40790b7f44b40b0c64bbabd3bc6c984000e12f33bc1cea460e8f5dd55ea59eec.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value::text AS \"value!\" FROM variable WHERE workspace_id = $1 AND path = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "40790b7f44b40b0c64bbabd3bc6c984000e12f33bc1cea460e8f5dd55ea59eec" +} diff --git a/backend/.sqlx/query-4ba98c3b07a3bd2fa51e4ddf93c182b3ac165cb6a343ad616409fa80b4b93484.json b/backend/.sqlx/query-4ba98c3b07a3bd2fa51e4ddf93c182b3ac165cb6a343ad616409fa80b4b93484.json new file mode 100644 index 0000000000..8cead5cb8e --- /dev/null +++ b/backend/.sqlx/query-4ba98c3b07a3bd2fa51e4ddf93c182b3ac165cb6a343ad616409fa80b4b93484.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET variable_expiration_failed_at = NULL\n WHERE workspace_id = $1 AND variable_expiration_failed_at IS NOT NULL\n RETURNING 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "4ba98c3b07a3bd2fa51e4ddf93c182b3ac165cb6a343ad616409fa80b4b93484" +} diff --git a/backend/.sqlx/query-56bb398d53c1c54b27fe3b61b866c752ea54871db71a8859311e18bf6b6da82c.json b/backend/.sqlx/query-56bb398d53c1c54b27fe3b61b866c752ea54871db71a8859311e18bf6b6da82c.json new file mode 100644 index 0000000000..17a9650d3e --- /dev/null +++ b/backend/.sqlx/query-56bb398d53c1c54b27fe3b61b866c752ea54871db71a8859311e18bf6b6da82c.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value AS \"value: sqlx::types::Json\"\n FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value: sqlx::types::Json", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true + ] + }, + "hash": "56bb398d53c1c54b27fe3b61b866c752ea54871db71a8859311e18bf6b6da82c" +} diff --git a/backend/.sqlx/query-1587639f13a1a3ad97bd324df38661f3e846f1992704c86ddad74dea151b0ec4.json b/backend/.sqlx/query-5d344853a65a2438c87f12124428609933e4363c11455709e1785228abd3366f.json similarity index 61% rename from backend/.sqlx/query-1587639f13a1a3ad97bd324df38661f3e846f1992704c86ddad74dea151b0ec4.json rename to backend/.sqlx/query-5d344853a65a2438c87f12124428609933e4363c11455709e1785228abd3366f.json index 0ed678d485..386435e806 100644 --- a/backend/.sqlx/query-1587639f13a1a3ad97bd324df38661f3e846f1992704c86ddad74dea151b0ec4.json +++ b/backend/.sqlx/query-5d344853a65a2438c87f12124428609933e4363c11455709e1785228abd3366f.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "SELECT value, is_secret, path from variable WHERE variable.path = $1 AND variable.workspace_id = $2", + "query": "SELECT value::text AS \"value!\", is_secret, path from variable WHERE variable.path = $1 AND variable.workspace_id = $2", "describe": { "columns": [ { "ordinal": 0, - "name": "value", - "type_info": "Varchar" + "name": "value!", + "type_info": "Text" }, { "ordinal": 1, @@ -26,10 +26,10 @@ ] }, "nullable": [ - false, + null, false, false ] }, - "hash": "1587639f13a1a3ad97bd324df38661f3e846f1992704c86ddad74dea151b0ec4" + "hash": "5d344853a65a2438c87f12124428609933e4363c11455709e1785228abd3366f" } diff --git a/backend/.sqlx/query-0c2e53960dc7786c848079b155e62994d4a861f1dbb9752c0a27428d83040e13.json b/backend/.sqlx/query-698758d7ef61b3f1de00088a36fe9f2e94c1c25b02bf324a730e4f734eb61c16.json similarity index 57% rename from backend/.sqlx/query-0c2e53960dc7786c848079b155e62994d4a861f1dbb9752c0a27428d83040e13.json rename to backend/.sqlx/query-698758d7ef61b3f1de00088a36fe9f2e94c1c25b02bf324a730e4f734eb61c16.json index 5c33539878..17e8d20bb5 100644 --- a/backend/.sqlx/query-0c2e53960dc7786c848079b155e62994d4a861f1dbb9752c0a27428d83040e13.json +++ b/backend/.sqlx/query-698758d7ef61b3f1de00088a36fe9f2e94c1c25b02bf324a730e4f734eb61c16.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "SELECT value, is_secret FROM variable WHERE path = $1", + "query": "SELECT value::text AS \"value!\", is_secret FROM variable WHERE path = $1", "describe": { "columns": [ { "ordinal": 0, - "name": "value", - "type_info": "Varchar" + "name": "value!", + "type_info": "Text" }, { "ordinal": 1, @@ -20,9 +20,9 @@ ] }, "nullable": [ - false, + null, false ] }, - "hash": "0c2e53960dc7786c848079b155e62994d4a861f1dbb9752c0a27428d83040e13" + "hash": "698758d7ef61b3f1de00088a36fe9f2e94c1c25b02bf324a730e4f734eb61c16" } diff --git a/backend/.sqlx/query-74742f785888f280bf1d7e28793150c3ddc2fd4be3b8c682f6faa7b917387593.json b/backend/.sqlx/query-74742f785888f280bf1d7e28793150c3ddc2fd4be3b8c682f6faa7b917387593.json new file mode 100644 index 0000000000..5bf83d1b33 --- /dev/null +++ b/backend/.sqlx/query-74742f785888f280bf1d7e28793150c3ddc2fd4be3b8c682f6faa7b917387593.json @@ -0,0 +1,214 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n variable_expiration_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "slack_team_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "teams_team_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "teams_team_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "teams_team_guid", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "slack_name", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "slack_command_script", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "teams_command_script", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "slack_email", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "slack_oauth_client_id", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "slack_oauth_client_secret", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "customer_id", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "plan", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "webhook", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "ai_config", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "dbt_warehouses", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "large_file_storage", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "datatable", + "type_info": "Jsonb" + }, + { + "ordinal": 18, + "name": "ducklake", + "type_info": "Jsonb" + }, + { + "ordinal": 19, + "name": "git_sync", + "type_info": "Jsonb" + }, + { + "ordinal": 20, + "name": "deploy_ui", + "type_info": "Jsonb" + }, + { + "ordinal": 21, + "name": "default_app", + "type_info": "Varchar" + }, + { + "ordinal": 22, + "name": "default_scripts", + "type_info": "Jsonb" + }, + { + "ordinal": 23, + "name": "mute_critical_alerts", + "type_info": "Bool" + }, + { + "ordinal": 24, + "name": "color", + "type_info": "Varchar" + }, + { + "ordinal": 25, + "name": "operator_settings", + "type_info": "Jsonb" + }, + { + "ordinal": 26, + "name": "git_app_installations", + "type_info": "Jsonb" + }, + { + "ordinal": 27, + "name": "auto_invite", + "type_info": "Jsonb" + }, + { + "ordinal": 28, + "name": "error_handler", + "type_info": "Jsonb" + }, + { + "ordinal": 29, + "name": "success_handler", + "type_info": "Jsonb" + }, + { + "ordinal": 30, + "name": "variable_expiration_handler", + "type_info": "Jsonb" + }, + { + "ordinal": 31, + "name": "public_app_execution_limit_per_minute", + "type_info": "Int4" + }, + { + "ordinal": 32, + "name": "error_handler_fallback_to_instance_alerts", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + true, + false + ] + }, + "hash": "74742f785888f280bf1d7e28793150c3ddc2fd4be3b8c682f6faa7b917387593" +} diff --git a/backend/.sqlx/query-77d599e4f7c574dffac4824f37127c7ae2ef5f27d665ad9018f5cdea0f6f2cb1.json b/backend/.sqlx/query-77d599e4f7c574dffac4824f37127c7ae2ef5f27d665ad9018f5cdea0f6f2cb1.json deleted file mode 100644 index f504d7a809..0000000000 --- a/backend/.sqlx/query-77d599e4f7c574dffac4824f37127c7ae2ef5f27d665ad9018f5cdea0f6f2cb1.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, teams_command_script, teams_team_id, teams_team_name, git_app_installations, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url FROM workspace_settings WHERE workspace_id = $2", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Varchar", - "Text" - ] - }, - "nullable": [] - }, - "hash": "77d599e4f7c574dffac4824f37127c7ae2ef5f27d665ad9018f5cdea0f6f2cb1" -} diff --git a/backend/.sqlx/query-809a2dd647ddfc6fa79ca2c9039e4ebfc4b2880600a03b82e60b89c4a1fd5cc8.json b/backend/.sqlx/query-809a2dd647ddfc6fa79ca2c9039e4ebfc4b2880600a03b82e60b89c4a1fd5cc8.json new file mode 100644 index 0000000000..cb33edacdf --- /dev/null +++ b/backend/.sqlx/query-809a2dd647ddfc6fa79ca2c9039e4ebfc4b2880600a03b82e60b89c4a1fd5cc8.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at, value_valid_until)\n SELECT $2, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at, value_valid_until\n FROM variable\n WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "809a2dd647ddfc6fa79ca2c9039e4ebfc4b2880600a03b82e60b89c4a1fd5cc8" +} diff --git a/backend/.sqlx/query-9a3677f9bfc1b5b679578572302129371c7c62e30d77ba6c5e74b575de635b30.json b/backend/.sqlx/query-9a3677f9bfc1b5b679578572302129371c7c62e30d77ba6c5e74b575de635b30.json new file mode 100644 index 0000000000..db0e11a62b --- /dev/null +++ b/backend/.sqlx/query-9a3677f9bfc1b5b679578572302129371c7c62e30d77ba6c5e74b575de635b30.json @@ -0,0 +1,26 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE variable SET expiration_dispatched_for = $3, expiration_notified_at = now()\n WHERE workspace_id = $1 AND path = $2 AND value_valid_until = $3\n AND expiration_dispatched_for IS NOT DISTINCT FROM $4\n AND expiration_notified_at IS NOT DISTINCT FROM $5\n RETURNING 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Timestamptz", + "Timestamptz", + "Timestamptz" + ] + }, + "nullable": [ + null + ] + }, + "hash": "9a3677f9bfc1b5b679578572302129371c7c62e30d77ba6c5e74b575de635b30" +} diff --git a/backend/.sqlx/query-9aaee16823c7d3fc0954274ba71bc2eae7d950f759d146339c30df039cf10fd8.json b/backend/.sqlx/query-9aaee16823c7d3fc0954274ba71bc2eae7d950f759d146339c30df039cf10fd8.json new file mode 100644 index 0000000000..38bad3fcde --- /dev/null +++ b/backend/.sqlx/query-9aaee16823c7d3fc0954274ba71bc2eae7d950f759d146339c30df039cf10fd8.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT p.email FROM password p LEFT JOIN usr m ON m.workspace_id = $1 AND m.email = p.email WHERE p.super_admin AND NOT p.disabled AND (m.email IS NOT NULL AND NOT m.disabled OR m.email IS NULL AND NOT EXISTS (SELECT 1 FROM usr c WHERE c.workspace_id = $1 AND c.username = COALESCE(p.username, p.email))) ORDER BY m.email IS NULL, p.email LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "email", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "9aaee16823c7d3fc0954274ba71bc2eae7d950f759d146339c30df039cf10fd8" +} diff --git a/backend/.sqlx/query-9bbd373847d5cc8e0d84349dbea73af0d18e3b4ce2eb7a20929f8c4c101141b6.json b/backend/.sqlx/query-9bbd373847d5cc8e0d84349dbea73af0d18e3b4ce2eb7a20929f8c4c101141b6.json new file mode 100644 index 0000000000..ab555a2efd --- /dev/null +++ b/backend/.sqlx/query-9bbd373847d5cc8e0d84349dbea73af0d18e3b4ce2eb7a20929f8c4c101141b6.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO variable (workspace_id, path, value, is_secret, description, value_valid_until)\n VALUES ('test-workspace', 'u/admin/expiring', 'v', false, 'same', '2027-01-01T00:00:00Z')", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "9bbd373847d5cc8e0d84349dbea73af0d18e3b4ce2eb7a20929f8c4c101141b6" +} diff --git a/backend/.sqlx/query-9c2b997126090a5bc191f8d7460e14e0f95b81c61241ffd0c9d633e562c4c4f6.json b/backend/.sqlx/query-9c2b997126090a5bc191f8d7460e14e0f95b81c61241ffd0c9d633e562c4c4f6.json new file mode 100644 index 0000000000..ecb5eb2c4b --- /dev/null +++ b/backend/.sqlx/query-9c2b997126090a5bc191f8d7460e14e0f95b81c61241ffd0c9d633e562c4c4f6.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET variable_expiration_handler = NULL WHERE workspace_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "9c2b997126090a5bc191f8d7460e14e0f95b81c61241ffd0c9d633e562c4c4f6" +} diff --git a/backend/.sqlx/query-9dabc87e05f72fbd4b586c80d9c7f41c3a72838c5d4c5f795638506084561894.json b/backend/.sqlx/query-9dabc87e05f72fbd4b586c80d9c7f41c3a72838c5d4c5f795638506084561894.json new file mode 100644 index 0000000000..6557494459 --- /dev/null +++ b/backend/.sqlx/query-9dabc87e05f72fbd4b586c80d9c7f41c3a72838c5d4c5f795638506084561894.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler FROM workspace_settings WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9dabc87e05f72fbd4b586c80d9c7f41c3a72838c5d4c5f795638506084561894" +} diff --git a/backend/.sqlx/query-61f3627cbd2a50b745c4e491ed8ee71e58db22b7f53202c5360a01c92091fc08.json b/backend/.sqlx/query-a0f799d0211f0a7bd649747a6627b82cd0127581ad831089231317af587bb25a.json similarity index 54% rename from backend/.sqlx/query-61f3627cbd2a50b745c4e491ed8ee71e58db22b7f53202c5360a01c92091fc08.json rename to backend/.sqlx/query-a0f799d0211f0a7bd649747a6627b82cd0127581ad831089231317af587bb25a.json index f97d5e310a..5ac876fa73 100644 --- a/backend/.sqlx/query-61f3627cbd2a50b745c4e491ed8ee71e58db22b7f53202c5360a01c92091fc08.json +++ b/backend/.sqlx/query-a0f799d0211f0a7bd649747a6627b82cd0127581ad831089231317af587bb25a.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "SELECT value, is_secret\n FROM variable\n WHERE path = $1 AND workspace_id = $2", + "query": "SELECT value::text AS \"value!\", is_secret\n FROM variable\n WHERE path = $1 AND workspace_id = $2", "describe": { "columns": [ { "ordinal": 0, - "name": "value", - "type_info": "Varchar" + "name": "value!", + "type_info": "Text" }, { "ordinal": 1, @@ -21,9 +21,9 @@ ] }, "nullable": [ - false, + null, false ] }, - "hash": "61f3627cbd2a50b745c4e491ed8ee71e58db22b7f53202c5360a01c92091fc08" + "hash": "a0f799d0211f0a7bd649747a6627b82cd0127581ad831089231317af587bb25a" } diff --git a/backend/.sqlx/query-a4badf66d2ae524d15925869c9bfa6b590cb357e19233318d2a2a31555dbc515.json b/backend/.sqlx/query-a4badf66d2ae524d15925869c9bfa6b590cb357e19233318d2a2a31555dbc515.json new file mode 100644 index 0000000000..828e5ba470 --- /dev/null +++ b/backend/.sqlx/query-a4badf66d2ae524d15925869c9bfa6b590cb357e19233318d2a2a31555dbc515.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT value::text AS \"value!\" FROM variable WHERE path = $1 AND workspace_id = $2 AND is_secret = true", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "value!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "a4badf66d2ae524d15925869c9bfa6b590cb357e19233318d2a2a31555dbc515" +} diff --git a/backend/.sqlx/query-af37e5579b729a1aedcd63ab70d189c7e0376ca1f0071ae8360183ed1300d2bb.json b/backend/.sqlx/query-af37e5579b729a1aedcd63ab70d189c7e0376ca1f0071ae8360183ed1300d2bb.json new file mode 100644 index 0000000000..c832fdeeda --- /dev/null +++ b/backend/.sqlx/query-af37e5579b729a1aedcd63ab70d189c7e0376ca1f0071ae8360183ed1300d2bb.json @@ -0,0 +1,238 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n workspace_id,\n slack_team_id,\n teams_team_id,\n teams_team_name,\n teams_team_guid,\n slack_name,\n slack_command_script,\n teams_command_script,\n slack_email,\n slack_oauth_client_id,\n slack_oauth_client_secret,\n customer_id,\n plan,\n webhook,\n ai_config,\n dbt_warehouses,\n large_file_storage,\n datatable,\n ducklake,\n git_sync,\n deploy_ui,\n default_app,\n default_scripts,\n mute_critical_alerts,\n color,\n operator_settings,\n git_app_installations,\n auto_invite,\n error_handler,\n success_handler,\n variable_expiration_handler,\n public_app_execution_limit_per_minute,\n error_handler_fallback_to_instance_alerts,\n guest_access_enabled,\n guest_jwt_public_key,\n guest_jwt_jwks_url,\n add_admins_and_developers_to_forks\n FROM\n workspace_settings\n WHERE\n workspace_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "slack_team_id", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "teams_team_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "teams_team_name", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "teams_team_guid", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "slack_name", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "slack_command_script", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "teams_command_script", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "slack_email", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "slack_oauth_client_id", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "slack_oauth_client_secret", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "customer_id", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "plan", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "webhook", + "type_info": "Text" + }, + { + "ordinal": 14, + "name": "ai_config", + "type_info": "Jsonb" + }, + { + "ordinal": 15, + "name": "dbt_warehouses", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "large_file_storage", + "type_info": "Jsonb" + }, + { + "ordinal": 17, + "name": "datatable", + "type_info": "Jsonb" + }, + { + "ordinal": 18, + "name": "ducklake", + "type_info": "Jsonb" + }, + { + "ordinal": 19, + "name": "git_sync", + "type_info": "Jsonb" + }, + { + "ordinal": 20, + "name": "deploy_ui", + "type_info": "Jsonb" + }, + { + "ordinal": 21, + "name": "default_app", + "type_info": "Varchar" + }, + { + "ordinal": 22, + "name": "default_scripts", + "type_info": "Jsonb" + }, + { + "ordinal": 23, + "name": "mute_critical_alerts", + "type_info": "Bool" + }, + { + "ordinal": 24, + "name": "color", + "type_info": "Varchar" + }, + { + "ordinal": 25, + "name": "operator_settings", + "type_info": "Jsonb" + }, + { + "ordinal": 26, + "name": "git_app_installations", + "type_info": "Jsonb" + }, + { + "ordinal": 27, + "name": "auto_invite", + "type_info": "Jsonb" + }, + { + "ordinal": 28, + "name": "error_handler", + "type_info": "Jsonb" + }, + { + "ordinal": 29, + "name": "success_handler", + "type_info": "Jsonb" + }, + { + "ordinal": 30, + "name": "variable_expiration_handler", + "type_info": "Jsonb" + }, + { + "ordinal": 31, + "name": "public_app_execution_limit_per_minute", + "type_info": "Int4" + }, + { + "ordinal": 32, + "name": "error_handler_fallback_to_instance_alerts", + "type_info": "Bool" + }, + { + "ordinal": 33, + "name": "guest_access_enabled", + "type_info": "Bool" + }, + { + "ordinal": 34, + "name": "guest_jwt_public_key", + "type_info": "Text" + }, + { + "ordinal": 35, + "name": "guest_jwt_jwks_url", + "type_info": "Text" + }, + { + "ordinal": 36, + "name": "add_admins_and_developers_to_forks", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + true, + false, + false, + true, + true, + false + ] + }, + "hash": "af37e5579b729a1aedcd63ab70d189c7e0376ca1f0071ae8360183ed1300d2bb" +} diff --git a/backend/.sqlx/query-44a12919fb154055f1142cc078ef131f8a0c9cdb37cfba6283a6718480b02a4b.json b/backend/.sqlx/query-b2c35e00474b8fa335d9485c1f98018dc033582642988ed7a3181989fef72fed.json similarity index 63% rename from backend/.sqlx/query-44a12919fb154055f1142cc078ef131f8a0c9cdb37cfba6283a6718480b02a4b.json rename to backend/.sqlx/query-b2c35e00474b8fa335d9485c1f98018dc033582642988ed7a3181989fef72fed.json index 18c572b1fb..44dcca2457 100644 --- a/backend/.sqlx/query-44a12919fb154055f1142cc078ef131f8a0c9cdb37cfba6283a6718480b02a4b.json +++ b/backend/.sqlx/query-b2c35e00474b8fa335d9485c1f98018dc033582642988ed7a3181989fef72fed.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "SELECT value, is_secret, expires_at FROM variable WHERE path = $1", + "query": "SELECT value::text AS \"value!\", is_secret, expires_at FROM variable WHERE path = $1", "describe": { "columns": [ { "ordinal": 0, - "name": "value", - "type_info": "Varchar" + "name": "value!", + "type_info": "Text" }, { "ordinal": 1, @@ -25,10 +25,10 @@ ] }, "nullable": [ - false, + null, false, true ] }, - "hash": "44a12919fb154055f1142cc078ef131f8a0c9cdb37cfba6283a6718480b02a4b" + "hash": "b2c35e00474b8fa335d9485c1f98018dc033582642988ed7a3181989fef72fed" } diff --git a/backend/.sqlx/query-b4e9a0266f4112467f534826c664c928d1e608ddd063d9d5ce3cea8ea62faca4.json b/backend/.sqlx/query-b4e9a0266f4112467f534826c664c928d1e608ddd063d9d5ce3cea8ea62faca4.json new file mode 100644 index 0000000000..aee28fd8b6 --- /dev/null +++ b/backend/.sqlx/query-b4e9a0266f4112467f534826c664c928d1e608ddd063d9d5ce3cea8ea62faca4.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE variable SET value_valid_until = '2029-12-31T00:00:00Z'\n WHERE workspace_id = 'wm-fork-expiry' AND path = 'u/admin/expiring'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "b4e9a0266f4112467f534826c664c928d1e608ddd063d9d5ce3cea8ea62faca4" +} diff --git a/backend/.sqlx/query-ade0696ec69bec2258d2e3ff86bfba6ed3b1d573c298f9d7fe8056ba7e32ed81.json b/backend/.sqlx/query-bc004ad060664cc75fc000229af16538576b978a1083365b5a98fc4ca7ea9576.json similarity index 58% rename from backend/.sqlx/query-ade0696ec69bec2258d2e3ff86bfba6ed3b1d573c298f9d7fe8056ba7e32ed81.json rename to backend/.sqlx/query-bc004ad060664cc75fc000229af16538576b978a1083365b5a98fc4ca7ea9576.json index 3fe61f6f8e..09fa4b0b11 100644 --- a/backend/.sqlx/query-ade0696ec69bec2258d2e3ff86bfba6ed3b1d573c298f9d7fe8056ba7e32ed81.json +++ b/backend/.sqlx/query-bc004ad060664cc75fc000229af16538576b978a1083365b5a98fc4ca7ea9576.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT workspace_id, path, value FROM variable WHERE is_secret = true AND value IS NOT NULL AND value != ''", + "query": "SELECT workspace_id, path, value::text AS \"value!\" FROM variable WHERE is_secret = true AND value IS NOT NULL AND value != ''", "describe": { "columns": [ { @@ -15,8 +15,8 @@ }, { "ordinal": 2, - "name": "value", - "type_info": "Varchar" + "name": "value!", + "type_info": "Text" } ], "parameters": { @@ -25,8 +25,8 @@ "nullable": [ false, false, - false + null ] }, - "hash": "ade0696ec69bec2258d2e3ff86bfba6ed3b1d573c298f9d7fe8056ba7e32ed81" + "hash": "bc004ad060664cc75fc000229af16538576b978a1083365b5a98fc4ca7ea9576" } diff --git a/backend/.sqlx/query-bda9fc0c6e41d8afebee538e6997c6bad7843f93d0a5780632b02fa215aa404b.json b/backend/.sqlx/query-bda9fc0c6e41d8afebee538e6997c6bad7843f93d0a5780632b02fa215aa404b.json deleted file mode 100644 index 02a979f32f..0000000000 --- a/backend/.sqlx/query-bda9fc0c6e41d8afebee538e6997c6bad7843f93d0a5780632b02fa215aa404b.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT path, value FROM variable\n WHERE path LIKE ('u/' || $1 || '/%')\n AND workspace_id = $2\n AND is_secret = true\n AND (value LIKE '$vault:%' OR value LIKE '$azure_kv:%')", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "path", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "value", - "type_info": "Varchar" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false, - false - ] - }, - "hash": "bda9fc0c6e41d8afebee538e6997c6bad7843f93d0a5780632b02fa215aa404b" -} diff --git a/backend/.sqlx/query-c0aff25f0cc3b71842b0ba9ae55b6bc5eca203bf02f46164db08580d128b860a.json b/backend/.sqlx/query-c0aff25f0cc3b71842b0ba9ae55b6bc5eca203bf02f46164db08580d128b860a.json deleted file mode 100644 index 74a9d24135..0000000000 --- a/backend/.sqlx/query-c0aff25f0cc3b71842b0ba9ae55b6bc5eca203bf02f46164db08580d128b860a.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT value, account FROM variable WHERE workspace_id = $1 AND path = $2", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "value", - "type_info": "Varchar" - }, - { - "ordinal": 1, - "name": "account", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Text", - "Text" - ] - }, - "nullable": [ - false, - true - ] - }, - "hash": "c0aff25f0cc3b71842b0ba9ae55b6bc5eca203bf02f46164db08580d128b860a" -} diff --git a/backend/.sqlx/query-cc2dbdc132fe751c7e82445aae5031d05da988837ce41d63a43d13c2018a57d3.json b/backend/.sqlx/query-cc2dbdc132fe751c7e82445aae5031d05da988837ce41d63a43d13c2018a57d3.json new file mode 100644 index 0000000000..5abf2efb76 --- /dev/null +++ b/backend/.sqlx/query-cc2dbdc132fe751c7e82445aae5031d05da988837ce41d63a43d13c2018a57d3.json @@ -0,0 +1,71 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT v.workspace_id, v.path, v.description, v.value_valid_until AS \"value_valid_until!\",\n v.is_secret, v.expiration_dispatched_for, v.expiration_notified_at,\n ws.variable_expiration_handler->>'path' AS \"handler_path!\",\n (ws.variable_expiration_handler->'extra_args')::text::json\n AS \"extra_args: Json>\"\n FROM variable v\n JOIN workspace_settings ws ON ws.workspace_id = v.workspace_id\n JOIN workspace w ON w.id = v.workspace_id\n WHERE NOT w.deleted\n AND v.value_valid_until IS NOT NULL\n AND (v.value_valid_until IS DISTINCT FROM v.expiration_dispatched_for\n OR (v.value_valid_until > now()\n AND v.expiration_notified_at <= now() - make_interval(secs =>\n NULLIF((ws.variable_expiration_handler->>'remind_every_secs')::bigint, 0) - $2)))\n AND v.value_valid_until <= now() + make_interval(secs =>\n COALESCE((ws.variable_expiration_handler->>'notify_before_secs')::bigint, $1))\n AND ws.variable_expiration_handler->>'path' IS NOT NULL\n AND (NOT COALESCE((ws.variable_expiration_handler->>'muted_on_user_path')::boolean, false)\n OR v.path NOT LIKE 'u/%')", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "value_valid_until!", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "is_secret", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "expiration_dispatched_for", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "expiration_notified_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "handler_path!", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "extra_args: Json>", + "type_info": "Json" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + true, + true, + null, + null + ] + }, + "hash": "cc2dbdc132fe751c7e82445aae5031d05da988837ce41d63a43d13c2018a57d3" +} diff --git a/backend/.sqlx/query-bc6ebef9d41aba232f115f95404922c6054df01fa7f38f5d15d6d4af6c726a3c.json b/backend/.sqlx/query-de0d78bf28a5b6457e5568dce9823a4c216b4be67b68241461b42326d9d65256.json similarity index 74% rename from backend/.sqlx/query-bc6ebef9d41aba232f115f95404922c6054df01fa7f38f5d15d6d4af6c726a3c.json rename to backend/.sqlx/query-de0d78bf28a5b6457e5568dce9823a4c216b4be67b68241461b42326d9d65256.json index d77682a4ea..e2145fc10a 100644 --- a/backend/.sqlx/query-bc6ebef9d41aba232f115f95404922c6054df01fa7f38f5d15d6d4af6c726a3c.json +++ b/backend/.sqlx/query-de0d78bf28a5b6457e5568dce9823a4c216b4be67b68241461b42326d9d65256.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT COUNT(*) FROM group_ WHERE name != 'all' AND name != 'error_handler' AND name != 'slack' AND name != 'wm_deployers'", + "query": "SELECT COUNT(*) FROM group_ WHERE name != 'all' AND name != 'error_handler' AND name != 'slack' AND name != 'wm_deployers' AND name != 'variable_expiration_handler'", "describe": { "columns": [ { @@ -16,5 +16,5 @@ null ] }, - "hash": "bc6ebef9d41aba232f115f95404922c6054df01fa7f38f5d15d6d4af6c726a3c" + "hash": "de0d78bf28a5b6457e5568dce9823a4c216b4be67b68241461b42326d9d65256" } diff --git a/backend/.sqlx/query-dec3b1a5a743bfcee0c6a1efdfc7398c1c11277e968af4acb9393ba2b340dd1e.json b/backend/.sqlx/query-dec3b1a5a743bfcee0c6a1efdfc7398c1c11277e968af4acb9393ba2b340dd1e.json new file mode 100644 index 0000000000..6f1cc55c51 --- /dev/null +++ b/backend/.sqlx/query-dec3b1a5a743bfcee0c6a1efdfc7398c1c11277e968af4acb9393ba2b340dd1e.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT path, value::text AS \"value!\" FROM variable\n WHERE path LIKE ('u/' || $1 || '/%')\n AND workspace_id = $2\n AND is_secret = true\n AND (value LIKE '$vault:%' OR value LIKE '$azure_kv:%')", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "path", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "value!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "dec3b1a5a743bfcee0c6a1efdfc7398c1c11277e968af4acb9393ba2b340dd1e" +} diff --git a/backend/.sqlx/query-e8eaa80631362dee4d696e41abf220cb6f8346d2bfcd901371b480a16a70109c.json b/backend/.sqlx/query-e8eaa80631362dee4d696e41abf220cb6f8346d2bfcd901371b480a16a70109c.json new file mode 100644 index 0000000000..b3dd32073d --- /dev/null +++ b/backend/.sqlx/query-e8eaa80631362dee4d696e41abf220cb6f8346d2bfcd901371b480a16a70109c.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET variable_expiration_handler = $1 WHERE workspace_id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Text" + ] + }, + "nullable": [] + }, + "hash": "e8eaa80631362dee4d696e41abf220cb6f8346d2bfcd901371b480a16a70109c" +} diff --git a/backend/.sqlx/query-eac25b9ce5df40a896687d2ba3f9279396ed7213da4598034332bb0ba6ce271c.json b/backend/.sqlx/query-eac25b9ce5df40a896687d2ba3f9279396ed7213da4598034332bb0ba6ce271c.json new file mode 100644 index 0000000000..736b176227 --- /dev/null +++ b/backend/.sqlx/query-eac25b9ce5df40a896687d2ba3f9279396ed7213da4598034332bb0ba6ce271c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET variable_expiration_failed_at = now()\n WHERE workspace_id = $1 AND variable_expiration_failed_at IS NULL\n RETURNING 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "?column?", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "eac25b9ce5df40a896687d2ba3f9279396ed7213da4598034332bb0ba6ce271c" +} diff --git a/backend/.sqlx/query-eacce0ccde20eddc4aded69c35d62122478424a36193254d5e00b8723ca28079.json b/backend/.sqlx/query-eacce0ccde20eddc4aded69c35d62122478424a36193254d5e00b8723ca28079.json new file mode 100644 index 0000000000..f0ced3e76b --- /dev/null +++ b/backend/.sqlx/query-eacce0ccde20eddc4aded69c35d62122478424a36193254d5e00b8723ca28079.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE workspace_settings SET variable_expiration_failed_at = NULL\n WHERE variable_expiration_failed_at IS NOT NULL AND variable_expiration_handler IS NULL\n RETURNING workspace_id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "workspace_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "eacce0ccde20eddc4aded69c35d62122478424a36193254d5e00b8723ca28079" +} diff --git a/backend/.sqlx/query-ee8b92d51f220cac5a32f0edcb3c4effa302380d4b434d8c74677bfbf895333c.json b/backend/.sqlx/query-ee8b92d51f220cac5a32f0edcb3c4effa302380d4b434d8c74677bfbf895333c.json new file mode 100644 index 0000000000..d101f5ec0a --- /dev/null +++ b/backend/.sqlx/query-ee8b92d51f220cac5a32f0edcb3c4effa302380d4b434d8c74677bfbf895333c.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO workspace_diff\n (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes)\n VALUES ('test-workspace', 'wm-fork-expiry', 'u/admin/expiring', 'variable', 0, 1, NULL)", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "ee8b92d51f220cac5a32f0edcb3c4effa302380d4b434d8c74677bfbf895333c" +} diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 97626ab116..3ae4630fe5 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1992,18 +1992,18 @@ dependencies = [ [[package]] name = "bon" -version = "3.10.2" +version = "3.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "214f5df094ce551a10a30ffb9c70243d61f121a3d985a6495933e181dee6a7d2" +checksum = "a05442cd7005532d0cfedec676b5b9333d2b4c7c3afabe2b1a9ce2742fbe2ee6" dependencies = [ "bon-macros", ] [[package]] name = "bon-macros" -version = "3.10.2" +version = "3.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2706da6c749998cc555d04184909608956a69ad3f8ab1a076fbc867b4a3c3ce5" +checksum = "93833e97ac7c46ba97045afcf247238c27057538fcf2f06344662ddd5f567be6" dependencies = [ "darling 0.24.1", "ident_case", @@ -2366,9 +2366,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" dependencies = [ "find-msvc-tools", "jobserver", @@ -2695,9 +2695,9 @@ checksum = "3618cccc083bb987a415d85c02ca6c9994ea5b44731ec28b9ecf09658655fba9" [[package]] name = "const_format" -version = "0.2.36" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e" +checksum = "a3decb4d6965448659923544a634a0571ba44cede4e11ef15a5a317126840454" dependencies = [ "const_format_proc_macros", "konst", @@ -2705,9 +2705,9 @@ dependencies = [ [[package]] name = "const_format_proc_macros" -version = "0.2.34" +version = "0.2.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +checksum = "941b570269a642506f08cb5b688c04bfa6cfd41a26f8bcdc49db8fd00c0ab659" dependencies = [ "proc-macro2", "quote", @@ -2964,9 +2964,9 @@ dependencies = [ [[package]] name = "crypto-bigint" -version = "0.7.5" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +checksum = "d726facc8b27e604391b232b970cdc19b5468c69f08b466b9734c759216c7415" dependencies = [ "cpubits", "ctutils", @@ -3055,9 +3055,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.3" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", "subtle", @@ -5070,7 +5070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b805154de2e68f59874ec217ca36790dcffe500cd872c60fe509d28d0814a74d" dependencies = [ "ed448", - "elliptic-curve 0.14.1", + "elliptic-curve 0.14.2", "hash2curve", "rand_core 0.10.1", "serdect 0.4.3", @@ -5126,12 +5126,12 @@ dependencies = [ [[package]] name = "elliptic-curve" -version = "0.14.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d65aa39b3a5c1c9c1b745c9a019234bb7a21b77abcb4f4d266d706e2d577d65" +checksum = "0ad4496221fcfc21015fd8c0e26fcfab00504832dc0604f52acdd7bad2a8dc81" dependencies = [ "base16ct 1.0.0", - "crypto-bigint 0.7.5", + "crypto-bigint 0.7.8", "crypto-common 0.2.2", "ff 0.14.0", "group 0.14.0", @@ -5218,9 +5218,9 @@ checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "erased-serde" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +checksum = "1e59b668157f32d3459d4bd634861a06ffa2503a461ca5f0916b9f0252605e66" dependencies = [ "serde", "serde_core", @@ -6241,7 +6241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1eaf40612d7d854743e7189228a6d528f0f6e8502cf6a0cb831d28a218b7f3f6" dependencies = [ "digest 0.11.3", - "elliptic-curve 0.14.1", + "elliptic-curve 0.14.2", ] [[package]] @@ -6745,7 +6745,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.6", "tower-service", - "webpki-roots 1.0.10", + "webpki-roots 1.0.11", ] [[package]] @@ -10889,7 +10889,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", - "webpki-roots 1.0.10", + "webpki-roots 1.0.11", ] [[package]] @@ -12408,9 +12408,9 @@ checksum = "1b6709c7b6754dca1311b3c73e79fcce40dd414c782c66d88e8823030093b02b" [[package]] name = "sketches-ddsketch" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "513c3f5f732bfd6fbb187619c2dfe9d2f25f1a2976f01d575f0fd329d565df56" +checksum = "e53e2f8d4e9c692f23a90208fad6a64862f413e286d533f1f649f160e2d5caee" dependencies = [ "serde", ] @@ -12423,9 +12423,9 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.16.3" +version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" dependencies = [ "serde", ] @@ -15052,9 +15052,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.28.0" +version = "1.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -15369,9 +15369,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58ec80e73fd8baf57bf0d9615554c75348bda990d6d5bbeab964dd068319fecb" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" dependencies = [ "rustls-pki-types", ] @@ -15382,14 +15382,14 @@ version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.10", + "webpki-roots 1.0.11", ] [[package]] name = "webpki-roots" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] @@ -15487,7 +15487,7 @@ dependencies = [ [[package]] name = "windmill" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-nats", @@ -15577,7 +15577,7 @@ dependencies = [ [[package]] name = "windmill-ai" -version = "1.847.0" +version = "1.849.0" dependencies = [ "async-stream", "async-trait", @@ -15612,7 +15612,7 @@ dependencies = [ [[package]] name = "windmill-alerting" -version = "1.847.0" +version = "1.849.0" dependencies = [ "axum", "chrono", @@ -15625,7 +15625,7 @@ dependencies = [ [[package]] name = "windmill-api" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "argon2", @@ -15768,7 +15768,7 @@ dependencies = [ [[package]] name = "windmill-api-agent-workers" -version = "1.847.0" +version = "1.849.0" dependencies = [ "axum", "chrono", @@ -15792,7 +15792,7 @@ dependencies = [ [[package]] name = "windmill-api-assets" -version = "1.847.0" +version = "1.849.0" dependencies = [ "axum", "chrono", @@ -15809,7 +15809,7 @@ dependencies = [ [[package]] name = "windmill-api-auth" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "axum", @@ -15835,7 +15835,7 @@ dependencies = [ [[package]] name = "windmill-api-client" -version = "1.847.0" +version = "1.849.0" dependencies = [ "reqwest 0.12.28", "serde", @@ -15845,7 +15845,7 @@ dependencies = [ [[package]] name = "windmill-api-configs" -version = "1.847.0" +version = "1.849.0" dependencies = [ "axum", "chrono", @@ -15862,7 +15862,7 @@ dependencies = [ [[package]] name = "windmill-api-debug" -version = "1.847.0" +version = "1.849.0" dependencies = [ "axum", "base64 0.22.1", @@ -15884,7 +15884,7 @@ dependencies = [ [[package]] name = "windmill-api-embeddings" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "axum", @@ -15907,7 +15907,7 @@ dependencies = [ [[package]] name = "windmill-api-flow-conversations" -version = "1.847.0" +version = "1.849.0" dependencies = [ "axum", "chrono", @@ -15923,7 +15923,7 @@ dependencies = [ [[package]] name = "windmill-api-flows" -version = "1.847.0" +version = "1.849.0" dependencies = [ "axum", "chrono", @@ -15945,7 +15945,7 @@ dependencies = [ [[package]] name = "windmill-api-groups" -version = "1.847.0" +version = "1.849.0" dependencies = [ "axum", "chrono", @@ -15967,7 +15967,7 @@ dependencies = [ [[package]] name = "windmill-api-inputs" -version = "1.847.0" +version = "1.849.0" dependencies = [ "axum", "chrono", @@ -15981,7 +15981,7 @@ dependencies = [ [[package]] name = "windmill-api-integration-tests" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-nats", @@ -16017,7 +16017,7 @@ dependencies = [ [[package]] name = "windmill-api-jobs" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "axum", @@ -16043,7 +16043,7 @@ dependencies = [ [[package]] name = "windmill-api-npm-proxy" -version = "1.847.0" +version = "1.849.0" dependencies = [ "axum", "chrono", @@ -16071,7 +16071,7 @@ dependencies = [ [[package]] name = "windmill-api-openapi" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "axum", @@ -16093,7 +16093,7 @@ dependencies = [ [[package]] name = "windmill-api-schedule" -version = "1.847.0" +version = "1.849.0" dependencies = [ "axum", "chrono", @@ -16113,7 +16113,7 @@ dependencies = [ [[package]] name = "windmill-api-scripts" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "axum", @@ -16155,7 +16155,7 @@ dependencies = [ [[package]] name = "windmill-api-settings" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "axum", @@ -16185,7 +16185,7 @@ dependencies = [ [[package]] name = "windmill-api-sse" -version = "1.847.0" +version = "1.849.0" dependencies = [ "lazy_static", "serde", @@ -16197,7 +16197,7 @@ dependencies = [ [[package]] name = "windmill-api-users" -version = "1.847.0" +version = "1.849.0" dependencies = [ "argon2", "axum", @@ -16221,7 +16221,7 @@ dependencies = [ [[package]] name = "windmill-api-workers" -version = "1.847.0" +version = "1.849.0" dependencies = [ "axum", "chrono", @@ -16236,7 +16236,7 @@ dependencies = [ [[package]] name = "windmill-api-workspaces" -version = "1.847.0" +version = "1.849.0" dependencies = [ "axum", "chrono", @@ -16275,7 +16275,7 @@ dependencies = [ [[package]] name = "windmill-audit" -version = "1.847.0" +version = "1.849.0" dependencies = [ "chrono", "lazy_static", @@ -16290,7 +16290,7 @@ dependencies = [ [[package]] name = "windmill-autoscaling" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "axum", @@ -16309,7 +16309,7 @@ dependencies = [ [[package]] name = "windmill-common" -version = "1.847.0" +version = "1.849.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -16415,7 +16415,7 @@ dependencies = [ "url", "urlencoding", "uuid", - "webpki-roots 1.0.10", + "webpki-roots 1.0.11", "windmill-macros", "windmill-parser", "windmill-parser-py", @@ -16427,7 +16427,7 @@ dependencies = [ [[package]] name = "windmill-dep-map" -version = "1.847.0" +version = "1.849.0" dependencies = [ "chrono", "futures", @@ -16447,7 +16447,7 @@ dependencies = [ [[package]] name = "windmill-git-sync" -version = "1.847.0" +version = "1.849.0" dependencies = [ "regex", "serde", @@ -16464,7 +16464,7 @@ dependencies = [ [[package]] name = "windmill-indexer" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "astral-tokio-tar", @@ -16491,7 +16491,7 @@ dependencies = [ [[package]] name = "windmill-jseval" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "futures", @@ -16508,7 +16508,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.847.0" +version = "1.849.0" dependencies = [ "itertools 0.14.0", "lazy_static", @@ -16524,7 +16524,7 @@ dependencies = [ [[package]] name = "windmill-mcp" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-trait", @@ -16546,7 +16546,7 @@ dependencies = [ [[package]] name = "windmill-native-triggers" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-trait", @@ -16577,7 +16577,7 @@ dependencies = [ [[package]] name = "windmill-oauth" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "arc-swap", @@ -16602,7 +16602,7 @@ dependencies = [ [[package]] name = "windmill-object-store" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-stream", @@ -16638,7 +16638,7 @@ dependencies = [ [[package]] name = "windmill-operator" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "futures", @@ -16657,7 +16657,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.847.0" +version = "1.849.0" dependencies = [ "convert_case 0.6.0", "serde", @@ -16666,7 +16666,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "lazy_static", @@ -16678,7 +16678,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "serde_json", @@ -16690,7 +16690,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "gosyn", @@ -16702,7 +16702,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "lazy_static", @@ -16714,7 +16714,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "serde_json", @@ -16726,7 +16726,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "nu-parser", @@ -16737,7 +16737,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16748,7 +16748,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -16760,7 +16760,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16771,7 +16771,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-recursion", @@ -16794,7 +16794,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "serde_json", @@ -16806,7 +16806,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "lazy_static", @@ -16820,7 +16820,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "convert_case 0.6.0", @@ -16837,7 +16837,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "lazy_static", @@ -16850,7 +16850,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "serde", @@ -16862,7 +16862,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "lazy_static", @@ -16880,7 +16880,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -16896,7 +16896,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -16913,7 +16913,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "lazy_static", @@ -16927,7 +16927,7 @@ dependencies = [ [[package]] name = "windmill-queue" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-recursion", @@ -16966,7 +16966,7 @@ dependencies = [ [[package]] name = "windmill-runtime-nativets" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "const_format", @@ -17004,7 +17004,7 @@ dependencies = [ [[package]] name = "windmill-sql-datatype-parser-wasm" -version = "1.847.0" +version = "1.849.0" dependencies = [ "getrandom 0.3.4", "wasm-bindgen", @@ -17015,7 +17015,7 @@ dependencies = [ [[package]] name = "windmill-store" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-recursion", @@ -17050,7 +17050,7 @@ dependencies = [ [[package]] name = "windmill-test-utils" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-trait", @@ -17074,7 +17074,7 @@ dependencies = [ [[package]] name = "windmill-trigger" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-trait", @@ -17107,7 +17107,7 @@ dependencies = [ [[package]] name = "windmill-trigger-amqp" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-trait", @@ -17134,7 +17134,7 @@ dependencies = [ [[package]] name = "windmill-trigger-azure" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-trait", @@ -17167,7 +17167,7 @@ dependencies = [ [[package]] name = "windmill-trigger-email" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-trait", @@ -17187,7 +17187,7 @@ dependencies = [ [[package]] name = "windmill-trigger-gcp" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-trait", @@ -17221,7 +17221,7 @@ dependencies = [ [[package]] name = "windmill-trigger-http" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-trait", @@ -17258,7 +17258,7 @@ dependencies = [ [[package]] name = "windmill-trigger-kafka" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-trait", @@ -17281,7 +17281,7 @@ dependencies = [ [[package]] name = "windmill-trigger-mqtt" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-trait", @@ -17305,7 +17305,7 @@ dependencies = [ [[package]] name = "windmill-trigger-nats" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-nats", @@ -17329,7 +17329,7 @@ dependencies = [ [[package]] name = "windmill-trigger-postgres" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-trait", @@ -17364,7 +17364,7 @@ dependencies = [ [[package]] name = "windmill-trigger-sqs" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-trait", @@ -17392,7 +17392,7 @@ dependencies = [ [[package]] name = "windmill-trigger-websocket" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-trait", @@ -17417,7 +17417,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "bitflags 2.13.2", @@ -17436,7 +17436,7 @@ dependencies = [ [[package]] name = "windmill-worker" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-once-cell", @@ -17554,7 +17554,7 @@ dependencies = [ [[package]] name = "windmill-worker-volumes" -version = "1.847.0" +version = "1.849.0" dependencies = [ "bytes", "futures", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index d2d4678718..b42cac10f0 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "windmill" -version = "1.847.0" +version = "1.849.0" authors.workspace = true edition.workspace = true @@ -88,7 +88,7 @@ members = [ exclude = ["./windmill-duckdb-ffi-internal", "./parsers/windmill-parser-wasm"] [workspace.package] -version = "1.847.0" +version = "1.849.0" authors = ["Ruben Fiszel "] edition = "2021" diff --git a/backend/ee-repo-ref.txt b/backend/ee-repo-ref.txt index 96a620c43f..71a531c84a 100644 --- a/backend/ee-repo-ref.txt +++ b/backend/ee-repo-ref.txt @@ -1 +1 @@ -9a5ff8e4e562930d57d29e2e90f17f6b82ee96a5 +943c39b56522f7980385a750ba5e9ca5af4df5a8 diff --git a/backend/migrations/20260824180615_variable_value_valid_until.down.sql b/backend/migrations/20260824180615_variable_value_valid_until.down.sql new file mode 100644 index 0000000000..963fd00f8e --- /dev/null +++ b/backend/migrations/20260824180615_variable_value_valid_until.down.sql @@ -0,0 +1,9 @@ +ALTER TABLE variable DROP CONSTRAINT IF EXISTS variable_oauth_no_value_expiry; +DROP INDEX IF EXISTS idx_variable_expiration_due; + +COMMENT ON COLUMN variable.expires_at IS NULL; + +ALTER TABLE variable + DROP COLUMN IF EXISTS value_valid_until, + DROP COLUMN IF EXISTS expiration_dispatched_for, + DROP COLUMN IF EXISTS expiration_notified_at; diff --git a/backend/migrations/20260824180615_variable_value_valid_until.up.sql b/backend/migrations/20260824180615_variable_value_valid_until.up.sql new file mode 100644 index 0000000000..308394d7e0 --- /dev/null +++ b/backend/migrations/20260824180615_variable_value_valid_until.up.sql @@ -0,0 +1,30 @@ +-- `value_valid_until` is when the value stored in the variable stops working; the row itself +-- lives on. `expires_at` keeps its unrelated meaning — delete the row — so no existing writer +-- changes meaning under a rolling deploy and no data is rewritten here. +ALTER TABLE variable ADD COLUMN IF NOT EXISTS value_valid_until TIMESTAMP WITH TIME ZONE; + +COMMENT ON COLUMN variable.expires_at IS + 'Garbage collection deadline: the row is deleted once passed. Set by the ephemeral secret_arg mint. Not the value''s expiry — see value_valid_until.'; +COMMENT ON COLUMN variable.value_valid_until IS + 'When the value stops working. Drives the workspace variable expiration handler; never deletes the row.'; + +-- Holds the `value_valid_until` that was dispatched, rather than a plain "was dispatched" flag, +-- so that moving the date re-arms the handler by construction — no writer has to remember to +-- clear anything. +ALTER TABLE variable + ADD COLUMN IF NOT EXISTS expiration_dispatched_for TIMESTAMP WITH TIME ZONE; + +-- When the handler last ran for this variable, which paces its reminders. +ALTER TABLE variable + ADD COLUMN IF NOT EXISTS expiration_notified_at TIMESTAMP WITH TIME ZONE; + +-- Serves the sweep's due predicate. Partial so it stays the size of the opted-in set rather +-- than the whole variable table. Not partial on `expiration_dispatched_for`: reminders keep a +-- dispatched variable due until it expires. +CREATE INDEX IF NOT EXISTS idx_variable_expiration_due ON variable (value_valid_until) + WHERE value_valid_until IS NOT NULL; + +-- An OAuth-linked value is refreshed by Windmill itself, so a rotation date on it would only +-- announce an expiry that never comes. +ALTER TABLE variable ADD CONSTRAINT variable_oauth_no_value_expiry + CHECK (account IS NULL OR value_valid_until IS NULL); diff --git a/backend/migrations/20260824180616_workspace_variable_expiration_handler.down.sql b/backend/migrations/20260824180616_workspace_variable_expiration_handler.down.sql new file mode 100644 index 0000000000..92c048bd52 --- /dev/null +++ b/backend/migrations/20260824180616_workspace_variable_expiration_handler.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE workspace_settings + DROP COLUMN IF EXISTS variable_expiration_handler, + DROP COLUMN IF EXISTS variable_expiration_failed_at; diff --git a/backend/migrations/20260824180616_workspace_variable_expiration_handler.up.sql b/backend/migrations/20260824180616_workspace_variable_expiration_handler.up.sql new file mode 100644 index 0000000000..8e6cb03938 --- /dev/null +++ b/backend/migrations/20260824180616_workspace_variable_expiration_handler.up.sql @@ -0,0 +1,6 @@ +-- Shape mirrors `error_handler` / `success_handler` as grouped by 20260124172000: +-- {"path": ..., "extra_args": ..., "muted_on_user_path": ...} +ALTER TABLE workspace_settings ADD COLUMN IF NOT EXISTS variable_expiration_handler JSONB; + +-- When the handler last failed to start; the sweep skips the workspace for a while after it. +ALTER TABLE workspace_settings ADD COLUMN IF NOT EXISTS variable_expiration_failed_at TIMESTAMP WITH TIME ZONE; diff --git a/backend/migrations/20260903161430_variable_expiration_handler_slack_perms.down.sql b/backend/migrations/20260903161430_variable_expiration_handler_slack_perms.down.sql new file mode 100644 index 0000000000..40443fc999 --- /dev/null +++ b/backend/migrations/20260903161430_variable_expiration_handler_slack_perms.down.sql @@ -0,0 +1 @@ +UPDATE folder SET extra_perms = extra_perms - 'g/variable_expiration_handler' WHERE name = 'slack_bot'; diff --git a/backend/migrations/20260903161430_variable_expiration_handler_slack_perms.up.sql b/backend/migrations/20260903161430_variable_expiration_handler_slack_perms.up.sql new file mode 100644 index 0000000000..25adde9d36 --- /dev/null +++ b/backend/migrations/20260903161430_variable_expiration_handler_slack_perms.up.sql @@ -0,0 +1,7 @@ +-- Windmill never created this group before this release, so one present now was made by a user, +-- who would otherwise keep it as it gains read on the Slack bot token below. +DELETE FROM usr_to_group WHERE group_ = 'variable_expiration_handler'; +UPDATE group_ SET extra_perms = '{}'::jsonb WHERE name = 'variable_expiration_handler'; + +-- Backfill for slack_bot folders created before this grant (see create_slack_workspace_artifacts). +UPDATE folder SET extra_perms = JSONB_SET(extra_perms, '{g/variable_expiration_handler}', 'false', true) WHERE name = 'slack_bot'; diff --git a/backend/oauth_connect.json b/backend/oauth_connect.json index 611e3f7c17..48957fee2f 100644 --- a/backend/oauth_connect.json +++ b/backend/oauth_connect.json @@ -337,12 +337,29 @@ "auth_url": "https://www.linkedin.com/oauth/v2/authorization", "token_url": "https://www.linkedin.com/oauth/v2/accessToken", "grant_types": ["authorization_code", "client_credentials"], - "scopes": ["w_member_social", "r_liteprofile", "r_emailaddress"], + "scopes": ["openid", "profile", "email", "w_member_social"], + "scope_options": [ + "openid", + "profile", + "email", + "w_member_social", + "r_member_social", + "w_organization_social", + "r_organization_social", + "r_organization_admin", + "rw_organization_admin", + "r_ads", + "r_basicprofile" + ], "req_body_auth": true }, "quickbooks": { "auth_url": "https://appcenter.intuit.com/connect/oauth2", "token_url": "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer", + "connect_fields": { + "realmId": "callback.realmId" + }, + "resource_fields": ["realmId", "isSandBox"], "scopes": ["com.intuit.quickbooks.accounting"] }, "visma": { @@ -468,15 +485,26 @@ "docusign": { "auth_url": "https://account.docusign.com/oauth/auth", "token_url": "https://account.docusign.com/oauth/token", + "userinfo_url": "https://account.docusign.com/oauth/userinfo", + "connect_fields": { + "account_id": "userinfo.accounts[is_default=true].account_id", + "base_uri": "userinfo.accounts[is_default=true].base_uri" + }, + "resource_fields": ["account_id", "base_uri"], "scopes": ["signature"], "sandbox": { "auth_url": "https://account-d.docusign.com/oauth/auth", - "token_url": "https://account-d.docusign.com/oauth/token" + "token_url": "https://account-d.docusign.com/oauth/token", + "userinfo_url": "https://account-d.docusign.com/oauth/userinfo" } }, "salesforce": { "auth_url": "https://login.salesforce.com/services/oauth2/authorize", "token_url": "https://login.salesforce.com/services/oauth2/token", + "connect_fields": { + "instance_url": "token.instance_url" + }, + "resource_fields": ["instance_url"], "scopes": ["api", "refresh_token", "offline_access"], "sandbox": { "auth_url": "https://test.salesforce.com/services/oauth2/authorize", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.lock b/backend/parsers/windmill-parser-wasm/Cargo.lock index a73343f8b9..ba2d483531 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.lock +++ b/backend/parsers/windmill-parser-wasm/Cargo.lock @@ -6340,7 +6340,7 @@ checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windmill-common" -version = "1.847.0" +version = "1.849.0" dependencies = [ "aes-gcm", "aho-corasick", @@ -6430,7 +6430,7 @@ dependencies = [ [[package]] name = "windmill-macros" -version = "1.847.0" +version = "1.849.0" dependencies = [ "proc-macro2", "quote", @@ -6442,7 +6442,7 @@ dependencies = [ [[package]] name = "windmill-parser" -version = "1.847.0" +version = "1.849.0" dependencies = [ "convert_case", "serde", @@ -6451,7 +6451,7 @@ dependencies = [ [[package]] name = "windmill-parser-bash" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "lazy_static", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "windmill-parser-csharp" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "serde_json", @@ -6475,7 +6475,7 @@ dependencies = [ [[package]] name = "windmill-parser-go" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "gosyn", @@ -6487,7 +6487,7 @@ dependencies = [ [[package]] name = "windmill-parser-graphql" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "lazy_static", @@ -6499,7 +6499,7 @@ dependencies = [ [[package]] name = "windmill-parser-java" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "serde_json", @@ -6511,7 +6511,7 @@ dependencies = [ [[package]] name = "windmill-parser-nu" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "nu-parser", @@ -6522,7 +6522,7 @@ dependencies = [ [[package]] name = "windmill-parser-php" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6533,7 +6533,7 @@ dependencies = [ [[package]] name = "windmill-parser-py" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "itertools 0.14.0", @@ -6545,7 +6545,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-asset" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6556,7 +6556,7 @@ dependencies = [ [[package]] name = "windmill-parser-py-imports" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "async-recursion", @@ -6579,7 +6579,7 @@ dependencies = [ [[package]] name = "windmill-parser-r" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "serde_json", @@ -6591,7 +6591,7 @@ dependencies = [ [[package]] name = "windmill-parser-ruby" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "lazy_static", @@ -6605,7 +6605,7 @@ dependencies = [ [[package]] name = "windmill-parser-rust" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "convert_case", @@ -6622,7 +6622,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "lazy_static", @@ -6635,7 +6635,7 @@ dependencies = [ [[package]] name = "windmill-parser-sql-asset" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "serde", @@ -6647,7 +6647,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "lazy_static", @@ -6665,7 +6665,7 @@ dependencies = [ [[package]] name = "windmill-parser-ts-asset" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "serde-wasm-bindgen", @@ -6681,7 +6681,7 @@ dependencies = [ [[package]] name = "windmill-parser-wac" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "ruff_python_ast", @@ -6698,7 +6698,7 @@ dependencies = [ [[package]] name = "windmill-parser-wasm" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "getrandom 0.2.17", @@ -6730,7 +6730,7 @@ dependencies = [ [[package]] name = "windmill-parser-yaml" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "lazy_static", @@ -6744,7 +6744,7 @@ dependencies = [ [[package]] name = "windmill-types" -version = "1.847.0" +version = "1.849.0" dependencies = [ "anyhow", "bitflags", diff --git a/backend/parsers/windmill-parser-wasm/Cargo.toml b/backend/parsers/windmill-parser-wasm/Cargo.toml index 6955752a98..0d0697c3fd 100644 --- a/backend/parsers/windmill-parser-wasm/Cargo.toml +++ b/backend/parsers/windmill-parser-wasm/Cargo.toml @@ -12,7 +12,7 @@ resolver = "2" members = ["."] [workspace.package] -version = "1.847.0" +version = "1.849.0" edition = "2021" authors = ["Ruben Fiszel "] diff --git a/backend/src/monitor.rs b/backend/src/monitor.rs index fbadfa4269..868e558f8d 100644 --- a/backend/src/monitor.rs +++ b/backend/src/monitor.rs @@ -129,6 +129,7 @@ use windmill_object_store::reload_object_store_setting; use windmill_queue::{ add_completed_job_error, cancel_job, canceled_result, get_queued_job_v2, schedule::{find_unarmed_schedules, rearm_schedule, RearmOutcome}, + variable_expiration::dispatch_expiring_variables, CanceledBy, MiniCompletedJob, SameWorkerPayload, }; use windmill_store::resources::MAX_RESOURCE_VERSIONS; @@ -4411,6 +4412,15 @@ pub async fn monitor_db( } }; + // Run every 5 minutes (10 iterations * 30s) + let dispatch_expiring_variables_f = async { + if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(10) { + if let Some(db) = conn.as_sql() { + dispatch_expiring_variables(&db).await; + } + } + }; + // run every hour (120 iterations * 30s = 3600s) let cleanup_stale_server_heartbeats_f = async { if server_mode && iteration.is_some() && iteration.as_ref().unwrap().should_run(120) { @@ -4617,6 +4627,7 @@ pub async fn monitor_db( native_triggers_sync_f, cleanup_notify_events_f, check_expiring_tokens_f, + dispatch_expiring_variables_f, cleanup_stale_server_heartbeats_f, manage_audit_partitions_f, export_audit_logs_to_object_store_f, diff --git a/backend/summarized_schema.txt b/backend/summarized_schema.txt index 2bf6b0188f..418108cd30 100644 --- a/backend/summarized_schema.txt +++ b/backend/summarized_schema.txt @@ -220,7 +220,7 @@ v2_job_runtime: id(uuid), ping(ts), memory_peak(int) FK: (id) -> v2_job_queue(id) v2_job_status: id(uuid), flow_status(jsonb), flow_leaf_jobs(jsonb), workflow_as_code_status(jsonb) FK: (id) -> v2_job_queue(id) -variable: workspace_id(char), path(char), value(char), is_secret(bool), description(char), extra_perms(jsonb), account(int), is_oauth(bool), expires_at(ts), labels(text[]) +variable: workspace_id(char), path(char), value(char), is_secret(bool), description(char), extra_perms(jsonb), account(int), is_oauth(bool), expires_at(ts), labels(text[]), value_valid_until(timestamptz), expiration_dispatched_for(timestamptz), expiration_notified_at(timestamptz) FK: (workspace_id) -> workspace(id) websocket_trigger: path(char), url(char), script_path(char), is_flow(bool), workspace_id(char), edited_by(char), email(char), edited_at(ts), extra_perms(jsonb), server_id(char), last_server_ping(ts), error(text), filters(jsonb[]), initial_messages(jsonb[]), url_runnable_args(jsonb), can_return_message(bool), error_handler_path(char), error_handler_args(jsonb), retry(jsonb), can_return_error_result(bool), mode(trigger_mode), labels(text[]) windmill_migrations: name(text), created_at(ts) @@ -244,7 +244,7 @@ workspace_protection_rule: workspace_id(char), name(char), rules(int), bypass_gr FK: (workspace_id) -> workspace(id) workspace_runnable_dependencies: flow_path(char), runnable_path(char), script_hash(bigint), runnable_is_flow(bool), workspace_id(char), app_path(char), id(bigint), runnable_is_agent(bool) FK: (app_path, workspace_id) -> app(path, workspace_id) | (flow_path, workspace_id) -> flow(path, workspace_id) -workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool), remote_deploy_target(jsonb), remote_deploy_target_changed_at(ts) +workspace_settings: workspace_id(char), slack_team_id(char), slack_name(char), slack_command_script(char), slack_email(char), customer_id(char), plan(char), webhook(text), ai_config(jsonb), large_file_storage(jsonb), git_sync(jsonb), default_app(char), default_scripts(jsonb), deploy_ui(jsonb), mute_critical_alerts(bool), color(char), operator_settings(jsonb), teams_command_script(text), teams_team_id(text), teams_team_name(text), git_app_installations(jsonb), ducklake(jsonb), slack_oauth_client_id(char), slack_oauth_client_secret(char), datatable(jsonb), teams_team_guid(text), auto_invite(jsonb), error_handler(jsonb), success_handler(jsonb), public_app_execution_limit_per_minute(int), dbt_warehouses(jsonb), guest_access_enabled(bool), guest_jwt_public_key(text), guest_jwt_jwks_url(text), ai_sessions_backup_generation(int), add_admins_and_developers_to_forks(bool), remote_deploy_target(jsonb), remote_deploy_target_changed_at(ts), variable_expiration_handler(jsonb), variable_expiration_failed_at(ts) FK: (workspace_id) -> workspace(id) zombie_job_counter: job_id(uuid), counter(int) FK: (job_id) -> v2_job(id) diff --git a/backend/tests/ai_agent_linked.rs b/backend/tests/ai_agent_linked.rs new file mode 100644 index 0000000000..8b7f328213 --- /dev/null +++ b/backend/tests/ai_agent_linked.rs @@ -0,0 +1,55 @@ +use serde_json::json; +use sqlx::{Pool, Postgres}; +use std::time::Duration; +use windmill_common::{flows::FlowValue, jobs::JobPayload}; +use windmill_test_utils::{ + completed_job, in_test_worker, listen_for_completed_jobs, ApiServer, RunJob, StreamFind, +}; + +/// Only writes of an agent are checked for operators, so a linked step must refuse a path whose +/// resource is no longer an agent rather than run the `tools` it now holds. +#[sqlx::test(fixtures("base"))] +#[serial_test::serial] +async fn linked_agent_step_refuses_a_resource_of_another_type( + db: Pool, +) -> anyhow::Result<()> { + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type) + VALUES ('test-workspace', 'u/test-user/agent', $1, 'object')", + ) + .bind( + json!({"tools": [{"id": "t", "value": {"tool_type": "flowmodule", + "type": "rawscript", "language": "bun", "input_transforms": {}, + "content": "export async function main() { return 1 }"}}]}), + ) + .execute(&db) + .await?; + + let server = ApiServer::start(db.clone()).await?; + let flow: FlowValue = serde_json::from_value(json!({"modules": [{"id": "agent", "value": { + "type": "aiagent", "tools": [], "agent": "$res:u/test-user/agent", + "input_transforms": {"user_message": {"type": "static", "value": "hi"}} + }}]}))?; + let id = RunJob::from(JobPayload::RawFlow { + value: flow, + path: Some("u/test-user/linked".into()), + restarted_from: None, + }) + .push(&db) + .await; + let notifications = listen_for_completed_jobs(&db).await; + tokio::time::timeout( + Duration::from_secs(45), + in_test_worker(&db, notifications.find(&id), server.addr.port()), + ) + .await?; + let job = completed_job(id, &db).await; + server.close().await?; + assert!(!job.success); + assert!( + format!("{:?}", job.result).contains("not an AI agent"), + "{:?}", + job.result + ); + Ok(()) +} diff --git a/backend/tests/app_access_mode_only.rs b/backend/tests/app_access_mode_only.rs new file mode 100644 index 0000000000..b113e9de9a --- /dev/null +++ b/backend/tests/app_access_mode_only.rs @@ -0,0 +1,311 @@ +//! `EditApp.execution_mode`: changing who may open an app without sending its policy. +//! +//! The change is applied to the deployed policy under the row lock a deploy takes, so it keeps +//! every other field a concurrent deploy wrote. It is held to the rules a policy write is held +//! to, and it is refused to a caller who could not keep the app's run-as user, since a policy +//! write from them would rebind it. +//! +//! One test: the protection rules and the workspace roles are cached per workspace id across +//! the process, and every test here uses `test-workspace`. +//! +//! Users from the `base` fixture: +//! test-user (admin, token SECRET_TOKEN) +//! test-user-2 (non-admin, token SECRET_TOKEN_2) +//! test-user-3 (made a compose-only operator below, token SECRET_TOKEN_3) + +use reqwest::multipart; +use serde_json::{json, Value}; +use sqlx::{Pool, Postgres}; +use windmill_common::workspaces::invalidate_protection_rules_cache; +use windmill_test_utils::*; + +const ADMIN: &str = "SECRET_TOKEN"; +const USER: &str = "SECRET_TOKEN_2"; +const BUILDER: &str = "SECRET_TOKEN_3"; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +async fn update(ws: &str, path: &str, token: &str, body: Value) -> anyhow::Result<(u16, String)> { + let resp = client() + .post(format!("{ws}/apps/update/{path}")) + .bearer_auth(token) + .json(&body) + .send() + .await?; + Ok((resp.status().as_u16(), resp.text().await?)) +} + +async fn create(ws: &str, token: &str, path: &str, policy: Value) -> anyhow::Result<()> { + let resp = client() + .post(format!("{ws}/apps/create")) + .bearer_auth(token) + .json(&json!({ "path": path, "summary": "", "value": {}, "policy": policy })) + .send() + .await?; + assert_eq!(resp.status(), 201, "create {path}: {}", resp.text().await?); + Ok(()) +} + +/// An anonymous, sandboxed raw app with no runnables, as a builder may deploy one. +async fn create_raw(ws: &str, token: &str, path: &str) -> anyhow::Result<()> { + let app = json!({ + "path": path, + "summary": "", + "value": { "files": {}, "runnables": {} }, + "policy": { "execution_mode": "anonymous", "sandbox": true, "triggerables_v2": {} }, + }); + let form = multipart::Form::new() + .part( + "app", + multipart::Part::text(app.to_string()).mime_str("application/json")?, + ) + .part("js", multipart::Part::text("console.log('app')")); + let resp = client() + .post(format!("{ws}/apps/create_raw")) + .bearer_auth(token) + .multipart(form) + .send() + .await?; + assert_eq!( + resp.status(), + 201, + "create_raw {path}: {}", + resp.text().await? + ); + Ok(()) +} + +async fn policy(db: &Pool, path: &str) -> anyhow::Result { + Ok(sqlx::query_scalar( + "SELECT policy FROM app WHERE path = $1 AND workspace_id = 'test-workspace'", + ) + .bind(path) + .fetch_one(db) + .await?) +} + +fn triggerable(key: &str) -> Value { + json!({ key: { "static_inputs": {}, "one_of_inputs": {} } }) +} + +#[sqlx::test(fixtures("base"))] +async fn test_access_mode_only_update(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + invalidate_protection_rules_cache("test-workspace"); + let server = ApiServer::start(db.clone()).await?; + let ws = format!( + "http://localhost:{}/api/w/test-workspace", + server.addr.port() + ); + + // ======================================== + // 1. The mode alone changes; every other field of the deployed policy stays. + // ======================================== + const SHARED: &str = "u/test-user-2/shared"; + create( + &ws, + ADMIN, + SHARED, + json!({ + "execution_mode": "publisher", + "triggerables_v2": triggerable("c:script/u/test-user/noop"), + "sandbox": true, + "allowed_s3_keys": [{ "s3_path": "a/b.txt" }], + }), + ) + .await?; + let before = policy(&db, SHARED).await?; + assert_eq!(before["on_behalf_of"], "u/test-user"); + + let (status, body) = + update(&ws, SHARED, ADMIN, json!({ "execution_mode": "anonymous" })).await?; + assert_eq!(status, 200, "{body}"); + let mut after = policy(&db, SHARED).await?; + assert_eq!(after["execution_mode"], "anonymous"); + after["execution_mode"] = before["execution_mode"].clone(); + assert_eq!( + after, before, + "a mode-only change must keep every other policy field" + ); + + // Sending a policy too is ambiguous about which mode wins. + let (status, _) = update( + &ws, + SHARED, + ADMIN, + json!({ "execution_mode": "publisher", "policy": { "execution_mode": "anonymous" } }), + ) + .await?; + assert_eq!(status, 400); + + // ======================================== + // 2. Someone who cannot keep the run-as user cannot change the mode of an app that runs + // as someone else, even with write access to it. + // ======================================== + let (status, body) = + update(&ws, SHARED, USER, json!({ "execution_mode": "publisher" })).await?; + assert_eq!(status, 403, "{body}"); + assert!(body.contains("wm_deployers"), "{body}"); + assert_eq!(policy(&db, SHARED).await?["execution_mode"], "anonymous"); + + // ======================================== + // 3. A policy without a mode keeps the deployed one. + // ======================================== + const OWN: &str = "u/test-user-2/own"; + create(&ws, USER, OWN, json!({ "execution_mode": "publisher" })).await?; + sqlx::query( + "INSERT INTO draft (workspace_id, path, typ, value, email) + VALUES ('test-workspace', $1, 'app', '{}', 'test2@windmill.dev')", + ) + .bind(OWN) + .execute(&db) + .await?; + let (status, body) = update(&ws, OWN, USER, json!({ "execution_mode": "anonymous" })).await?; + assert_eq!(status, 200, "{body}"); + let drafts: i64 = sqlx::query_scalar( + "SELECT count(*) FROM draft WHERE workspace_id = 'test-workspace' AND path = $1", + ) + .bind(OWN) + .fetch_one(&db) + .await?; + assert_eq!(drafts, 1, "a mode-only change must keep the caller's draft"); + let (status, body) = update( + &ws, + OWN, + USER, + json!({ "value": { "v": 2 }, "policy": { "triggerables_v2": triggerable("c:script/f/x/new") } }), + ) + .await?; + assert_eq!(status, 200, "{body}"); + let p = policy(&db, OWN).await?; + assert_eq!(p["execution_mode"], "anonymous"); + assert!( + p["triggerables_v2"].get("c:script/f/x/new").is_some(), + "{p}" + ); + + // ======================================== + // 4. A compose-only operator: an omitted mode inherits the deployed one unless it is Viewer. + // ======================================== + sqlx::query("UPDATE usr SET operator = true WHERE workspace_id = 'test-workspace' AND username = 'test-user-3'") + .execute(&db) + .await?; + sqlx::query( + r#"UPDATE workspace_settings SET role_overrides = '{"operator": {"capabilities": {"apps": "compose"}}}' + WHERE workspace_id = 'test-workspace'"#, + ) + .execute(&db) + .await?; + // In wm_deployers, so the refusal in the last step below holds for a builder who may keep + // another user's identity on deploy. + sqlx::query( + "INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ('test-workspace', 'wm_deployers', '', '{}')", + ) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO usr_to_group (workspace_id, group_, usr) VALUES ('test-workspace', 'wm_deployers', 'test-user-3')", + ) + .execute(&db) + .await?; + windmill_common::roles::invalidate_workspace_roles_cache("test-workspace"); + windmill_common::roles::invalidate_member_role_cache("test-workspace", "test-user-3"); + + const BUILT: &str = "u/test-user-3/built"; + create_raw(&ws, BUILDER, BUILT).await?; + + let settings_only = json!({ "policy": { "sandbox": true, "triggerables_v2": {} } }); + let (status, body) = update(&ws, BUILT, BUILDER, settings_only.clone()).await?; + assert_eq!(status, 200, "{body}"); + assert_eq!( + policy(&db, BUILT).await?["execution_mode"], + "anonymous", + "a builder's policy without a mode must keep a non-Viewer deployed mode" + ); + + // Viewer stays refused to a builder, alone as in a policy. + let (status, body) = update(&ws, BUILT, BUILDER, json!({ "execution_mode": "viewer" })).await?; + assert_eq!(status, 403, "{body}"); + + // An admin may put the builder's app in Viewer; the builder's next policy without a mode + // then cannot inherit it. + let (status, body) = update(&ws, BUILT, ADMIN, json!({ "execution_mode": "viewer" })).await?; + assert_eq!(status, 200, "{body}"); + let (status, body) = update(&ws, BUILT, BUILDER, settings_only).await?; + assert_eq!(status, 200, "{body}"); + assert_eq!(policy(&db, BUILT).await?["execution_mode"], "publisher"); + + let (status, body) = update( + &ws, + BUILT, + BUILDER, + json!({ "execution_mode": "anonymous" }), + ) + .await?; + assert_eq!( + status, 200, + "a builder may change the mode of its own app: {body}" + ); + assert_eq!(policy(&db, BUILT).await?["execution_mode"], "anonymous"); + + // A builder's deploy would sandbox an app whose policy leaves it unset, which a mode-only + // change must not do. + sqlx::query( + "UPDATE app SET policy = policy - 'sandbox' WHERE path = $1 AND workspace_id = 'test-workspace'", + ) + .bind(BUILT) + .execute(&db) + .await?; + let (status, body) = update( + &ws, + BUILT, + BUILDER, + json!({ "execution_mode": "publisher" }), + ) + .await?; + assert_eq!(status, 403, "{body}"); + let p = policy(&db, BUILT).await?; + assert!(p.get("sandbox").is_none(), "{p}"); + assert_eq!(p["execution_mode"], "anonymous"); + + // Changing the mode alone skips the check of the triggerables against the builder's RLS, so + // it stays refused on an app that runs as someone else; deploying it is the way. + const ADMIN_RUN: &str = "u/test-user-3/admin_run"; + create_raw(&ws, ADMIN, ADMIN_RUN).await?; + let (status, body) = update( + &ws, + ADMIN_RUN, + BUILDER, + json!({ "execution_mode": "publisher" }), + ) + .await?; + assert_eq!(status, 403, "{body}"); + assert!(body.contains("by deploying it"), "{body}"); + + // ======================================== + // 5. Widening is gated by the protection rule; narrowing is not. + // ======================================== + let resp = client() + .post(format!("{ws}/workspaces/protection_rules")) + .bearer_auth(ADMIN) + .json(&json!({ + "name": "no-public-apps", + "rules": ["RestrictAnonymousAppDeployment"], + "bypass_users": [], + "bypass_groups": [] + })) + .send() + .await?; + assert_eq!(resp.status(), 200, "{}", resp.text().await?); + + let (status, body) = update(&ws, OWN, USER, json!({ "execution_mode": "publisher" })).await?; + assert_eq!(status, 200, "narrowing stays open: {body}"); + let (status, body) = update(&ws, OWN, USER, json!({ "execution_mode": "anonymous" })).await?; + assert_eq!(status, 403, "{body}"); + assert!(body.contains("no-public-apps"), "{body}"); + assert_eq!(policy(&db, OWN).await?["execution_mode"], "publisher"); + + Ok(()) +} diff --git a/backend/tests/app_run_mode_lock_strip.rs b/backend/tests/app_run_mode_lock_strip.rs index 7a1bba75f6..77265f2bcd 100644 --- a/backend/tests/app_run_mode_lock_strip.rs +++ b/backend/tests/app_run_mode_lock_strip.rs @@ -110,10 +110,13 @@ async fn test_run_mode_strips_caller_lock_and_modules(db: Pool) -> any .json(&json!({ // The args map is the other injection channel: an inline run is a // `JobKind::Preview` job, so the worker/executors read `_MODULES` and - // `_TEMP_SCRIPT_REFS` back out of the job args. Both must be stripped. + // `_TEMP_SCRIPT_REFS` back out of the job args. Both must be stripped — + // including the NUL spellings, which `push` stores under the plain name. "args": { "_MODULES": { "m.py": { "content": "print('evil')\n", "language": "python3" } }, - "_TEMP_SCRIPT_REFS": { "../evil": "deadbeef" } + "_MODULES\u{0}": { "m.py": { "content": "print('evil')\n", "language": "python3" } }, + "_TEMP_SCRIPT_REFS": { "../evil": "deadbeef" }, + "_TEMP_SCRIPT_REFS\u{0}": { "../evil": "deadbeef" } }, "component": "comp", "raw_code": inline_raw_code(Some(CALLER_HASH), true) diff --git a/backend/tests/caller_modules_strip.rs b/backend/tests/caller_modules_strip.rs index a65ca22d1b..bf4a096ee1 100644 --- a/backend/tests/caller_modules_strip.rs +++ b/backend/tests/caller_modules_strip.rs @@ -20,10 +20,16 @@ fn modules(v: &str) -> serde_json::Value { /// Pushes `payload` with a caller `_MODULES` both in `args` and in `extra` (where webhook /// query and headers land), and returns the stored `_MODULES`. +/// +/// Every spelling `push` stores as `_MODULES` is sent, not just the exact name: it writes +/// the args with their `\u0000` escapes removed, keys included, so `_MODULES\0` lands in +/// the job as `_MODULES` unless the removal compares the name that will be stored. async fn stored_modules(db: &Pool, payload: JobPayload) -> Option { let caller: Box = serde_json::value::to_raw_value(&modules("caller")).unwrap(); - let args = HashMap::from([("_MODULES".to_string(), caller.clone())]); - let extra = HashMap::from([("_MODULES".to_string(), caller)]); + let spellings = + || ["_MODULES", "_MODULES\0", "_MOD\0ULES"].map(|k| (k.to_string(), caller.clone())); + let args = HashMap::from(spellings()); + let extra = HashMap::from(spellings()); let (id, tx) = windmill_queue::push( db, PushIsolationLevel::IsolatedRoot(db.clone()), diff --git a/backend/tests/dependency_map.rs b/backend/tests/dependency_map.rs index dad6c86aa3..e68828fe24 100644 --- a/backend/tests/dependency_map.rs +++ b/backend/tests/dependency_map.rs @@ -511,6 +511,7 @@ def main(): ) .unwrap(), policy: None, + execution_mode: None, deployment_message: None, custom_path: None, preserve_on_behalf_of: None, diff --git a/backend/tests/git_sync_autopull_identity.rs b/backend/tests/git_sync_autopull_identity.rs index 4e7562c3f3..bc76e4c1cb 100644 --- a/backend/tests/git_sync_autopull_identity.rs +++ b/backend/tests/git_sync_autopull_identity.rs @@ -192,3 +192,25 @@ async fn unstamped_fork_pull_runs_as_the_parents_first_admin( assert_eq!(pull_identities(&db, FORK).await?, vec![identity("aaron")]); Ok(()) } + +/// A workspace with no admin and no stamp runs its pulls as an instance superadmin, skipping +/// sam, whose instance username a member holds, and the deactivated seeded superadmin. +#[sqlx::test(fixtures("git_sync_autopull_identity"))] +async fn unstamped_pull_without_admins_runs_as_a_superadmin( + db: Pool, +) -> anyhow::Result<()> { + sqlx::query("UPDATE usr SET is_admin = false WHERE workspace_id = $1") + .bind(PARENT) + .execute(&db) + .await?; + sqlx::query("UPDATE password SET disabled = true WHERE email = 'admin@windmill.dev'") + .execute(&db) + .await?; + let mut repo = repo_enabled_by("alice@windmill.dev"); + repo.auto_pull.as_mut().unwrap().enabled_by = None; + + let job = reconcile_and_enqueue_pull(&db, PARENT, &repo, "main", "abc123", None).await?; + assert!(job.is_some(), "{}", recorded_pull_error(&db, PARENT).await?); + assert_eq!(pull_identities(&db, PARENT).await?, vec![identity("sue")]); + Ok(()) +} diff --git a/backend/tests/reserved_control_args.rs b/backend/tests/reserved_control_args.rs new file mode 100644 index 0000000000..a733546e51 --- /dev/null +++ b/backend/tests/reserved_control_args.rs @@ -0,0 +1,436 @@ +//! `_ENTRYPOINT_OVERRIDE`, `_MODULES` and `_TEMP_SCRIPT_REFS` steer *how* a runnable runs: +//! which function the worker calls instead of `main`, what module files land in the job +//! directory, where its workspace imports resolve from. They are legitimate for a caller +//! running its own code under its own identity, and must not be settable by a third party +//! whose payload runs under somebody else's: an HTTP route body or native trigger message +//! (the job runs as the trigger's owner) and a public app viewer's inputs (as the +//! publisher). +//! +//! `push` stores the args with their `\u0000` escapes removed, keys included, so every +//! check also has to hold for a NUL-spelled key: `_ENTRYPOINT_OVERRIDE\0` is read back as +//! `_ENTRYPOINT_OVERRIDE`. The same rename is what lets a viewer's `tenant\0` land as +//! `tenant` over an app policy's pinned `tenant`. + +use std::collections::HashMap; + +use serde_json::{json, value::RawValue, Value}; +use sqlx::{Pool, Postgres}; +use windmill_common::{ + jobs::JobPayload, + runnable_settings::{ConcurrencySettings, DebouncingSettings}, + scripts::{ScriptHash, ScriptLang}, + triggers::TriggerMetadata, +}; +use windmill_queue::{PushArgs, PushArgsOwned, PushIsolationLevel}; +use windmill_test_utils::*; + +const SCRIPT_PATH: &str = "f/system/hello"; +const SCRIPT_HASH: i64 = 123412; +/// Two top-level functions, as in the report: `main` authenticates, `apply` acts. +const SCRIPT_CONTENT: &str = "export async function main(data: string) { return data }\n\ + export async function apply(action: string) { return action }\n"; + +fn client() -> reqwest::Client { + reqwest::Client::new() +} + +fn authed(b: reqwest::RequestBuilder, token: &str) -> reqwest::RequestBuilder { + b.header("Authorization", format!("Bearer {}", token)) +} + +fn raw(v: Value) -> Box { + serde_json::value::to_raw_value(&v).unwrap() +} + +fn modules() -> Value { + json!({ "bunfig.toml": { "content": "preload = [\"./x.ts\"]", "language": "bun" } }) +} + +/// Deploy `SCRIPT_PATH` (a non-null `lock` is what makes a script count as deployed). +async fn deploy_script(db: &Pool) -> anyhow::Result<()> { + sqlx::query( + r#"INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, + schema, summary, description, lock) + VALUES ('test-workspace', $1, $2, $3, 'bun', 'script', 'test-user', '{}', '', '', + E'{}\n//bun.lock\n')"#, + ) + .bind(SCRIPT_HASH) + .bind(SCRIPT_PATH) + .bind(SCRIPT_CONTENT) + .execute(db) + .await?; + Ok(()) +} + +async fn job_args_and_entrypoint( + db: &Pool, + id: uuid::Uuid, +) -> anyhow::Result<(Value, Option)> { + Ok( + sqlx::query_as("SELECT args, script_entrypoint_override FROM v2_job WHERE id = $1") + .bind(id) + .fetch_one(db) + .await?, + ) +} + +fn trigger_authed() -> windmill_api_auth::ApiAuthed { + windmill_api_auth::ApiAuthed { + email: "test@windmill.dev".to_string(), + username: "test-user".to_string(), + is_admin: true, + is_operator: false, + groups: vec![], + folders: vec![], + scopes: None, + username_override: None, + username_override_is_token_label: false, + is_session_token: false, + token_prefix: None, + read_only: false, + job_id: None, + credential_expiry: None, + } +} + +/// `trigger_runnable_inner` is the one funnel every trigger reaches `push` through, so it +/// stands in here for the HTTP route whose body keys become the job's top-level args. The +/// sender is anonymous on a `none`/`custom_script` route; the job runs as the route owner. +#[sqlx::test(fixtures("base"))] +async fn trigger_args_lose_every_reserved_control_key(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + deploy_script(&db).await?; + + let args = PushArgsOwned { + args: HashMap::from([ + // Picks `apply` instead of `main`, skipping whatever `main` authenticates. + ("_ENTRYPOINT_OVERRIDE".to_string(), raw(json!("apply"))), + ("_ENTRYPOINT_OVERRIDE\u{0}".to_string(), raw(json!("apply"))), + ("_ENTRY\u{0}POINT_OVERRIDE".to_string(), raw(json!("apply"))), + ("_MODULES\u{0}".to_string(), raw(modules())), + ( + "_TEMP_SCRIPT_REFS\u{0}".to_string(), + raw(json!({ "f/system/dep": "deadbeef" })), + ), + // An ordinary input must still get through, NUL-renamed or not. + ("data".to_string(), raw(json!("payload"))), + ("action\u{0}".to_string(), raw(json!("refund"))), + ]), + extra: None, + }; + + let (id, _, _, _, _) = windmill_trigger::trigger_helpers::trigger_runnable_inner( + &db, + None, + None, + trigger_authed(), + "test-workspace", + SCRIPT_PATH, + false, + args, + None, + None, + None, + "u/test-user/route".to_string(), + None, + TriggerMetadata::new( + Some("u/test-user/route".to_string()), + windmill_common::jobs::JobTriggerKind::Http, + ), + None, + ) + .await?; + + let (args, entrypoint) = job_args_and_entrypoint(&db, id).await?; + assert_eq!( + args, + json!({ "data": "payload", "action": "refund" }), + "a trigger payload may carry the runnable's inputs and nothing that steers how it runs" + ); + assert_eq!( + entrypoint, None, + "a trigger payload must not pick the function the worker calls instead of `main`" + ); + Ok(()) +} + +/// The counterpart: `_ENTRYPOINT_OVERRIDE` stays for a caller that runs the script under +/// its own identity with `jobs:run` — the script editor's preprocessor test, dynamic-select +/// helpers, the CLI and workflow-as-code all send it this way (GHSA-wxjq-w5pj-jqhx bounds +/// what it may contain, not who may set it). Stripping it in `push` would break them. +#[sqlx::test(fixtures("base"))] +async fn an_authenticated_push_keeps_the_entrypoint_override( + db: Pool, +) -> anyhow::Result<()> { + deploy_script(&db).await?; + + // Both spellings: `push` normalizes the names to what it stores, so the NUL-spelled one + // selects the function exactly like the plain one instead of being silently different. + for key in ["_ENTRYPOINT_OVERRIDE", "_ENTRYPOINT_OVERRIDE\u{0}"] { + let args = HashMap::from([ + (key.to_string(), raw(json!("apply"))), + ("action".to_string(), raw(json!("refund"))), + ]); + let (id, tx) = windmill_queue::push( + &db, + PushIsolationLevel::IsolatedRoot(db.clone()), + "test-workspace", + JobPayload::ScriptHash { + hash: ScriptHash(SCRIPT_HASH), + path: SCRIPT_PATH.to_string(), + cache_ttl: None, + cache_ignore_s3_path: None, + dedicated_worker: None, + language: ScriptLang::Bun, + priority: None, + apply_preprocessor: false, + concurrency_settings: ConcurrencySettings::default(), + debouncing_settings: DebouncingSettings::default(), + labels: None, + job_token_scopes: None, + }, + PushArgs { args: &args, extra: None }, + "test-user", + "test@windmill.dev", + "u/test-user".to_string(), + None, + None, + None, + None, + None, + None, + None, + None, + false, + false, + None, + true, + None, + None, + None, + None, + None, + false, + None, + None, + None, + None, + ) + .await?; + tx.commit().await?; + let (args, entrypoint) = job_args_and_entrypoint(&db, id).await?; + assert_eq!( + entrypoint.as_deref(), + Some("apply"), + "an authenticated caller running a script as itself may still pick the entrypoint \ + (sent as {key:?})" + ); + assert_eq!( + args, + json!({ "_ENTRYPOINT_OVERRIDE": "apply", "action": "refund" }), + "the stored name is the normalized one (sent as {key:?})" + ); + } + Ok(()) +} + +/// Create a public (anonymous) app whose single component is the deployed script, with the +/// given policy inputs, and return its path. +async fn create_public_app( + ws: &str, + path: &str, + static_inputs: Value, + one_of_inputs: Value, +) -> anyhow::Result<()> { + let resp = authed(client().post(format!("{ws}/apps/create")), "SECRET_TOKEN") + .json(&json!({ + "path": path, + "summary": "", + "value": {}, + "policy": { + "execution_mode": "anonymous", + "triggerables_v2": { + format!("comp:script/{SCRIPT_PATH}"): { + "static_inputs": static_inputs, + "one_of_inputs": one_of_inputs, + } + } + } + })) + .send() + .await?; + assert_eq!(resp.status(), 201, "create app: {}", resp.text().await?); + Ok(()) +} + +/// A viewer of a public app runs the publisher's pinned script as the publisher: it may +/// fill in the inputs the policy leaves free and nothing else. +#[sqlx::test(fixtures("base"))] +async fn public_app_viewer_cannot_steer_the_pinned_runnable( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let ws = format!( + "http://localhost:{}/api/w/test-workspace", + server.addr.port() + ); + deploy_script(&db).await?; + + let app_path = "u/test-user/reserved_args"; + create_public_app( + &ws, + app_path, + json!({ "tenant": "acme" }), + json!({ "greeting": ["Hello,", "Bonjour,"] }), + ) + .await?; + + // No `Authorization` header at all: the anonymous visitor of a public app. + let run = |args: &'static str| { + client() + .post(format!("{ws}/apps_u/execute_component/{app_path}")) + .header("content-type", "application/json") + .body(format!( + r#"{{"component": "comp", "path": "script/{SCRIPT_PATH}", "args": {args}}}"# + )) + .send() + }; + + // A name spelled with a NUL is the same name once stored, so the policy's pinned value + // must still win and the one-of list must still be enforced. + let resp = run(r#"{"tenant\u0000": "globex", "greeting": "Hello,"}"#).await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!(status, 200, "run must be accepted: {body}"); + let (args, _) = job_args_and_entrypoint(&db, uuid::Uuid::parse_str(body.trim())?).await?; + assert_eq!( + args["tenant"], + json!("acme"), + "a NUL-renamed arg must not override an input the policy pins: {args}" + ); + + let resp = run(r#"{"greeting\u0000": "anything"}"#).await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!( + status, 400, + "a NUL-renamed arg must still be held to the policy's one_of list: {body}" + ); + assert!( + body.contains("must be one of"), + "expected the one_of rejection, got: {body}" + ); + + // The control keys, in both spellings, in one request. + let resp = run( + r#"{"_ENTRYPOINT_OVERRIDE": "apply", "_ENTRYPOINT_OVERRIDE\u0000": "apply", + "_MODULES": {"bunfig.toml": {"content": "x", "language": "bun"}}, + "_MODULES\u0000": {"bunfig.toml": {"content": "x", "language": "bun"}}, + "_TEMP_SCRIPT_REFS": {"f/system/dep": "deadbeef"}, + "_TEMP_SCRIPT_REFS\u0000": {"f/system/dep": "deadbeef"}, + "greeting": "Hello,"}"#, + ) + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!(status, 200, "run must be accepted: {body}"); + let (args, entrypoint) = + job_args_and_entrypoint(&db, uuid::Uuid::parse_str(body.trim())?).await?; + assert_eq!( + entrypoint, None, + "a viewer must not pick the function the worker calls instead of `main`" + ); + assert_eq!( + args, + json!({ "tenant": "acme", "greeting": "Hello," }), + "only the policy's pinned input and the viewer's allowed one may reach the job: {args}" + ); + Ok(()) +} + +/// A deployed flow's dynamic-select code has no deployed runnable, so it is queued as a +/// `JobKind::Preview` job — the one kind on which the bun and python executors honor +/// `_TEMP_SCRIPT_REFS` and resolve the code's workspace imports from the temporary script +/// store, which any workspace member can write. The caller of this route only fills in that +/// code's inputs (it may be an operator, who is refused `jobs/run/preview` outright), so the +/// key must not reach the job, in either spelling. +#[sqlx::test(fixtures("base"))] +async fn flow_dynamic_select_strips_caller_control_args(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let ws = format!( + "http://localhost:{}/api/w/test-workspace", + server.addr.port() + ); + + // Someone else's flow: `test-user-2` below can run it, not edit it. + let flow_path = "u/test-user/dynselect_control_args"; + let resp = authed(client().post(format!("{ws}/flows/create")), "SECRET_TOKEN") + .json(&json!({ + "path": flow_path, + "summary": "", + "description": "", + "value": { "modules": [] }, + "schema": { + "type": "object", + "properties": { "region": { "type": "object", "format": "dynselect-region" } }, + "x-windmill-dyn-select-lang": "bun", + "x-windmill-dyn-select-code": + "import { regions } from \"/f/system/regions\"\n\ + export async function region() { return regions() }\n" + } + })) + .send() + .await?; + assert_eq!(resp.status(), 201, "create flow: {}", resp.text().await?); + + // Read-only for the caller below: running the dyn-select code is all it may do. + sqlx::query( + "UPDATE flow SET extra_perms = '{\"u/test-user-2\": false}' + WHERE path = $1 AND workspace_id = 'test-workspace'", + ) + .bind(flow_path) + .execute(&db) + .await?; + + let refs = json!({ "f/system/regions": "deadbeef" }); + let modules = json!({ "regions.ts": { "content": "export const v = 1", "language": "bun" } }); + let resp = authed( + client().post(format!("{ws}/jobs/run/dynamic_select")), + "SECRET_TOKEN_2", + ) + .json(&json!({ + "entrypoint_function": "region", + "runnable_ref": { "source": "deployed", "path": flow_path, "runnable_kind": "flow" }, + "args": { + "_TEMP_SCRIPT_REFS": refs, + "_TEMP_SCRIPT_REFS\u{0}": refs, + "_MODULES": modules, + "_MODULES\u{0}": modules, + "filterText": "eu" + } + })) + .send() + .await?; + let status = resp.status(); + let body = resp.text().await?; + assert_eq!(status, 201, "dynamic select must be accepted: {body}"); + + let id = uuid::Uuid::parse_str(body.trim())?; + let kind: String = sqlx::query_scalar("SELECT kind::text FROM v2_job WHERE id = $1") + .bind(id) + .fetch_one(&db) + .await?; + assert_eq!( + kind, "preview", + "the premise of this test: a deployed flow's dyn-select code runs as a preview job" + ); + let (args, _) = job_args_and_entrypoint(&db, id).await?; + assert_eq!( + args, + json!({ "_ENTRYPOINT_OVERRIDE": "region", "filterText": "eu" }), + "only the caller's inputs and the server-set entrypoint may reach the job" + ); + Ok(()) +} diff --git a/backend/tests/variable_expiration.rs b/backend/tests/variable_expiration.rs new file mode 100644 index 0000000000..94a4ed3f78 --- /dev/null +++ b/backend/tests/variable_expiration.rs @@ -0,0 +1,534 @@ +//! Tests for the variable expiration sweep. No worker: the sweep ends at the push, and the +//! queued job is what the tests assert on. + +use sqlx::{Pool, Postgres}; +use windmill_queue::variable_expiration::dispatch_expiring_variables; +use windmill_test_utils::{initialize_tracing, ApiServer}; + +const WS: &str = "test-workspace"; + +async fn configure_handler(db: &Pool, handler: &str) -> anyhow::Result<()> { + sqlx::query("UPDATE workspace_settings SET variable_expiration_handler = $2::jsonb WHERE workspace_id = $1") + .bind(WS) + .bind(handler) + .execute(db) + .await?; + Ok(()) +} + +/// Seed a deployed script the handler setting can point at. +async fn seed_handler_script(db: &Pool, path: &str) -> anyhow::Result<()> { + sqlx::query( + r#"INSERT INTO script (workspace_id, hash, path, summary, description, content, created_by, language, lock) + VALUES ($1, $2, $3, '', '', 'export function main() {}', 'test-user', 'bun', '')"#, + ) + .bind(WS) + .bind(path.bytes().fold(0i64, |h, b| h.wrapping_mul(31).wrapping_add(b as i64))) + .bind(path) + .execute(db) + .await?; + Ok(()) +} + +async fn seed_variable(db: &Pool, path: &str, expires_in: &str) -> anyhow::Result<()> { + sqlx::query(&format!( + r#"INSERT INTO variable (workspace_id, path, value, is_secret, description, value_valid_until) + VALUES ($1, $2, 'MUST_NOT_LEAK', true, 'a credential', now() + interval '{expires_in}')"# + )) + .bind(WS) + .bind(path) + .execute(db) + .await?; + Ok(()) +} + +async fn queued_handler_jobs(db: &Pool, path: &str) -> anyhow::Result { + Ok( + sqlx::query_scalar::<_, i64>("SELECT count(*) FROM v2_job WHERE runnable_path = $1") + .bind(path) + .fetch_one(db) + .await?, + ) +} + +/// The sweep dispatches inside the handler's `notify_before_secs`, leaves everything outside it +/// alone, and does not fire twice for the same expiry. +#[sqlx::test(fixtures("base"))] +async fn test_dispatch_within_lead_time_once(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + seed_handler_script(&db, "u/test-user/expiry_handler").await?; + configure_handler( + &db, + r#"{"path": "script/u/test-user/expiry_handler", "notify_before_secs": 3600}"#, + ) + .await?; + + seed_variable(&db, "u/test-user/due", "30 minutes").await?; + seed_variable(&db, "u/test-user/far", "2 hours").await?; + + dispatch_expiring_variables(&db).await; + + assert_eq!( + queued_handler_jobs(&db, "u/test-user/expiry_handler").await?, + 1, + "exactly the variable inside the lead window should dispatch" + ); + + // Admins grant this group access to the secrets it rotates, so the job must run as it. + let (path, is_secret, permissioned_as, permissioned_as_email): (String, bool, String, String) = + sqlx::query_as( + "SELECT args->>'variable_path', (args->>'is_secret')::boolean, permissioned_as, + permissioned_as_email FROM v2_job WHERE runnable_path = $1", + ) + .bind("u/test-user/expiry_handler") + .fetch_one(&db) + .await?; + assert_eq!(path, "u/test-user/due"); + assert!(is_secret); + assert_eq!(permissioned_as, "g/variable_expiration_handler"); + assert_eq!( + permissioned_as_email, + "variable_expiration_handler@windmill.dev" + ); + + let args: String = sqlx::query_scalar("SELECT args::text FROM v2_job WHERE runnable_path = $1") + .bind("u/test-user/expiry_handler") + .fetch_one(&db) + .await?; + assert!( + !args.contains("MUST_NOT_LEAK"), + "handler args leaked the value: {args}" + ); + + // The claim is what makes concurrent replicas safe. + dispatch_expiring_variables(&db).await; + assert_eq!( + queued_handler_jobs(&db, "u/test-user/expiry_handler").await?, + 1, + "a dispatched variable must not fire again" + ); + + Ok(()) +} + +/// A variable's writer must not get the handler to resolve a reference as its own identity; +/// the admin's `extra_args` still resolve. +#[sqlx::test(fixtures("base"))] +async fn test_variable_fields_do_not_resolve_in_handler(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + seed_handler_script(&db, "u/test-user/expiry_handler").await?; + configure_handler( + &db, + r#"{"path": "script/u/test-user/expiry_handler", "extra_args": {"slack": "$res:f/slack_bot/bot_token"}}"#, + ) + .await?; + seed_variable(&db, "u/test-user/due", "30 minutes").await?; + sqlx::query( + "UPDATE variable SET description = '$var:u/test-user/other' WHERE path = 'u/test-user/due'", + ) + .execute(&db) + .await?; + + dispatch_expiring_variables(&db).await; + + let (description, slack): (String, String) = sqlx::query_as( + "SELECT args->>'description', args->>'slack' FROM v2_job WHERE runnable_path = $1", + ) + .bind("u/test-user/expiry_handler") + .fetch_one(&db) + .await?; + assert!( + !description.starts_with("$var:"), + "description still resolves: {description}" + ); + assert_eq!(slack, "$res:f/slack_bot/bot_token"); + Ok(()) +} + +/// `remind_every_secs` runs the handler again once the interval has elapsed, and not before. +#[sqlx::test(fixtures("base"))] +async fn test_remind_every_repeats_the_notice(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + seed_handler_script(&db, "u/test-user/expiry_handler").await?; + configure_handler( + &db, + r#"{"path": "script/u/test-user/expiry_handler", "remind_every_secs": 600}"#, + ) + .await?; + seed_variable(&db, "u/test-user/due", "30 minutes").await?; + + dispatch_expiring_variables(&db).await; + dispatch_expiring_variables(&db).await; + assert_eq!( + queued_handler_jobs(&db, "u/test-user/expiry_handler").await?, + 1, + "no reminder before the interval has elapsed" + ); + + // A sweep landing a few seconds short of the interval must still remind. + sqlx::query( + "UPDATE variable SET expiration_notified_at = now() - interval '9 minutes 50 seconds'", + ) + .execute(&db) + .await?; + dispatch_expiring_variables(&db).await; + assert_eq!( + queued_handler_jobs(&db, "u/test-user/expiry_handler").await?, + 2 + ); + + sqlx::query( + "UPDATE variable SET value_valid_until = now() - interval '1 minute', + expiration_dispatched_for = now() - interval '1 minute', + expiration_notified_at = now() - interval '11 minutes'", + ) + .execute(&db) + .await?; + dispatch_expiring_variables(&db).await; + assert_eq!( + queued_handler_jobs(&db, "u/test-user/expiry_handler").await?, + 2 + ); + Ok(()) +} + +/// Moving `value_valid_until` re-arms the variable; re-writing the same date does not. +#[sqlx::test(fixtures("base"))] +async fn test_rearm_on_date_change(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base_url = format!("http://localhost:{}", server.addr.port()); + seed_handler_script(&db, "u/test-user/expiry_handler").await?; + configure_handler(&db, r#"{"path": "script/u/test-user/expiry_handler"}"#).await?; + seed_variable(&db, "u/test-user/due", "30 minutes").await?; + + // Through the real endpoint: writing the column directly would assert nothing. + let update = async |body: serde_json::Value| -> anyhow::Result { + Ok(reqwest::Client::new() + .post(format!( + "{base_url}/api/w/{WS}/variables/update/u/test-user/due" + )) + .bearer_auth("SECRET_TOKEN") + .json(&body) + .send() + .await? + .status() + .as_u16()) + }; + + dispatch_expiring_variables(&db).await; + assert_eq!( + queued_handler_jobs(&db, "u/test-user/expiry_handler").await?, + 1 + ); + + let stored: chrono::DateTime = sqlx::query_scalar( + "SELECT value_valid_until FROM variable WHERE workspace_id = $1 AND path = $2", + ) + .bind(WS) + .bind("u/test-user/due") + .fetch_one(&db) + .await?; + + // An unchanged `wmill sync push` rewrites the same date. + assert_eq!( + update(serde_json::json!({ "value_valid_until": stored })).await?, + 200 + ); + dispatch_expiring_variables(&db).await; + assert_eq!( + queued_handler_jobs(&db, "u/test-user/expiry_handler").await?, + 1, + "an unchanged expiry must not re-arm" + ); + + assert_eq!( + update(serde_json::json!({ "description": "touched" })).await?, + 200 + ); + dispatch_expiring_variables(&db).await; + assert_eq!( + queued_handler_jobs(&db, "u/test-user/expiry_handler").await?, + 1, + "an update that omits value_valid_until must not re-arm" + ); + + assert_eq!( + update(serde_json::json!({ "value_valid_until": stored + chrono::Duration::minutes(5) })) + .await?, + 200 + ); + dispatch_expiring_variables(&db).await; + assert_eq!( + queued_handler_jobs(&db, "u/test-user/expiry_handler").await?, + 2, + "a moved expiry must re-arm the handler" + ); + + Ok(()) +} + +/// `muted_on_user_path` keeps personal-path variables out. +#[sqlx::test(fixtures("base"))] +async fn test_muted_user_paths(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + seed_handler_script(&db, "u/test-user/expiry_handler").await?; + seed_variable(&db, "u/test-user/personal", "30 minutes").await?; + seed_variable(&db, "f/test_folder/shared", "30 minutes").await?; + configure_handler( + &db, + r#"{"path": "script/u/test-user/expiry_handler", "muted_on_user_path": true}"#, + ) + .await?; + dispatch_expiring_variables(&db).await; + + let dispatched: Vec = + sqlx::query_scalar("SELECT args->>'variable_path' FROM v2_job WHERE runnable_path = $1") + .bind("u/test-user/expiry_handler") + .fetch_all(&db) + .await?; + assert_eq!( + dispatched, + vec!["f/test_folder/shared".to_string()], + "a muted personal path must not dispatch" + ); + + Ok(()) +} + +/// Broken workspaces' due rows must not hold back anyone else's first notice or reminder: every +/// due row is tried each pass, and a broken handler only once per pass. The broken rows go in +/// first, so a cap on the pass would cut off the working workspace. +#[sqlx::test(fixtures("base"))] +async fn test_broken_handlers_do_not_hold_back_others(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + sqlx::raw_sql( + r#"INSERT INTO workspace (id, name, owner) SELECT 'broken-' || i, 'broken-' || i, 'test-user' FROM generate_series(1, 100) i; + INSERT INTO workspace_key (workspace_id, kind, key) SELECT 'broken-' || i, 'cloud', 'k' FROM generate_series(1, 100) i; + INSERT INTO workspace_settings (workspace_id, variable_expiration_handler) + SELECT 'broken-' || i, '{"path": "script/u/test-user/does_not_exist"}'::jsonb FROM generate_series(1, 100) i; + INSERT INTO variable (workspace_id, path, value, is_secret, description, value_valid_until) + SELECT 'broken-' || i, 'f/creds/v' || j, 'v', false, '', now() - interval '1 day' + FROM generate_series(1, 100) i, generate_series(1, 2) j;"#, + ) + .execute(&db) + .await?; + seed_handler_script(&db, "u/test-user/expiry_handler").await?; + configure_handler( + &db, + r#"{"path": "script/u/test-user/expiry_handler", "remind_every_secs": 60}"#, + ) + .await?; + seed_variable(&db, "u/test-user/fresh", "30 minutes").await?; + sqlx::query( + "INSERT INTO variable (workspace_id, path, value, is_secret, description, value_valid_until, + expiration_dispatched_for, expiration_notified_at) + VALUES ($1, 'u/test-user/reminded', 'v', false, '', now() + interval '10 minutes', + now() + interval '10 minutes', now() - interval '2 minutes')", + ) + .bind(WS) + .execute(&db) + .await?; + + dispatch_expiring_variables(&db).await; + + assert_eq!( + queued_handler_jobs(&db, "u/test-user/expiry_handler").await?, + 2, + "the first notice and the reminder must both go out" + ); + assert_eq!( + queued_handler_jobs(&db, "u/test-user/does_not_exist").await?, + 0 + ); + Ok(()) +} + +/// A broken handler alerts once, however many passes it fails, runs on the first pass after +/// it is deployed, and that resolves its alert. +#[sqlx::test(fixtures("base"))] +async fn test_deploying_the_handler_ends_the_failure(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + configure_handler(&db, r#"{"path": "script/u/test-user/redeployed_handler"}"#).await?; + seed_variable(&db, "u/test-user/due", "30 minutes").await?; + let alerts = || async { + Ok::<_, anyhow::Error>(sqlx::query_as::<_, (i64, i64)>( + "SELECT count(*), count(*) FILTER (WHERE NOT acknowledged) FROM alerts + WHERE alert_type = 'critical_error' AND resource = 'variable_expiration_handler:test-workspace'", + ) + .fetch_one(&db) + .await?) + }; + + dispatch_expiring_variables(&db).await; + dispatch_expiring_variables(&db).await; + assert_eq!( + alerts().await?, + (1, 1), + "one alert per failure, not per pass" + ); + + seed_handler_script(&db, "u/test-user/redeployed_handler").await?; + dispatch_expiring_variables(&db).await; + + assert_eq!( + queued_handler_jobs(&db, "u/test-user/redeployed_handler").await?, + 1, + "the deployed handler did not run on the next pass" + ); + assert_eq!(alerts().await?, (1, 0), "starting again resolves the alert"); + Ok(()) +} + +/// A handler script still waiting on its dependency job runs on the first pass after its lock +/// lands, though nothing about the script changes but the lock. +#[sqlx::test(fixtures("base"))] +async fn test_handler_runs_once_its_lock_lands(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + configure_handler( + &db, + r#"{"path": "script/u/test-user/pending_lock_handler"}"#, + ) + .await?; + seed_variable(&db, "u/test-user/due", "30 minutes").await?; + seed_handler_script(&db, "u/test-user/pending_lock_handler").await?; + sqlx::query("UPDATE script SET lock = NULL WHERE workspace_id = $1") + .bind(WS) + .execute(&db) + .await?; + + dispatch_expiring_variables(&db).await; + assert_eq!( + queued_handler_jobs(&db, "u/test-user/pending_lock_handler").await?, + 0 + ); + sqlx::query("UPDATE script SET lock = '' WHERE workspace_id = $1") + .bind(WS) + .execute(&db) + .await?; + dispatch_expiring_variables(&db).await; + + assert_eq!( + queued_handler_jobs(&db, "u/test-user/pending_lock_handler").await?, + 1, + "the handler did not run once its lock landed" + ); + Ok(()) +} + +/// A removed handler has nothing left to retry, so the next pass ends its failure and resolves +/// its alert. +#[sqlx::test(fixtures("base"))] +async fn test_removing_a_failing_handler_resolves_its_alert( + db: Pool, +) -> anyhow::Result<()> { + initialize_tracing().await; + sqlx::query( + "UPDATE workspace_settings SET variable_expiration_handler = NULL, + variable_expiration_failed_at = now() WHERE workspace_id = $1", + ) + .bind(WS) + .execute(&db) + .await?; + sqlx::query( + "INSERT INTO alerts (alert_type, message, acknowledged, acknowledged_workspace, workspace_id, resource) + VALUES ('critical_error', 'failed', false, false, $1, 'variable_expiration_handler:' || $1)", + ) + .bind(WS) + .execute(&db) + .await?; + + dispatch_expiring_variables(&db).await; + + let (failing, open_alerts): (bool, i64) = sqlx::query_as( + "SELECT ws.variable_expiration_failed_at IS NOT NULL, + (SELECT count(*) FROM alerts WHERE alert_type = 'critical_error' AND NOT acknowledged + AND workspace_id = $1) + FROM workspace_settings ws WHERE ws.workspace_id = $1", + ) + .bind(WS) + .fetch_one(&db) + .await?; + assert!(!failing, "the removed handler's failure must end"); + assert_eq!( + open_alerts, 0, + "the removed handler's alert must be resolved" + ); + Ok(()) +} + +/// The handler endpoint: admin-only, existence-checked, and clearing by omission, which +/// `wmill sync push` relies on to propagate an absent handler. +#[sqlx::test(fixtures("base"))] +async fn test_edit_handler_endpoint(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base_url = format!("http://localhost:{}", server.addr.port()); + + let edit = async |token: &str, body: serde_json::Value| -> anyhow::Result { + Ok(reqwest::Client::new() + .post(format!( + "{base_url}/api/w/{WS}/workspaces/edit_variable_expiration_handler" + )) + .bearer_auth(token) + .json(&body) + .send() + .await? + .status() + .as_u16()) + }; + let stored = async || -> anyhow::Result> { + Ok(sqlx::query_scalar( + "SELECT variable_expiration_handler FROM workspace_settings WHERE workspace_id = $1", + ) + .bind(WS) + .fetch_one(&db) + .await?) + }; + + seed_handler_script(&db, "u/test-user/expiry_handler").await?; + let handler = serde_json::json!({ "path": "script/u/test-user/expiry_handler" }); + assert_eq!(edit("SECRET_TOKEN_2", handler.clone()).await?, 403); + assert!(stored().await?.is_none()); + + for path in [ + "u/test-user/expiry_handler", + "script/u/test-user/does_not_exist", + ] { + assert_eq!( + edit("SECRET_TOKEN", serde_json::json!({ "path": path })).await?, + 400, + "{path}" + ); + } + + // A recorded failure is the sweep's to end, so the endpoint leaves it alone either way. + sqlx::query("UPDATE workspace_settings SET variable_expiration_failed_at = now() WHERE workspace_id = $1") + .bind(WS) + .execute(&db) + .await?; + let failing = || async { + Ok::<_, anyhow::Error>(sqlx::query_scalar::<_, bool>( + "SELECT variable_expiration_failed_at IS NOT NULL FROM workspace_settings WHERE workspace_id = $1", + ) + .bind(WS) + .fetch_one(&db) + .await?) + }; + assert_eq!(edit("SECRET_TOKEN", handler).await?, 200); + assert_eq!( + stored() + .await? + .and_then(|h| h["path"].as_str().map(String::from)), + Some("script/u/test-user/expiry_handler".to_string()) + ); + assert!(failing().await?, "saving the handler must keep its failure"); + + assert_eq!(edit("SECRET_TOKEN", serde_json::json!({})).await?, 200); + assert!( + stored().await?.is_none(), + "an omitted path clears the handler" + ); + + Ok(()) +} diff --git a/backend/tests/workspace_export.rs b/backend/tests/workspace_export.rs index 5894ca8394..792d41b487 100644 --- a/backend/tests/workspace_export.rs +++ b/backend/tests/workspace_export.rs @@ -288,6 +288,48 @@ async fn test_tarball_export_gates_values_on_item_scopes(db: Pool) -> Ok(()) } +/// The tarball drops variables by `expires_at`, never `value_valid_until`, and the exported +/// entry carries the date, or a pull/push round-trip clears it. +#[sqlx::test(fixtures("base"))] +async fn test_tarball_export_excludes_only_gc_variables(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let base_url = format!("http://localhost:{}", server.addr.port()); + + sqlx::query( + r#"INSERT INTO variable (workspace_id, path, value, is_secret, description, expires_at, value_valid_until) + VALUES ('test-workspace', 'u/test-user/expiring', 'v', false, '', NULL, '2030-01-15T08:00:00Z'), + ('test-workspace', 'u/test-user/ephemeral', 'v', false, '', now() + interval '7 days', NULL)"#, + ) + .execute(&db) + .await?; + + let resp = reqwest::Client::new() + .get(format!( + "{base_url}/api/w/test-workspace/workspaces/tarball" + )) + .bearer_auth("SECRET_TOKEN") + .send() + .await?; + assert_eq!(resp.status().as_u16(), 200); + let body = String::from_utf8_lossy(&resp.bytes().await?).into_owned(); + + assert!( + body.contains("u/test-user/expiring.variable.json"), + "a variable with an expiry must still reach git sync" + ); + assert!( + body.contains("2030-01-15T08:00:00Z"), + "the exported entry must carry the expiry back" + ); + assert!( + !body.contains("u/test-user/ephemeral.variable.json"), + "a variable awaiting garbage collection must stay out of the export" + ); + + Ok(()) +} + /// `settings.json` carries the admin-managed integration config that `get_settings` /// is admin-only for (the webhook URL, ai_config, git_sync, handler extra_args), so /// `include_settings` takes the same admin check as `get_settings` rather than diff --git a/backend/windmill-ai/src/ai_providers.rs b/backend/windmill-ai/src/ai_providers.rs index 311c8d3eb7..489a965ef1 100644 --- a/backend/windmill-ai/src/ai_providers.rs +++ b/backend/windmill-ai/src/ai_providers.rs @@ -213,7 +213,9 @@ impl AIProvider { AIProvider::Groq => Ok("https://api.groq.com/openai/v1".to_string()), AIProvider::OpenRouter => Ok("https://openrouter.ai/api/v1".to_string()), AIProvider::TogetherAI => Ok("https://api.together.xyz/v1".to_string()), - AIProvider::Anthropic => Ok("https://api.anthropic.com/v1".to_string()), + AIProvider::Anthropic => { + Ok(crate::providers::anthropic::ANTHROPIC_API_BASE_URL.to_string()) + } AIProvider::Mistral => Ok("https://api.mistral.ai/v1".to_string()), AIProvider::TypeSafe => Ok(TYPESAFE_BASE_URL.to_string()), AIProvider::Cloudflare => Err(Error::BadRequest( diff --git a/backend/windmill-ai/src/credentials.rs b/backend/windmill-ai/src/credentials.rs index 5a779938ed..f5489c5c30 100644 --- a/backend/windmill-ai/src/credentials.rs +++ b/backend/windmill-ai/src/credentials.rs @@ -32,6 +32,21 @@ pub struct ProviderCredentials { /// A role trust policy that conditions on `sub` has to admit both, or only /// one of the copilot and the AI agent step will be able to assume the role. pub oidc_role_arn: Option, + /// Anthropic Workload Identity Federation settings, from an `anthropic_federated` + /// resource. The API proxy exchanges an OIDC token minted for the requesting + /// user for a short-lived access token, which then goes out as `access_token`. + pub anthropic_federation: Option, pub platform: AIPlatform, pub custom_headers: HashMap, } + +/// The identifiers a Claude Console federation rule needs in a token exchange. +/// None of them is a secret: what authorises the exchange is the OIDC token. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct AnthropicFederation { + pub federation_rule_id: String, + pub organization_id: String, + pub service_account_id: String, + /// Required by Anthropic only when the rule is enabled in several workspaces. + pub workspace_id: Option, +} diff --git a/backend/windmill-ai/src/providers/anthropic.rs b/backend/windmill-ai/src/providers/anthropic.rs index 7acd2db939..2a3417bc62 100644 --- a/backend/windmill-ai/src/providers/anthropic.rs +++ b/backend/windmill-ai/src/providers/anthropic.rs @@ -2,6 +2,7 @@ use super::{anthropic_model_rejects_sampling_params, REASONING_OFF_SENTINEL}; use crate::{ ai_google::parse_data_url, ai_providers::{AIPlatform, AIProvider, DISABLE_ANTHROPIC_PROMPT_CACHING}, + credentials::AnthropicFederation, image_handler::prepare_messages_for_api, proxy::{ add_user_to_body, common_outbound_headers, credential_header, ProxyBuildArgs, ProxyRequest, @@ -837,6 +838,169 @@ impl QueryBuilder for AnthropicQueryBuilder { } } +pub const ANTHROPIC_API_BASE_URL: &str = "https://api.anthropic.com/v1"; + +/// Audience of the OIDC token Windmill presents in an Anthropic token exchange, +/// which a federation rule can pin with its `audience` matcher. +pub const ANTHROPIC_OIDC_AUDIENCE: &str = "https://api.anthropic.com"; + +/// Lifetime of that OIDC token. A federation issuer refuses a token that lives +/// longer than its maximum (one hour by default), and Anthropic caps the access +/// token at twice the assertion's remaining life, so 30 minutes still lets a rule +/// mint its default one-hour token. +pub const ANTHROPIC_OIDC_TOKEN_LIFETIME_SECS: i64 = 1800; + +/// Exchange again this long before the access token expires, so a long streamed +/// response that starts on a reused token cannot outlive it. Capped at half the +/// token's lifetime: a rule may mint tokens for as little as a minute, and a +/// margin longer than that would exchange again on every request. +const FEDERATED_TOKEN_REFRESH_MARGIN: std::time::Duration = std::time::Duration::from_secs(300); + +/// A short-lived Anthropic access token minted by a federated token exchange. +#[derive(Clone, Debug)] +pub struct FederatedAccessToken { + pub access_token: String, + refresh_at: std::time::SystemTime, +} + +impl FederatedAccessToken { + fn new(access_token: String, lifetime: std::time::Duration) -> Self { + // Anthropic mints tokens for at most a day; the clamp keeps an out-of-range + // `expires_in` from overflowing the clock arithmetic. + let lifetime = lifetime.min(std::time::Duration::from_secs(86400)); + let margin = FEDERATED_TOKEN_REFRESH_MARGIN.min(lifetime / 2); + Self { access_token, refresh_at: std::time::SystemTime::now() + lifetime - margin } + } + + pub fn is_fresh(&self) -> bool { + std::time::SystemTime::now() < self.refresh_at + } +} + +#[derive(Serialize)] +struct FederatedTokenRequest<'a> { + grant_type: &'static str, + assertion: &'a str, + federation_rule_id: &'a str, + organization_id: &'a str, + service_account_id: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + workspace_id: Option<&'a str>, +} + +#[derive(Deserialize)] +struct FederatedTokenResponse { + access_token: String, + expires_in: u64, +} + +/// Exchange an OIDC token for an Anthropic access token at `POST /v1/oauth/token` +/// (RFC 7523 `jwt-bearer` grant). `client` must already be pinned to the +/// validated address of `base_url`. +pub async fn exchange_federated_token( + client: &reqwest::Client, + base_url: &str, + federation: &AnthropicFederation, + assertion: &str, +) -> Result { + let exchanged = request_federated_token(client, base_url, federation, assertion).await; + windmill_common::feature_usage::log_feature_usage( + "anthropic_federation", + "token_exchange", + if exchanged.is_ok() { "ok" } else { "failed" }, + ); + exchanged +} + +async fn request_federated_token( + client: &reqwest::Client, + base_url: &str, + federation: &AnthropicFederation, + assertion: &str, +) -> Result { + let url = AIProvider::build_anthropic_api_url(base_url, "oauth/token"); + let response = client + .post(url) + .header("anthropic-version", ANTHROPIC_VERSION_STANDARD) + .json(&FederatedTokenRequest { + grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", + assertion, + federation_rule_id: &federation.federation_rule_id, + organization_id: &federation.organization_id, + service_account_id: &federation.service_account_id, + workspace_id: federation.workspace_id.as_deref(), + }) + .send() + .await + .map_err(|e| Error::internal_err(format!("Anthropic token exchange failed: {e}")))?; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + // Anthropic answers every denied assertion with the same opaque 401; the + // reason is only recorded in the Console's authentication history. + let hint = if status == reqwest::StatusCode::UNAUTHORIZED { + " The reason the exchange was denied is in the authentication history of the Claude Console's Workload identity page." + } else { + "" + }; + return Err(Error::BadRequest(format!( + "Anthropic token exchange failed with status {status}: {body}.{hint}" + ))); + } + + let token = response + .json::() + .await + .map_err(|e| { + Error::internal_err(format!( + "Failed to parse the Anthropic token exchange response: {e}" + )) + })?; + Ok(FederatedAccessToken::new( + token.access_token, + std::time::Duration::from_secs(token.expires_in), + )) +} + +/// The access token of a federated resource for a job, reusing `cached` while it +/// is still fresh, so the agent loop exchanges once per job rather than per turn. +/// Returns `None` for a resource that does not federate. +pub async fn refresh_federated_access_token<'a>( + credentials: &crate::credentials::ProviderCredentials, + cached: &'a mut Option, + client: &AuthedClient, +) -> Result, Error> { + let Some(federation) = credentials.anthropic_federation.as_ref() else { + return Ok(None); + }; + if !cached.as_ref().is_some_and(FederatedAccessToken::is_fresh) { + // A worker holds no OIDC signing key, so it asks the API to mint the token for this job. + let assertion = client + .get_id_token_expiring_in( + ANTHROPIC_OIDC_AUDIENCE, + Some(ANTHROPIC_OIDC_TOKEN_LIFETIME_SECS), + ) + .await + .map_err(|e| Error::ExecutionErr(format!("Failed to get an OIDC token: {e}")))?; + let http_client = crate::utils::pinned_ai_client_for(&credentials.base_url).await?; + *cached = Some( + exchange_federated_token(&http_client, &credentials.base_url, federation, &assertion) + .await?, + ); + } + Ok(cached.as_ref().map(|token| token.access_token.as_str())) +} + +/// Headers authenticating a request with a federated access token, which Anthropic +/// takes as a bearer token in place of `x-api-key`. +pub fn federated_auth_headers(access_token: &str) -> Vec<(&'static str, String)> { + vec![ + ("Authorization", format!("Bearer {access_token}")), + ("anthropic-version", ANTHROPIC_VERSION_STANDARD.to_string()), + ] +} + #[cfg(test)] mod tests { use super::*; @@ -859,6 +1023,7 @@ mod tests { aws_secret_access_key: None, aws_session_token: None, oidc_role_arn: None, + anthropic_federation: None, platform, custom_headers: HashMap::new(), } diff --git a/backend/windmill-ai/src/providers/google_ai.rs b/backend/windmill-ai/src/providers/google_ai.rs index bbf45e5e57..06a404e5a4 100644 --- a/backend/windmill-ai/src/providers/google_ai.rs +++ b/backend/windmill-ai/src/providers/google_ai.rs @@ -805,6 +805,7 @@ mod tests { aws_secret_access_key: None, aws_session_token: None, oidc_role_arn: None, + anthropic_federation: None, platform, custom_headers: HashMap::new(), } diff --git a/backend/windmill-ai/src/providers/mod.rs b/backend/windmill-ai/src/providers/mod.rs index 82dceaa02f..ffddd33d3a 100644 --- a/backend/windmill-ai/src/providers/mod.rs +++ b/backend/windmill-ai/src/providers/mod.rs @@ -370,6 +370,7 @@ mod parity_tests { aws_secret_access_key: None, aws_session_token: None, oidc_role_arn: None, + anthropic_federation: None, platform: kase.platform.clone(), custom_headers: HashMap::new(), } diff --git a/backend/windmill-ai/src/proxy.rs b/backend/windmill-ai/src/proxy.rs index ca6ee1b59b..66285437e4 100644 --- a/backend/windmill-ai/src/proxy.rs +++ b/backend/windmill-ai/src/proxy.rs @@ -285,6 +285,7 @@ mod tests { aws_secret_access_key: None, aws_session_token: None, oidc_role_arn: None, + anthropic_federation: None, platform: AIPlatform::Standard, custom_headers: HashMap::new(), } diff --git a/backend/windmill-ai/src/types.rs b/backend/windmill-ai/src/types.rs index 59733e352c..3950576aed 100644 --- a/backend/windmill-ai/src/types.rs +++ b/backend/windmill-ai/src/types.rs @@ -19,7 +19,7 @@ pub struct McpToolSource { use crate::{ ai_google::sanitize_schema_for_google, ai_providers::{empty_string_as_none, AIProvider}, - credentials::ProviderCredentials, + credentials::{AnthropicFederation, ProviderCredentials}, }; use windmill_common::{db::DB, error::Error, flow_status::AgentAction, flows::FlowModule}; use windmill_parser::Typ; @@ -270,6 +270,16 @@ pub struct ProviderResource { /// credentials exchange this crate does not perform. #[serde(default, deserialize_with = "empty_string_as_none")] pub token_url: Option, + /// Set only by `anthropic_federated` resources, which authenticate through + /// Workload Identity Federation with an OIDC token minted for the job. + #[serde(default, deserialize_with = "empty_string_as_none")] + pub federation_rule_id: Option, + #[serde(default, deserialize_with = "empty_string_as_none")] + pub organization_id: Option, + #[serde(default, deserialize_with = "empty_string_as_none")] + pub service_account_id: Option, + #[serde(default, deserialize_with = "empty_string_as_none")] + pub workspace_id: Option, /// The API token of a `cloudflare` resource, which names it `token` rather than `api_key`. #[serde(default, deserialize_with = "empty_string_as_none")] pub token: Option, @@ -323,8 +333,13 @@ impl ProviderWithResource { /// Callers must only pass resources that were already authorized for the /// current job/workspace; this helper does not perform access checks. pub async fn to_provider_credentials(&self, db: &DB) -> Result { + let anthropic_federation = self.anthropic_federation()?; let base_url = if self.kind == AIProvider::AWSBedrock { String::new() + } else if anthropic_federation.is_some() { + // The exchanged token is the job's identity, so it only ever goes to + // Anthropic: a resource cannot point it at another host. + crate::providers::anthropic::ANTHROPIC_API_BASE_URL.to_string() } else { self.get_base_url(db).await? }; @@ -348,11 +363,40 @@ impl ProviderWithResource { aws_secret_access_key: self.resource.aws_secret_access_key.clone(), aws_session_token: self.resource.aws_session_token.clone(), oidc_role_arn: self.resource.oidc_role_arn.clone(), + anthropic_federation, platform: self.resource.platform.clone(), custom_headers: self.resource.headers.clone(), }) } + /// The federation settings of an `anthropic_federated` resource, which only + /// `federation_rule_id` identifies; the other required fields are checked here. + fn anthropic_federation(&self) -> Result, Error> { + let resource = &self.resource; + let Some(federation_rule_id) = resource.federation_rule_id.clone() else { + return Ok(None); + }; + if self.kind != AIProvider::Anthropic { + return Err(Error::ExecutionErr(format!( + "An anthropic_federated resource can only be used with the Anthropic provider, not {:?}", + self.kind + ))); + } + let required = |value: &Option, name: &str| { + value.clone().ok_or_else(|| { + Error::ExecutionErr(format!( + "The anthropic_federated resource needs {name} to be set" + )) + }) + }; + Ok(Some(AnthropicFederation { + federation_rule_id, + organization_id: required(&resource.organization_id, "organization_id")?, + service_account_id: required(&resource.service_account_id, "service_account_id")?, + workspace_id: resource.workspace_id.clone(), + })) + } + #[cfg(feature = "bedrock")] pub fn get_region(&self) -> Option<&str> { self.resource.region.as_deref() @@ -983,6 +1027,47 @@ pub struct S3ObjectWithType { mod tests { use super::*; + #[test] + fn a_federated_resource_resolves_its_federation_and_names_a_missing_field() { + let provider = |kind: &str, resource: &str| -> ProviderWithResource { + serde_json::from_str(&format!( + r#"{{"kind":"{kind}","model":"m","resource":{resource}}}"# + )) + .unwrap() + }; + let federated = r#"{"federation_rule_id":"fdrl_x","organization_id":"org","service_account_id":"svac_x","workspace_id":""}"#; + + assert_eq!( + provider("anthropic", federated) + .anthropic_federation() + .unwrap(), + Some(AnthropicFederation { + federation_rule_id: "fdrl_x".to_string(), + organization_id: "org".to_string(), + service_account_id: "svac_x".to_string(), + workspace_id: None, + }) + ); + let incomplete = provider( + "anthropic", + r#"{"federation_rule_id":"fdrl_x","organization_id":"org"}"#, + ); + assert!(incomplete + .anthropic_federation() + .unwrap_err() + .to_string() + .contains("service_account_id")); + assert!(provider("openai", federated) + .anthropic_federation() + .is_err()); + assert_eq!( + provider("anthropic", r#"{"api_key":"k","organization_id":"org"}"#) + .anthropic_federation() + .unwrap(), + None + ); + } + /// The schema is part of the prompt prefix the provider caches on, so its keys must /// serialize in the same order on every request. #[test] diff --git a/backend/windmill-api-auth/src/composed.rs b/backend/windmill-api-auth/src/composed.rs new file mode 100644 index 0000000000..e9d79240dd --- /dev/null +++ b/backend/windmill-api-auth/src/composed.rs @@ -0,0 +1,171 @@ +//! Authorizing what a restricted role composes: the references a value-only walk +//! (`check_flow_is_composition_only`) collects, checked against the caller's own permissions. + +use sqlx::{Postgres, Transaction}; +use windmill_common::{ + error::{self, Error}, + flows::FlowValue, + roles::AuthorLevel, + workspaces::{check_operator_can_build, BuilderKind}, + DB, +}; + +use crate::{get_scope_tags, is_super_admin_authed, ApiAuthed}; + +/// `validate_operator_composed_flow` on a transaction the caller already holds, so a write that +/// has one open does not wait on a second connection. It must have been begun as the caller +/// (`UserDB::begin`): its row-level security is the check. +pub async fn authorize_composed_flow( + value: &FlowValue, + flow_tag: &Option, + authed: &ApiAuthed, + db: &DB, + tx: &mut Transaction<'_, Postgres>, + w_id: &str, +) -> error::Result<()> { + let mut refs = windmill_common::flows::check_flow_is_composition_only(value)?; + + // A builder links only an agent it can read, and binds only the tool inputs it leaves open. + for agent in &refs.agents { + let path = &agent.path; + let value = sqlx::query_scalar!( + "SELECT value AS \"value: sqlx::types::Json\" + FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent'", + w_id, + path, + ) + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| { + Error::PermissionDenied(format!( + "Agent {path} does not exist or is not readable by you" + )) + })?; + if agent.bound_inputs.is_empty() { + continue; + } + let tools: Vec = value + .and_then(|v| v.0.get("tools").cloned()) + .map(serde_json::from_value) + .transpose() + .map_err(|e| Error::BadRequest(format!("Invalid tools in agent {path}: {e}")))? + .unwrap_or_default(); + windmill_common::flows::check_bound_agent_inputs_are_open( + path, + &tools, + &agent.bound_inputs, + )?; + } + + // A tag is how a step picks the worker group it runs on: unauthorized, a builder could route + // a job onto a privileged one. + refs.tags.extend(flow_tag.clone().filter(|t| !t.is_empty())); + if !refs.tags.is_empty() { + // Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags. + let is_super_admin = is_super_admin_authed(db, authed).await?; + for tag in &refs.tags { + windmill_common::jobs::check_tag_available_for_workspace_internal( + db, + w_id, + tag, + None, + std::future::ready(w_id.to_string()), + is_super_admin, + get_scope_tags(authed), + ) + .await?; + } + } + + if refs.runnables.is_empty() && refs.pinned_scripts.is_empty() { + return Ok(()); + } + // A flow can step through the same script thirty times; this runs on every write, preview and + // dependency job. + refs.runnables.sort(); + refs.runnables.dedup(); + refs.pinned_scripts + .sort_by_key(|(path, hash)| (path.clone(), hash.0)); + refs.pinned_scripts + .dedup_by_key(|(path, hash)| (path.clone(), hash.0)); + // The worker resolves a step's path with the root DB handle and runs it as that runnable's + // `on_behalf_of`, so composing an unreadable path would run code the builder cannot see. RLS + // on this transaction is the check. + for (is_flow, path) in &refs.runnables { + let readable = if *is_flow { + sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2)", + w_id, + path, + ) + } else { + sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2)", + w_id, + path, + ) + } + .fetch_one(&mut **tx) + .await? + .unwrap_or(false); + if !readable { + return Err(Error::PermissionDenied(format!( + "{} {path} does not exist or is not readable by you", + if *is_flow { "Flow" } else { "Script" } + ))); + } + } + // A pinned step is dispatched by its hash alone, ignoring the path beside it, so a readable + // path paired with another script's hash would still run that other script. + for (path, hash) in &refs.pinned_scripts { + let exists = sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2 AND hash = $3)", + w_id, + path, + hash.0, + ) + .fetch_one(&mut **tx) + .await? + .unwrap_or(false); + if !exists { + return Err(Error::PermissionDenied(format!( + "Version {hash} is not a readable version of {path}" + ))); + } + } + Ok(()) +} + +/// What a restricted role must pass to store an `ai_agent` resource, deployed or as a draft. Its +/// tools run in the agent's own runs and in every flow step linking it, so it is held to the rules +/// of a composed flow, under the same capability. `tx` is the write's own, begun as the caller. +pub async fn validate_operator_agent( + value: &serde_json::Value, + authed: &ApiAuthed, + db: &DB, + tx: &mut Transaction<'_, Postgres>, + w_id: &str, +) -> error::Result<()> { + if check_operator_can_build(db, w_id, authed, BuilderKind::Flows, "write AI agents").await? + == AuthorLevel::Full + { + return Ok(()); + } + let tools = value + .get("tools") + .cloned() + .unwrap_or_else(|| serde_json::json!([])); + let flow: FlowValue = serde_json::from_value(serde_json::json!({"modules": [{ + "id": "agent", + "value": {"type": "aiagent", "input_transforms": {}, "tools": tools} + }]})) + .map_err(|e| Error::BadRequest(format!("Invalid agent tools: {e}")))?; + authorize_composed_flow(&flow, &None, authed, db, tx, w_id) + .await + .map_err(|e| match e { + Error::PermissionDenied(msg) => { + Error::PermissionDenied(msg.replace("edit this flow", "edit this agent")) + } + e => e, + }) +} diff --git a/backend/windmill-api-auth/src/lib.rs b/backend/windmill-api-auth/src/lib.rs index bfbd4470c8..82a40377a0 100644 --- a/backend/windmill-api-auth/src/lib.rs +++ b/backend/windmill-api-auth/src/lib.rs @@ -7,6 +7,7 @@ */ pub mod auth; +pub mod composed; #[cfg(feature = "private")] pub mod ee; pub mod ee_oss; diff --git a/backend/windmill-api-configs/src/lib.rs b/backend/windmill-api-configs/src/lib.rs index cfa74bd2f0..8336888088 100644 --- a/backend/windmill-api-configs/src/lib.rs +++ b/backend/windmill-api-configs/src/lib.rs @@ -35,6 +35,7 @@ pub fn global_service() -> Router { "/list_autoscaling_events/{worker_group}", get(list_autoscaling_events), ) + .route("/test_autoscaling/{worker_group}", post(test_autoscaling)) .route( "/native_kubernetes_autoscaling_healthcheck", get(native_kubernetes_autoscaling_healthcheck), @@ -419,6 +420,48 @@ async fn native_kubernetes_autoscaling_healthcheck() -> Result<(), error::Error> )) } +#[derive(Serialize)] +struct TestAutoscalingResponse { + job_id: Option, +} + +#[cfg(all(feature = "enterprise", feature = "private"))] +async fn test_autoscaling( + authed: ApiAuthed, + Extension(db): Extension, + Path(worker_group): Path, + Json(test): Json, +) -> error::JsonResult { + require_devops_role(&db, &authed).await?; + audit_log( + &db, + &authed, + "worker_config.test_autoscaling", + ActionKind::Execute, + "global", + Some(&worker_group), + None, + ) + .await?; + let job_id = windmill_autoscaling::autoscaling_ee::test_autoscaling( + &db, + &worker_group, + test, + &authed.email, + ) + .await?; + Ok(Json(TestAutoscalingResponse { + job_id: job_id.map(|id| id.to_string()), + })) +} + +#[cfg(not(all(feature = "enterprise", feature = "private")))] +async fn test_autoscaling() -> error::JsonResult { + Err(error::Error::BadRequest( + "Autoscaling available only in the enterprise version".to_string(), + )) +} + async fn list_available_python_versions() -> error::JsonResult> { #[cfg(not(all(feature = "python", feature = "run_inline")))] return Err(error::Error::BadRequest( diff --git a/backend/windmill-api-flows/src/flows.rs b/backend/windmill-api-flows/src/flows.rs index 3ef3de7a33..5d4268a93c 100644 --- a/backend/windmill-api-flows/src/flows.rs +++ b/backend/windmill-api-flows/src/flows.rs @@ -16,8 +16,8 @@ use axum::{ }; use windmill_api_auth::{ auth::{list_tokens_internal, TruncatedTokenWithEmail}, - build_scope_path_predicate, check_scopes, get_scope_tags, maybe_refresh_folders, - require_owner_of_path, ApiAuthed, + build_scope_path_predicate, check_scopes, maybe_refresh_folders, require_owner_of_path, + ApiAuthed, }; use windmill_common::roles::AuthorLevel; use windmill_common::workspaces::{ @@ -134,6 +134,17 @@ async fn list_search_flows( Ok(Json(rows)) } +/// `WHERE` condition matching one label, directly on the flow or inherited from its folder. +/// +/// The template has two `?`, so both are filled in a single `binds` pass. Chaining `bind` +/// would not work: `bind` replaces the first `?` of the string *as it is at that moment*, +/// so the second call would find the `?` that the first call inserted as part of the bound +/// value and substitute there, leaving the value partly outside its quotes. +fn label_condition(label: &str) -> String { + "(o.labels @> ARRAY[?] OR folder_labels(o.workspace_id, o.path) @> ARRAY[?])" + .binds(&[&label.trim()]) +} + async fn list_flows( authed: ApiAuthed, Extension(user_db): Extension, @@ -217,11 +228,7 @@ async fn list_flows( } if let Some(label) = &lq.label { for l in label.split(',') { - sqlb.and_where( - "(o.labels @> ARRAY[?] OR folder_labels(o.workspace_id, o.path) @> ARRAY[?])" - .bind(&l.trim()) - .bind(&l.trim()), - ); + sqlb.and_where(label_condition(l)); } } @@ -643,85 +650,11 @@ pub async fn validate_operator_composed_flow( user_db: &UserDB, w_id: &str, ) -> error::Result<()> { - let mut refs = windmill_common::flows::check_flow_is_composition_only(value)?; - - // A tag is how a step picks the worker group it runs on: unauthorized, a builder could route - // a job onto a privileged one. - refs.tags.extend(flow_tag.clone().filter(|t| !t.is_empty())); - if !refs.tags.is_empty() { - // Job-aware: a WM_TOKEN running as a superadmin must not unlock restricted tags. - let is_super_admin = windmill_api_auth::is_super_admin_authed(db, authed).await?; - for tag in &refs.tags { - windmill_common::jobs::check_tag_available_for_workspace_internal( - db, - w_id, - tag, - None, - std::future::ready(w_id.to_string()), - is_super_admin, - get_scope_tags(authed), - ) - .await?; - } - } - - if refs.runnables.is_empty() && refs.pinned_scripts.is_empty() { - return Ok(()); - } - // A flow can step through the same script thirty times; this runs on every write, preview and - // dependency job. - refs.runnables.sort(); - refs.runnables.dedup(); - refs.pinned_scripts - .sort_by_key(|(path, hash)| (path.clone(), hash.0)); - refs.pinned_scripts - .dedup_by_key(|(path, hash)| (path.clone(), hash.0)); - // The worker resolves a step's path with the root DB handle and runs it as that runnable's - // `on_behalf_of`, so composing an unreadable path would run code the builder cannot see. RLS - // on this transaction is the check. let mut tx = user_db.clone().begin(authed).await?; - for (is_flow, path) in &refs.runnables { - let readable = if *is_flow { - sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2)", - w_id, - path, - ) - } else { - sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2)", - w_id, - path, - ) - } - .fetch_one(&mut *tx) - .await? - .unwrap_or(false); - if !readable { - return Err(Error::PermissionDenied(format!( - "{} {path} does not exist or is not readable by you", - if *is_flow { "Flow" } else { "Script" } - ))); - } - } - // A pinned step is dispatched by its hash alone, ignoring the path beside it, so a readable - // path paired with another script's hash would still run that other script. - for (path, hash) in &refs.pinned_scripts { - let exists = sqlx::query_scalar!( - "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2 AND hash = $3)", - w_id, - path, - hash.0, - ) - .fetch_one(&mut *tx) - .await? - .unwrap_or(false); - if !exists { - return Err(Error::PermissionDenied(format!( - "Version {hash} is not a readable version of {path}" - ))); - } - } + windmill_api_auth::composed::authorize_composed_flow( + value, flow_tag, authed, db, &mut tx, w_id, + ) + .await?; tx.commit().await?; Ok(()) } @@ -2358,6 +2291,31 @@ mod tests { const SECOND: Duration = Duration::from_secs(1); + #[test] + fn label_condition_fills_both_placeholders_in_one_pass() { + // Ordinary label: unchanged behaviour, the value appears quoted in both positions. + assert_eq!( + super::label_condition(" finance "), + "(o.labels @> ARRAY['finance'] OR folder_labels(o.workspace_id, o.path) @> ARRAY['finance'])" + ); + + // A `?` in the label must stay inside the quoted literal in both positions and must + // not leave a placeholder behind. With chained `bind`s this produced + // `ARRAY['a'a?b'b'] ... ARRAY[?]`, i.e. label text outside the quotes. + let cond = super::label_condition("a?b"); + assert_eq!( + cond, + "(o.labels @> ARRAY['a?b'] OR folder_labels(o.workspace_id, o.path) @> ARRAY['a?b'])" + ); + assert!(!cond.contains("ARRAY[?]")); + + // Single quotes keep being escaped by doubling. + assert_eq!( + super::label_condition("a'?b"), + "(o.labels @> ARRAY['a''?b'] OR folder_labels(o.workspace_id, o.path) @> ARRAY['a''?b'])" + ); + } + #[test] fn flowmodule_serde() { let fv = FlowValue { diff --git a/backend/windmill-api-groups/src/groups.rs b/backend/windmill-api-groups/src/groups.rs index 860e486a75..0d3b5e360d 100644 --- a/backend/windmill-api-groups/src/groups.rs +++ b/backend/windmill-api-groups/src/groups.rs @@ -218,12 +218,13 @@ pub async fn require_is_owner( } async fn _check_nb_of_groups(db: &DB) -> Result<()> { - let nb_groups = sqlx::query_scalar!("SELECT COUNT(*) FROM group_ WHERE name != 'all' AND name != 'error_handler' AND name != 'slack' AND name != 'wm_deployers'",) + // Groups that back workspace settings do not count toward the limit. + let nb_groups = sqlx::query_scalar!("SELECT COUNT(*) FROM group_ WHERE name != 'all' AND name != 'error_handler' AND name != 'slack' AND name != 'wm_deployers' AND name != 'variable_expiration_handler'",) .fetch_one(db) .await?; if nb_groups.unwrap_or(0) >= 3 { return Err(Error::BadRequest( - "You have reached the maximum number of groups (3 outside of native groups 'all', 'slack', 'error_handler' and 'wm_deployers') without an enterprise license" + "You have reached the maximum number of groups (3 outside of native groups 'all', 'slack', 'error_handler', 'wm_deployers' and 'variable_expiration_handler') without an enterprise license" .to_string(), )); } diff --git a/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs b/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs index b9c4e60800..6bed27eca5 100644 --- a/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs +++ b/backend/windmill-api-integration-tests/tests/operator_builder_flows.rs @@ -325,5 +325,155 @@ async fn test_operator_builder_flows_boundary(db: Pool) -> anyhow::Res resp.text().await? ); + check_builder_agents(&db, &api, &c).await +} + +/// An agent is a resource an operator may otherwise write, and its stored tools run as they are, +/// so only the flow right opens it, and every write of one, drafts included, is checked like a +/// composed flow. Part of the boundary test: the right is read through a process-global cache +/// keyed by workspace id, which a second test in this binary would race. +async fn check_builder_agents( + db: &Pool, + api: &str, + c: &reqwest::Client, +) -> anyhow::Result<()> { + let tool = |mut value: serde_json::Value| { + value["tool_type"] = json!("flowmodule"); + value["input_transforms"] = json!({}); + json!({"tools": [{"id": "t", "summary": "t", "value": value}]}) + }; + let composed = tool(json!({"type": "script", "path": "u/operator/some_script"})); + let inline = tool(json!({"type": "rawscript", "content": "echo x", "language": "bash"})); + let agent = |path: &str, value: &serde_json::Value| json!({"path": path, "resource_type": "ai_agent", "value": value}); + + set_builder(db, false).await?; + let resp = c + .post(format!("{api}/resources/create")) + .json(&agent("u/operator/a1", &composed)) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "an operator without the builder right must not write an agent" + ); + + set_builder(db, true).await?; + let resp = c + .post(format!("{api}/resources/create")) + .json(&agent("u/operator/a1", &inline)) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a builder must not save an agent with an inline tool" + ); + let resp = c + .post(format!("{api}/resources/create")) + .json(&agent("u/operator/a1", &composed)) + .send() + .await?; + assert_eq!(resp.status(), 201, "{}", resp.text().await?); + let resp = c + .post(format!("{api}/resources/update_value/u/operator/a1")) + .json(&json!({"value": inline})) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a builder must not swap an agent's tools for inline code" + ); + // A draft of a deployed agent may leave its type to the deployed row. + let resp = c + .post(format!("{api}/drafts/update/resource/u/operator/a1")) + .json(&json!({"value": {"args": inline, "description": ""}, "force": true})) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a builder must not draft an agent with an inline tool" + ); + // Deploying a draft over an agent keeps the row's type, whatever type the draft names. + let resp = c + .post(format!("{api}/drafts/update/resource/u/operator/a1")) + .json(&json!({"value": {"args": inline, "description": "", "resource_type": "object"}, + "force": true})) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a builder must not draft inline tools over an agent under another type" + ); + + let linking = |path: &str, agent: &str| { + json!({"path": path, "summary": "", "description": "", "schema": {}, + "value": {"modules": [{"id": "a", "value": {"type": "aiagent", + "input_transforms": {}, "tools": [], "agent": format!("$res:{agent}")}}]}}) + }; + let resp = c + .post(format!("{api}/flows/create")) + .json(&linking("u/operator/agent_f1", "u/operator/a1")) + .send() + .await?; + assert!( + resp.status().is_success(), + "a builder must be able to link a readable agent: {}", + resp.text().await? + ); + let resp = c + .post(format!("{api}/flows/create")) + .json(&linking("u/operator/agent_f2", "u/alice/agent")) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a builder must not link an agent it cannot read" + ); + + // A developer's agent may run inline code, and linking it runs it as authored. A step may bind + // the inputs the agent leaves to the model, never one its author set. + let mut dev_agent = inline.clone(); + dev_agent["tools"][0]["value"]["input_transforms"] = json!({ + "set": {"type": "static", "value": "fixed"}, "open": {"type": "ai"}}); + sqlx::query( + "INSERT INTO resource (workspace_id, path, value, resource_type, created_by) + VALUES ($1, 'u/operator/dev_agent', $2, 'ai_agent', 'alice')", + ) + .bind(WS) + .bind(&dev_agent) + .execute(db) + .await?; + let binding = |path: &str, input: &str| { + let mut flow = linking(path, "u/operator/dev_agent"); + flow["value"]["modules"][0]["value"]["tool_inputs"] = + json!({"t": {input: {"type": "static", "value": "bound"}}}); + flow + }; + let resp = c + .post(format!("{api}/flows/create")) + .json(&binding("u/operator/agent_f4", "open")) + .send() + .await?; + assert!( + resp.status().is_success(), + "a builder must be able to bind an input the agent leaves open: {}", + resp.text().await? + ); + let resp = c + .post(format!("{api}/flows/create")) + .json(&binding("u/operator/agent_f3", "set")) + .send() + .await?; + assert_eq!( + resp.status(), + 403, + "a builder must not override an input the agent's author set" + ); + Ok(()) } diff --git a/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs b/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs index cfbeae7a69..dbc5f36e01 100644 --- a/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs +++ b/backend/windmill-api-integration-tests/tests/operator_manage_rights.rs @@ -406,3 +406,62 @@ async fn test_operators_draft_resources_and_variables(db: Pool) -> any Ok(()) } + +/// The two endpoints that make the API server run `pg_dump`/`psql` against a database a request +/// names are closed to a role that cannot run code. +/// +/// Both polarities on each route: a 403 alone would equally be a route that does not exist or one +/// refusing the source for some other reason, and the admin's 400 shows the gate is about the role +/// rather than the request. The source is deliberately unresolvable, so the gate is the only thing +/// that can answer 403 and no database is ever reached. +#[sqlx::test(migrations = "../migrations", fixtures("base", "permissions_test"))] +#[serial] +async fn test_dumping_a_database_needs_the_code_right(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + windmill_common::roles::invalidate_all_roles_cache(); + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let api = format!("http://localhost:{port}/api/w/{WS}"); + + let cases = [ + ( + "workspaces/export_pg_schema", + json!({"source": "u/operator/not-a-source"}), + "export a database schema", + ), + ( + "workspaces/import_pg_database", + json!({"source": "u/operator/not-a-source", "target": "u/operator/not-a-source", + "fork_behavior": "schema_only"}), + "import a database", + ), + ]; + + for (route, body, action) in cases { + let resp = operator_client() + .post(format!("{api}/{route}")) + .json(&body) + .send() + .await?; + let status = resp.status(); + let text = resp.text().await?; + assert_eq!(status, 403, "{route}: {text}"); + assert!( + text.contains(&format!("Your role cannot {action} in this workspace")), + "{route} refused for another reason: {text}" + ); + + let resp = reqwest::Client::new() + .post(format!("{api}/{route}")) + .header("Authorization", "Bearer SECRET_TOKEN") + .json(&body) + .send() + .await?; + let status = resp.status(); + let text = resp.text().await?; + assert_eq!(status, 400, "{route}: {text}"); + assert!(text.contains("Invalid source format"), "{route}: {text}"); + } + + Ok(()) +} diff --git a/backend/windmill-api-integration-tests/tests/variables.rs b/backend/windmill-api-integration-tests/tests/variables.rs index 7332383936..adb9f29c36 100644 --- a/backend/windmill-api-integration-tests/tests/variables.rs +++ b/backend/windmill-api-integration-tests/tests/variables.rs @@ -339,6 +339,74 @@ async fn test_variables_are_cached(db: Pool) -> anyhow::Result<()> { Ok(()) } +/// A draft-only row is built field by field, so a forgotten field is dropped silently. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_draft_only_row_carries_value_valid_until(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + authed(client().post(format!( + "http://localhost:{port}/api/w/test-workspace/drafts/update/variable/u/test-user/draft_var" + ))) + .json(&json!({ + "value": { + "path": "u/test-user/draft_var", + "variable": { "value": "x", "is_secret": true, "description": "" }, + "wsSpecific": false, + "value_valid_until": "2027-03-15T08:00:00Z" + } + })) + .send() + .await?; + + let resp = authed(client().get(format!( + "http://localhost:{port}/api/w/test-workspace/variables/list?include_draft_only=true" + ))) + .send() + .await?; + assert_eq!(resp.status(), 200); + let rows = resp.json::>().await?; + + let row = rows + .iter() + .find(|v| v["path"] == "u/test-user/draft_var") + .expect("draft-only row is listed"); + assert_eq!(row["draft_only"], true); + assert_eq!(row["value_valid_until"], "2027-03-15T08:00:00Z"); + + Ok(()) +} + +/// An expiry on an OAuth-linked variable is refused with a readable error, not a raw +/// constraint violation. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_oauth_variable_refuses_value_expiry(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + + sqlx::query( + "WITH a AS (INSERT INTO account (workspace_id, expires_at, refresh_token, client) + VALUES ('test-workspace', now() + interval '1 hour', 'r', 'github') RETURNING id) + INSERT INTO variable (workspace_id, path, value, is_secret, description, account, is_oauth) + SELECT 'test-workspace', 'u/test-user/oauth_var', 't', false, '', id, true FROM a", + ) + .execute(&db) + .await?; + + let resp = authed(client().post(format!( + "http://localhost:{port}/api/w/test-workspace/variables/update/u/test-user/oauth_var" + ))) + .json(&json!({ "value_valid_until": "2027-03-15T08:00:00Z" })) + .send() + .await?; + assert_eq!(resp.status(), 400); + assert!(resp.text().await?.contains("OAuth")); + + Ok(()) +} + /// Reading a variable linked to an OAuth account refreshes through it and returns the fresh token, /// so the variable endpoints must not let a caller choose the link. #[sqlx::test(migrations = "../migrations", fixtures("base"))] diff --git a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs index bef3b5dd78..830a29e8c2 100644 --- a/backend/windmill-api-integration-tests/tests/workspace_comparison.rs +++ b/backend/windmill-api-integration-tests/tests/workspace_comparison.rs @@ -2770,3 +2770,83 @@ async fn test_compare_secret_variables_by_value(db: Pool) -> anyhow::R Ok(()) } + +/// `compare_two_variables` hand-lists its columns, so a forgotten one hides that difference +/// from the fork diff, and it can never be promoted. +#[sqlx::test(migrations = "../migrations", fixtures("base"))] +async fn test_compare_variables_detects_value_valid_until(db: Pool) -> anyhow::Result<()> { + initialize_tracing().await; + + let server = ApiServer::start(db.clone()).await?; + let port = server.addr.port(); + let base_url = format!("http://localhost:{port}/api"); + let admin = windmill_api_client::create_client( + &format!("http://localhost:{port}"), + "SECRET_TOKEN".to_string(), + ); + + sqlx::query!( + "INSERT INTO variable (workspace_id, path, value, is_secret, description, value_valid_until) + VALUES ('test-workspace', 'u/admin/expiring', 'v', false, 'same', '2027-01-01T00:00:00Z')" + ) + .execute(&db) + .await?; + + let resp = admin + .client() + .post(&format!( + "{base_url}/w/test-workspace/workspaces/create_fork" + )) + .json(&json!({ "id": "wm-fork-expiry", "name": "Expiry Fork" })) + .send() + .await?; + assert!( + resp.status().is_success(), + "fork creation failed: {}", + resp.status() + ); + + // Before the UPDATE below overwrites it: the fork must inherit the date from the source. + let inherited: Option = sqlx::query_scalar( + "SELECT to_char(value_valid_until, 'YYYY-MM-DD') FROM variable + WHERE workspace_id = 'wm-fork-expiry' AND path = 'u/admin/expiring'", + ) + .fetch_one(&db) + .await?; + assert_eq!(inherited.as_deref(), Some("2027-01-01")); + + // Only the expiry differs. + sqlx::query!( + "UPDATE variable SET value_valid_until = '2029-12-31T00:00:00Z' + WHERE workspace_id = 'wm-fork-expiry' AND path = 'u/admin/expiring'" + ) + .execute(&db) + .await?; + + // Forces the endpoint to re-evaluate the pair rather than trust a cached verdict. + sqlx::query!( + "INSERT INTO workspace_diff + (source_workspace_id, fork_workspace_id, path, kind, ahead, behind, has_changes) + VALUES ('test-workspace', 'wm-fork-expiry', 'u/admin/expiring', 'variable', 0, 1, NULL)" + ) + .execute(&db) + .await?; + + let comparison = admin + .client() + .get(&format!( + "{base_url}/w/test-workspace/workspaces/compare/wm-fork-expiry" + )) + .send() + .await? + .json::() + .await?; + + assert_eq!( + comparison["summary"]["variables_changed"].as_u64(), + Some(1), + "an expiry-only change must show as deployable: {comparison}" + ); + + Ok(()) +} diff --git a/backend/windmill-api-jobs/src/query.rs b/backend/windmill-api-jobs/src/query.rs index 96d8ca3698..ebfc0c3725 100644 --- a/backend/windmill-api-jobs/src/query.rs +++ b/backend/windmill-api-jobs/src/query.rs @@ -268,9 +268,10 @@ pub fn filter_list_queue_query( if let Some(bf) = &lq.broad_filter { let pat = format!("%{}%", escape_ilike_pattern(bf)); + // `binds` fills every `?` in one pass; chained `bind`s would re-scan a `?` in the bound value sqlb.and_where( "(runnable_path ILIKE ? OR v2_job.tag ILIKE ? OR trigger ILIKE ? OR trigger_kind::text ILIKE ?)" - .bind(&pat).bind(&pat).bind(&pat).bind(&pat) + .binds(&[&pat]), ); } @@ -490,7 +491,7 @@ pub fn filter_list_completed_query( } if let Some(dt) = &lq.created_or_started_after { let ts = dt.to_rfc3339(); - sqlb.and_where("(created_at >= ? OR started_at >= ?)".bind(&ts).bind(&ts)); + sqlb.and_where("(created_at >= ? OR started_at >= ?)".binds(&[&ts])); } if let Some(dt) = &lq.created_before { @@ -591,10 +592,11 @@ pub fn filter_list_completed_query( if let Some(bf) = &lq.broad_filter { let pat = format!("%{}%", escape_ilike_pattern(bf)); + // `binds` fills every `?` in one pass; chained `bind`s would re-scan a `?` in the bound value sqlb.and_where( "(runnable_path ILIKE ? OR v2_job.tag ILIKE ? OR trigger ILIKE ? OR trigger_kind::text ILIKE ? \ OR EXISTS (SELECT 1 FROM unnest(v2_job.labels) lbl WHERE lbl ILIKE ?))" - .bind(&pat).bind(&pat).bind(&pat).bind(&pat).bind(&pat) + .binds(&[&pat]), ); } @@ -945,6 +947,20 @@ mod tests { assert!(sql.contains("'schedule'")); } + #[test] + fn test_queue_broad_filter_with_question_mark() { + let lq = ListQueueQuery { broad_filter: Some("a?b".to_string()), ..empty_queue_query() }; + let sqlb = filter_list_queue_query( + SqlBuilder::select_from("v2_job_queue").clone(), + &lq, + "ws", + false, + ); + let sql = build_sql(sqlb); + assert_eq!(sql.matches("ILIKE '%a?b%'").count(), 4); + assert!(!sql.contains("ILIKE ?")); + } + // --- Completed query tests --- #[test] @@ -1124,4 +1140,19 @@ mod tests { let sql = build_sql(sqlb); assert!(sql.contains("result @>")); } + + #[test] + fn test_completed_broad_filter_with_question_mark() { + let lq = + ListCompletedQuery { broad_filter: Some("a?b".to_string()), ..empty_completed_query() }; + let sqlb = filter_list_completed_query( + SqlBuilder::select_from("v2_job_completed").clone(), + &lq, + "ws", + false, + ); + let sql = build_sql(sqlb); + assert_eq!(sql.matches("ILIKE '%a?b%'").count(), 5); + assert!(!sql.contains("ILIKE ?")); + } } diff --git a/backend/windmill-api-schedule/src/lib.rs b/backend/windmill-api-schedule/src/lib.rs index 787742d18d..e2fa7da8fe 100644 --- a/backend/windmill-api-schedule/src/lib.rs +++ b/backend/windmill-api-schedule/src/lib.rs @@ -894,17 +894,18 @@ async fn list_schedule( } if let Some(broad_filter) = &lsq.broad_filter { let pat = format!("%{}%", escape_ilike_pattern(broad_filter)); + // `binds` fills every `?` in one pass; chained `bind`s would re-scan a `?` in the bound value sqlb.and_where( "(path ILIKE ? OR script_path ILIKE ? OR description ILIKE ? OR summary ILIKE ? OR schedule ILIKE ?)" - .bind(&pat).bind(&pat).bind(&pat).bind(&pat).bind(&pat) + .binds(&[&pat]), ); } if let Some(label) = &lsq.label { for l in label.split(',') { + // `binds` fills every `?` in one pass; chained `bind`s would re-scan a `?` in the bound value sqlb.and_where( "(labels @> ARRAY[?] OR folder_labels(workspace_id, path) @> ARRAY[?])" - .bind(&l.trim()) - .bind(&l.trim()), + .binds(&[&l.trim()]), ); } } diff --git a/backend/windmill-api-scripts/src/scripts.rs b/backend/windmill-api-scripts/src/scripts.rs index e6dcfe8155..4e8516b8ed 100644 --- a/backend/windmill-api-scripts/src/scripts.rs +++ b/backend/windmill-api-scripts/src/scripts.rs @@ -309,10 +309,10 @@ async fn list_scripts( } if let Some(label) = &lq.label { for l in label.split(',') { + // `binds` fills every `?` in one pass; chained `bind`s would re-scan a `?` in the bound value sqlb.and_where( "(o.labels @> ARRAY[?] OR folder_labels(o.workspace_id, o.path) @> ARRAY[?])" - .bind(&l.trim()) - .bind(&l.trim()), + .binds(&[&l.trim()]), ); } } diff --git a/backend/windmill-api-workspaces/src/datatable_clone.rs b/backend/windmill-api-workspaces/src/datatable_clone.rs index 1e6c6b55c2..067a248aa7 100644 --- a/backend/windmill-api-workspaces/src/datatable_clone.rs +++ b/backend/windmill-api-workspaces/src/datatable_clone.rs @@ -104,7 +104,7 @@ pub(crate) async fn connection_snapshot( continue; } let row: Option<(String, bool)> = sqlx::query_as( - "SELECT value, is_secret FROM variable WHERE workspace_id = $1 AND path = $2", + "SELECT value::text, is_secret FROM variable WHERE workspace_id = $1 AND path = $2", ) .bind(w_id) .bind(var) diff --git a/backend/windmill-api-workspaces/src/workspaces.rs b/backend/windmill-api-workspaces/src/workspaces.rs index a779c240cc..26d147301d 100644 --- a/backend/windmill-api-workspaces/src/workspaces.rs +++ b/backend/windmill-api-workspaces/src/workspaces.rs @@ -127,6 +127,10 @@ pub fn workspaced_service() -> Router { .route("/billable_seats", get(get_billable_seats)) .route("/edit_error_handler", post(edit_error_handler)) .route("/edit_success_handler", post(edit_success_handler)) + .route( + "/edit_variable_expiration_handler", + post(edit_variable_expiration_handler), + ) .route( "/edit_large_file_storage_config", post(edit_large_file_storage_config), @@ -342,6 +346,8 @@ pub struct WorkspaceSettings { #[serde(skip_serializing_if = "Option::is_none")] pub success_handler: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub variable_expiration_handler: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub public_app_execution_limit_per_minute: Option, pub error_handler_fallback_to_instance_alerts: bool, /// Whether this workspace admits guest sessions (`ExecutionMode::Guest`). An app's @@ -1203,6 +1209,7 @@ async fn get_settings( auto_invite, error_handler, success_handler, + variable_expiration_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, @@ -3359,6 +3366,10 @@ pub(crate) async fn resolve_pg_source_checked_with_kind( let pg: PgDatabase = serde_json::from_value(db_resource) .map_err(|e| Error::internal_err(format!("Failed to parse database credentials: {}", e)))?; + // Checked here rather than at each caller: every source and target of a dump, restore or copy + // comes through this function, and `validate_dbname` only ever reaches a target. The check is + // deliberately the narrow one — see `validate_dbname_not_option_like`. + windmill_common::validate_dbname_not_option_like(&pg.dbname)?; Ok((pg, managed_kind)) } @@ -3448,21 +3459,20 @@ pub(crate) struct PgDumpOptions<'a> { pub(crate) no_acl: bool, } -/// Run pg_dump against a PgDatabase, writing output to a temp file on disk. -/// Returns a DumpFile handle; the file is deleted when the handle is dropped. -pub(crate) async fn pg_dump_database( +/// A pg_dump invocation against `pg_db` writing to `dump_path`, carrying the connection settings +/// the CLI reads from the environment. The returned file, when there is one, holds the root +/// certificate the command points at and must outlive it. +fn pg_dump_command( pg_db: &PgDatabase, opts: PgDumpOptions<'_>, -) -> Result { - let dump_file = DumpFile::new()?; - + dump_path: &std::path::Path, +) -> Result<(tokio::process::Command, Option)> { let host = &pg_db.host; let port = pg_db.port.unwrap_or(5432).to_string(); let user = pg_db.login_name(); - let dbname = &pg_db.dbname; let mut cmd = tokio::process::Command::new("pg_dump"); - cmd.arg("--format=plain").arg("--file").arg(&dump_file.path); + cmd.arg("--format=plain").arg("--file").arg(dump_path); if opts.schema_only { cmd.arg("--schema-only"); } @@ -3480,13 +3490,31 @@ pub(crate) async fn pg_dump_database( .arg("--port") .arg(&port) .arg("--username") - .arg(user) - .arg(dbname); + .arg(user); + + // The name comes from a resource, so it never goes on the command line: as the positional + // argument a name starting with `-` is read as an option of pg_dump (`--file`, + // `--restrict-key`), and as `--dbname` a name holding `=` is expanded as a libpq connection + // string overriding the host, port and user set above. libpq takes the environment variable + // as a plain database name, neither parsed as an option nor expanded. + cmd.env("PGDATABASE", &pg_db.dbname); if let Some(ref password) = pg_db.password { cmd.env("PGPASSWORD", password); } - let _root_cert = apply_pg_tls_env(&mut cmd, pg_db)?; + let root_cert = apply_pg_tls_env(&mut cmd, pg_db)?; + Ok((cmd, root_cert)) +} + +/// Run pg_dump against a PgDatabase, writing output to a temp file on disk. +/// Returns a DumpFile handle; the file is deleted when the handle is dropped. +pub(crate) async fn pg_dump_database( + pg_db: &PgDatabase, + opts: PgDumpOptions<'_>, +) -> Result { + let dump_file = DumpFile::new()?; + + let (mut cmd, _root_cert) = pg_dump_command(pg_db, opts, &dump_file.path)?; let output = cmd .output() @@ -3612,12 +3640,14 @@ fn psql_command(pg_db: &PgDatabase) -> Result<(tokio::process::Command, Option PgDatabase { + PgDatabase { + host: "127.0.0.1".to_string(), + user: None, + password: None, + port: Some(1), + sslmode: None, + dbname: dbname.to_string(), + root_certificate_pem: None, + accept_invalid_certs: None, + use_iam_auth: None, + region: None, + options: None, + } + } + + fn pgdatabase_of(cmd: &std::process::Command) -> Option { + cmd.get_envs() + .find(|(k, _)| *k == "PGDATABASE") + .and_then(|(_, v)| v.map(|v| v.to_os_string())) + } + + #[test] + fn pg_dump_takes_the_dbname_from_the_environment() { + let dump_file = DumpFile::new().unwrap(); + let name = "--file=/tmp/written_by_the_caller"; + let (cmd, _root_cert) = + pg_dump_command(&pg_db(name), PgDumpOptions::default(), &dump_file.path).unwrap(); + let cmd = cmd.as_std(); + assert!( + cmd.get_args().all(|arg| arg != name), + "the dbname is still in pg_dump's argument vector" + ); + assert_eq!(pgdatabase_of(cmd).as_deref(), Some(name.as_ref())); + // The only `--file` pg_dump is given is still Windmill's own. + assert_eq!( + cmd.get_args() + .filter(|arg| arg.to_string_lossy().starts_with("--file")) + .count(), + 1 + ); + } + + #[test] + fn psql_takes_the_dbname_from_the_environment() { + let name = "host=elsewhere"; + let (cmd, _root_cert) = psql_command(&pg_db(name)).unwrap(); + let cmd = cmd.as_std(); + assert!( + cmd.get_args().all(|arg| arg != name), + "the dbname is still in psql's argument vector" + ); + assert_eq!(pgdatabase_of(cmd).as_deref(), Some(name.as_ref())); + } + + /// The same, observed on the real binary: an option-shaped name must reach the connection + /// attempt rather than pg_dump's option parser. Skipped where pg_dump is not installed — + /// which is why the assertion also requires that pg_dump ran at all (our `pg_dump failed:` + /// prefix) instead of only that the option message is absent. + #[tokio::test] + async fn pg_dump_does_not_read_an_option_shaped_dbname_as_an_option() { + if std::process::Command::new("pg_dump") + .arg("--version") + .output() + .is_err() + { + eprintln!("skipped: pg_dump is not installed"); + return; + } + let Err(err) = pg_dump_database(&pg_db("--no-such-option"), PgDumpOptions::default()).await + else { + panic!("pg_dump reached a server on 127.0.0.1:1"); + }; + let err = err.to_string(); + assert!( + err.contains("pg_dump failed:"), + "pg_dump did not run: {err}" + ); + assert!(!err.contains("unrecognized option"), "{err}"); + } +} + /// GUC names the server backing `pg_db` knows about. /// /// Asked through psql rather than a tokio-postgres connection so the lookup reaches @@ -4054,6 +4175,25 @@ fn ensure_copied_without_roles(governing: &GoverningDatatable) -> Result<()> { Ok(()) } +/// Gate for the endpoints that make the API server run `pg_dump`/`psql` against a database a +/// request names. +/// +/// Those two processes are children of the API server, outside any job sandbox and holding its +/// environment, and the dump they exchange is a `psql` script assembled from strings the source +/// server returns — so reaching them is close enough to running code on the server that the role +/// which may not run code at all must not reach them either. `code` is the capability the data +/// table migration endpoints next door already ask for (`datatable_migrations::ensure_not_operator`), +/// and not admin: the database manager offers an export and an import of a user's own Postgres +/// resource to every author, who can run the same `pg_dump` in a job anyway. +async fn ensure_can_dump_databases( + db: &DB, + w_id: &str, + authed: &ApiAuthed, + action: &str, +) -> Result<()> { + windmill_common::workspaces::check_role_can_code(db, w_id, authed, action).await +} + /// Import (pg_dump/pg_import) from source to target async fn import_pg_database( authed: ApiAuthed, @@ -4062,6 +4202,8 @@ async fn import_pg_database( Path(w_id): Path, Json(req): Json, ) -> Result { + ensure_can_dump_databases(&db, &w_id, &authed, "import a database").await?; + if req.fork_behavior == DataTableForkBehavior::KeepOriginal { return Ok("No action needed for KeepOriginal behavior".to_string()); } @@ -4178,6 +4320,8 @@ async fn export_pg_schema( Path(w_id): Path, Json(req): Json, ) -> Result { + ensure_can_dump_databases(&db, &w_id, &authed, "export a database schema").await?; + let pg = resolve_pg_source_checked(&db, &user_db, &authed, &w_id, &req.source).await?; let dump_file = pg_dump_database( &pg, @@ -6253,6 +6397,157 @@ async fn edit_error_handler( Ok(format!("Edit error_handler for workspace {}", &w_id)) } +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EditVariableExpirationHandler { + pub path: Option, + pub extra_args: Option, + #[serde(default)] + pub muted_on_user_path: bool, + pub notify_before_secs: Option, + pub remind_every_secs: Option, +} + +async fn edit_variable_expiration_handler( + authed: ApiAuthed, + Extension(db): Extension, + Path(w_id): Path, + Json(es): Json, +) -> Result { + require_admin(authed.is_admin, &authed.username)?; + + let mut tx = db.begin().await?; + + // Created with no access: an admin grants it write access to each variable it rotates. + sqlx::query_as!( + Group, + "INSERT INTO group_ (workspace_id, name, summary, extra_perms) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING", + w_id, + "variable_expiration_handler", + "The group the variable expiration handler acts on behalf of", + serde_json::json!({username_to_permissioned_as(&authed.username): true}) + ) + .execute(&mut *tx) + .await?; + + if let Some(path) = &es.path { + if es + .extra_args + .as_ref() + .is_some_and(|extra_args| !extra_args.is_object()) + { + return Err(Error::BadRequest( + "Field `extra_args` expected to be JSON object".to_string(), + )); + } + + // Any hub script is left unchecked, so saving never needs the hub. + let exists = if let Some(p) = path.strip_prefix("script/") { + p.starts_with("hub/") + || sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM script WHERE workspace_id = $1 AND path = $2 AND archived = false AND deleted = false)", + w_id, + p + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false) + } else if let Some(p) = path.strip_prefix("flow/") { + sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM flow WHERE workspace_id = $1 AND path = $2 AND archived = false)", + w_id, + p + ) + .fetch_one(&mut *tx) + .await? + .unwrap_or(false) + } else { + return Err(Error::BadRequest(format!( + "Field `path` must start with script/ or flow/ (got {path})" + ))); + }; + if !exists { + return Err(Error::BadRequest(format!( + "Variable expiration handler '{path}' not found. The handler must be an existing, non-archived script or flow." + ))); + } + + // Persisted even when false, like `error_handler`'s. + let mut variable_expiration_handler = serde_json::json!({ + "path": path, + "muted_on_user_path": es.muted_on_user_path, + }); + if let Some(extra_args) = &es.extra_args { + variable_expiration_handler["extra_args"] = extra_args.clone(); + } + if let Some(notify_before_secs) = es.notify_before_secs { + variable_expiration_handler["notify_before_secs"] = notify_before_secs.into(); + } + if let Some(remind_every_secs) = es.remind_every_secs { + variable_expiration_handler["remind_every_secs"] = remind_every_secs.into(); + } + + // A recorded failure is the sweep's: it ends at the handler's next start. + sqlx::query!( + "UPDATE workspace_settings SET variable_expiration_handler = $1 WHERE workspace_id = $2", + variable_expiration_handler, + &w_id + ) + .execute(&mut *tx) + .await?; + } else { + sqlx::query!( + "UPDATE workspace_settings SET variable_expiration_handler = NULL WHERE workspace_id = $1", + &w_id + ) + .execute(&mut *tx) + .await?; + } + + audit_log( + &mut *tx, + &authed, + "workspaces.edit_variable_expiration_handler", + ActionKind::Update, + &w_id, + Some(&authed.email), + Some([("variable_expiration_handler", &format!("{:?}", es.path)[..])].into()), + ) + .await?; + tx.commit().await?; + + handle_deployment_metadata( + &authed.email, + &authed.username, + &db, + &w_id, + windmill_git_sync::DeployedObject::Settings { + setting_type: "variable_expiration_handler".to_string(), + }, + Some("Variable expiration handler configuration updated".to_string()), + false, + None, + ) + .await?; + + windmill_common::feature_usage::log_feature_usage( + "variable_expiration", + "handler_configured", + // By slug, not hub id, so a bumped hub version still counts as the built-in. + match es.path.as_deref() { + None => "cleared", + Some(p) if p.ends_with("/variable-expiration-handler-slack") => "slack", + Some(p) if p.ends_with("/variable-expiration-handler-teams") => "teams", + Some(_) => "custom", + }, + ); + + Ok(format!( + "Edit variable_expiration_handler for workspace {}", + &w_id + )) +} + async fn edit_success_handler( authed: ApiAuthed, Extension(db): Extension, @@ -6524,7 +6819,7 @@ async fn set_encryption_key( ); let all_variables = sqlx::query!( - "SELECT path, value, is_secret FROM variable WHERE workspace_id = $1", + "SELECT path, value::text AS \"value!\", is_secret FROM variable WHERE workspace_id = $1", w_id ) .fetch_all(&mut *tx) @@ -7925,9 +8220,10 @@ async fn clone_variables( source_workspace_id: &str, target_workspace_id: &str, ) -> Result<()> { + // Dispatch markers are not copied, so the fork re-arms its own handler. sqlx::query!( - "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at) - SELECT $2, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at + "INSERT INTO variable (workspace_id, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at, value_valid_until) + SELECT $2, path, value, is_secret, description, extra_perms, account, is_oauth, expires_at, value_valid_until FROM variable WHERE workspace_id = $1", source_workspace_id, @@ -14464,7 +14760,7 @@ async fn compare_two_variables( // Get variable from each workspace let source_variable = sqlx::query!( - "SELECT value, is_secret, description + "SELECT value::text AS \"value!\", is_secret, description, value_valid_until FROM variable WHERE workspace_id = $1 AND path = $2", source_workspace_id, @@ -14474,7 +14770,7 @@ async fn compare_two_variables( .await?; let target_variable = sqlx::query!( - "SELECT value, is_secret, description + "SELECT value::text AS \"value!\", is_secret, description, value_valid_until FROM variable WHERE workspace_id = $1 AND path = $2", fork_workspace_id, @@ -14487,7 +14783,10 @@ async fn compare_two_variables( // Check metadata differences if let (Some(source), Some(target)) = (&source_variable, &target_variable) { - if source.is_secret != target.is_secret || source.description != target.description { + if source.is_secret != target.is_secret + || source.description != target.description + || source.value_valid_until != target.value_valid_until + { has_changes = true; } else if source.value != target.value || (source.is_secret && is_external_stored_value(&source.value)) @@ -15316,6 +15615,7 @@ pub enum SettingsEdit { InstanceGroups, ErrorHandler, SuccessHandler, + VariableExpirationHandler, LargeFileStorage, GitSync, DefaultScripts, @@ -15368,6 +15668,9 @@ pub async fn apply_settings_edit( InstanceGroups => edit_instance_groups(a, d, p, settings_edit_body(edit, body)?).await?, ErrorHandler => edit_error_handler(a, d, p, settings_edit_body(edit, body)?).await?, SuccessHandler => edit_success_handler(a, d, p, settings_edit_body(edit, body)?).await?, + VariableExpirationHandler => { + edit_variable_expiration_handler(a, d, p, settings_edit_body(edit, body)?).await? + } LargeFileStorage => { edit_large_file_storage_config(a, d, p, settings_edit_body(edit, body)?).await? } diff --git a/backend/windmill-api-workspaces/src/workspaces_extra.rs b/backend/windmill-api-workspaces/src/workspaces_extra.rs index 64aa5c6a08..aea887624d 100644 --- a/backend/windmill-api-workspaces/src/workspaces_extra.rs +++ b/backend/windmill-api-workspaces/src/workspaces_extra.rs @@ -135,7 +135,7 @@ pub(crate) async fn change_workspace_id( // Duplicate workspace settings (keep copy in old workspace for reference) info!("Duplicating workspace_settings table"); sqlx::query!( - "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at FROM workspace_settings WHERE workspace_id = $2", + "INSERT INTO workspace_settings (workspace_id, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler) SELECT $1, slack_team_id, slack_name, slack_command_script, slack_email, customer_id, plan, webhook, ai_config, large_file_storage, git_sync, default_app, default_scripts, deploy_ui, mute_critical_alerts, color, operator_settings, role_overrides, teams_command_script, teams_team_id, teams_team_name, git_app_installations, git_credentials, ducklake, dbt_warehouses, slack_oauth_client_id, slack_oauth_client_secret, slack_signing_secret, datatable, teams_team_guid, auto_invite, error_handler, success_handler, public_app_execution_limit_per_minute, error_handler_fallback_to_instance_alerts, guest_access_enabled, guest_jwt_public_key, guest_jwt_jwks_url, add_admins_and_developers_to_forks, remote_deploy_target, remote_deploy_target_changed_at, variable_expiration_handler FROM workspace_settings WHERE workspace_id = $2", &rw.new_id, &old_id ) diff --git a/backend/windmill-api/openapi.yaml b/backend/windmill-api/openapi.yaml index e4d9c709a5..3ba85557b7 100644 --- a/backend/windmill-api/openapi.yaml +++ b/backend/windmill-api/openapi.yaml @@ -1,7 +1,7 @@ openapi: "3.0.3" info: - version: 1.847.0 + version: 1.849.0 title: Windmill API contact: @@ -4366,6 +4366,8 @@ paths: $ref: "#/components/schemas/ErrorHandlerConfig" success_handler: $ref: "#/components/schemas/SuccessHandlerConfig" + variable_expiration_handler: + $ref: "#/components/schemas/VariableExpirationHandlerConfig" large_file_storage: $ref: "#/components/schemas/LargeFileStorage" ducklake: @@ -5432,6 +5434,29 @@ paths: schema: type: string + /w/{workspace}/workspaces/edit_variable_expiration_handler: + post: + summary: edit variable expiration handler + operationId: editVariableExpirationHandler + tags: + - workspace + parameters: + - $ref: "#/components/parameters/WorkspaceId" + requestBody: + description: WorkspaceVariableExpirationHandler + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/EditVariableExpirationHandler" + responses: + "200": + description: status + content: + text/plain: + schema: + type: string + /w/{workspace}/workspaces/edit_success_handler: post: summary: edit success handler @@ -8837,6 +8862,11 @@ paths: type: string state: type: string + params: + type: object + description: The callback URL's other query params, which the provider's `connect_fields` may read (QuickBooks' `realmId`) + additionalProperties: + type: string required: - code - state @@ -15791,6 +15821,10 @@ paths: value: {} policy: $ref: "#/components/schemas/Policy" + execution_mode: + type: string + enum: [viewer, publisher, guest, anonymous] + description: "Changes who may open the app and leaves the rest of the deployed policy, its run-as user included, as it is. Applied under the same lock as a deploy, so neither overwrites the other. Cannot be sent with `policy`, which states its own mode. When the app runs as another user, refused to a caller who is neither an admin nor in 'wm_deployers', and to an operator with builder rights even in 'wm_deployers'; they change the mode by deploying." deployment_message: type: string custom_path: @@ -24699,6 +24733,56 @@ paths: items: $ref: "#/components/schemas/AutoscalingEvent" + /configs/test_autoscaling/{worker_group}: + post: + summary: Run an autoscaling integration once for a worker group, without recording an autoscaling event + operationId: testAutoscaling + tags: + - config + parameters: + - name: worker_group + in: path + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + desired_workers: + type: integer + minimum: 0 + integration: + description: The integration to run, as in the worker group's autoscaling config + type: object + properties: + type: + type: string + enum: [script, kubernetes, dryrun] + path: + type: string + tag: + type: string + required: + - type + required: + - desired_workers + - integration + responses: + "200": + description: The job queued for a script integration + content: + application/json: + schema: + type: object + properties: + job_id: + type: string + nullable: true + /configs/native_kubernetes_autoscaling_healthcheck: get: summary: Check Kubernetes autoscaling health for a worker group @@ -29832,6 +29916,45 @@ components: type: boolean default: false + EditVariableExpirationHandler: + type: object + description: > + Request body for editing the workspace variable expiration handler. Omit + `path` (or send null) to unset the handler. + properties: + path: + type: string + nullable: true + description: Path to the variable expiration handler script or flow + extra_args: + $ref: "#/components/schemas/ScriptArgs" + muted_on_user_path: + type: boolean + description: Do not run the handler for variables on a personal (u/) path + notify_before_secs: + type: integer + minimum: 0 + description: How long before a variable's value expires the handler runs. 1 day when omitted. + remind_every_secs: + type: integer + minimum: 0 + description: Run the handler again at this interval until the value expires. No reminders when omitted or 0. + + VariableExpirationHandlerConfig: + type: object + description: Workspace variable expiration handler configuration + properties: + path: + type: string + extra_args: + $ref: "#/components/schemas/ScriptArgs" + muted_on_user_path: + type: boolean + notify_before_secs: + type: integer + remind_every_secs: + type: integer + EditSuccessHandler: description: Request body for editing the workspace success handler. Accepts both new grouped format and legacy flat format for backward compatibility. oneOf: @@ -32613,6 +32736,10 @@ components: expires_at: type: string format: date-time + value_valid_until: + type: string + nullable: true + format: date-time labels: type: array items: @@ -32689,7 +32816,18 @@ components: description: Whether the variable is an OAuth variable expires_at: type: string - description: The expiration date of the variable + description: > + When to garbage-collect the variable. Used for ephemeral secrets; + the variable is deleted once this is reached. Not the value's expiry + — see value_valid_until. + format: date-time + value_valid_until: + type: string + description: > + When the value stored in this variable stops working. Triggers the + workspace's variable expiration handler ahead of it, by the handler's + `notify_before_secs` (1 day by default); never causes the variable + to be deleted. format: date-time labels: type: array @@ -32718,6 +32856,14 @@ components: description: type: string description: The new description of the variable + value_valid_until: + type: string + nullable: true + description: > + When the value stored in this variable stops working. Omit to leave + it unchanged; send null to clear it. Changing it re-arms the + workspace's variable expiration handler. + format: date-time labels: type: array items: @@ -32825,6 +32971,7 @@ components: - "workspaces.edit_webhook" - "workspaces.edit_copilot_config" - "workspaces.edit_error_handler" + - "workspaces.edit_variable_expiration_handler" - "workspaces.create" - "workspaces.update" - "workspaces.archive" @@ -37934,6 +38081,13 @@ components: type: string grant_type: type: string + resource_fields: + type: object + additionalProperties: {} + description: > + Resource fields the provider's `connect_fields` picked on connect + (Salesforce's instance_url, Docusign's account_id and base_uri, + QuickBooks' realmId), for the connect dialog to prefill. required: - access_token diff --git a/backend/windmill-api/src/ai.rs b/backend/windmill-api/src/ai.rs index 8de5e16c11..213a41b4eb 100644 --- a/backend/windmill-api/src/ai.rs +++ b/backend/windmill-api/src/ai.rs @@ -24,7 +24,10 @@ use windmill_ai::ai_providers::{ empty_string_as_none, AIPlatform, AIProvider, ProviderConfig, ProviderModel, }; use windmill_ai::ai_types::{validate_token_limit, CONTEXT_WINDOWS, MAX_MODEL_RATE, OUTPUT_LIMITS}; -use windmill_ai::credentials::ProviderCredentials; +use windmill_ai::credentials::{AnthropicFederation, ProviderCredentials}; +use windmill_ai::providers::anthropic::{ + exchange_federated_token, FederatedAccessToken, ANTHROPIC_API_BASE_URL, +}; #[cfg(feature = "bedrock")] use windmill_ai::providers::bedrock::{ handle_bedrock_proxy, BedrockProxyResponse, BedrockProxyResponseBody, @@ -119,6 +122,10 @@ lazy_static::lazy_static! { pub static ref AI_REQUEST_CACHE: Cache<(String, AIProvider), ExpiringProviderCredentials> = Cache::new(500); + /// Anthropic access tokens from a federated exchange, keyed by (workspace, + /// federation settings, user email). Like BEDROCK_ASSUMED_ROLE_CACHE, an entry + /// is never handed to another user than the one the OIDC token named. + static ref ANTHROPIC_FEDERATED_TOKEN_CACHE: Cache<(String, AnthropicFederation, String), FederatedAccessToken> = Cache::new(500); } #[cfg(feature = "bedrock")] @@ -132,6 +139,8 @@ lazy_static::lazy_static! { pub(crate) fn invalidate_ai_request_cache_for_workspace(workspace_id: &str) { AI_REQUEST_CACHE.retain(|(cached_workspace_id, _), _| cached_workspace_id != workspace_id); + ANTHROPIC_FEDERATED_TOKEN_CACHE + .retain(|(cached_workspace_id, _, _), _| cached_workspace_id != workspace_id); // Dropped alongside the credentials that named the role, so that changing a // workspace's AI settings does not leave a session assumed under the old ones // in play until STS expires it. @@ -247,10 +256,33 @@ struct OAuthTokens { access_token: String, } +/// An `anthropic_federated` resource: the Claude Console federation rule, +/// organization, service account and (for a rule enabled in several workspaces) +/// workspace to exchange the requesting user's OIDC token against. +/// +/// It has no base URL on purpose. The exchanged token is the identity of whoever +/// uses the resource, not of whoever wrote it, so a configurable host would let +/// the resource's author collect other users' tokens. +/// +/// Only `federation_rule_id` decides that a value is one: the other fields are +/// optional here and checked afterwards, so a resource missing one is reported +/// rather than read as a standard resource with no key. +#[derive(Deserialize, Debug)] +struct AIAnthropicFederatedResource { + federation_rule_id: String, + #[serde(default)] + organization_id: String, + #[serde(default)] + service_account_id: String, + #[serde(default, deserialize_with = "empty_string_as_none")] + workspace_id: Option, +} + #[derive(Deserialize, Debug)] #[serde(untagged)] enum AIResource { OAuth(AIOAuthResource), + AnthropicFederated(AIAnthropicFederatedResource), Standard(AIStandardResource), } @@ -345,10 +377,50 @@ async fn resolve_provider_credentials( aws_secret_access_key, aws_session_token, oidc_role_arn, + anthropic_federation: None, platform: resource.platform, custom_headers: resource.headers, }) } + AIResource::AnthropicFederated(resource) => { + if *provider != AIProvider::Anthropic { + return Err(Error::BadRequest(format!( + "An anthropic_federated resource can only be used for the Anthropic provider, not {provider:?}" + ))); + } + let required = [ + ("federation_rule_id", &resource.federation_rule_id), + ("organization_id", &resource.organization_id), + ("service_account_id", &resource.service_account_id), + ]; + if let Some((field, _)) = required.iter().find(|(_, value)| value.is_empty()) { + return Err(Error::BadRequest(format!( + "The anthropic_federated resource needs {field} to be set" + ))); + } + + Ok(ProviderCredentials { + provider: provider.clone(), + base_url: ANTHROPIC_API_BASE_URL.to_string(), + api_key: None, + access_token: None, + organization_id: None, + user: None, + region: None, + aws_access_key_id: None, + aws_secret_access_key: None, + aws_session_token: None, + oidc_role_arn: None, + anthropic_federation: Some(AnthropicFederation { + federation_rule_id: resource.federation_rule_id, + organization_id: resource.organization_id, + service_account_id: resource.service_account_id, + workspace_id: resource.workspace_id, + }), + platform: AIPlatform::Standard, + custom_headers: HashMap::new(), + }) + } AIResource::OAuth(resource) => { let user = if let Some(user) = resource.user.clone() { Some(resolve_var(user, db, w_id, user_db.as_ref(), authed).await?) @@ -370,6 +442,7 @@ async fn resolve_provider_credentials( aws_secret_access_key: None, aws_session_token: None, oidc_role_arn: None, + anthropic_federation: None, platform: AIPlatform::Standard, custom_headers: HashMap::new(), }) @@ -522,6 +595,73 @@ async fn assume_bedrock_role( } } +/// Exchange an OIDC token minted for the requesting user for an Anthropic access +/// token, in place, when the resource is set up for Workload Identity Federation. +/// +/// Runs after the workspace credential cache for the same reason the Bedrock +/// exchange does: the token's `sub` names the user, and is what a federation rule +/// matches and the Claude Console's authentication history records. +async fn resolve_anthropic_federated_credentials( + db: &DB, + w_id: &str, + authed: &ApiAuthed, + credentials: &mut ProviderCredentials, +) -> Result<()> { + let Some(federation) = credentials.anthropic_federation.as_ref() else { + return Ok(()); + }; + + let cache_key = (w_id.to_string(), federation.clone(), authed.email.clone()); + let token = match ANTHROPIC_FEDERATED_TOKEN_CACHE + .get(&cache_key) + .filter(FederatedAccessToken::is_fresh) + { + Some(cached) => cached, + None => { + let assertion = mint_anthropic_oidc_token(db, w_id, authed).await?; + let client = pinned_ai_client_for(&credentials.base_url).await?; + let token = + exchange_federated_token(&client, &credentials.base_url, federation, &assertion) + .await?; + ANTHROPIC_FEDERATED_TOKEN_CACHE.insert(cache_key, token.clone()); + token + } + }; + + credentials.access_token = Some(token.access_token); + Ok(()) +} + +async fn mint_anthropic_oidc_token(db: &DB, w_id: &str, authed: &ApiAuthed) -> Result { + #[cfg(all(feature = "enterprise", feature = "openidconnect", feature = "private"))] + { + use windmill_ai::providers::anthropic::{ + ANTHROPIC_OIDC_AUDIENCE, ANTHROPIC_OIDC_TOKEN_LIFETIME_SECS, + }; + use windmill_common::oidc_oss::{generate_id_token, OidcCaller, WorkspaceClaim}; + + let id_token = generate_id_token( + OidcCaller::Anthropic, + Some(db), + WorkspaceClaim { workspace: w_id.to_string() }, + ANTHROPIC_OIDC_AUDIENCE, + format!("{}::{}", authed.email, w_id), + Some(authed.email.clone()), + Some(ANTHROPIC_OIDC_TOKEN_LIFETIME_SECS), + ) + .await?; + Ok(id_token.to_string()) + } + #[cfg(not(all(feature = "enterprise", feature = "openidconnect", feature = "private")))] + { + let _ = (db, w_id, authed); + Err(Error::BadRequest( + "Anthropic Workload Identity Federation through Windmill's OIDC provider requires an enterprise license" + .to_string(), + )) + } +} + #[derive(Clone, Debug)] pub struct ExpiringProviderCredentials { credentials: ProviderCredentials, @@ -1129,6 +1269,7 @@ async fn global_proxy( aws_secret_access_key: None, aws_session_token: None, oidc_role_arn: None, + anthropic_federation: None, platform: AIPlatform::Standard, custom_headers: HashMap::new(), }; @@ -1435,6 +1576,7 @@ async fn proxy( #[cfg(feature = "bedrock")] resolve_bedrock_oidc_credentials(&db, &w_id, &authed, &mut credentials).await?; + resolve_anthropic_federated_credentials(&db, &w_id, &authed, &mut credentials).await?; // Free tier: pin the model and clamp max_tokens server-side before forwarding, // since the request body is otherwise client-controlled. @@ -1643,11 +1785,41 @@ mod tests { aws_secret_access_key: None, aws_session_token: None, oidc_role_arn: None, + anthropic_federation: None, platform: AIPlatform::Standard, custom_headers: HashMap::new(), } } + #[test] + fn only_a_federation_rule_makes_a_resource_anthropic_federated() { + let federated = serde_json::json!({ + "federation_rule_id": "fdrl_x", + "organization_id": "00000000-0000-0000-0000-000000000000", + "service_account_id": "svac_x", + "workspace_id": "" + }); + assert!(matches!( + serde_json::from_value::(federated), + Ok(AIResource::AnthropicFederated( + AIAnthropicFederatedResource { workspace_id: None, .. } + )) + )); + + let incomplete = serde_json::json!({"federation_rule_id": "fdrl_x"}); + assert!(matches!( + serde_json::from_value::(incomplete), + Ok(AIResource::AnthropicFederated(_)) + )); + + // `organization_id` is also the OpenAI organization of a standard resource. + let standard = serde_json::json!({"api_key": "k", "organization_id": "org-x"}); + assert!(matches!( + serde_json::from_value::(standard), + Ok(AIResource::Standard(_)) + )); + } + #[test] fn ai_standard_resource_ignores_legacy_enable_1m_context_keys() { // Resources created before the field was removed still carry the legacy key diff --git a/backend/windmill-api/src/apps.rs b/backend/windmill-api/src/apps.rs index bc0124b685..cdc66496c0 100644 --- a/backend/windmill-api/src/apps.rs +++ b/backend/windmill-api/src/apps.rs @@ -66,8 +66,8 @@ use windmill_common::{ db::{DbWithOptAuthed, UserDB}, error::{to_anyhow, Error, JsonResult, Result}, jobs::{ - get_payload_tag_from_prefixed_path, resolve_delete_after_secs, schedule_job_deletion, - JobPayload, JobTriggerKind, RawCode, + get_payload_tag_from_prefixed_path, normalize_nul_in_args, resolve_delete_after_secs, + sanitize_untrusted_args, schedule_job_deletion, JobPayload, JobTriggerKind, RawCode, }, scripts::ScriptLang, triggers::TriggerMetadata, @@ -602,6 +602,11 @@ pub struct EditApp { pub summary: Option, pub value: Option>>, pub policy: Option, + /// Changes only who may open the app: applied to the deployed policy under the row lock a + /// deploy takes, so it neither reverts a concurrent deploy's policy nor is reverted by it. + /// Exclusive with `policy`, which states its own mode. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub execution_mode: Option, pub deployment_message: Option, pub custom_path: Option, pub preserve_on_behalf_of: Option, @@ -735,10 +740,10 @@ async fn list_apps( if let Some(label) = &lq.label { for l in label.split(',') { + // `binds` fills every `?` in one pass; chained `bind`s would re-scan a `?` in the bound value sqlb.and_where( "(app.labels @> ARRAY[?] OR folder_labels(app.workspace_id, app.path) @> ARRAY[?])" - .bind(&l.trim()) - .bind(&l.trim()), + .binds(&[&l.trim()]), ); } } @@ -2366,18 +2371,12 @@ fn check_operator_composed_app( // Viewer mode would make the triggerables collected above non-exhaustive (see the docs), and // the deploy panel shows it as members-only, so an admin deploying a draft would not see it. - // Pin an omitted mode on deploy: `update_app_internal` resolves it to the deployed app's, so a - // redeploy over an admin's viewer-mode app would otherwise inherit `Viewer`. - match policy.stated_execution_mode() { - Some(ExecutionMode::Viewer) => { - return Err(Error::PermissionDenied(format!( - "Operators with builder rights cannot {verb} an app that runs as its viewer" - ))) - } - None if write == BuilderAppWrite::Deploy => { - policy.set_execution_mode(ExecutionMode::Publisher) - } - _ => {} + // An omitted mode is resolved by the deploy: create's default, or on update the deployed mode, + // which `update_app_internal` turns into `Publisher` for a builder when it is `Viewer`. + if policy.stated_execution_mode() == Some(ExecutionMode::Viewer) { + return Err(Error::PermissionDenied(format!( + "Operators with builder rights cannot {verb} an app that runs as its viewer" + ))); } referenced.sort(); @@ -2473,15 +2472,7 @@ pub(crate) async fn validate_operator_composed_app( // docs/operator-builder-rights.md); a draft keeps whatever it names, which an admin's // one-click draft deploy preserves without showing it. if let (BuilderAppWrite::Draft, Some(policy)) = (write, policy.as_deref()) { - let names_another = policy - .on_behalf_of - .as_deref() - .is_some_and(|p| p != username_to_permissioned_as(&authed.username)) - || policy - .on_behalf_of_email - .as_deref() - .is_some_and(|e| e != authed.email); - if names_another { + if policy_runs_as_another(policy, authed) { return Err(Error::PermissionDenied( "Operators with builder rights cannot save an app that runs as someone else" .to_string(), @@ -2492,6 +2483,17 @@ pub(crate) async fn validate_operator_composed_app( require_runnables_readable(authed, db, user_db, w_id, referenced).await } +fn policy_runs_as_another(policy: &Policy, authed: &ApiAuthed) -> bool { + policy + .on_behalf_of + .as_deref() + .is_some_and(|p| p != username_to_permissioned_as(&authed.username)) + || policy + .on_behalf_of_email + .as_deref() + .is_some_and(|e| e != authed.email) +} + async fn require_runnables_readable( authed: &ApiAuthed, db: &DB, @@ -3809,10 +3811,22 @@ async fn update_app_internal<'a>( } } - if caller_capabilities(&db, w_id, &authed).await?.apps == AuthorLevel::Compose { + if ns.policy.is_some() && ns.execution_mode.is_some() { + return Err(Error::BadRequest( + "Send execution_mode either inside the policy or alone, not both".to_string(), + )); + } + // The policy a mode-only change writes is only known under the row lock, so a builder's + // check on it runs there. + let mode_only = ns.policy.is_none() && ns.value.is_none() && ns.execution_mode.is_some(); + + let composes_only = caller_capabilities(&db, w_id, &authed).await?.apps == AuthorLevel::Compose; + if composes_only { // Restoring a full-code version always sends allow_kind_change. A builder converts // nothing, so drop it and let the locked kind check below refuse an actual conversion. ns.allow_kind_change = None; + } + if composes_only && !mode_only { validate_operator_composed_app( &authed, &db, @@ -3890,6 +3904,7 @@ async fn update_app_internal<'a>( || ns.summary.is_some() || ns.custom_path.is_some() || ns.labels.is_some() + || ns.execution_mode.is_some() // A source deploy may send nothing but the value, and its runnables are // what the derived grants describe: skipping the UPDATE here would keep // the grants keyed to the sources this deploy just replaced. @@ -3907,7 +3922,12 @@ async fn update_app_internal<'a>( // that carries no policy keeps the deployed mode, read under the row // lock so a policy update landing alongside cannot slip a guest app // onto a path it cannot be scoped to. - let mode = match ns.policy.as_ref().and_then(|p| p.stated_execution_mode()) { + let stated = ns + .policy + .as_ref() + .and_then(|p| p.stated_execution_mode()) + .or(ns.execution_mode); + let mode = match stated { Some(mode) => mode, None => sqlx::query_scalar::<_, Option>( "SELECT policy->>'execution_mode' FROM app @@ -3987,12 +4007,13 @@ async fn update_app_internal<'a>( // no policy at all, in which case the rest of the deployed one carries // over. Either way a policy is about to be written wholesale. let caller_sent_policy = ns.policy.is_some(); - if caller_sent_policy || derived_triggerables.is_some() { + if caller_sent_policy || derived_triggerables.is_some() || ns.execution_mode.is_some() { // One locked read serving everything below: the mode an omitted one // inherits, the already-anonymous check, and the policy a source - // deploy carries over. FOR UPDATE holds the row until this - // transaction's UPDATE commits, so a policy edit landing between the - // read and the write cannot be clobbered by this stale snapshot. + // deploy or a mode-only change carries over. FOR UPDATE holds the row + // until this transaction's UPDATE commits, so a policy edit landing + // between the read and the write cannot be clobbered by this stale + // snapshot. let deployed = sqlx::query_scalar!( "SELECT policy FROM app WHERE path = $1 AND workspace_id = $2 FOR UPDATE", path, @@ -4006,30 +4027,81 @@ async fn update_app_internal<'a>( let mut npolicy = match ns.policy.take() { Some(npolicy) => npolicy, + None if deployed.is_none() => { + return Err(Error::NotFound(format!("App {path} not found"))) + } // Carrying the deployed policy forward is only safe if all of it // survived the round trip; a partial one would silently drop // `on_behalf_of`, sandboxing or S3 rules. None => deployed_policy.clone().ok_or_else(|| { - Error::internal_err(format!( - "app {path} has no readable policy to deploy its new sources under" - )) + Error::internal_err(format!("app {path} has no readable policy to update")) })?, }; if let Some(triggerables) = derived_triggerables { npolicy.triggerables = None; npolicy.triggerables_v2 = Some(triggerables); } + if let Some(mode) = ns.execution_mode { + // A policy write from someone who cannot keep the run-as user rebinds it + // to them, so the mode alone must not let them open an app running as + // someone else to a new audience. A builder who may keep it is refused too: + // its deploy checks the triggerables against its own RLS, which needs a + // second pool connection and so cannot run under this lock. + if policy_runs_as_another(&npolicy, &authed) + && (composes_only || !windmill_common::can_preserve_on_behalf_of(&authed)) + { + let runs_as = npolicy + .on_behalf_of_email + .as_deref() + .or(npolicy.on_behalf_of.as_deref()) + .unwrap_or("another user"); + return Err(Error::PermissionDenied(if composes_only { + format!( + "app {path} runs as {runs_as}: change who can open it by deploying it" + ) + } else { + format!( + "app {path} runs as {runs_as}: only admins and members of \ + wm_deployers can change who can open it without deploying it, and a \ + deploy from you makes it run as you" + ) + })); + } + npolicy.set_execution_mode(mode); + if composes_only { + // The check below sandboxes an app whose policy leaves `sandbox` unset; a + // mode-only change keeps every other field, so that is left to a deploy. + if npolicy.sandbox != Some(true) { + return Err(Error::PermissionDenied(format!( + "app {path} is not sandboxed: change who can open it by deploying it" + ))); + } + check_operator_composed_app( + BuilderAppWrite::Deploy, + raw_app, + None, + Some(&mut npolicy), + )?; + } + } validate_frontend_sdk_scopes(&npolicy)?; // The policy is written wholesale, so one that states no mode would // otherwise re-permission the app to the create-time default: a // `viewer` app would start running on the publisher's identity. Keep - // the deployed mode instead, and make the stored policy state it. + // the deployed mode instead, and make the stored policy state it. A + // builder may not deploy `Viewer` (see `check_operator_composed_app`), + // so a builder's redeploy over a viewer-mode app makes it `Publisher`. if npolicy.stated_execution_mode().is_none() { + let deployed_mode = deployed_policy + .as_ref() + .map(|d| d.execution_mode()) + .unwrap_or_default(); npolicy.set_execution_mode( - deployed_policy - .as_ref() - .map(|d| d.execution_mode()) - .unwrap_or_default(), + if composes_only && deployed_mode == ExecutionMode::Viewer { + ExecutionMode::Publisher + } else { + deployed_mode + }, ); } refuse_unscopable_guest_app( @@ -4155,7 +4227,8 @@ async fn update_app_internal<'a>( // CLI / git-sync deploys ask us to preserve any existing user draft at this // path instead of wiping it as part of the deploy. Only wipe the deployer's // own draft (plus the legacy NULL-email row) — see create_app_internal. - if !ns.skip_draft_deletion.unwrap_or(false) { + // A mode-only change publishes nothing from the draft, so it keeps it. + if !ns.skip_draft_deletion.unwrap_or(false) && !mode_only { sqlx::query!( "DELETE FROM draft WHERE path = $1 AND workspace_id = $2 AND typ IN ('app', 'raw_app') \ AND (email = $3 OR email IS NULL)", @@ -4890,11 +4963,15 @@ async fn execute_component( let resolved_delete_secs = resolve_delete_after_secs(None, policy_triggerables.delete_after_secs); - // `_TEMP_SCRIPT_REFS` is a server-injected control key (into `extra`) that the - // worker reads back for a `Preview` job, which an inline run is. A caller - // supplying it in `args` would redirect relative-import resolution, unpinned, as - // the app identity. Drop it; the legitimate value rides in `extra`. - payload.args.remove("_TEMP_SCRIPT_REFS"); + // The caller here is a viewer of the app, down to an anonymous visitor of a public one, + // and the job runs as the app's identity on code the policy pins. So the viewer fills in + // the runnable's inputs and nothing else: not the function called instead of `main` + // (which would reach helpers the app never exposes, as the publisher), not the module + // files written into the job directory, and not `_TEMP_SCRIPT_REFS`, which would + // redirect relative-import resolution, unpinned, as the app identity. The legitimate + // `_TEMP_SCRIPT_REFS` is server-injected into `extra` further below, which this leaves + // alone — as it does the policy's own `static_inputs`, which the publisher writes. + sanitize_untrusted_args(&mut payload.args); // An agent runnable takes the message and its files, plus optionally the conversation they // belong to, which names the agent's memory rather than an input of its run. Without it the @@ -6567,6 +6644,16 @@ async fn build_args( let mut job_id: Option = None; let mut safe_args = HashMap::>::new(); + // Every check below matches an argument by name against the policy the publisher wrote, + // and `push` stores the args with their `\u0000` escapes removed, names included. So + // compare the names that will actually be stored: otherwise a viewer's `tenant\0` is + // never recognized as the `tenant` the policy pins in `static_inputs` (and is stored as + // `tenant`, winning over the pinned value), and `greeting\0` is never recognized as the + // `greeting` restricted by `one_of_inputs`, so neither list is enforced. Values are + // normalized along with the names, so the checks that look at a value see what the job + // will receive. + normalize_nul_in_args(&mut args); + // Before anything reads `args`: a context input the runnable declares is never taken // from the request, or a viewer could name someone else as themselves. for (k, prop) in ctx_inputs.into_iter().flatten() { diff --git a/backend/windmill-api/src/drafts.rs b/backend/windmill-api/src/drafts.rs index 6605a3ba92..e2f8406bd4 100644 --- a/backend/windmill-api/src/drafts.rs +++ b/backend/windmill-api/src/drafts.rs @@ -393,6 +393,7 @@ async fn validate_composed_draft( user_db: &UserDB, w_id: &str, kind: UserDraftItemKind, + path: &str, value: &str, ) -> Result<()> { let caps = windmill_common::roles::caller_capabilities(db, w_id, authed).await?; @@ -420,6 +421,41 @@ async fn validate_composed_draft( .await?; } + // An agent draft is held to the rules of its deploy, as a flow draft is: a developer who + // tests it in the agent editor runs its tools as themselves. + if caps.flows != AuthorLevel::Full && kind == UserDraftItemKind::Resource { + #[derive(Deserialize)] + struct ResourceDraft { + #[serde(default)] + args: serde_json::Value, + resource_type: Option, + } + let draft: ResourceDraft = serde_json::from_str(&strip_json_nul(value)) + .map_err(|e| Error::BadRequest(format!("Invalid resource draft: {e}")))?; + // Whatever type the draft names, deploying it over a deployed agent keeps the row's type. + let is_agent = draft.resource_type.as_deref() == Some("ai_agent") + || sqlx::query_scalar!( + "SELECT EXISTS(SELECT 1 FROM resource WHERE workspace_id = $1 AND path = $2 AND resource_type = 'ai_agent')", + w_id, + path, + ) + .fetch_one(db) + .await? + .unwrap_or(false); + if is_agent { + let mut tx = user_db.clone().begin(authed).await?; + windmill_api_auth::composed::validate_operator_agent( + &draft.args, + authed, + db, + &mut tx, + w_id, + ) + .await?; + tx.commit().await?; + } + } + // A raw-app draft carries the app value and its policy side by side. if caps.apps == AuthorLevel::Compose && kind == UserDraftItemKind::RawApp { let stripped = strip_json_nul(value); @@ -535,7 +571,7 @@ async fn update_draft( } if let Some(value) = &req.value { - validate_composed_draft(&authed, &db, &user_db, &w_id, kind, value.0.get()).await?; + validate_composed_draft(&authed, &db, &user_db, &w_id, kind, path, value.0.get()).await?; } let applied = if let Some(value) = &req.value { @@ -1116,8 +1152,16 @@ async fn transfer_drafts( // The copy is a save in the target, so a role that only composes there is held to the // rules a direct save would apply. Checked on the locked rows, the ones copied. for row in &source_rows { - validate_composed_draft(target_authed, &db, &user_db, target, row.typ, &row.value) - .await?; + validate_composed_draft( + target_authed, + &db, + &user_db, + target, + row.typ, + &row.path, + &row.value, + ) + .await?; } if req.replace_existing { sqlx::query!( diff --git a/backend/windmill-api/src/jobs.rs b/backend/windmill-api/src/jobs.rs index c5dd374c7a..bf3d7ea24c 100644 --- a/backend/windmill-api/src/jobs.rs +++ b/backend/windmill-api/src/jobs.rs @@ -36,7 +36,7 @@ use windmill_common::error::JsonResult; use windmill_common::flow_status::{JobResult, RestartedFrom}; use windmill_common::jobs::{ format_completed_job_result, format_result, is_safe_log_file_path, is_valid_entrypoint_name, - DynamicInput, ENTRYPOINT_OVERRIDE, + sanitize_untrusted_args, DynamicInput, ENTRYPOINT_OVERRIDE, }; #[cfg(feature = "run_inline")] use windmill_common::jobs::{ @@ -10488,7 +10488,7 @@ async fn run_dynamic_select( Extension(user_db): Extension, Path(w_id): Path, Query(run_query): Query, - Json(request): Json, + Json(mut request): Json, ) -> error::Result { #[cfg(feature = "enterprise")] check_license_key_valid().await?; @@ -10510,6 +10510,20 @@ async fn run_dynamic_select( ))); } + // The dyn-select code is the runnable's, not necessarily the caller's: a deployed flow + // has no deployed runnable for it, so it runs as a `JobKind::Preview` job, and the + // executors honor `_TEMP_SCRIPT_REFS` on every preview — it resolves the code's + // workspace imports from the temporary script store, which any workspace member can + // write. A caller of this route (an operator, for a deployed flow) only fills in that + // code's inputs; the function to call is taken from `entrypoint_function`, validated + // above and inserted server-side below, never from the args. So the reserved control + // keys are dropped here, in the spellings `push` would store them under. Nothing + // server-side puts them in `args`: the request has no `temp_script_refs` field, and the + // run form does not send them. + if let Some(args) = request.args.as_mut() { + sanitize_untrusted_args(args); + } + // Deployed scripts keep their normal deployed-run path (a `script` job, resolved below by // push_script_job_by_path_into_queue). Deployed flows and inline snippets have no deployed // runnable, so they run their dyn-select code as a `preview`; the flow carries its path and diff --git a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs index 8ff3e9f5c2..9add0792ee 100644 --- a/backend/windmill-api/src/mcp/auto_generated_endpoints.rs +++ b/backend/windmill-api/src/mcp/auto_generated_endpoints.rs @@ -184,7 +184,12 @@ pub fn all_tools() -> Vec { }, "expires_at": { "type": "string", - "description": "The expiration date of the variable", + "description": "When to garbage-collect the variable. Used for ephemeral secrets; the variable is deleted once this is reached. Not the value's expiry — see value_valid_until.\n", + "format": "date-time" + }, + "value_valid_until": { + "type": "string", + "description": "When the value stored in this variable stops working. Triggers the workspace's variable expiration handler ahead of it, by the handler's `notify_before_secs` (1 day by default); never causes the variable to be deleted.\n", "format": "date-time" }, "labels": { @@ -272,6 +277,12 @@ pub fn all_tools() -> Vec { "type": "string", "description": "The new description of the variable" }, + "value_valid_until": { + "type": "string", + "nullable": true, + "description": "When the value stored in this variable stops working. Omit to leave it unchanged; send null to clear it. Changing it re-arms the workspace's variable expiration handler.\n", + "format": "date-time" + }, "labels": { "type": "array", "items": { diff --git a/backend/windmill-api/src/mcp/session/deploy.rs b/backend/windmill-api/src/mcp/session/deploy.rs index e508a16174..ba65488c75 100644 --- a/backend/windmill-api/src/mcp/session/deploy.rs +++ b/backend/windmill-api/src/mcp/session/deploy.rs @@ -268,11 +268,16 @@ async fn deploy_variable( || draft.get("is_oauth") == Some(&Value::Bool(true)) || non_null(draft.get("account")).is_some(); let keep_stored = protected && stages_no_value; - let body = if keep_stored { + let mut body = if keep_stored { without(draft, &["value"]) } else { draft.clone() }; + // The draft holds the full variable, so an absent expiry was cleared, while the update + // endpoint reads an absent one as unchanged. + if let Some(body) = body.as_object_mut() { + body.entry("value_valid_until").or_insert(Value::Null); + } ctx.post( &ctx.ws(&format!("variables/update/{}", path_enc(path))), body, diff --git a/backend/windmill-api/src/mcp/session/gen/instructions.json b/backend/windmill-api/src/mcp/session/gen/instructions.json index 796bdb7ffd..9cdb920c99 100644 --- a/backend/windmill-api/src/mcp/session/gen/instructions.json +++ b/backend/windmill-api/src/mcp/session/gen/instructions.json @@ -225,13 +225,13 @@ "script.bunnative": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (bunnative)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n\n\n# TypeScript (Bun Native)\n\nNative TypeScript execution. Native scripts are Bun scripts that run on the native worker — a lightweight V8 isolate that exposes `fetch` and the JavaScript standard library — and can be heavily parallelized. Every script MUST start with `//native` on its first line so Windmill routes it to the native worker; without it the exact same script runs on the regular Bun worker. You may import npm packages and other Windmill scripts (e.g. `./helper.ts`) — imports are resolved and bundled just like a regular Bun script — as long as everything (your code and its dependencies) relies only on `fetch` and the standard library. Libraries that need Node/Bun runtime APIs (filesystem, `node:*` modules, child processes, native addons) will not work on the native worker; use the regular `bun` language for those.\n\n## Structure\n\nExport a single **async** function called `main`:\n\n```typescript\n//native\nexport async function main(param1: string, param2: number) {\n // Your code here\n return { result: param1, count: param2 };\n}\n```\n\nDo not call the main function.\n\n## Resource Types\n\nOn Windmill, credentials and configuration are stored in resources and passed as parameters to main.\n\nUse the `RT` namespace for resource types:\n\n```typescript\n//native\nexport async function main(stripe: RT.Stripe) {\n // stripe contains API key and config from the resource\n}\n```\n\nOnly use resource types if you need them to satisfy the instructions. Always use the RT namespace.\n\n## Imports\n\n**The constraint is the runtime, not the import list.** You may import npm packages and relative Windmill scripts; they are resolved and bundled exactly like a regular Bun script. But the native worker only provides `fetch` and the JavaScript standard library, so any imported code must work using only those. Anything requiring Node/Bun built-ins (`node:fs`, `child_process`, the `Bun` API, native modules) belongs in a regular `bun` script instead. Use the globally available `fetch` for HTTP:\n\n```typescript\n//native\nexport async function main(url: string) {\n const response = await fetch(url);\n return await response.json();\n}\n```\n\n## Pure computation: `//no_network`\n\nA native script that only transforms its inputs can declare `//no_network` in its leading comment block (right after `//native`). The runtime then refuses every connection the script attempts: `fetch` to any host, the Windmill API (so `windmill-client` calls fail too), raw sockets and unix sockets, and `WM_TOKEN` is not set. Without `//native` it means something else: the script must run in an nsjail sandbox (`//sandbox`, or instance-wide sandboxing), which then loses its network, and it fails to run otherwise. Use it only when the script needs no external data beyond its arguments:\n\n```typescript\n//native\n//no_network\nexport async function main(items: { price: number; qty: number }[]) {\n return items.reduce((sum, i) => sum + i.price * i.qty, 0);\n}\n```\n\n## Windmill Client\n\n`windmill-client` works on the native worker (its calls go over `fetch`), so use it as the **preferred way to talk to Windmill** — reading resources/variables/states, running scripts and flows, and the S3 helpers below (`loadS3File`, `loadS3FileStream`, `writeS3File`, `S3Object`). It handles auth, the workspace, and the base URL for you. Reserve raw `fetch` for calling *external* HTTP APIs that aren't Windmill.\n\nThe full `windmill-client` API reference (every exported function and its signature) is included below — consult it for the exact method instead of hand-rolling a `fetch` against the Windmill API.\n\n## Preprocessor Scripts\n\nFor preprocessor scripts, the function should be named `preprocessor` and receives an `event` parameter:\n\n```typescript\n//native\ntype Event = {\n kind:\n | \"webhook\"\n | \"http\"\n | \"websocket\"\n | \"kafka\"\n | \"email\"\n | \"nats\"\n | \"postgres\"\n | \"sqs\"\n | \"mqtt\"\n | \"amqp\"\n | \"gcp\"\n | \"azure\";\n body: any;\n headers: Record;\n query: Record;\n};\n\nexport async function preprocessor(event: Event) {\n return {\n param1: event.body.field1,\n param2: event.query.id,\n };\n}\n```\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations. The `wmill.S3Object` type covers both the `s3://storage/key` URI form (`s3:///key` for the workspace default storage) and the `{ s3, storage? }` record form — always use it instead of redefining your own.\n\n### Receiving an S3Object as a script parameter\n\n```typescript\n//native\nimport * as wmill from \"windmill-client\";\n\nexport async function main(file: wmill.S3Object) {\n const content = await wmill.loadS3File(file);\n // ...\n}\n```\n\n### S3 operations\n\n```typescript\n//native\nimport * as wmill from \"windmill-client\";\n\n// Load file content from S3\nconst content: Uint8Array = await wmill.loadS3File(s3object);\n\n// Load file as stream\nconst blob: Blob = await wmill.loadS3FileStream(s3object);\n\n// Write file to S3\nconst result: wmill.S3Object = await wmill.writeS3File(\n s3object, // Target path (or undefined to auto-generate)\n fileContent, // string or Blob\n s3ResourcePath // Optional: specific S3 resource to use\n);\n```\n\n\n# TypeScript SDK (windmill-client)\n\nImport: import * as wmill from 'windmill-client'\n\nThe client configures itself from the job's environment — base URL, token and credentials mode\nare all set before your code runs, so there is nothing to initialize and no reason to read\nWM_TOKEN or BASE_INTERNAL_URL and build an API URL yourself. Reconstructing that by hand only\nreintroduces details the client already handles. Call the SDK for anything Windmill, and use raw\nHTTP for third-party APIs.\n\nThe helpers below are the surface to prefer. For an endpoint none of them covers, import the\ngenerated service classes (JobService, ScriptService, ...) from 'windmill-client' — they are not\nlisted here but they do exist. What does not exist is a helper name you guessed at: if it is\nneither listed below nor a service method, do not call it.\n\nTo know who is running the script, read the contextual variables rather than calling the API:\n`process.env.WM_END_USER_EMAIL || process.env.WM_EMAIL`. WM_END_USER_EMAIL is the app viewer when\nthe run was triggered from an app and empty otherwise (both variables are always defined), WM_EMAIL\nis the user the job is permissioned as. WM_USERNAME is the matching username.\n\nworkerHasInternalServer(): boolean\n\n/**\n * Initialize the Windmill client with authentication token and base URL\n * @param token - Authentication token (defaults to WM_TOKEN env variable)\n * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable)\n */\nsetClient(token?: string, baseUrl?: string): void\n\n/**\n * Create a client configuration from env variables\n * @returns client configuration\n */\ngetWorkspace(): string\n\n/**\n * Get a resource value by path\n * @param path path of the resource, default to internal state path\n * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error\n * @returns resource value\n */\nasync getResource(path?: string, undefinedIfEmpty?: boolean): Promise\n\n/**\n * Get the true root job id\n * @param jobId job id to get the root job id from (default to current job)\n * @returns root job id\n */\nasync getRootJobId(jobId?: string): Promise\n\n/**\n * Run a script synchronously by its path and wait for the result\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByPath(path: string, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Run a script synchronously by its hash and wait for the result\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByHash(hash_: string, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Append a text to the result stream\n * @param text text to append to the result stream\n */\nappendToResultStream(text: string): void\n\n/**\n * Stream to the result stream\n * @param stream stream to stream to the result stream\n */\nasync streamResult(stream: AsyncIterable): Promise\n\n/**\n * Run a flow synchronously by its path and wait for the result\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Flow execution result\n */\nasync runFlow(path: string | null = null, args: Record | null = null, verbose: boolean = false, tag: string | null = null): Promise\n\n/**\n * Wait for a job to complete and return its result\n * @param jobId - ID of the job to wait for\n * @param verbose - Enable verbose logging\n * @returns Job result when completed\n */\nasync waitJob(jobId: string, verbose: boolean = false): Promise\n\n/**\n * Get the result of a completed job\n * @param jobId - ID of the completed job\n * @returns Job result\n */\nasync getResult(jobId: string): Promise\n\n/**\n * Get the result of a job if completed, or its current status\n * @param jobId - ID of the job\n * @returns Object with started, completed, success, and result properties\n */\nasync getResultMaybe(jobId: string): Promise\n\n/**\n * Cancel a queued or running job by ID.\n * @param jobId - UUID of the job to cancel\n * @param reason - Optional reason for cancellation\n * @returns Response message from the cancel endpoint\n */\nasync cancelJob(jobId: string, reason: string | undefined = undefined): Promise\n\n/**\n * Run a script asynchronously by its path\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByPathAsync(path: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise\n\n/**\n * Run a script asynchronously by its hash\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByHashAsync(hash_: string, args: Record | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise\n\n/**\n * Run a flow asynchronously by its path\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job)\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runFlowAsync(path: string | null, args: Record | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true, tag: string | null = null): Promise\n\n/**\n * Resolve a resource value in case the default value was picked because the input payload was undefined\n * @param obj resource value or path of the resource under the format `$res:path`\n * @returns resource value\n */\nasync resolveDefaultResource(obj: any): Promise\n\n/**\n * Get the state file path from environment variables\n * @returns State path string\n */\ngetStatePath(): string\n\n/**\n * Set a resource value by path\n * @param path path of the resource to set, default to state path\n * @param value new value of the resource to set\n * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type\n */\nasync setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise\n\n/**\n * Set the state\n * @param state state to set\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync setState(state: any, path?: string): Promise\n\n/**\n * Set the progress\n * Progress cannot go back and limited to 0% to 99% range\n * @param percent Progress to set in %\n * @param jobId? Job to set progress for\n */\nasync setProgress(percent: number, jobId?: any): Promise\n\n/**\n * Get the progress\n * @param jobId? Job to get progress from\n * @returns Optional clamped between 0 and 100 progress value\n */\nasync getProgress(jobId?: any): Promise\n\n/**\n * Whether the worker running this job has received its shutdown signal.\n * A draining worker waits for its current job to finish before exiting, so a\n * long-running script can poll this and return early to let the worker be replaced.\n * @param jobId? Job to check, defaults to the current job\n */\nasync workerIsDraining(jobId?: string): Promise\n\n/**\n * Set a flow user state\n * @param key key of the state\n * @param value value of the state\n */\nasync setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get a flow user state\n * @param path path of the variable\n */\nasync getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise\n\n/**\n * Get the state shared across executions\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync getState(path?: string): Promise\n\n/**\n * Get a variable by path\n * @param path path of the variable\n * @returns variable value\n */\nasync getVariable(path: string): Promise\n\n/**\n * Set a variable by path, create if not exist\n * @param path path of the variable\n * @param value value of the variable\n * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false)\n * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: \"\")\n */\nasync setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise\n\n/**\n * Build a PostgreSQL connection URL from a database resource\n * @param path - Path to the database resource\n * @returns PostgreSQL connection URL string\n */\nasync databaseUrlFromResource(path: string): Promise\n\nasync polarsConnectionSettings(s3_resource_path: string | undefined): Promise\n\nasync duckdbConnectionSettings(s3_resource_path: string | undefined): Promise\n\n/**\n * Get S3 client settings from a resource or workspace default\n * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)\n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n * @returns S3 client configuration settings\n */\nasync denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContent = await wmill.loadS3FileContent(inputFile)\n * // if the file is a raw text file, it can be decoded and printed directly:\n * const text = new TextDecoder().decode(fileContentStream)\n * console.log(text);\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContentBlob = await wmill.loadS3FileStream(inputFile)\n * // if the content is plain text, the blob can be read directly:\n * console.log(await fileContentBlob.text());\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * const s3object = await writeS3File(s3Object, \"Hello Windmill!\")\n * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')\n * console.log(fileContentAsUtf8Str)\n * ```\n * \n * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var)\n */\nasync writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise\n\n/**\n * Permanently delete a file from S3 by key.\n * \n * ```typescript\n * await wmill.deleteS3File({ s3: \"path/to/file.txt\" })\n * ```\n * \n * @param s3object - S3 object identifying the file to delete (must have `s3` set)\n * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var)\n */\nasync deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise\n\n/**\n * Sign S3 objects to be used by anonymous users in public apps\n * @param s3objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 objects\n */\nasync signS3Objects(s3objects: S3Object[], { expirySecs }: { expirySecs?: number } = {}): Promise\n\n/**\n * Sign S3 object to be used by anonymous users in public apps\n * @param s3object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 object\n */\nasync signS3Object(s3object: S3Object, { expirySecs }: { expirySecs?: number } = {}): Promise\n\n/**\n * Generate a presigned public URL for an array of S3 objects.\n * If an S3 object is not signed yet, it will be signed first.\n * @param s3Objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns list of signed public URLs\n */\nasync getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise\n\n/**\n * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.\n * @param s3Object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed public URL\n */\nasync getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise\n\n/**\n * Get URLs needed for resuming a flow after this step\n * @param approver approver name\n * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.\n * This allows pre-approvals that can be consumed by any later suspend step in the same flow.\n * @returns approval page UI URL, resume and cancel API URLs for resuming the flow\n */\nasync getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only)\n * @param audience audience of the token\n * @param expiresIn Optional number of seconds until the token expires\n * @returns jwt token\n */\nasync getIdToken(audience: string, expiresIn?: number): Promise\n\n/**\n * Convert a base64-encoded string to Uint8Array\n * @param data - Base64-encoded string\n * @returns Decoded Uint8Array\n */\nbase64ToUint8Array(data: string): Uint8Array\n\n/**\n * Convert a Uint8Array to base64-encoded string\n * @param arrayBuffer - Uint8Array to encode\n * @returns Base64-encoded string\n */\nuint8ArrayToBase64(arrayBuffer: Uint8Array): string\n\n/**\n * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Slack approval request.\n * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill.\n * @param {string} options.channelId - The Slack channel ID where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Slack approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * @param {string} [options.resumeButtonText] - Optional text for the resume button.\n * @param {string} [options.cancelButtonText] - Optional text for the cancel button.\n * \n * @returns {Promise} Resolves when the Slack approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getSlackApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveSlackApproval({\n * slackResourcePath: \"/u/alex/my_slack_resource\",\n * channelId: \"admins-slack-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * resumeButtonText: \"Resume\",\n * cancelButtonText: \"Cancel\",\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise\n\n/**\n * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Teams approval request.\n * @param {string} options.teamName - The Teams team name where the approval request will be sent.\n * @param {string} options.channelName - The Teams channel name where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Teams approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * \n * @returns {Promise} Resolves when the Teams approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveTeamsApproval({\n * teamName: \"admins-teams\",\n * channelName: \"admins-teams-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise\n\nsetWorkflowCtx(ctx: WorkflowCtx | null): void\n\nasync sleep(seconds: number): Promise\n\n/**\n * Execute `fn` inline and checkpoint the result. On replay the cached value is\n * returned without re-executing `fn`.\n * \n * `fn`'s result is encoded as JSON and decoded back before it is returned, so\n * the round that runs the body sees the same types every replay sees: a `Date`\n * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.\n * \n * Called from inside a task body, `fn` runs in place and nothing is\n * checkpointed: a task is a single job and is never replayed.\n */\nasync step(name: string, fn: () => T | Promise,): Promise>>\n\n/**\n * Create a task that dispatches to a separate Windmill script.\n * \n * @example\n * const extract = taskScript(\"f/data/extract\");\n * // inside workflow: await extract({ url: \"https://...\" })\n */\ntaskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Create a task that dispatches to a separate Windmill flow.\n * \n * @example\n * const pipeline = taskFlow(\"f/etl/pipeline\");\n * // inside workflow: await pipeline({ input: data })\n */\ntaskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike\n\n/**\n * Mark an async function as a workflow-as-code entry point.\n * \n * The function must be **deterministic**: given the same inputs it must call\n * tasks in the same order on every replay. Branching on task results is fine\n * (results are replayed from checkpoint), but branching on external state\n * (current time, random values, external API calls) must use `step()` to\n * checkpoint the value so replays see the same result.\n */\nworkflow(fn: (...args: any[]) => Promise): void\n\n/**\n * Suspend the workflow and wait for an external approval.\n * \n * Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that\n * resume exactly this approval — route them through your own channel. Without a\n * key the steps are named `approval`, `approval_2`, ...\n * \n * `skin: \"minimal\"` shows approvers only the request (form and approve/reject)\n * instead of the detailed page with the workflow's details. `description` is\n * shown above the form: a string, or a rich value such as `{ markdown: \"...\" }`.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * const { value, approver } = await waitForApproval({ key: \"manager\", timeout: 3600 });\n */\nwaitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: \"detailed\" | \"minimal\"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>\n\n/**\n * Resume/cancel/approval-page URLs bound to one `waitForApproval` step.\n * \n * Unlike `getResumeUrls()`, which signs a random nonce, these address the very\n * `resume_job` record the step's built-in approval buttons use, so they are\n * stable across replays and safe to embed in a custom notification.\n * \n * `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique\n * within a workflow; reusing one throws rather than silently renaming it. The URL\n * only resumes while that step is awaiting approval; used at any other moment it is\n * rejected rather than banking a row a different approval would consume. Send it\n * ahead of time — approvers just cannot act before the workflow reaches the step.\n * \n * `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's\n * approval page, which acts on whichever approval is pending when it is used.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * await waitForApproval({ key: \"manager\" });\n */\nasync getApprovalUrls(stepKey: string = \"approval\", approver?: string): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Process items in parallel with optional concurrency control.\n * \n * Each item is processed by calling `fn(item)`, which should be a task().\n * Items are dispatched in batches of `concurrency` (default: all at once).\n * \n * @example\n * const process = task(async (item: string) => { ... });\n * const results = await parallel(items, process, { concurrency: 5 });\n */\nasync parallel(items: T[], fn: (item: T) => PromiseLike | R, options?: { concurrency?: number },): Promise\n\n/**\n * Commit Kafka offsets for a trigger with auto_commit disabled.\n * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path)\n * @param topic - Kafka topic name (from event.topic)\n * @param partition - Partition number (from event.partition)\n * @param offset - Message offset to commit (from event.offset)\n */\nasync commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise\n\n/**\n * Parse an S3 object from URI string or record format\n * @param s3Object - S3 object as URI string (`s3://storage/key`, `s3:///key`\n * for the default storage) or record. Any other string throws rather than\n * falling back to an auto-generated key: an auto key is requested by\n * omitting the object, and a fallback would silently misplace the upload\n * on any typo.\n * @returns S3 object record with storage and s3 key\n */\nparseS3Object(s3Object: S3Object): S3ObjectRecord\n\n/**\n * Create a SQL template function for PostgreSQL/datatable queries\n * @param name - Database/datatable name (default: \"main\")\n * @param opts.role - Connect as this data table role instead of the data table's default one.\n * Only meaningful on a data table under roles, and only for a role you are a tenant of.\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.datatable()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}::int\n * `.fetch()\n * @example\n * // Read through a restricted role\n * let sql = wmill.datatable(\"main\", { role: \"analytics\" })\n */\ndatatable(name: string = \"main\", opts?: DatatableOptions): DatatableSqlTemplateFunction\n\n/**\n * Create a SQL template function for DuckDB/ducklake queries\n * @param name - DuckDB database name, optionally with a schema as `name:schema` (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.ducklake()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}\n * `.fetch()\n * @example\n * // Target a specific schema within the ducklake\n * let sql = wmill.ducklake(\"my_lake:analytics\")\n */\nducklake(name: string = \"main\"): SqlTemplateFunction\n\n/**\n * Idempotently materialize `selectSql` into a ducklake table for one\n * partition (or the whole table when `partition` is omitted) — the client-side\n * equivalent of the `// materialize` engine.\n * With `uniqueKey` it upserts the slice (delete-by-key + insert); otherwise it\n * replaces it (whole table → `CREATE OR REPLACE`; partition → delete + insert).\n * Safe to re-run for the same partition (backfill / failure-recovery).\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.upsertPartition({ table, selectSql, partition }).execute()`.\n */\nupsertPartition(opts: DucklakeMaterializeOptions): SqlStatement\n\n/**\n * INSERT-only materialization (no dedup/replace) for append-only tables.\n * Re-running the same partition duplicates rows — use only for immutable\n * event-log sources.\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.appendPartition({ table, selectSql, partition }).execute()`.\n */\nappendPartition(opts: Omit,): SqlStatement\n", "script.dbt": "# Global draft script instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, or generate metadata.\n- A script draft is a workspace item: `{ type: 'script', path, summary?, language, value, isDraft }` where `value` is the source code string.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Preserve the current path/language when modifying unless the user asked to change them.\n- Use `edit_script` for small localized changes (provide `old_string`/`new_string`); use `write_script` for full rewrites.\n\n# Windmill script authoring reference (dbt)\n\n# Windmill Script Writing Guide\n\n## General Principles\n\n- A script's inputs are its parameters. Credentials and configuration come in as resource-typed parameters, never hard-coded or read from the environment; the language section below shows how that language declares parameters\n- When no resource type exists for a service, still take its connection as one resource-typed parameter, of a custom type holding the connection fields (base URL, account identifiers, credentials), its secret in a variable the resource references. A custom type is named `c_`, which keeps it apart from a hub type synced later: the parameter takes `RT.C` in TypeScript, a `c_` TypedDict in Python. A plain string parameter or a variable read inside the code only when the user asks for it: the resource keeps a connection's fields together, lets whoever runs the script pick an existing connection, and keeps the secret out of the arguments a run records. If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services. Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it\n- A Windmill Hub script takes a resource type the hub publishes, so on an instance that has not synced the hub's types that type is missing: say so, and that a superadmin can sync them, before asking for a resource of it. Hub scripts often declare the type inline (`type Stripe = { token: string }`), which is still the `stripe` resource type; in code you write, name it the way the language section shows (`RT.Stripe` in TypeScript) rather than copying the inline declaration\n- Libraries are installed automatically - do not show installation instructions\n- In a language with an entrypoint function (TypeScript, Python, Go, Rust, PHP, R, …), name it `main` (`Main` in C#) and do not call it; in TypeScript it must be async. SQL, GraphQL, Bash, PowerShell and Ansible scripts have no `main`: their language section shows how they take arguments\n- Where the language has a Windmill client (`wmill`), use it to interact with the platform\n- A script's input schema may carry a top-level `prompt_for_ai` string: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that script, and keep it when you rewrite the schema\n\n## Return Values\n\n- A script can return any JSON-serializable value; a SQL script returns the rows its query produces\n- Return values become available to subsequent flow steps via `results.step_id`\n\n## Preprocessor Scripts\n\nPreprocessor scripts process raw trigger data from various sources (webhook, custom HTTP route, SQS, WebSocket, Kafka, NATS, MQTT, AMQP, Postgres, GCP Pub/Sub, Azure, or email) before passing it to the flow. This separates the trigger logic from the flow logic and keeps the auto-generated UI clean.\n\nA preprocessor is written in TypeScript or Python: its function is named `preprocessor` instead of `main`, and it receives a single parameter called `event` (the language section gives its type).\n\nThe returned object determines the parameter values passed to the flow.\ne.g., `{ b: 1, a: 2 }` calls the flow with `a = 2` and `b = 1`, assuming the flow has two inputs called `a` and `b`.\n", "flow": "# Global draft flow instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.\n- Paths follow the conventions in the system prompt: default to `u//` when the user gave a bare name; only use `f//` when the folder is known to exist. Never invent a folder.\n- `write_flow` mirrors flow mode's `set_flow_json`: pass `path`, optional `summary`, optional `description`, required `modules`, and optional `schema`, `preprocessor_module`, `failure_module`, `groups`, and `notes`. `summary` and `description` are top-level flow metadata (not part of the compact value `patch_flow_json` edits); the flow-structure arguments are JSON strings, matching the tool schema descriptions. When overwriting an existing flow, top-level flow settings (see below) are preserved from the current flow — use `patch_flow_json` to change them.\n- `read_workspace_item` returns a compact flow `value` object with `modules`, `schema`, `preprocessor_module`, `failure_module`, `groups`, `notes`, and any top-level flow settings that are set.\n- Top-level flow settings appear as top-level keys of the compact flow value and can be added, edited, or removed with `patch_flow_json`: same_worker, preserve_step_tags, concurrent_limit, concurrency_key, concurrency_time_window_s, debounce_delay_s, debounce_key, debounce_args_to_accumulate, max_total_debouncing_time, max_total_debounces_amount, skip_expr, cache_ttl, cache_ignore_s3_path, delete_after_secs, flow_env, priority, early_return, chat_input_enabled. For example, `chat_input_enabled: true` marks a flow as chat-style (flow-as-chat); keep it intact when restructuring such a flow.\n- `modules` contains normal sequential modules. Use top-level `preprocessor_module` and `failure_module` for special modules; do not put `preprocessor` or `failure` in `modules`.\n- Every module needs a stable unique `id` and a useful `summary` when the schema supports it.\n- Prefer path/script/flow modules when composing existing workspace logic. Use rawscript modules only when new inline code is needed.\n- When writing rawscript module code, call `get_instructions` with `subject: \"script\"` and the rawscript language first.\n\n## Groups and notes\n\n- Add `groups` to any non-trivial flow without being asked; \"Organizing Flows: Groups and Notes\" in the reference below gives the fields and the rules the editor enforces. Pass `null` to `write_flow` to clear them, and the same for `notes`.\n- With `patch_flow_json`, edit `groups` and `notes` the same way as any other field — they appear as top-level keys in the compact flow value.\n\n## Compact view: how rawscript bodies surface in tool I/O\n\n- `read_workspace_item` and `patch_flow_json` operate on a **compact view** of the flow: every rawscript module's `value.content` is replaced with the placeholder `\"inline_script.\"` so inline script bodies don't bloat tool I/O. Schema, groups, notes, preprocessor_module and failure_module are all shown in this view.\n- Inline rawscript content is **not** part of the JSON `patch_flow_json` sees. Edits to inline bodies happen via dedicated tools:\n - `read_flow_module_code(path, module_id)` — returns the raw inline script content for one module.\n - `set_flow_module_code(path, module_id, code)` — overwrites that module's inline script content; saves to the draft.\n- Use `patch_flow_json` for *structural* edits: module ids, paths, input_transforms, branch arrangement, summaries, preprocessor/failure swaps, schema/groups/notes. Use `set_flow_module_code` for changes inside a specific rawscript body.\n- When **adding a new rawscript module** with `patch_flow_json`, set its `content` to the placeholder `\"inline_script.\"` (matching the compact view) — never real code — then **immediately fill the body** with `set_flow_module_code(path, module_id, code)`. The module is saved with an empty body until you do; the tool result lists the module ids still awaiting code. A placeholder that names anything other than the module's own id or an existing rawscript module is rejected.\n- `write_flow` is for full overwrites / create-from-scratch. Its `modules`, `preprocessor_module`, and `failure_module` arguments are **non-compact** flow modules: inline each rawscript body directly in `content` by default. But if a body is long or quote/backslash-heavy enough that escaping it into the JSON string is error-prone — or if a `write_flow` call comes back with a JSON parse error — create that module with **empty content** (`\"content\": \"\"`) and fill it with `set_flow_module_code(path, module_id, code)`, whose `code` is a plain argument with no nested escaping. `write_flow` reports which modules still have empty bodies so you know what to fill.\n - When overwriting an **existing** flow, set `\"content\": \"inline_script.\"` on any rawscript module whose code you are not changing — the placeholder resolves to that module's current body, so you never re-send (or re-read) unchanged code. Placeholders that match no existing rawscript module and are not the module's own id are rejected.\n\n\n# Windmill flow authoring reference\n\n# Windmill Flow Building Guide\n\n## OpenFlow Schema\n\nThe OpenFlow schema (openflow.openapi.yaml) is the source of truth for flow structure. Refer to OPENFLOW_SCHEMA for the complete type definitions.\n\n## Reserved Module IDs\n\n- `failure` - Reserved for failure handler module\n- `preprocessor` - Reserved for preprocessor module\n- `Input` - Reserved for flow input reference\n\n## Hard Structural Rules\n\nThese are strict Windmill schema rules. Follow them exactly.\n\n- `value.modules` is only for normal sequential steps\n- `value.preprocessor_module` and `value.failure_module` are special top-level fields inside `value`, not entries in `value.modules`\n- If a flow needs a preprocessor, create `value.preprocessor_module` with `id: preprocessor`\n- If a flow needs a failure handler, create `value.failure_module` with `id: failure`\n- Do NOT create regular modules inside `value.modules` named `preprocessor` or `failure`\n- `preprocessor_module` and `failure_module` only support `script` or `rawscript`\n- `preprocessor_module` runs before normal modules and cannot reference `results.*`\n- `failure_module` can use the `error` object with `error.message`, `error.step_id`, `error.name`, and `error.stack`\n- A flow whose `failure_module` runs still ends as failed, unless the handler returns an object with `recover: true`: the flow then ends as a success. This also holds when the failing step is inside a loop, a branch or a subflow. `recover` never changes which steps run; whether a loop or branch carries on past a failure is still decided by its own settings (`skip_failures`, `continue_on_error`)\n\nCorrect shape:\n\n```yaml\nvalue:\n preprocessor_module:\n id: preprocessor\n value:\n type: rawscript\n ...\n failure_module:\n id: failure\n value:\n type: rawscript\n ...\n modules:\n - id: process_event\n value:\n type: rawscript\n ...\n```\n\nIncorrect shape:\n\n```yaml\nvalue:\n modules:\n - id: preprocessor\n ...\n - id: process_event\n ...\n - id: failure\n ...\n```\n\n## Module ID Rules\n\n- Must be unique across the entire flow\n- Use underscores, not spaces (e.g., `fetch_data` not `fetch data`)\n- Use descriptive names that reflect the step's purpose\n\n## AI Agent Modules\n\nAn `aiagent` module runs an LLM that can call tools. Each entry of `value.tools` is a module-shaped\nobject with an extra `value.tool_type`: `flowmodule` for a script/flow tool, `mcp` for an MCP server\ntool, `websearch` for web search.\n\n```json\n{\n \"id\": \"support_agent\",\n \"summary\": \"AI agent for customer support\",\n \"value\": {\n \"type\": \"aiagent\",\n \"input_transforms\": {\n \"provider\": {\n \"type\": \"static\",\n \"value\": { \"kind\": \"openai\", \"resource\": \"$res:f/ai_providers/openai\", \"model\": \"gpt-4o\" }\n },\n \"output_type\": { \"type\": \"static\", \"value\": \"text\" },\n \"user_message\": { \"type\": \"javascript\", \"expr\": \"flow_input.query\" },\n \"system_prompt\": { \"type\": \"static\", \"value\": \"You are a helpful assistant.\" }\n },\n \"tools\": [\n {\n \"id\": \"search_docs\",\n \"summary\": \"search_documentation\",\n \"description\": \"Search the product documentation. Use it whenever the user asks how a feature works.\",\n \"value\": {\n \"tool_type\": \"flowmodule\",\n \"type\": \"rawscript\",\n \"language\": \"bun\",\n \"content\": \"export async function main(query: string) { return ['doc1', 'doc2']; }\",\n \"input_transforms\": { \"query\": { \"type\": \"static\", \"value\": \"\" } }\n }\n }\n ]\n }\n}\n```\n\n- `provider` is an object, not a bare resource string: `{ \"kind\": ,\n \"resource\": \"$res:\", \"model\": }`. Required unless the module links to a saved\n agent through `value.agent`. Static is right for a flow run from a form; a chat flow wires its\n fields to flow inputs instead — see below\n\n### Chat-Mode Flows\n\nA flow with `value.chat_input_enabled: true` is run from a chat instead of a form: the composer\nsends one message per turn and renders the conversation. It needs a required `user_message` string\ninput, read by the agent. Any other flow input the composer does not edit itself is asked for\nunder Configure inputs.\n\n**A static `provider` gives a chat that cannot change its model.** Feed it from flow inputs\ninstead, either way round: one input carrying the whole object (`\"expr\": \"flow_input.model_config\"`)\nmakes every field editable, or wire it field by field to fix some and expose others. A field the\nchat can write becomes a control in the composer — a provider picker, a model list, a thinking\ncontrol — and a field left static is fixed, with no control drawn for it. `kind` is the one\nexception: the composer writes it only together with `resource`, since a provider is picked as a\npair, so a `kind` input wired on its own stays askable under Configure inputs and nothing the run\nneeds becomes unreachable.\n\n```json\n{\n \"id\": \"chat_agent\",\n \"value\": {\n \"type\": \"aiagent\",\n \"input_transforms\": {\n \"provider\": {\n \"type\": \"javascript\",\n \"expr\": \"({ kind: 'anthropic', resource: '$res:f/ai/claude', model: flow_input.model, reasoning_effort: flow_input.thinking })\"\n },\n \"user_message\": { \"type\": \"javascript\", \"expr\": \"flow_input.user_message\" },\n \"user_attachments\": { \"type\": \"javascript\", \"expr\": \"flow_input.files\" },\n \"memory\": { \"type\": \"static\", \"value\": { \"kind\": \"compaction\" } },\n \"streaming\": { \"type\": \"static\", \"value\": true },\n \"output_type\": { \"type\": \"static\", \"value\": \"text\" }\n },\n \"tools\": []\n }\n}\n```\n\n- Wiring field by field means one object literal whose values are literals or bare `flow_input.x`\n references. A spread, a call or a computed key leaves the composer unable to tell which input\n feeds which field, so it offers no control at all — a bare `flow_input.x` for the whole object\n is read instead as that one input carrying every field\n- A step linked to a saved agent (`agent` set) has no `provider` of its own. It exposes the same\n two controls through its `model` and `reasoning_effort` inputs, which replace what the agent\n saved: `\"model\": { \"type\": \"javascript\", \"expr\": \"flow_input.model\" }`. An empty model keeps the\n saved one, an empty effort sends none, and the provider resource always stays the agent's\n- `memory` is what lets the agent see earlier turns; without it every message starts from nothing\n- `streaming` on makes the answer and its thinking appear token by token instead of all at once\n- `user_attachments` points at a flow input typed as an array of s3 objects\n (`{ \"type\": \"array\", \"items\": { \"type\": \"object\", \"resourceType\": \"s3object\" } }`), so files\n sent with a message reach the agent\n- Running one needs a `memory_id` **query parameter** — not a flow argument — naming the\n conversation the turn belongs to: a fresh UUID starts one, reusing a UUID continues it. The chat\n supplies it itself; a run driven any other way has to pass it or the server refuses the job\n\n### Tool Naming Rules\n\nThese rules cover `flowmodule` tools, the ones the agent calls by name. A `websearch` tool's\n`summary` is a plain label (`Web Search`), and an `mcp` tool exposes the MCP server's own tool\nnames, so neither is name-checked at all — leave those summaries as they are.\n\n- A flowmodule tool's `summary` is the **name the agent calls it by**, not a human label. Put the\n human-readable explanation in `description`\n- `summary` must match `^[a-zA-Z0-9_]+$`: letters, numbers and underscores only. No spaces, dashes,\n dots or accents — `search_documentation`, never `Search documentation`\n- Always set `summary`. It must be unique among that agent's tools, and must not be one of the\n reserved ids (`do`, `bg`, `ctx`, `state`, `if`, `else`, `for`, `delete`, `while`, `new`, `in`,\n `failure`, `preprocessor`, `as`, `Input`, `Result`, `Trigger`)\n- A tool name outside that character set fails any run that offers the tool to the agent, with `Invalid tool name`.\n The flow write tools refuse such a name.\n- Tool `id` follows the same rules as any module ID — unique across the flow, underscores not spaces\n- `description` is optional free text telling the agent when and how to call the tool. Set it\n whenever the name alone does not make that obvious; it overrides the description derived from the\n underlying script\n\n## AI Decision Modules\n\nAn `aidecision` module asks a decision model (TypeSafe's Jev, Cloudflare's Clef or OpenAI's Decisions API) typed questions about a `state` and\nanswers each with calibrated probabilities instead of text. Prefer it over an `aiagent` when the step\nis a judgment (classify, route, score or a yes/no check) that needs no tools and no free text: it\nis faster, cheaper, and its answers have a fixed shape.\n\n```json\n{\n \"id\": \"triage\",\n \"summary\": \"Classify the ticket\",\n \"value\": {\n \"type\": \"aidecision\",\n \"input_transforms\": {\n \"provider\": {\n \"type\": \"static\",\n \"value\": { \"kind\": \"typesafe\", \"resource\": \"$res:f/ai/typesafe\", \"model\": \"jev-latest\" }\n },\n \"state\": { \"type\": \"javascript\", \"expr\": \"flow_input.message\" },\n \"questions\": {\n \"type\": \"static\",\n \"value\": {\n \"intent\": {\n \"type\": \"choice\",\n \"instructions\": \"What does the customer want?\",\n \"criteria\": { \"refund\": \"Money back\", \"bug\": \"Something is broken\", \"other\": \"Anything else\" }\n },\n \"urgency\": {\n \"type\": \"score\",\n \"instructions\": \"How urgent is the ticket?\",\n \"criteria\": [\"Can wait\", \"This week\", \"Today\", \"Right now\"]\n },\n \"angry\": { \"type\": \"noul\", \"instructions\": \"Is the customer angry?\" }\n }\n }\n }\n }\n}\n```\n\n- `provider.kind` is `typesafe` (`model` `jev-latest` unless a version is pinned), `cloudflare`\n (`model` `clef`, or `clef-flash` for faster answers) or `openai` (`model` `gpt-6-luna`, never a\n chat model); the resource is of that same type\n- `state` is the content to evaluate: usually a text, such as the message to classify. To combine\n several values, pass an object with descriptive keys holding only what the questions need\n (`({ message: flow_input.message, plan: results.get_account.plan })`); an array of strings also works\n- `questions` maps each question name to `{ type, instructions, criteria }`:\n - `choice`: `criteria` maps each option to its description (up to 255 options). The answer has\n `choice`, `probabilities` and `confidence`\n - `score`: `criteria` is an ordered array of 2 to 10 level descriptions. The answer has `score`,\n `legend`, `probabilities` and `confidence`\n - `noul`: yes/no, `criteria` optionally `{ \"true\": ..., \"false\": ... }` descriptions. The answer has\n `noul`, the probability of yes from 0 to 1\n- The result is `{ output, model, usage }` with `output` keyed by question name, so a later step\n reads `results.triage.output.intent.choice`, `results.triage.output.urgency.score` or\n `results.triage.output.angry.noul > 0.7`\n- On `openai` the same `questions` are translated for its Decisions API and the answers read the\n same way (`choice`, `score`, `noul`), except that `probabilities` is a list of\n `{ value, probability }` (plus `label` on a score) and a score has no `legend`. A question OpenAI\n refuses to answer fails the step. A `state` that is\n an array of user messages (`[{ role: \"user\", content: [{ type: \"input_text\", text }, { type: \"input_image\", image_url: \"data:image/png;base64,...\" }] }]`)\n is sent as is, which is how an image is evaluated; any other object or array is sent as JSON text\n\n### Branching on the Answers\n\nTo route on the answers, put a `branchone` right after the decision, one branch per option, with\neach `expr` reading the decision by id:\n\n```json\n{\n \"id\": \"route\",\n \"value\": {\n \"type\": \"branchone\",\n \"branches\": [\n { \"summary\": \"refund\", \"expr\": \"results.triage.output.intent.choice === 'refund'\", \"modules\": [...] },\n { \"summary\": \"bug\", \"expr\": \"results.triage.output.intent.choice === 'bug'\", \"modules\": [...] }\n ],\n \"default\": [...]\n }\n}\n```\n\nFor a yes/no question, one branch with `\"expr\": \"results.triage.output.angry.noul >= 0.5\"` (tune the\nthreshold as needed) and the `default` as the \"no\" path.\n\n### As an Agent Tool\n\nAn `aidecision` can be a `flowmodule` tool of an `aiagent` (`\"tool_type\": \"flowmodule\", \"type\":\n\"aidecision\"`): set `state` to `{ \"type\": \"ai\" }` so the calling model supplies it, and only `output`\ngoes back to the model. The Tool Naming Rules apply to its `summary`. Only a flow's own agent step\ncan call one, not an agent used as a tool.\n\n## Common Mistakes to Avoid\n\n- Missing `input_transforms` - Rawscript parameters won't receive values without them\n- Referencing future steps - `results.step_id` only works for steps that execute before the current one\n- Duplicate module IDs - Each module ID must be unique in the flow\n- AI agent flowmodule tool names with spaces - `summary` is the tool name and only accepts letters, numbers and underscores\n\n## Data Flow Between Steps\n\n- `flow_input.property` - Access flow input parameters\n- `results.step_id` - Access output from a previous step only when that step result is in scope\n- `results.step_id.property` - Access specific property from a previous step output only when that step result is in scope\n- `flow_input.iter.value` - Current iteration value inside a `forloopflow`; in a `whileloopflow` it is just the iteration index (a plain number, same as `flow_input.iter.index`)\n- `flow_input.iter.index` - Current loop index when inside a loop (`forloopflow` or `whileloopflow`)\n- After a suspend step (approval), the next step's javascript expressions also get:\n - `resume` - the last resume payload (the resume form values); with `continue_on_disapprove_timeout`, a disapproval or timeout yields `{ error: { name: \"SuspendedDisapproved\" | \"SuspendedTimedOut\", message } }` instead\n - `resumes` - every resume payload, in arrival order\n - `approvers` - a plain array of strings, one per resume: for a resume through the `resume`/`cancel` URL from `getResumeUrls(approver)`, the `approver` passed there if any; otherwise (including the `approvalPage` link) the resuming user's username, else `\"anonymous\"`, e.g. `approvers[0]`. It is NOT the `{ resume_id, approver }` objects stored in the flow status, so `approvers[0].approver` is always undefined\n\n## Loop Structure Rules\n\n- A `forloopflow` runs its `modules` once per element of `iterator`, a javascript expression returning an array (e.g. `results.get_items`); `parallel: true` runs the iterations concurrently, and `skip_failures: true` carries on past a failed iteration\n- For `whileloopflow`, break the loop with a module-level `stop_after_if`: on the loop module itself, or on an inner step (required when that step carries state via its own `results` — see below)\n- `stop_after_if` is always a sibling of `id` and `value` on a flow module — never a direct key of the loop's `value` object\n- `stop_after_all_iters_if` is for checks after the whole loop finishes, not the normal per-iteration break condition\n- `stop_after_if` is evaluated after each iteration: on the loop module, `result` is that iteration's result (what its last step returned); on an inner step, it is that step's result\n- `flow_input.iter.value` in a `whileloopflow` is just the iteration index (same number as `flow_input.iter.index`) — it never carries state, so `flow_input.iter.value.` is always undefined and a loop whose stop condition depends on it never terminates\n- To carry state across iterations, a step reads its own previous-iteration result via `results.` with a first-iteration fallback (e.g. `results.b ?? flow_input.start`) — but then the loop's `stop_after_if` MUST sit on that inner step, not on the loop module: a body that is exactly one plain step with the stop condition on the loop module runs on a fast path where `results.` is null on every iteration and the loop never terminates (bodies with 2+ steps, or whose single step has its own `stop_after_if`, retry or similar, resolve `results` across iterations regardless of stop placement)\n- For state that is just a counter, derive it from the index instead (e.g. `flow_input.iter.index + 1`) — that works in every configuration, including with `stop_after_if` on the loop module\n- If the user asks for a final scalar/object after a loop, add a normal step after the loop that extracts the final value from the loop result instead of returning the whole loop result array\n\nCorrect `whileloopflow` shape:\n\n```yaml\n- id: loop_until_done\n stop_after_if:\n expr: result.done === true\n skip_if_stopped: false\n value:\n type: whileloopflow\n skip_failures: false\n modules:\n - id: advance_state\n value:\n type: rawscript\n input_transforms:\n count:\n type: javascript\n expr: flow_input.iter.index + 1\n- id: return_final_state\n value:\n type: rawscript\n input_transforms:\n final_state:\n type: javascript\n expr: results.loop_until_done[results.loop_until_done.length - 1]\n```\n\nCorrect `whileloopflow` shape carrying state via `results` (stop condition on the inner step):\n\n```yaml\n- id: loop_until_done\n value:\n type: whileloopflow\n skip_failures: false\n modules:\n - id: advance_state\n stop_after_if:\n expr: result.done === true\n skip_if_stopped: false\n value:\n type: rawscript\n input_transforms:\n state:\n type: javascript\n expr: results.advance_state ?? flow_input.initial_state\n```\n\nIncorrect `whileloopflow` patterns:\n\n```yaml\n- id: loop_until_done\n value:\n type: whileloopflow\n stop_after_if:\n expr: result.done === true\n```\n\n```yaml\ninput_transforms:\n state:\n type: javascript\n # iter.value is a number (the iteration index); there is no previous-iteration state\n expr: flow_input.iter.value.count\n```\n\n```yaml\ninput_transforms:\n final_state:\n type: javascript\n expr: results.loop_until_done\n```\n\n## Approval / Suspend Structure\n\nAn approval step is a normal **script** step (`type: rawscript` or `type: script`) that is turned into an approval by adding a module-level `suspend`. Its script calls `wmill.getResumeUrls(approver)` to generate the secret resume/cancel URLs and returns them so they can be sent to the approver(s) (Slack, email, etc.) or approved from the run page.\n\n- `suspend` belongs on the flow module object itself, as a sibling of `id` and `value`\n- Never put `suspend` inside `value`\n- Do NOT use `type: identity` for an approval step. An identity step suspends but never produces the resume URLs, so approvers have no link to act on — it is not a functional approval.\n\nCorrect shape:\n\n```yaml\n- id: request_approval\n suspend:\n required_events: 1\n resume_form:\n schema:\n type: object\n properties:\n comment:\n type: string\n required: [comment]\n value:\n type: rawscript\n language: bun\n input_transforms:\n approver:\n type: static\n value: ''\n content: |\n import * as wmill from \"windmill-client\"\n\n export async function main(approver?: string) {\n const urls = await wmill.getResumeUrls(approver)\n // send urls.resume / urls.cancel to the approver(s), e.g. via Slack or email\n return urls\n }\n```\n\nIncorrect shape (suspend misplaced inside `value`):\n\n```yaml\n- id: request_approval\n value:\n type: rawscript\n suspend:\n required_events: 1\n```\n\nIncorrect shape (identity has no resume URLs — not a real approval):\n\n```yaml\n- id: request_approval\n suspend:\n required_events: 1\n value:\n type: identity\n```\n\n## Branch Result Scope Rules\n\n- A `branchone` runs the first of its `branches` whose `expr` is true, in order, and its `default` modules when none is; a `branchall` runs every branch (concurrently with `parallel: true`)\n- Inside a branch, you may reference earlier outer steps and earlier steps in the same branch\n- Outside a `branchone`, do NOT reference ids of steps that only exist inside its branches or default branch. Use `results.` instead\n- Outside a `branchall`, do NOT reference ids of steps inside its branches. Use `results.` instead\n- If downstream steps need a stable shape after a branch, make each branch return the same fields\n- When needed, add a normalization step immediately after the branch and consume `results.` there\n\nCorrect after `branchone`:\n\n```yaml\n- id: route_order\n value:\n type: branchone\n ...\n- id: send_confirmation\n value:\n input_transforms:\n routed:\n type: javascript\n expr: results.route_order\n```\n\nIncorrect after `branchone`:\n\n```yaml\nexpr: results.create_shipment\nexpr: results.create_backorder\n```\n\nCorrect after `branchall`:\n\n```yaml\n- id: enrich_parallel\n value:\n type: branchall\n parallel: true\n ...\n- id: combine_data\n value:\n input_transforms:\n enrichments:\n type: javascript\n expr: results.enrich_parallel\n```\n\n## Input Transforms\n\nEvery rawscript module needs `input_transforms` to map function parameters to values:\n\nStatic transform (fixed value):\n{\"param_name\": {\"type\": \"static\", \"value\": \"fixed_string\"}}\n\nJavaScript transform (dynamic expression):\n{\"param_name\": {\"type\": \"javascript\", \"expr\": \"results.previous_step.data\"}}\n\n## Resource References\n\n- For flow inputs: Use type `\"object\"` with format `\"resource-{type}\"` (e.g., `\"resource-postgresql\"`)\n- For step inputs: Use static value `\"$res:path/to/resource\"`\n\n## Reusing Existing Scripts and Flows\n\nUnless the user asked for new code, look for a workspace script or flow that already does a step's job before writing it, and reuse it by path instead of copying its logic into a rawscript:\n\n- a workspace script: `type: script` with `path` (e.g. `f/folder/send_email`)\n- a workspace flow, run as a subflow: `type: flow` with `path`\n- a Hub script: `type: script` with a `hub///` path\n\nThe step's `input_transforms` must cover the reused item's inputs, so read its input schema first.\n\n## Additional Prompt for AI\n\nA flow's input schema may carry a top-level `prompt_for_ai` string, as a script's can: its author's instructions to an AI choosing the inputs. Follow it when you pick arguments to run that flow, and keep it when you rewrite the schema.\n\n## Organizing Flows: Groups and Notes\n\nGroups and notes shape how a flow reads in the editor; neither changes what it does.\n\n**Segment every non-trivial flow into groups without waiting to be asked.** Whenever a flow has more than a couple of steps, or consecutive steps form a stage (\"fetch\", \"transform\", \"notify\"), put them in a group, and aim for every meaningful step to belong to one. Use notes sparingly, for flow-wide information that belongs to no span of steps: the flow's purpose, key assumptions, warnings, TODOs. One note is usually enough; never label a run of steps with a note, which is what a group is for.\n\n`value.groups` lists the groups, each spanning the steps from `start_id` to `end_id`:\n\n- `start_id`, `end_id` (required): ids of the group's first and last step; the same id for both makes a one-step group\n- `summary`: the group's title\n- `note`: markdown shown under the title\n- `color`: one of `yellow`, `blue`, `green`, `purple`, `pink`, `orange`, `red`, `cyan`, `lime`, `gray`, never a hex code or CSS color; leave it out and the editor picks one\n- `autocollapse`: `true` shows the group collapsed by default\n\nThe editor refuses to draw a flow whose groups break any of these rules:\n\n- `start_id` and `end_id` are steps of the same list: both top-level, or both in the same loop body or branch. A group can hold a loop or branch step whole, but cannot start outside one and end inside it\n- `start_id` does not come after `end_id` in that list\n- groups nest (one entirely inside another) but never partly overlap, and no two groups share both `start_id` and `end_id`\n- groups hold ordinary steps only: never `preprocessor`, `failure`, `Input`, `Result`, `Trigger`, or an AI agent's tools\n\n`value.notes` lists sticky notes, each with a unique `id`, markdown `text`, a `color` from the same list, and `type: free`. The `group` note type is deprecated; use `value.groups` instead.\nLeave a note's `position` and `size` out and they are filled in for you.\n\n## Final Structural Self-Check\n\nBefore finalizing a flow, verify:\n\n- any preprocessor is in `value.preprocessor_module`\n- any failure handler is in `value.failure_module`\n- any approval step has module-level `suspend`\n- no downstream step references inner branch step ids from outside the branch\n- every AI agent flowmodule tool has a unique `summary` made only of letters, numbers and underscores\n- every group starts and ends on steps of the same list, start before end, nesting without partial overlap\n\n## S3 Object Operations\n\nWindmill provides built-in support for S3-compatible storage operations.\n\nTo accept an S3 object as flow input:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"file\": {\n \"type\": \"object\",\n \"format\": \"resource-s3_object\",\n \"description\": \"File to process\"\n }\n }\n}\n```\n\n## Using Resources in Flows\n\nOn Windmill, credentials and configuration are stored in resources. Resource types define the format of the resource.\n\n### As Flow Input\n\nIn the flow schema, set the property type to `\"object\"` with format `\"resource-{type}\"`:\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"database\": {\n \"type\": \"object\",\n \"format\": \"resource-postgresql\",\n \"description\": \"Database connection\"\n }\n }\n}\n```\n\n### When No Resource Type Fits\n\nA connection still comes in as one resource input, not as separate string inputs (a URL, a token) or a variable read inside a step: type the input `resource-c_` with a custom resource type holding the service's connection fields, its secret in a variable the resource references. The `c_` prefix of custom types keeps one apart from a hub type synced later under the same name.\n\n- If the instance is reported as not having synced the Windmill Hub's resource types, mention that a superadmin can sync them, since the hub has types for most services.\n- Otherwise, or when the user prefers it to syncing, propose the resource type and ask before creating it.\n- A plain variable is fine when the user asks for it.\n\n### As Step Input (Static Reference)\n\nReference a specific resource using `$res:` prefix:\n\n```json\n{\n \"database\": {\n \"type\": \"static\",\n \"value\": \"$res:f/folder/my_database\"\n }\n}\n```\n\n\n## OpenFlow Schema\n\n{\"OpenFlow\":{\"type\":\"object\",\"description\":\"Top-level flow definition containing metadata, configuration, and the flow structure\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this flow does\"},\"description\":{\"type\":\"string\",\"description\":\"Detailed documentation for this flow\"},\"value\":{\"$ref\":\"#/components/schemas/FlowValue\"},\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for flow inputs. Use this to define input parameters, their types, defaults, and validation. For resource inputs, set type to 'object' and format to 'resource-' (e.g., 'resource-stripe')\"},\"on_behalf_of_email\":{\"type\":\"string\",\"description\":\"Address of the account the flow runs on behalf of. Derived from on_behalf_of on read; accepted on write, where it is resolved to the account it names.\"},\"on_behalf_of\":{\"type\":\"string\",\"description\":\"The flow runs with the permissions of this identity: u/{username}, g/{group}, or a bare email when the username is itself email-shaped. The only stored half of the identity; on_behalf_of_email is derived from it. Omit it when writing and it is resolved from that address instead.\"}},\"required\":[\"summary\",\"value\"]},\"FlowValue\":{\"type\":\"object\",\"description\":\"The flow structure containing modules and optional preprocessor/failure handlers\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Array of steps that execute in sequence. Each step can be a script, subflow, loop, or branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"failure_module\":{\"description\":\"Special module that executes when the flow fails. Receives error object with message, name, stack, and step_id. Must have id 'failure'. Only supports script/rawscript types\",\"$ref\":\"#/components/schemas/FlowModule\"},\"preprocessor_module\":{\"description\":\"Special module that runs before the first step on external triggers. Must have id 'preprocessor'. Only supports script/rawscript types. Cannot reference other step results\",\"$ref\":\"#/components/schemas/FlowModule\"},\"same_worker\":{\"type\":\"boolean\",\"description\":\"If true, all steps run on the same worker for better performance\"},\"preserve_step_tags\":{\"type\":\"boolean\",\"description\":\"If true and the flow runs on a custom worker tag, steps that declare their own non-empty tag run on it instead of inheriting the flow tag. Steps without their own tag still inherit the flow tag.\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum number of concurrent executions of this flow\"},\"concurrency_key\":{\"type\":\"string\",\"description\":\"Expression to group concurrent executions (e.g., by user ID)\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window in seconds for concurrent_limit\"},\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce flow executions\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds that a job can be debounced\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of times a job can be debounced\"},\"skip_expr\":{\"type\":\"string\",\"description\":\"JavaScript expression to conditionally skip the entire flow\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for flow results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the flow job's args, result and logs after this many seconds following job completion\"},\"flow_env\":{\"type\":\"object\",\"description\":\"Environment variables available to all steps. Values can be strings, JSON values, or special references: '$var:path' (workspace variable) or '$res:path' (resource).\",\"additionalProperties\":{}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority (higher numbers run first)\"},\"early_return\":{\"type\":\"string\",\"description\":\"Id of the step whose result a sync webhook returns as soon as that step completes, while the rest of the flow keeps running. When the step does not run, or fails while the flow has a failure module, the flow's final result is returned instead.\"},\"chat_input_enabled\":{\"type\":\"boolean\",\"description\":\"Whether this flow accepts chat-style input\"},\"notes\":{\"type\":\"array\",\"description\":\"Sticky notes attached to the flow\",\"items\":{\"$ref\":\"#/components/schemas/FlowNote\"}},\"groups\":{\"type\":\"array\",\"description\":\"Semantic groups of modules for organizational purposes\",\"items\":{\"$ref\":\"#/components/schemas/FlowGroup\"}}},\"required\":[\"modules\"]},\"Retry\":{\"type\":\"object\",\"description\":\"Retry configuration for failed module executions\",\"properties\":{\"constant\":{\"type\":\"object\",\"description\":\"Retry with constant delay between attempts\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"seconds\":{\"type\":\"integer\",\"description\":\"Seconds to wait between retries\"}}},\"exponential\":{\"type\":\"object\",\"description\":\"Retry with exponential backoff: the n-th retry waits multiplier \\u00d7 seconds^n\",\"properties\":{\"attempts\":{\"type\":\"integer\",\"description\":\"Number of retry attempts\"},\"multiplier\":{\"type\":\"integer\",\"description\":\"Multiplier for exponential backoff\"},\"seconds\":{\"type\":\"integer\",\"minimum\":1,\"description\":\"Base of the exponential (seconds); the n-th retry waits multiplier \\u00d7 seconds^n, where n counts constant retries too\"},\"random_factor\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":100,\"description\":\"Random jitter percentage (0-100) to avoid thundering herd\"}}},\"retry_if\":{\"$ref\":\"#/components/schemas/RetryIf\"}}},\"FlowNote\":{\"type\":\"object\",\"description\":\"A sticky note attached to a flow for documentation and annotation\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for the note\"},\"text\":{\"type\":\"string\",\"description\":\"Content of the note\"},\"position\":{\"type\":\"object\",\"description\":\"Position of the note in the flow editor\",\"properties\":{\"x\":{\"type\":\"number\",\"description\":\"X coordinate\"},\"y\":{\"type\":\"number\",\"description\":\"Y coordinate\"}},\"required\":[\"x\",\"y\"]},\"size\":{\"type\":\"object\",\"description\":\"Size of the note in the flow editor\",\"properties\":{\"width\":{\"type\":\"number\",\"description\":\"Width in pixels\"},\"height\":{\"type\":\"number\",\"description\":\"Height in pixels\"}},\"required\":[\"width\",\"height\"]},\"color\":{\"type\":\"string\",\"description\":\"Color of the note, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Any other value renders unstyled.\"},\"type\":{\"type\":\"string\",\"enum\":[\"free\",\"group\"],\"description\":\"Type of note - 'free' for standalone notes. 'group' notes are deprecated; segment a flow with FlowValue.groups instead.\"},\"locked\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Whether the note is locked and cannot be edited or moved\"},\"contained_node_ids\":{\"type\":\"array\",\"items\":{\"type\":\"string\"},\"description\":\"For group notes, the IDs of nodes contained within this group\"}},\"required\":[\"id\",\"text\",\"color\",\"type\"]},\"FlowGroup\":{\"type\":\"object\",\"description\":\"A semantic group of flow modules for organizational purposes. Does not affect execution \\u2014 modules remain in their original position in the flow. Groups provide naming and collapsibility in the editor. Members are computed dynamically from all nodes on paths between start_id and end_id.\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Display name for this group\"},\"note\":{\"type\":\"string\",\"description\":\"Markdown note shown below the group header\"},\"autocollapse\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this group is collapsed by default in the flow editor. UI hint only.\"},\"start_id\":{\"type\":\"string\",\"description\":\"ID of the first flow module in this group (topological entry point)\"},\"end_id\":{\"type\":\"string\",\"description\":\"ID of the last flow module in this group (topological exit point)\"},\"color\":{\"type\":\"string\",\"description\":\"Color for the group in the flow editor, one of: yellow, blue, green, purple, pink, orange, red, cyan, lime, gray. Omit it to let the editor pick one.\"}},\"required\":[\"start_id\",\"end_id\"]},\"RetryIf\":{\"type\":\"object\",\"description\":\"Conditional retry based on error or result\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to retry. Has access to 'result' and 'error' variables\"}},\"required\":[\"expr\"]},\"StopAfterIf\":{\"type\":\"object\",\"description\":\"Early termination condition for a module\",\"properties\":{\"skip_if_stopped\":{\"type\":\"boolean\",\"description\":\"If true, following steps are skipped when this condition triggers\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression evaluated after the module runs. Can use 'result' (step's result) or 'flow_input'. Return true to stop\"},\"error_message\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Custom error message when stopping with an error. Mutually exclusive with skip_if_stopped. If set to a non-empty string, the flow stops with this error. If empty string, a default error message is used. If null or omitted, no error is raised.\"},\"error_include_result\":{\"type\":\"boolean\",\"description\":\"When stopping with an error (error_message set), embed the stopping step's own result inside the raised error object (as error.result) instead of discarding it. The top-level result stays { error }. Defaults to false.\"}},\"required\":[\"expr\"]},\"FlowModule\":{\"type\":\"object\",\"description\":\"A single step in a flow. Can be a script, subflow, loop, or branch\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this step. Used to reference results via 'results.step_id'. Must be a valid identifier (alphanumeric, underscore, hyphen)\"},\"value\":{\"$ref\":\"#/components/schemas/FlowModuleValue\"},\"stop_after_if\":{\"description\":\"Early termination condition evaluated after this step completes\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"stop_after_all_iters_if\":{\"description\":\"For loops only - early termination condition evaluated after all iterations complete\",\"$ref\":\"#/components/schemas/StopAfterIf\"},\"skip_if\":{\"type\":\"object\",\"description\":\"Conditionally skip this step based on previous results or flow inputs\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns true to skip. Can use 'flow_input' or 'results.'\"}},\"required\":[\"expr\"]},\"sleep\":{\"description\":\"Delay before executing this step (in seconds or as expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"cache_ttl\":{\"type\":\"number\",\"description\":\"Cache duration in seconds for this step's results\"},\"cache_ignore_s3_path\":{\"type\":\"boolean\"},\"timeout\":{\"description\":\"Maximum execution time in seconds (static value or expression)\",\"$ref\":\"#/components/schemas/InputTransform\"},\"delete_after_secs\":{\"type\":\"integer\",\"description\":\"If set, delete the step's args, result and logs after this many seconds following job completion\"},\"summary\":{\"type\":\"string\",\"description\":\"Short description of what this step does\"},\"mock\":{\"type\":\"object\",\"description\":\"Mock configuration for testing without executing the actual step\",\"properties\":{\"enabled\":{\"type\":\"boolean\",\"description\":\"If true, return mock value instead of executing\"},\"return_value\":{\"description\":\"Value to return when mocked\"}}},\"suspend\":{\"type\":\"object\",\"description\":\"Configuration for approval/resume steps that wait for user input\",\"properties\":{\"required_events\":{\"type\":\"integer\",\"description\":\"Number of approvals required before continuing\"},\"timeout\":{\"type\":\"integer\",\"description\":\"Timeout in seconds before auto-continuing or canceling\"},\"resume_form\":{\"type\":\"object\",\"description\":\"Form schema for collecting input when resuming\",\"properties\":{\"schema\":{\"type\":\"object\",\"description\":\"JSON Schema for the resume form\"}}},\"user_auth_required\":{\"type\":\"boolean\",\"description\":\"If true, only authenticated users can approve\"},\"user_groups_required\":{\"description\":\"Expression or list of groups that can approve\",\"$ref\":\"#/components/schemas/InputTransform\"},\"self_approval_disabled\":{\"type\":\"boolean\",\"description\":\"If true, the user who started the flow cannot approve\"},\"hide_cancel\":{\"type\":\"boolean\",\"description\":\"If true, hide the cancel button on the approval form\"},\"continue_on_disapprove_timeout\":{\"type\":\"boolean\",\"description\":\"If true, continue flow on timeout instead of canceling\"},\"skin\":{\"type\":\"string\",\"enum\":[\"detailed\",\"minimal\"],\"description\":\"How the approval request is presented, on the approval page and in Slack/Teams approval messages. 'detailed' (used when unset) shows the flow details (arguments, graph, approvers); 'minimal' shows only the request: the step description, form and approve/reject actions\"}}},\"priority\":{\"type\":\"number\",\"description\":\"Execution priority for this step (higher numbers run first)\"},\"continue_on_error\":{\"type\":\"boolean\",\"description\":\"If true, flow continues even if this step fails\"},\"job_token_scopes\":{\"type\":\"array\",\"nullable\":true,\"items\":{\"type\":\"string\"},\"description\":\"Scopes the token of this step's jobs is restricted to (domain:action[:resource], e.g. oidc:write), on top of the flow's own restriction: a step can only narrow it. Applies to everything the step runs (loop and branch bodies, AI agent tools).\"},\"retry\":{\"description\":\"Retry configuration if this step fails\",\"$ref\":\"#/components/schemas/Retry\"},\"debouncing\":{\"description\":\"Debounce configuration for this step (EE only)\",\"type\":\"object\",\"properties\":{\"debounce_delay_s\":{\"type\":\"integer\",\"description\":\"Delay in seconds to debounce this step's executions across flow runs\"},\"debounce_key\":{\"type\":\"string\",\"description\":\"Expression to group debounced executions. Supports $workspace and $args[name]. Default: $workspace/flow/-\"},\"debounce_args_to_accumulate\":{\"type\":\"array\",\"description\":\"Array-type arguments to accumulate across debounced executions\",\"items\":{\"type\":\"string\"}},\"max_total_debouncing_time\":{\"type\":\"integer\",\"description\":\"Maximum total time in seconds before forced execution\"},\"max_total_debounces_amount\":{\"type\":\"integer\",\"description\":\"Maximum number of debounces before forced execution\"}}}},\"required\":[\"value\",\"id\"]},\"InputTransform\":{\"description\":\"Maps input parameters for a step. Can be a static value or a JavaScript expression that references previous results or flow inputs\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"StaticTransform\":{\"type\":\"object\",\"description\":\"Static value passed directly to the step. Use for hardcoded values or resource references like '$res:path/to/resource'\",\"properties\":{\"value\":{\"description\":\"The static value. For resources, use format '$res:path/to/resource'\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\"]},\"JavascriptTransform\":{\"type\":\"object\",\"description\":\"JavaScript expression evaluated at runtime. Can reference previous step results via 'results.step_id' or flow inputs via 'flow_input.property'. Inside for loops, use 'flow_input.iter.value' for the current iteration value (in while loops it equals 'flow_input.iter.index')\",\"properties\":{\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression returning the value. Available variables - results (object with all previous step results), flow_input (flow inputs), flow_input.iter (in loops)\"},\"type\":{\"type\":\"string\",\"enum\":[\"javascript\"]}},\"required\":[\"expr\",\"type\"]},\"AiTransform\":{\"type\":\"object\",\"description\":\"Value resolved by the AI runtime for this input. The AI engine decides how to satisfy the parameter.\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"ai\"]}},\"required\":[\"type\"]},\"AIProviderKind\":{\"type\":\"string\",\"description\":\"Supported AI provider types\",\"enum\":[\"openai\",\"azure_openai\",\"azure_foundry\",\"anthropic\",\"mistral\",\"deepseek\",\"googleai\",\"groq\",\"openrouter\",\"togetherai\",\"customai\",\"aws_bedrock\",\"typesafe\",\"cloudflare\"]},\"ProviderConfig\":{\"type\":\"object\",\"description\":\"Complete AI provider configuration with resource reference and model selection\",\"properties\":{\"kind\":{\"$ref\":\"#/components/schemas/AIProviderKind\"},\"resource\":{\"type\":\"string\",\"description\":\"Resource reference in format '$res:{resource_path}' pointing to provider credentials\"},\"model\":{\"type\":\"string\",\"description\":\"Model identifier (e.g., 'gpt-4', 'claude-3-opus-20240229', 'gemini-pro')\"},\"reasoning_effort\":{\"type\":\"string\",\"description\":\"Provider-native reasoning effort token (e.g. 'low', 'high', 'none') for models that support extended thinking. Optional; unset leaves the provider default.\"}},\"required\":[\"kind\",\"resource\",\"model\"]},\"StaticProviderTransform\":{\"type\":\"object\",\"description\":\"Static provider configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/ProviderConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"ProviderTransform\":{\"description\":\"Provider configuration - can be static (ProviderConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticProviderTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticProviderTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"MemoryOff\":{\"type\":\"object\",\"description\":\"No conversation memory/context\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"off\"]}},\"required\":[\"kind\"]},\"MemoryWindow\":{\"type\":\"object\",\"description\":\"Keeps the most recent messages of the memory named by the run's memory id (or the step's\\n`memory_id`). Without a memory id the agent runs without memory.\\n\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"window\"]},\"context_length\":{\"type\":\"integer\",\"description\":\"Number of most recent messages to load and store. 0 turns memory off.\"}},\"required\":[\"kind\",\"context_length\"]},\"MemoryAuto\":{\"type\":\"object\",\"deprecated\":true,\"description\":\"Deprecated, still read as it was written: the run's memory id, else the `memory_id` here.\\nThe step's own `memory_id` is not read while this kind is set; switch the kind to `window`\\nto use it. Without a `context_length`, or with 0, it is `off` and reads `previous_messages`.\\n\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"auto\"]},\"context_length\":{\"type\":\"integer\",\"description\":\"Maximum number of messages to retain in context\"},\"memory_id\":{\"type\":\"string\",\"description\":\"Identifier for persistent memory across agent invocations\"}},\"required\":[\"kind\"]},\"MemoryCompaction\":{\"type\":\"object\",\"description\":\"Keeps the whole memory named by the run's memory id (or the step's `memory_id`), replacing\\nits older part with a summary as the conversation approaches the model's context window.\\nWithout a memory id the agent runs without memory, and compaction bounds the run's own loop.\\n\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"compaction\"]},\"context_window\":{\"type\":\"integer\",\"description\":\"Overrides the context window looked up from the model, in tokens. Only a model\\nWindmill does not know needs one; those fall back to 128000.\\n\"}},\"required\":[\"kind\"]},\"MemoryMessage\":{\"type\":\"object\",\"description\":\"A single message in conversation history\",\"properties\":{\"role\":{\"type\":\"string\",\"enum\":[\"user\",\"assistant\",\"system\"]},\"content\":{\"type\":\"string\"}},\"required\":[\"role\",\"content\"]},\"MemoryManual\":{\"type\":\"object\",\"deprecated\":true,\"description\":\"Deprecated, still read as it was written. Move the step to `off` with `previous_messages` instead.\",\"properties\":{\"kind\":{\"type\":\"string\",\"enum\":[\"manual\"]},\"messages\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/MemoryMessage\"}}},\"required\":[\"kind\",\"messages\"]},\"MemoryConfig\":{\"description\":\"Managed memory, stored by Windmill and replayed with each request. The memory is named by a memory id, see `memory_id`. While it is off, a step can supply its history in `previous_messages`.\",\"oneOf\":[{\"$ref\":\"#/components/schemas/MemoryOff\"},{\"$ref\":\"#/components/schemas/MemoryWindow\"},{\"$ref\":\"#/components/schemas/MemoryCompaction\"},{\"$ref\":\"#/components/schemas/MemoryAuto\"},{\"$ref\":\"#/components/schemas/MemoryManual\"}],\"discriminator\":{\"propertyName\":\"kind\",\"mapping\":{\"off\":\"#/components/schemas/MemoryOff\",\"window\":\"#/components/schemas/MemoryWindow\",\"compaction\":\"#/components/schemas/MemoryCompaction\",\"auto\":\"#/components/schemas/MemoryAuto\",\"manual\":\"#/components/schemas/MemoryManual\"}}},\"StaticMemoryTransform\":{\"type\":\"object\",\"description\":\"Static memory configuration passed directly to the AI agent\",\"properties\":{\"value\":{\"$ref\":\"#/components/schemas/MemoryConfig\"},\"type\":{\"type\":\"string\",\"enum\":[\"static\"]}},\"required\":[\"type\",\"value\"]},\"MemoryTransform\":{\"description\":\"Memory configuration - can be static (MemoryConfig), JavaScript expression, or AI-determined\",\"oneOf\":[{\"$ref\":\"#/components/schemas/StaticMemoryTransform\"},{\"$ref\":\"#/components/schemas/JavascriptTransform\"},{\"$ref\":\"#/components/schemas/AiTransform\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"static\":\"#/components/schemas/StaticMemoryTransform\",\"javascript\":\"#/components/schemas/JavascriptTransform\",\"ai\":\"#/components/schemas/AiTransform\"}}},\"FlowModuleValue\":{\"description\":\"The actual implementation of a flow step. Can be a script (inline or referenced), subflow, loop, branch, or special module type\",\"oneOf\":[{\"$ref\":\"#/components/schemas/RawScript\"},{\"$ref\":\"#/components/schemas/PathScript\"},{\"$ref\":\"#/components/schemas/PathFlow\"},{\"$ref\":\"#/components/schemas/ForloopFlow\"},{\"$ref\":\"#/components/schemas/WhileloopFlow\"},{\"$ref\":\"#/components/schemas/BranchOne\"},{\"$ref\":\"#/components/schemas/BranchAll\"},{\"$ref\":\"#/components/schemas/Identity\"},{\"$ref\":\"#/components/schemas/AiAgent\"},{\"$ref\":\"#/components/schemas/AiDecision\"}],\"discriminator\":{\"propertyName\":\"type\",\"mapping\":{\"rawscript\":\"#/components/schemas/RawScript\",\"script\":\"#/components/schemas/PathScript\",\"flow\":\"#/components/schemas/PathFlow\",\"forloopflow\":\"#/components/schemas/ForloopFlow\",\"whileloopflow\":\"#/components/schemas/WhileloopFlow\",\"branchone\":\"#/components/schemas/BranchOne\",\"branchall\":\"#/components/schemas/BranchAll\",\"identity\":\"#/components/schemas/Identity\",\"aiagent\":\"#/components/schemas/AiAgent\",\"aidecision\":\"#/components/schemas/AiDecision\"}}},\"RawScript\":{\"type\":\"object\",\"description\":\"Inline script with code defined directly in the flow. Use 'bun' as default language if unspecified. The script receives arguments from input_transforms\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"content\":{\"type\":\"string\",\"description\":\"The script source code. Should export a 'main' function\"},\"language\":{\"type\":\"string\",\"description\":\"Programming language for this script\",\"enum\":[\"deno\",\"bun\",\"bunnative\",\"python3\",\"go\",\"bash\",\"powershell\",\"postgresql\",\"mysql\",\"bigquery\",\"snowflake\",\"mssql\",\"oracledb\",\"graphql\",\"nativets\",\"php\",\"rust\",\"ansible\",\"csharp\",\"nu\",\"java\",\"ruby\",\"rlang\",\"duckdb\"]},\"path\":{\"type\":\"string\",\"description\":\"Optional path for saving this script\"},\"lock\":{\"type\":\"string\",\"description\":\"Lock file content for dependencies\"},\"type\":{\"type\":\"string\",\"enum\":[\"rawscript\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing\"},\"concurrent_limit\":{\"type\":\"number\",\"description\":\"Maximum concurrent executions of this script\"},\"concurrency_time_window_s\":{\"type\":\"number\",\"description\":\"Time window for concurrent_limit\"},\"custom_concurrency_key\":{\"type\":\"string\",\"description\":\"Custom key for grouping concurrent executions\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"},\"assets\":{\"type\":\"array\",\"description\":\"External resources this script accesses (S3 objects, resources, etc.)\",\"items\":{\"type\":\"object\",\"required\":[\"path\",\"kind\"],\"properties\":{\"path\":{\"type\":\"string\",\"description\":\"Path to the asset\"},\"kind\":{\"type\":\"string\",\"description\":\"Type of asset\",\"enum\":[\"s3object\",\"resource\",\"ducklake\",\"datatable\",\"volume\",\"dbt\"]},\"access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Access level for this asset\",\"enum\":[\"r\",\"w\",\"rw\"]},\"alt_access_type\":{\"type\":\"string\",\"nullable\":true,\"description\":\"Alternative access level\",\"enum\":[\"r\",\"w\",\"rw\"]}}}}},\"required\":[\"type\",\"content\",\"language\",\"input_transforms\"]},\"PathScript\":{\"type\":\"object\",\"description\":\"Reference to an existing script by path. Use this when calling a previously saved script instead of writing inline code\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the script's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the script in the workspace (e.g., 'f/scripts/send_email')\"},\"hash\":{\"type\":\"string\",\"description\":\"Optional specific version hash of the script to use\"},\"type\":{\"type\":\"string\",\"enum\":[\"script\"]},\"tag_override\":{\"type\":\"string\",\"description\":\"Override the script's default worker group tag\"},\"is_trigger\":{\"type\":\"boolean\",\"description\":\"If true, this script is a trigger that can start the flow\"}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"PathFlow\":{\"type\":\"object\",\"description\":\"Reference to an existing flow by path. Use this to call another flow as a subflow\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Map of parameter names to their values (static or JavaScript expressions). These become the subflow's input arguments\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}},\"path\":{\"type\":\"string\",\"description\":\"Path to the flow in the workspace (e.g., 'f/flows/process_user')\"},\"type\":{\"type\":\"string\",\"enum\":[\"flow\"]}},\"required\":[\"type\",\"path\",\"input_transforms\"]},\"ForloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules in a loop over an iterator. Inside the loop, use 'flow_input.iter.value' to access the current iteration value, and 'flow_input.iter.index' for the index. Supports parallel execution for better performance on I/O-bound operations\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute for each iteration. These can reference the iteration value via 'flow_input.iter.value'\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"iterator\":{\"description\":\"JavaScript expression that returns an array to iterate over. Can reference 'results.step_id' or 'flow_input'\",\"$ref\":\"#/components/schemas/InputTransform\"},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"forloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (faster for I/O-bound operations). Use with parallelism to control concurrency\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true. Limits resource usage. Can be static number or expression\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"iterator\",\"skip_failures\",\"type\"]},\"WhileloopFlow\":{\"type\":\"object\",\"description\":\"Executes nested modules repeatedly until stopped. The implicit iterator is the iteration counter, so 'flow_input.iter.value' equals 'flow_input.iter.index' (0, 1, 2, ...) and never carries state. To carry state across iterations, a step reads its own previous-iteration result via 'results.' with a first-iteration fallback - the loop's stop_after_if must then be on that inner step (a plain single-step body with stop_after_if on the loop module does not resolve 'results' across iterations and never terminates); plain counters can instead be derived from 'flow_input.iter.index', which works in every configuration. stop_after_if is evaluated after each iteration - on the loop module 'result' is the last iteration's result\",\"properties\":{\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in each iteration\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"skip_failures\":{\"type\":\"boolean\",\"description\":\"If true, iteration failures don't stop the loop. Failed iterations return null\"},\"type\":{\"type\":\"string\",\"enum\":[\"whileloopflow\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, iterations run concurrently (use with caution in while loops)\"},\"parallelism\":{\"description\":\"Maximum number of concurrent iterations when parallel=true\",\"$ref\":\"#/components/schemas/InputTransform\"},\"squash\":{\"type\":\"boolean\"}},\"required\":[\"modules\",\"skip_failures\",\"type\"]},\"BranchOne\":{\"type\":\"object\",\"description\":\"Conditional branching where only the first matching branch executes. Branches are evaluated in order, and the first one with a true expression runs. If no branches match, the default branch executes\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches to evaluate in order. The first branch with expr evaluating to true executes\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch condition\"},\"expr\":{\"type\":\"string\",\"description\":\"JavaScript expression that returns boolean. Can use 'results.step_id' or 'flow_input'. First true expr wins\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute if this branch's expr is true\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\",\"expr\"]}},\"default\":{\"type\":\"array\",\"description\":\"Steps to execute if no branch expressions match\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}},\"type\":{\"type\":\"string\",\"enum\":[\"branchone\"]}},\"required\":[\"branches\",\"default\",\"type\"]},\"BranchAll\":{\"type\":\"object\",\"description\":\"Parallel branching where all branches execute simultaneously. Unlike BranchOne, all branches run regardless of conditions. Useful for executing independent tasks concurrently\",\"properties\":{\"branches\":{\"type\":\"array\",\"description\":\"Array of branches that all execute (either in parallel or sequentially)\",\"items\":{\"type\":\"object\",\"properties\":{\"summary\":{\"type\":\"string\",\"description\":\"Short description of this branch's purpose\"},\"skip_failure\":{\"type\":\"boolean\",\"description\":\"If true, failure in this branch doesn't fail the entire flow\"},\"modules\":{\"type\":\"array\",\"description\":\"Steps to execute in this branch\",\"items\":{\"$ref\":\"#/components/schemas/FlowModule\"}}},\"required\":[\"modules\"]}},\"type\":{\"type\":\"string\",\"enum\":[\"branchall\"]},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, all branches execute concurrently. If false, they execute sequentially\"}},\"required\":[\"branches\",\"type\"]},\"AgentTool\":{\"type\":\"object\",\"description\":\"A tool available to an AI agent. Can be a flow module or an external MCP (Model Context Protocol) tool\",\"properties\":{\"id\":{\"type\":\"string\",\"description\":\"Unique identifier for this tool. Cannot contain spaces - use underscores instead (e.g., 'get_user_data' not 'get user data')\"},\"summary\":{\"type\":\"string\",\"description\":\"The name the AI agent calls this tool by, not a human label. On a flowmodule tool it must match ^[a-zA-Z0-9_]+$ - letters, numbers and underscores only (e.g. 'search_documentation', not 'Search documentation') - and always be set; on an mcp or websearch tool it is a plain label. Put the human-readable explanation in 'description'.\"},\"description\":{\"type\":\"string\",\"description\":\"Free-text description of the tool given to the AI to decide when and how to call it. Overrides the description auto-derived from the underlying script.\"},\"job_token_scopes\":{\"type\":\"array\",\"nullable\":true,\"items\":{\"type\":\"string\"},\"description\":\"Scopes the token of this tool's jobs is restricted to, on top of the agent step's own restriction: a tool can only narrow it.\"},\"value\":{\"$ref\":\"#/components/schemas/ToolValue\"}},\"required\":[\"id\",\"value\"]},\"ToolValue\":{\"description\":\"The implementation of a tool. Can be a flow module (script/flow) or an MCP tool reference\",\"oneOf\":[{\"$ref\":\"#/components/schemas/FlowModuleTool\"},{\"$ref\":\"#/components/schemas/McpToolValue\"},{\"$ref\":\"#/components/schemas/WebsearchToolValue\"}],\"discriminator\":{\"propertyName\":\"tool_type\",\"mapping\":{\"flowmodule\":\"#/components/schemas/FlowModuleTool\",\"mcp\":\"#/components/schemas/McpToolValue\",\"websearch\":\"#/components/schemas/WebsearchToolValue\"}}},\"FlowModuleTool\":{\"description\":\"A tool implemented as a flow module (script, flow, etc.). The AI can call this like any other flow module\",\"allOf\":[{\"type\":\"object\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"flowmodule\"]}},\"required\":[\"tool_type\"]},{\"$ref\":\"#/components/schemas/FlowModuleValue\"}]},\"WebsearchToolValue\":{\"type\":\"object\",\"description\":\"A tool implemented as a websearch tool. The AI can call this like any other websearch tool\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"websearch\"]}},\"required\":[\"tool_type\"]},\"McpToolValue\":{\"type\":\"object\",\"description\":\"Reference to an external MCP (Model Context Protocol) tool. The AI can call tools from MCP servers\",\"properties\":{\"tool_type\":{\"type\":\"string\",\"enum\":[\"mcp\"]},\"resource_path\":{\"type\":\"string\",\"description\":\"Path to the MCP resource/server configuration\"},\"include_tools\":{\"type\":\"array\",\"description\":\"Whitelist of specific tools to include from this MCP server\",\"items\":{\"type\":\"string\"}},\"exclude_tools\":{\"type\":\"array\",\"description\":\"Blacklist of tools to exclude from this MCP server\",\"items\":{\"type\":\"string\"}}},\"required\":[\"tool_type\",\"resource_path\"]},\"AiAgent\":{\"type\":\"object\",\"description\":\"AI agent step that can use tools to accomplish tasks. The agent receives inputs and can call any of its configured tools to complete the task\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Input parameters for the AI agent mapped to their values\",\"properties\":{\"provider\":{\"$ref\":\"#/components/schemas/ProviderTransform\"},\"output_type\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Output format type.\\nValid values: 'text' (default) - plain text response, 'image' - image generation\\n\"},\"user_message\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"The user's prompt/message to the AI agent. Supports variable interpolation with\\nflow.input syntax. Required unless memory is off and `previous_messages` supplies\\nthe prompt; image output always needs it.\\n\"},\"system_prompt\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"System instructions that guide the AI's behavior, persona, and response style. Optional.\"},\"streaming\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Boolean. If true, stream the AI response incrementally.\\nStreaming events include: token_delta, reasoning_token_delta, tool_call, tool_call_arguments, tool_execution, tool_result\\n\"},\"memory\":{\"$ref\":\"#/components/schemas/MemoryTransform\"},\"memory_id\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"String. Names the memory this step reads and writes, overriding the memory id the run\\nwas started with (the chat conversation, an app chat session or the `memory_id` run\\nparameter). Leave unset to use the run's memory id. A fixed value shares one memory\\nacross every run; an expression such as `flow_input.customer_id` keeps one memory per\\nkey. When it evaluates to an empty value the agent runs without memory. Read only\\nwhile `memory` is `window` or `compaction`: it is ignored when memory is off, and an\\nolder `auto` or `manual` memory reads neither history input.\\n\"},\"previous_messages\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of MemoryMessage. History supplied by the flow, sent between the system prompt\\nand the user message. Read only while `memory` is off or absent: managed memory\\nignores it, and an older `auto` or `manual` memory reads neither history input.\\n\"},\"output_schema\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"JSON Schema object defining structured output format. Used when you need the AI to return data in a specific shape.\\nSupports standard JSON Schema properties: type, properties, required, items, enum, pattern, minLength, maxLength, minimum, maximum, etc.\\nExample: { type: 'object', properties: { name: { type: 'string' }, age: { type: 'integer' } }, required: ['name'] }\\n\"},\"user_attachments\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of file references (images or PDFs) for the AI agent.\\nFormat: Array<{ bucket: string, key: string }> - S3 object references\\nExample: [{ bucket: 'my-bucket', key: 'documents/report.pdf' }]\\n\"},\"enabled_tools\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Array of strings naming which of the tools configured in `tools` the agent may call\\nthis run. Leaving it unset carries every one of them; an empty array carries none.\\nA tool is named as the model is shown it. An entry the model is shown nothing of is\\nnamed by what identifies it instead: an MCP server by its resource path, carrying\\nevery tool it exposes (which of them stays that entry's include_tools/exclude_tools),\\nand a websearch entry by the reserved name '__wm_web_search', whatever summary it carries\\n(no tool may take that name).\\nExample: ['get_user', 'u/admin/github_mcp', '__wm_web_search']\\n\"},\"max_completion_tokens\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Integer. Maximum number of tokens the AI will generate in its response.\\nRange: 1 to 4,294,967,295. Typical values: 256-4096 for most use cases.\\n\"},\"temperature\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Float. Controls randomness/creativity of responses.\\nRange: 0.0 to 2.0 (provider-dependent)\\n- 0.0 = deterministic, focused responses\\n- 0.7 = balanced (common default)\\n- 1.0+ = more creative/random\\n\"},\"max_iterations\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Number. Limits how many times the agent can loop through reasoning and tool use.\\nRange: 1-1000.\\n\"},\"model\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"String. Only on a step linked to a saved agent (see `agent`): replaces the model\\nsaved on the agent for this step's runs, on the agent's own provider resource. An\\nempty value keeps the saved model. Set it to an expression such as\\n`flow_input.model` to choose the model per run.\\n\"},\"reasoning_effort\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"String. Only on a step linked to a saved agent (see `agent`): replaces the reasoning\\neffort saved on the agent for this step's runs, as a provider-native token (e.g.\\n'low', 'high', 'none'). An empty value sends no effort, so the model runs at its\\ndefault. Leave the input out to keep the saved effort, which holds only while the\\nstep runs the saved model. A step that is not linked sets both inside `provider`.\\n\"}}},\"tools\":{\"type\":\"array\",\"description\":\"Array of tools the agent can use. The agent decides which tools to call based on the task\",\"items\":{\"$ref\":\"#/components/schemas/AgentTool\"}},\"type\":{\"type\":\"string\",\"enum\":[\"aiagent\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing. If not set, the AI agent step runs on the flow's tag (default `flow`)\"},\"omit_output_from_conversation\":{\"type\":\"boolean\",\"default\":false,\"description\":\"If true, this AI agent step does not persist its assistant or tool messages to the flow conversation when chat mode is enabled.\"},\"agent\":{\"type\":\"string\",\"description\":\"Path of a reusable `ai_agent` resource (hybrid linking). When set, the agent brain\\nconfig (provider/model/system prompt/etc.) and tool set are resolved at runtime from\\nthat resource; the module's input_transforms then only carry the flow-local inputs\\n(user_message, user_attachments, enabled_tools and the history inputs memory_id and previous_messages)\\nand, optionally, the `model` and `reasoning_effort` overrides of what the agent saved.\\n\"},\"tool_inputs\":{\"type\":\"object\",\"description\":\"Host-local wiring for an agent's tool inputs, keyed by tool id then input key. Binds the\\nreferenced agent's tools to this flow's context (flow_input/results) without mutating the\\nshared resource; overlaid onto the tools' input_transforms at runtime \\u2014 including when\\n`agent` is unset, since a step forked for editing keeps these overrides until it is saved\\nback or unlinked.\\n\",\"additionalProperties\":{\"type\":\"object\",\"additionalProperties\":{\"$ref\":\"#/components/schemas/InputTransform\"}}},\"parallel\":{\"type\":\"boolean\",\"description\":\"If true, the agent can execute multiple tool calls in parallel\"}},\"required\":[\"type\",\"input_transforms\"]},\"AiDecision\":{\"type\":\"object\",\"description\":\"AI decision step: one call to a decision model (TypeSafe's Jev, Cloudflare's\\nJev-compatible Clef, or OpenAI's Decisions API) that answers typed questions about a\\nstate with calibrated probabilities, instead of generating text.\\nPrefer it over an AI agent to classify, route, score or check something: it is fast,\\ncheap and its answers are structured. Its result is { output: { : answer },\\nmodel, usage }, so a later step reads e.g. results..output.intent.choice. To route on\\nthe answers, follow it with a branchone whose branch exprs read\\nresults..output..choice. Usable as an AI agent tool, where the calling agent\\nsupplies the state.\\n\",\"properties\":{\"input_transforms\":{\"type\":\"object\",\"description\":\"Input parameters for the decision mapped to their values\",\"properties\":{\"provider\":{\"$ref\":\"#/components/schemas/ProviderTransform\"},\"state\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"What the questions are asked about: a string, an object or an array of strings.\\nAn object with descriptive keys holding only what the questions need works best.\\nExample: { message: flow_input.message, plan: results.a.plan }\\n\"},\"questions\":{\"allOf\":[{\"$ref\":\"#/components/schemas/InputTransform\"}],\"description\":\"Object mapping each question name to { type, instructions, criteria }, where type is:\\n- 'choice': criteria maps each option to its description (up to 255); the answer\\n has choice, probabilities and confidence\\n- 'score': criteria is an ordered array of 2 to 10 level descriptions; the answer\\n has score, legend, probabilities and confidence\\n- 'noul': yes/no, criteria optionally { true, false } descriptions; the answer has\\n noul, the probability of yes (0 to 1)\\nExample: { intent: { type: 'choice', instructions: 'What does the customer want?',\\ncriteria: { refund: 'Money back', bug: 'Something is broken' } } }\\n\"}},\"required\":[\"provider\",\"state\",\"questions\"]},\"type\":{\"type\":\"string\",\"enum\":[\"aidecision\"]},\"tag\":{\"type\":\"string\",\"description\":\"Worker group tag for execution routing. If not set, the step runs on the flow's tag (default `flow`)\"}},\"required\":[\"type\",\"input_transforms\"]},\"Identity\":{\"type\":\"object\",\"description\":\"Pass-through module that returns its input unchanged. Useful for flow structure or as a placeholder\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"identity\"]},\"flow\":{\"type\":\"boolean\",\"description\":\"If true, marks this as a flow identity (special handling)\"}},\"required\":[\"type\"]},\"FlowStatus\":{\"type\":\"object\",\"properties\":{\"step\":{\"type\":\"integer\"},\"modules\":{\"type\":\"array\",\"items\":{\"$ref\":\"#/components/schemas/FlowStatusModule\"}},\"user_states\":{\"additionalProperties\":true},\"preprocessor_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"}]},\"failure_module\":{\"allOf\":[{\"$ref\":\"#/components/schemas/FlowStatusModule\"},{\"type\":\"object\",\"properties\":{\"parent_module\":{\"type\":\"string\"}}}]},\"retry\":{\"type\":\"object\",\"properties\":{\"fail_count\":{\"type\":\"integer\"},\"failed_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}}}}},\"required\":[\"step\",\"modules\",\"failure_module\"]},\"FlowStatusModule\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"WaitingForPriorSteps\",\"WaitingForEvents\",\"WaitingForExecutor\",\"InProgress\",\"Success\",\"Failure\"]},\"id\":{\"type\":\"string\"},\"job\":{\"type\":\"string\",\"format\":\"uuid\"},\"count\":{\"type\":\"integer\"},\"progress\":{\"type\":\"integer\"},\"iterator\":{\"type\":\"object\",\"properties\":{\"index\":{\"type\":\"integer\"},\"itered\":{\"type\":\"array\",\"items\":{}},\"itered_len\":{\"type\":\"integer\"},\"args\":{}}},\"flow_jobs\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"flow_jobs_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}},\"flow_jobs_duration\":{\"type\":\"object\",\"properties\":{\"started_at\":{\"type\":\"array\",\"items\":{\"type\":\"string\"}},\"duration_ms\":{\"type\":\"array\",\"items\":{\"type\":\"integer\"}}}},\"branch_chosen\":{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"branch\",\"default\"]},\"branch\":{\"type\":\"integer\"}},\"required\":[\"type\"]},\"branchall\":{\"type\":\"object\",\"properties\":{\"branch\":{\"type\":\"integer\"},\"len\":{\"type\":\"integer\"}},\"required\":[\"branch\",\"len\"]},\"approvers\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"properties\":{\"resume_id\":{\"type\":\"integer\"},\"approver\":{\"type\":\"string\"}},\"required\":[\"resume_id\",\"approver\"]}},\"failed_retries\":{\"type\":\"array\",\"items\":{\"type\":\"string\",\"format\":\"uuid\"}},\"skipped\":{\"type\":\"boolean\"},\"agent_actions\":{\"type\":\"array\",\"items\":{\"type\":\"object\",\"oneOf\":[{\"type\":\"object\",\"properties\":{\"job_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"tool_call\"]},\"module_id\":{\"type\":\"string\"}},\"required\":[\"job_id\",\"function_name\",\"type\",\"module_id\"]},{\"type\":\"object\",\"properties\":{\"call_id\":{\"type\":\"string\",\"format\":\"uuid\"},\"function_name\":{\"type\":\"string\"},\"resource_path\":{\"type\":\"string\"},\"type\":{\"type\":\"string\",\"enum\":[\"mcp_tool_call\"]},\"arguments\":{\"type\":\"object\"}},\"required\":[\"call_id\",\"function_name\",\"resource_path\",\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"web_search\"]}},\"required\":[\"type\"]},{\"type\":\"object\",\"properties\":{\"type\":{\"type\":\"string\",\"enum\":[\"message\"]}},\"required\":[\"content\",\"type\"]}]}},\"agent_actions_success\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}}},\"required\":[\"type\"]}}", - "resource": "# Global draft resource & variable instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.\n- A resource draft is a workspace item: `{ type: 'resource', path, summary?, value, isDraft }`. `value` is a CreateResource body: `{ path, value, description?, resource_type, labels? }` where the inner `value` is the resource type's data shape.\n- Reading a variable returns `{ type: 'variable', path, summary?, isSecret, isDraft }` — never its value, secret or not. `isSecret` tells you whether the value is encrypted.\n- `write_variable` takes `{ path, value?, is_secret?, description?, account?, is_oauth?, expires_at?, labels? }`. Creating a variable needs `value` and `is_secret`; editing one needs only the fields you are changing. Omitting `value` keeps the stored value, which is the only way to edit a secret variable — you cannot read its value, so passing any `value` you did not get from the user destroys it.\n- For secret fields in a resource value, create the variable with `write_variable` and `is_secret: true`, and deploy it before the resource (see \"Secrets\" in the reference below).\n- Reference formats inside resource values: `$var:g/all/name` (global), `$var:u/user/name` (user), `$var:f/folder/name` (folder). Reference another resource with `$res:path/to/resource`. The same strings are also how a resource or variable is passed as a run argument (see the run-argument rule in the resource reference below); what they are never valid as is a variable's own value.\n- Use `search_resource_types` to discover valid `resource_type` names and their JSON Schemas. Match the resource value to that schema.\n- For OAuth resources, the `is_oauth: true` flag is managed by Windmill's OAuth flow; global mode generally creates manual resources, not OAuth ones.\n\n# Windmill resource & variable reference\n\n# Windmill Resources\n\nResources store credentials and configuration for external services.\n\n## Resource Structure\n\n```json\n{\n \"value\": {\n \"host\": \"db.example.com\",\n \"port\": 5432,\n \"user\": \"admin\",\n \"password\": \"$var:g/all/db_password\",\n \"dbname\": \"production\"\n },\n \"description\": \"Production PostgreSQL database\",\n \"resource_type\": \"postgresql\"\n}\n```\n\n## Required Fields\n\n- `value` - Object containing the resource configuration\n- `resource_type` - Name of the resource type (e.g., \"postgresql\", \"slack\")\n\n## Agents\n\nAn agent, as the Agents page lists it, is a resource of type `ai_agent`. Its value holds the\nagent's provider, system prompt, tools and memory settings, and a flow step uses a saved agent by\nlinking its path.\n\n## Variable References\n\nReference variables in resource values:\n\n```json\n{\n \"value\": {\n \"api_key\": \"$var:g/all/api_key\",\n \"secret\": \"$var:u/admin/secret\"\n }\n}\n```\n\n**Reference formats:**\n- `$var:g/all/name` - Global variable\n- `$var:u/username/name` - User variable\n- `$var:f/folder/name` - Folder variable\n\n## Secrets\n\nNever put a secret (password, API key, token) inline in a resource value. Store it in a secret variable and reference that variable as `$var:`.\n\n- `$var:` is a reference, not a value: it resolves to the variable's value at run time. Never invent a value for a variable.\n- A resource that references a variable needs the variable to exist first, so create or deploy the variable before the resource.\n\n## Resource References\n\nReference other resources:\n\n```json\n{\n \"value\": {\n \"database\": \"$res:f/databases/postgres\"\n }\n}\n```\n\n## Passing a Resource or Variable as a Run Argument\n\nA script or flow argument typed as a resource (schema `format: resource-`) is passed as\nthe **bare string** `$res:` — the whole argument value. Same for a variable, with\n`$var:`. This applies everywhere job arguments are supplied: `wmill script run/preview`,\n`wmill flow run/preview`, the `runScriptByPath` / `runFlowByPath` API, a schedule's `args`, a\ntrigger's configured static args.\n\n```json\n{\n \"db\": \"$res:f/databases/postgres_prod\",\n \"api_token\": \"$var:g/all/api_token\"\n}\n```\n\nThe reference is resolved when the job runs, under the job's run-as identity — the caller for an\nordinary run, but the configured principal for a schedule, a trigger, or a runnable set to run on\nbehalf of someone else. The run fails if that identity cannot read the referenced resource or\nvariable.\n\n**Never wrap it in an object.** The resolver only rewrites a JSON value that *is* a string\nstarting with `$res:` / `$var:`; keys are never inspected. These are all wrong and are passed\nthrough to the script unchanged:\n\n```json\n{ \"db\": { \"$res\": \"f/databases/postgres_prod\" } }\n{ \"db\": { \"resource\": \"f/databases/postgres_prod\" } }\n{ \"db\": \"f/databases/postgres_prod\" }\n```\n\nThe string may sit anywhere a string can — a top-level argument, a nested object field\n(`{ \"gh_auth\": { \"token\": \"$var:g/all/gh_token\" } }`), or an array element (array elements are\nwalked only while nested at most two levels deep, and only for arrays of at most 1000 items).\nThe prefix must be on the string itself.\n\n## Common Resource Types\n\n### PostgreSQL\n```json\n{\n \"resource_type\": \"postgresql\",\n \"value\": {\n \"host\": \"localhost\",\n \"port\": 5432,\n \"user\": \"postgres\",\n \"password\": \"$var:g/all/pg_password\",\n \"dbname\": \"windmill\",\n \"sslmode\": \"prefer\"\n }\n}\n```\n\n### MySQL\n```json\n{\n \"resource_type\": \"mysql\",\n \"value\": {\n \"host\": \"localhost\",\n \"port\": 3306,\n \"user\": \"root\",\n \"password\": \"$var:g/all/mysql_password\",\n \"database\": \"myapp\"\n }\n}\n```\n\n### Slack\n```json\n{\n \"resource_type\": \"slack\",\n \"value\": {\n \"token\": \"$var:g/all/slack_token\"\n }\n}\n```\n\n### AWS S3\n```json\n{\n \"resource_type\": \"s3\",\n \"value\": {\n \"bucket\": \"my-bucket\",\n \"region\": \"us-east-1\",\n \"accessKeyId\": \"$var:g/all/aws_access_key\",\n \"secretAccessKey\": \"$var:g/all/aws_secret_key\"\n }\n}\n```\n\n### HTTP/API\n```json\n{\n \"resource_type\": \"http\",\n \"value\": {\n \"baseUrl\": \"https://api.example.com\",\n \"headers\": {\n \"Authorization\": \"Bearer $var:g/all/api_token\"\n }\n }\n}\n```\n\n### Kafka\n```json\n{\n \"resource_type\": \"kafka\",\n \"value\": {\n \"brokers\": \"broker1:9092,broker2:9092\",\n \"sasl_mechanism\": \"PLAIN\",\n \"security_protocol\": \"SASL_SSL\",\n \"username\": \"$var:g/all/kafka_user\",\n \"password\": \"$var:g/all/kafka_password\"\n }\n}\n```\n\n### NATS\n```json\n{\n \"resource_type\": \"nats\",\n \"value\": {\n \"servers\": [\"nats://localhost:4222\"],\n \"user\": \"$var:g/all/nats_user\",\n \"password\": \"$var:g/all/nats_password\"\n }\n}\n```\n\n### MQTT\n```json\n{\n \"resource_type\": \"mqtt\",\n \"value\": {\n \"host\": \"mqtt.example.com\",\n \"port\": 8883,\n \"username\": \"$var:g/all/mqtt_user\",\n \"password\": \"$var:g/all/mqtt_password\",\n \"tls\": true\n }\n}\n```\n\n## When No Resource Type Fits\n\nThe Windmill Hub publishes resource types for most services, and an instance gets them by syncing with the hub. When none fits the service:\n\n- If the instance is reported as not having synced the hub's types, mention that a superadmin can sync them: the hub most likely has one for the service. That holds for a type a hub script takes too: it is missing here until the sync, so say so before asking for a resource of it. Create one of your own only if the user still wants it, since after a sync the workspace would hold both.\n- Otherwise, or when the user prefers it to syncing, propose a custom resource type named `c_` holding the service's connection fields (base URL, account or region identifiers, credentials), as in \"Custom Resource Types\" below, and ask the user before creating it: every resource of that type and every script taking one depends on its fields.\n- A plain variable instead of a resource is fine when the user asks for it; without that, default to the resource, its secret in a variable it references.\n\n## Custom Resource Types\n\nA custom type's name starts with `c_`: the hub's types reach every instance under their plain names, so the prefix keeps a custom type from clashing with one synced later. Scripts and flows refer to the full name: `format: resource-c_custom_api`, `RT.CCustomApi` in TypeScript, a `c_custom_api` TypedDict in Python.\n\nCreate custom resource types with JSON Schema:\n\n```json\n{\n \"name\": \"c_custom_api\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"base_url\": {\"type\": \"string\", \"format\": \"uri\"},\n \"api_key\": {\"type\": \"string\"},\n \"timeout\": {\"type\": \"integer\", \"default\": 30}\n },\n \"required\": [\"base_url\", \"api_key\"]\n },\n \"description\": \"Custom API connection\"\n}\n```\n\nSave as: `c_custom_api.resource-type.json`\n\n## OAuth Resources\n\nOAuth resources are managed through the Windmill UI and marked:\n\n```json\n{\n \"is_oauth\": true,\n \"account\": 123\n}\n```\n\nOAuth tokens are automatically refreshed by Windmill.\n\n## Using Resources in Scripts\n\n### TypeScript (Bun/Deno)\n```typescript\nexport async function main(db: RT.Postgresql) {\n // db contains the resource values\n const { host, port, user, password, dbname } = db;\n}\n```\n\n### Python\n```python\nclass postgresql(TypedDict):\n host: str\n port: int\n user: str\n password: str\n dbname: str\n\ndef main(db: postgresql):\n # db contains the resource values\n pass\n```\n\n", - "app": "# Global draft app instructions\n\n- Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy.\n- App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to `u//` for bare names; only use `f//` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.\n- To create a new app, use `init_app` with a path, optional summary, a framework (`react19` / `react18` / `svelte5`) and, for an app that stores data, its `data` setup. \"Starting a new app\" below decides the framework and when the data setup is asked. Ask the user each of its questions, suggested answer first, before `init_app` and before any SQL. `init_app` only records `data`: a new schema is yours to create with the datatable SQL tool. `init_app` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.\n- `init_app` seeds the starter inline runnables `greet`, `word_stats`, `slow_task`, `stream_text` so the demo tour works on first render. Replace or remove them once you start building real backend runnables.\n- Frontend file paths start with `/` (e.g. `/index.tsx`, `/App.tsx`, `/styles.css`). Use `write_app_file` / `patch_app_file` / `delete_app_file`.\n- Backend inline runnables are addressed as `backend//main.{ts|py}` from the file tools, but you create or update them via `write_app_runnable` / `delete_app_runnable` (which take the runnable shape directly: `{ name, type, inlineScript?, path?, staticInputs? }`).\n- `/wmill.d.ts` (or `wmill.ts`) is generated automatically from the backend runnables — never write it directly.\n- Inline runnables only support `bun` or `python3` in chat. Path runnables (`script`/`flow`/`hubscript`) reference an existing item.\n- When a path runnable points at a draft (see \"Draft code vs deployed code\" below), offer to deploy just that flow/script with deploy_workspace_item, not through the compare page.\n- The authoring reference below carries the TypeScript SDK. For a `python3` runnable, call `get_instructions` again with `subject: \"app\"` and `language: \"python3\"`.\n- Use `deploy_workspace_item` after explicit user deploy intent. The deploy tool bundles JS/CSS before saving the raw app.\n- Use `read_workspace_item` with `type: 'app'` for a metadata summary (file paths and runnable list, no contents). Use `read_app_file` to read an individual file; large files are truncated to a head slice, so pass `offset`/`limit` to page through the rest rather than re-reading the whole file.\n- To find where a symbol or string lives across the app, call `search_app` (greps every frontend file and inline runnable, returns matching `file:line` rows) instead of reading files one by one — then `read_app_file` only the ranges you need. The loop is list (`read_workspace_item`) → locate (`search_app`) → inspect (`read_app_file` with `offset`/`limit`).\n\n# Windmill raw app authoring reference\n\n# Windmill Raw Apps\n\nRaw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables.\n\n## App shape\n\nA raw app has three logical parts:\n\n- **Frontend** — bundled with esbuild from `index.tsx` as the entrypoint. Files include the entrypoint, components (`App.tsx`), styles, etc.\n- **Backend runnables** — server-side scripts the frontend calls, each addressed by a unique key.\n- **Data** — optional whitelisted datatables (managed PostgreSQL) that the backend runnables can query. The frontend never queries the database directly; backend runnables are the only bridge.\n\n## Starting a new app\n\nA new app gets the setup Windmill's own new-app dialog gives one: a framework, then where its data lives.\n\n### Framework\n\nUse the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead.\n\n### Data setup\n\nWhen a new app has to store data and the user did not say where, **ask before creating anything**: no app, no schema, no table. Where the data lives is the user's decision, and tables created in the wrong place stay there. Look up the workspace's data tables first so every choice you offer exists, then ask, as proposed answers rather than open questions:\n\n1. **Data table**: which one to use, the default first (`main` when it exists, otherwise the first listed). Skip this one when the workspace has a single data table.\n2. **Schema**: where the app's new tables go.\n - A **new schema**, the usual choice, offered first. Suggest the first unused name among `app1`, `app2`, …\n - An **existing schema** of that data table, by name.\n - **None**: the tables go in `public`.\n3. **Tables**: only when that data table already holds tables the app could use, whether to **reuse** them (name them) or **create new ones**.\n\nDo not ask when:\n\n- the user already said where the data goes. A part they left out takes its default above: the default data table, a new `appN` schema, new tables;\n- the app, or its saved draft, already carries a data config (`data.datatable`, `data.schema` or `data.tables`). That config is the answer;\n- the app stores nothing.\n\nWith no data table in the workspace there is nothing to choose: say that one has to be configured in the workspace settings before the app can store data.\n\nA question left unanswered or dismissed is not a go-ahead for the defaults: stop there and say what is still needed.\n\nThen build what was chosen: the data table and schema become the app's `data.datatable` and `data.schema`, the tables it uses, reused or new, go in `data.tables`, and a new schema is created (`CREATE SCHEMA IF NOT EXISTS`) before the tables that go in it.\n\n### An app that arrives already set up\n\nAn app the user just started from the new-app dialog is already set up: its files are the framework template it was started with, and a `data` config it carries (`data.datatable`, `data.schema` or `data.tables`) is the data setup it was started with. Build that app in place, with that data config, and in that framework unless the user now names another. Do not ask for either, and do not create a second app. Only when it carries no data config and has to store data is the data setup still to ask.\n\n## Frontend\n\n### Entrypoint\n\nThe entrypoint is `index.tsx` for React and `index.ts` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty `
` and auto-renders nothing, so the entrypoint must mount a top-level `App` itself. Keep the UI in `App.tsx` / `App.svelte` and keep the entrypoint as the mount shim.\n\nReact (`index.tsx`):\n\n```tsx\nimport React from 'react'\nimport { createRoot } from 'react-dom/client'\nimport App from './App'\n\ncreateRoot(document.getElementById('root')!).render()\n```\n\nSvelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`.\n\n**Never replace the entrypoint with a bare component** (`export default function App() { ... }` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts `App` into `#root`.\n\n**Set the browser tab title with `document.title = '...'`** (or a `` the framework renders into `<head>`). The app has no `index.html` of its own, so a `<title>` written there is never served; the deployed app's page shows whatever `document.title` holds, and follows it when it changes.\n\n**Always begin every React file (`.tsx`/`.jsx`) that uses JSX with `import React from 'react'`.** esbuild uses the classic JSX transform, so `React` must be in scope wherever JSX appears — a missing import compiles fine but throws `React is not defined` at runtime, leaving a blank screen.\n\n### Generated bindings (`wmill.d.ts` / `wmill.ts`)\n\nThe frontend imports a generated module that mirrors the backend runnables. **Never write to it directly** — it gets regenerated whenever backend runnables change. Modifying it by hand will be overwritten.\n\n### Calling backend runnables\n\nImport the generated bindings and call the runnable like a function. `./wmill` is the **only** way the frontend reaches anything server-side — datatables, workspace items, external services. Never `fetch` the Windmill API from frontend code: the bundle holds no token and builds no API URL.\n\n| Export | Resolves to | Use it for |\n|---|---|---|\n| `backend.<key>(args)` | the runnable's result | the default — run and wait |\n| `backendAsync.<key>(args)` | the **job id** (a string) | long-running work you want to track |\n| `waitJob(jobId)` | the job's **result** (rejects if the job failed) | awaiting a `backendAsync` job |\n| `getJob(jobId)` | a `Job` (`{ type, success, result, duration_ms, ... }`) | polling status without blocking |\n| `streamJob(jobId, onUpdate?)` | the final result, calling `onUpdate` per chunk | showing output as it is produced |\n\nA runnable is always called with **one object** whose keys are its `main` parameters — `main(user_id: string, limit: number)` is called as `backend.get_users({ user_id, limit })`, never with positional arguments. A runnable without parameters is called with no argument. Resource and variable ids handed to the `wmill` client are paths (`u/<user>/<name>` or `f/<folder>/<name>`).\n\nRun and wait — the common case:\n\n```tsx\nimport { backend } from './wmill';\n\nconst user = await backend.get_user({ user_id: '123' });\n```\n\nStart a long job, then await it:\n\n```tsx\nimport { backendAsync, waitJob } from './wmill';\n\nconst jobId = await backendAsync.run_report({ month: '2026-08' }); // a string\nconst report = await waitJob(jobId); // the result itself\n```\n\nOr poll it without blocking, to render progress:\n\n```tsx\nimport { getJob } from './wmill';\n\nconst job = await getJob(jobId);\nif (job.type === 'CompletedJob') setReport(job.result);\n```\n\n`backendAsync` resolves a job id and nothing else — guard on it before storing or polling. A poll loop started on an `undefined` id never completes and shows as a row stuck \"running\" forever:\n\n```tsx\nconst jobId = await backendAsync.run_report(args);\nif (!jobId) throw new Error('run_report did not start a job');\n```\n\n**Never hand-write a job-polling runnable.** A backend runnable that calls `jobs/list`, or that returns `getResultMaybe(...)` for the frontend to poll, reimplements `backendAsync` + `waitJob` / `getJob` / `streamJob` — and it is what leads to guessing at base URLs and tokens.\n\n### Keeping data out of recorded demos\n\nAn app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with `data-wm-no-record` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata:\n\n```tsx\n<label data-wm-no-record>\n Customer SSN <input value={ssn} onChange={onSsn} />\n</label>\n```\n\nApply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.\n\n### Chat UIs over a flow in chat mode\n\nA flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `@windmill-labs/chat-sdk` to `package.json` and use it directly, it detects the app's Windmill and credential.\n\n```tsx\nimport { useWindmillChat } from '@windmill-labs/chat-sdk/react'\n\nconst chat = useWindmillChat({ flowPath: 'f/support/assistant' })\n// chat.messages ({ role, content, pending, success, tool? }), chat.status, chat.sendMessage(text), chat.stop()\n```\n\n`@windmill-labs/chat-sdk/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `@windmill-labs/chat-sdk/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser.\n\n## Backend runnables\n\nEach runnable has a unique key (used to call it from the frontend) and one of four types:\n\n| Type | What it is |\n|---|---|\n| `inline` | Custom code stored on the app itself. Most common for app-specific logic. |\n| `script` | Reference to an existing workspace script by path. |\n| `flow` | Reference to an existing workspace flow by path. |\n| `hubscript` | Reference to a hub script by path. |\n\n### Inline runnables\n\nInline runnables carry their own source code, and must expose a `main` function as their entrypoint.\n\n**TypeScript example** (runnable `get_user`):\n\n```typescript\nimport * as wmill from 'windmill-client';\n\nexport async function main(user_id: string) {\n const sql = wmill.datatable();\n const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();\n return user;\n}\n```\n\n**Python example** (runnable `get_user`):\n\n```python\nimport wmill\n\ndef main(user_id: str):\n db = wmill.datatable()\n user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()\n return user\n```\n\n#### Sharing code between runnables\n\nAn inline runnable can import another inline runnable of the same app by its id, as a relative import: `import { getDb } from './shared'` (TypeScript, Bun) or `from .shared import get_db` (Python). This works across TypeScript (Bun) runnables and across Python runnables, not between the two. The imported runnable's own dependencies are picked up, and it can import further runnables in turn. A runnable that only holds helpers needs no `main`; the frontend just never calls it. A TypeScript runnable whose id is `main` cannot be imported, because that name is taken by the entrypoint file of every TypeScript job: give shared code any other id.\n\n#### The `wmill` client is already authenticated\n\nAn inline runnable runs as an ordinary Windmill job. `import * as wmill from 'windmill-client'` (TypeScript) and `import wmill` (Python) are already pointed at this instance and this workspace — there is nothing to configure.\n\n**Don't read `WM_TOKEN` or `BASE_INTERNAL_URL` and build an API URL to `fetch`.** The client's own `setClient` already reads exactly those, and it also sets the credentials mode a raw app needs (`WM_RAW_APP` suppresses credentials, because a sandboxed bundle calls the API from an opaque origin that can never pair with `Access-Control-Allow-Origin: *`). Rebuilding that by hand drops the parts you can't see. Use `wmill.*` for everything Windmill, and `fetch` only for third-party APIs.\n\nUse only `wmill` functions the SDK actually exports; for an endpoint none of them covers, the generated service classes (`JobService`, `ScriptService`, ...) are importable from `windmill-client`. What is not available is a name you guessed at: `getBaseUrl` and `getWorkspaceToken` are inventions, not API.\n\n### Path runnables (script / flow / hubscript)\n\nWhen `type` is `script`, `flow`, or `hubscript`, the runnable just stores a `path` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface.\n\nBefore writing an inline runnable, look for a workspace script or flow that already does the job, or a Hub script (a prebuilt integration at `hub/<version>/<app>/<name>`) for a third-party service, and reference it instead of copying its logic.\n\n### Draft code vs deployed code\n\nThis decides whether an app works before anything is deployed:\n\n- **Inline runnables run the app's current code.** The editor sends the runnable's source with each request, so an inline runnable works in the preview with nothing deployed.\n- **Path runnables (`script` / `flow` / `hubscript`) run the DEPLOYED item at that path.** So do `wmill.runFlow`, `wmill.runFlowAsync` and `wmill.runScriptByPath` called from inside a runnable. A draft — including a draft you just created — does not exist for them.\n\nSo an app wired to a flow you just wrote does nothing until **that flow is deployed**. The app itself does NOT have to be deployed for this: the preview runs the app's draft, so the referenced flow is the only thing that has to exist deployed.\n\nThat makes the fix a one-item deploy, not a release. Offer to deploy exactly the referenced flow or script and leave the app a draft the user keeps testing in the preview — do not push the whole change set through the review-and-deploy page, and do not ask the user to deploy the app, unless they said they want to ship it.\n\nDo NOT quietly reimplement the flow inside an inline runnable to dodge the deployment: that leaves the user with two copies of the same logic and an app that ignores the flow they asked for. Inline the logic only when the user actually wants it inline.\n\nPrefer a **path runnable of type `flow`** over an inline runnable that calls `wmill.runFlowAsync`. The path runnable gives the frontend the flow's real input schema and works with `backend` / `backendAsync` / `waitJob` like any other runnable; a hand-written wrapper gives up all of that.\n\n### Static inputs\n\n`staticInputs` is an optional `Record<string, any>` for arguments not overridable from the frontend. Useful with path runnables to pre-fill some args while leaving the rest to the frontend caller.\n\n## Who can open a deployed app\n\nA draft app is reachable by nobody; deploying is what exposes it, and its backend runnables with it. Otherwise only users with access to the app can open it, unless the app is opened to:\n\n- **anonymous** users: anyone with the URL, without logging in;\n- **guests**: anyone the instance's identity provider authenticates, whether or not they belong to the workspace.\n\nEither one lets those people run the app's backend runnables, so never open an app up unless the user asks.\nWhen a deploy widens who may open the app, tell the user in plain words.\n\n## Sandbox isolation and frontend API access\n\nNew apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in `window.ctx`, and whoami works with any of them.\n\nInside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry `Origin: null`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or `localStorage`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app.\nYou cannot turn isolation off; the user does it in the app's deploy settings.\n\n## Reading data as the viewer\n\nA backend runnable reads data as the app's identity (the publisher in publisher mode), the same for every viewer. An inline runnable can instead read data as the person using the app: its data table connections that name no role hold that viewer's roles, and object storage permission rules (which paths may be listed, read, written) are checked against the viewer. Resources, variables and everything else stay the app's, and an anonymous viewer is refused. Turn it on only when what the runnable reads should differ per viewer: row-level security on a data table, or per-user paths in object storage. Both are Enterprise Edition features.\nSet it with `viewerDataAccess: true` on the backend runnable.\n\n## Data Tables\n\nData tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the `wmill` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table.\n\n### Critical rules\n\n1. **Check what exists first**: look up the workspace's data tables and their tables before designing storage, and reuse a suitable table rather than creating another. Never assume a `main` data table exists.\n2. **Whitelisted tables only**: a runnable can only query tables listed in the app's `data.tables` config. Queries against unlisted tables fail at runtime, so register a new table there before using it.\n3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable.\n4. **Qualify table names**: an unqualified name means the `public` schema, so write every other table as `schema.table`, in table creation and queries alike. The app's `data` config sets the default datatable and schema its tables go in; use them consistently across runnables.\n5. **Pass the role**: when the app's `data.roles` gives a data table a role, every `wmill.datatable` call on it passes that role (`wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python). The role only reaches what it was granted, so a query outside it fails with `permission denied`.\n6. **Per-viewer rows**: a data table with no entry in `data.roles` is called with no role. Unless its default role is `admin`, the connection then holds the default role, if it may use it, plus every role it is a tenant of, and is refused when that is none. Whose roles those are follows the runnable's identity (see \"Reading data as the viewer\"). Row-level security policies written as `pg_has_role(current_user, '<role>', 'member')` then return the right rows with nothing added to the code. A role pins the connection to that one role either way, so a data table whose rows should differ per viewer gets no `data.roles` entry. Data table roles are an Enterprise Edition feature, self-hosted only.\n\n### Querying in TypeScript (Bun/Deno)\n\n```typescript\nimport * as wmill from 'windmill-client';\n\nexport async function main(user_id: string) {\n const sql = wmill.datatable(); // Or: wmill.datatable('other_datatable')\n\n // Parameterized queries (safe from SQL injection)\n const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();\n const users = await sql`SELECT * FROM users WHERE active = ${true}`.fetch();\n\n // Insert/Update\n await sql`INSERT INTO users (name, email) VALUES (${name}, ${email})`.execute();\n await sql`UPDATE users SET name = ${newName} WHERE id = ${user_id}`.execute();\n\n return user;\n}\n```\n\n### Querying in Python\n\n```python\nimport wmill\n\ndef main(user_id: str):\n db = wmill.datatable() # Or: wmill.datatable('other_datatable')\n\n # Use $1, $2, etc. for parameters\n user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()\n users = db.query('SELECT * FROM users WHERE active = $1', True).fetch()\n\n # Insert/Update\n db.query('INSERT INTO users (name, email) VALUES ($1, $2)', name, email).execute()\n db.query('UPDATE users SET name = $1 WHERE id = $2', new_name, user_id).execute()\n\n return user\n```\n\n## Best Practices\n\n1. **Check existing tables** before creating new ones — reuse beats schema growth.\n2. **Use parameterized queries** — never concatenate user input into SQL.\n3. **Terminate every datatable statement** — the tagged template and `db.query(...)` only build a statement. It runs when you call `fetch` / `fetchOne` / `fetchOneScalar` / `execute` (`fetch` / `fetch_one` / `fetch_one_scalar` / `execute` in Python). An INSERT or UPDATE without one writes nothing and raises nothing. Awaiting the statement itself is a no-op — it is not a promise.\n4. **Keep runnables focused** — one function per runnable; small surface area.\n5. **Use descriptive keys** — `get_user`, not `a`.\n6. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in `data.tables` first.\n7. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you.\n8. **Reach for `backendAsync` + `waitJob`** for long work — never a hand-written job-polling runnable.\n9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying.\n10. **Use `@windmill-labs/chat-sdk` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream.\n\n\n# TypeScript SDK (windmill-client)\n\nImport: import * as wmill from 'windmill-client'\n\nThe client configures itself from the job's environment — base URL, token and credentials mode\nare all set before your code runs, so there is nothing to initialize and no reason to read\nWM_TOKEN or BASE_INTERNAL_URL and build an API URL yourself. Reconstructing that by hand only\nreintroduces details the client already handles. Call the SDK for anything Windmill, and use raw\nHTTP for third-party APIs.\n\nThe helpers below are the surface to prefer. For an endpoint none of them covers, import the\ngenerated service classes (JobService, ScriptService, ...) from 'windmill-client' — they are not\nlisted here but they do exist. What does not exist is a helper name you guessed at: if it is\nneither listed below nor a service method, do not call it.\n\nTo know who is running the script, read the contextual variables rather than calling the API:\n`process.env.WM_END_USER_EMAIL || process.env.WM_EMAIL`. WM_END_USER_EMAIL is the app viewer when\nthe run was triggered from an app and empty otherwise (both variables are always defined), WM_EMAIL\nis the user the job is permissioned as. WM_USERNAME is the matching username.\n\nworkerHasInternalServer(): boolean\n\n/**\n * Initialize the Windmill client with authentication token and base URL\n * @param token - Authentication token (defaults to WM_TOKEN env variable)\n * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable)\n */\nsetClient(token?: string, baseUrl?: string): void\n\n/**\n * Create a client configuration from env variables\n * @returns client configuration\n */\ngetWorkspace(): string\n\n/**\n * Get a resource value by path\n * @param path path of the resource, default to internal state path\n * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error\n * @returns resource value\n */\nasync getResource(path?: string, undefinedIfEmpty?: boolean): Promise<any>\n\n/**\n * Get the true root job id\n * @param jobId job id to get the root job id from (default to current job)\n * @returns root job id\n */\nasync getRootJobId(jobId?: string): Promise<string>\n\n/**\n * Run a script synchronously by its path and wait for the result\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByPath(path: string, args: Record<string, any> | null = null, verbose: boolean = false, tag: string | null = null): Promise<any>\n\n/**\n * Run a script synchronously by its hash and wait for the result\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByHash(hash_: string, args: Record<string, any> | null = null, verbose: boolean = false, tag: string | null = null): Promise<any>\n\n/**\n * Append a text to the result stream\n * @param text text to append to the result stream\n */\nappendToResultStream(text: string): void\n\n/**\n * Stream to the result stream\n * @param stream stream to stream to the result stream\n */\nasync streamResult(stream: AsyncIterable<string>): Promise<void>\n\n/**\n * Run a flow synchronously by its path and wait for the result\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Flow execution result\n */\nasync runFlow(path: string | null = null, args: Record<string, any> | null = null, verbose: boolean = false, tag: string | null = null): Promise<any>\n\n/**\n * Wait for a job to complete and return its result\n * @param jobId - ID of the job to wait for\n * @param verbose - Enable verbose logging\n * @returns Job result when completed\n */\nasync waitJob(jobId: string, verbose: boolean = false): Promise<any>\n\n/**\n * Get the result of a completed job\n * @param jobId - ID of the completed job\n * @returns Job result\n */\nasync getResult(jobId: string): Promise<any>\n\n/**\n * Get the result of a job if completed, or its current status\n * @param jobId - ID of the job\n * @returns Object with started, completed, success, and result properties\n */\nasync getResultMaybe(jobId: string): Promise<any>\n\n/**\n * Cancel a queued or running job by ID.\n * @param jobId - UUID of the job to cancel\n * @param reason - Optional reason for cancellation\n * @returns Response message from the cancel endpoint\n */\nasync cancelJob(jobId: string, reason: string | undefined = undefined): Promise<string>\n\n/**\n * Run a script asynchronously by its path\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByPathAsync(path: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string>\n\n/**\n * Run a script asynchronously by its hash\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByHashAsync(hash_: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string>\n\n/**\n * Run a flow asynchronously by its path\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job)\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runFlowAsync(path: string | null, args: Record<string, any> | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true, tag: string | null = null): Promise<string>\n\n/**\n * Resolve a resource value in case the default value was picked because the input payload was undefined\n * @param obj resource value or path of the resource under the format `$res:path`\n * @returns resource value\n */\nasync resolveDefaultResource(obj: any): Promise<any>\n\n/**\n * Get the state file path from environment variables\n * @returns State path string\n */\ngetStatePath(): string\n\n/**\n * Set a resource value by path\n * @param path path of the resource to set, default to state path\n * @param value new value of the resource to set\n * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type\n */\nasync setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise<void>\n\n/**\n * Set the state\n * @param state state to set\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync setState(state: any, path?: string): Promise<void>\n\n/**\n * Set the progress\n * Progress cannot go back and limited to 0% to 99% range\n * @param percent Progress to set in %\n * @param jobId? Job to set progress for\n */\nasync setProgress(percent: number, jobId?: any): Promise<void>\n\n/**\n * Get the progress\n * @param jobId? Job to get progress from\n * @returns Optional clamped between 0 and 100 progress value\n */\nasync getProgress(jobId?: any): Promise<number | null>\n\n/**\n * Whether the worker running this job has received its shutdown signal.\n * A draining worker waits for its current job to finish before exiting, so a\n * long-running script can poll this and return early to let the worker be replaced.\n * @param jobId? Job to check, defaults to the current job\n */\nasync workerIsDraining(jobId?: string): Promise<boolean>\n\n/**\n * Set a flow user state\n * @param key key of the state\n * @param value value of the state\n */\nasync setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise<void>\n\n/**\n * Get a flow user state\n * @param path path of the variable\n */\nasync getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise<any>\n\n/**\n * Get the state shared across executions\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync getState(path?: string): Promise<any>\n\n/**\n * Get a variable by path\n * @param path path of the variable\n * @returns variable value\n */\nasync getVariable(path: string): Promise<string>\n\n/**\n * Set a variable by path, create if not exist\n * @param path path of the variable\n * @param value value of the variable\n * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false)\n * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: \"\")\n */\nasync setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise<void>\n\n/**\n * Build a PostgreSQL connection URL from a database resource\n * @param path - Path to the database resource\n * @returns PostgreSQL connection URL string\n */\nasync databaseUrlFromResource(path: string): Promise<string>\n\nasync polarsConnectionSettings(s3_resource_path: string | undefined): Promise<any>\n\nasync duckdbConnectionSettings(s3_resource_path: string | undefined): Promise<any>\n\n/**\n * Get S3 client settings from a resource or workspace default\n * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)\n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n * @returns S3 client configuration settings\n */\nasync denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise<DenoS3LightClientSettings>\n\n/**\n * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContent = await wmill.loadS3FileContent(inputFile)\n * // if the file is a raw text file, it can be decoded and printed directly:\n * const text = new TextDecoder().decode(fileContentStream)\n * console.log(text);\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise<Uint8Array | undefined>\n\n/**\n * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContentBlob = await wmill.loadS3FileStream(inputFile)\n * // if the content is plain text, the blob can be read directly:\n * console.log(await fileContentBlob.text());\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise<Blob | undefined>\n\n/**\n * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * const s3object = await writeS3File(s3Object, \"Hello Windmill!\")\n * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')\n * console.log(fileContentAsUtf8Str)\n * ```\n * \n * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var)\n */\nasync writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise<S3Object>\n\n/**\n * Permanently delete a file from S3 by key.\n * \n * ```typescript\n * await wmill.deleteS3File({ s3: \"path/to/file.txt\" })\n * ```\n * \n * @param s3object - S3 object identifying the file to delete (must have `s3` set)\n * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var)\n */\nasync deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise<void>\n\n/**\n * Sign S3 objects to be used by anonymous users in public apps\n * @param s3objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 objects\n */\nasync signS3Objects(s3objects: S3Object[], { expirySecs }: { expirySecs?: number } = {}): Promise<S3Object[]>\n\n/**\n * Sign S3 object to be used by anonymous users in public apps\n * @param s3object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 object\n */\nasync signS3Object(s3object: S3Object, { expirySecs }: { expirySecs?: number } = {}): Promise<S3Object>\n\n/**\n * Generate a presigned public URL for an array of S3 objects.\n * If an S3 object is not signed yet, it will be signed first.\n * @param s3Objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns list of signed public URLs\n */\nasync getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise<string[]>\n\n/**\n * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.\n * @param s3Object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed public URL\n */\nasync getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise<string>\n\n/**\n * Get URLs needed for resuming a flow after this step\n * @param approver approver name\n * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.\n * This allows pre-approvals that can be consumed by any later suspend step in the same flow.\n * @returns approval page UI URL, resume and cancel API URLs for resuming the flow\n */\nasync getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only)\n * @param audience audience of the token\n * @param expiresIn Optional number of seconds until the token expires\n * @returns jwt token\n */\nasync getIdToken(audience: string, expiresIn?: number): Promise<string>\n\n/**\n * Convert a base64-encoded string to Uint8Array\n * @param data - Base64-encoded string\n * @returns Decoded Uint8Array\n */\nbase64ToUint8Array(data: string): Uint8Array\n\n/**\n * Convert a Uint8Array to base64-encoded string\n * @param arrayBuffer - Uint8Array to encode\n * @returns Base64-encoded string\n */\nuint8ArrayToBase64(arrayBuffer: Uint8Array): string\n\n/**\n * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Slack approval request.\n * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill.\n * @param {string} options.channelId - The Slack channel ID where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Slack approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * @param {string} [options.resumeButtonText] - Optional text for the resume button.\n * @param {string} [options.cancelButtonText] - Optional text for the cancel button.\n * \n * @returns {Promise<void>} Resolves when the Slack approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getSlackApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveSlackApproval({\n * slackResourcePath: \"/u/alex/my_slack_resource\",\n * channelId: \"admins-slack-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * resumeButtonText: \"Resume\",\n * cancelButtonText: \"Cancel\",\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise<void>\n\n/**\n * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Teams approval request.\n * @param {string} options.teamName - The Teams team name where the approval request will be sent.\n * @param {string} options.channelName - The Teams channel name where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Teams approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * \n * @returns {Promise<void>} Resolves when the Teams approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveTeamsApproval({\n * teamName: \"admins-teams\",\n * channelName: \"admins-teams-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise<void>\n\nsetWorkflowCtx(ctx: WorkflowCtx | null): void\n\nasync sleep(seconds: number): Promise<void>\n\n/**\n * Execute `fn` inline and checkpoint the result. On replay the cached value is\n * returned without re-executing `fn`.\n * \n * `fn`'s result is encoded as JSON and decoded back before it is returned, so\n * the round that runs the body sees the same types every replay sees: a `Date`\n * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.\n * \n * Called from inside a task body, `fn` runs in place and nothing is\n * checkpointed: a task is a single job and is never replayed.\n */\nasync step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>\n\n/**\n * Create a task that dispatches to a separate Windmill script.\n * \n * @example\n * const extract = taskScript(\"f/data/extract\");\n * // inside workflow: await extract({ url: \"https://...\" })\n */\ntaskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike<any>\n\n/**\n * Create a task that dispatches to a separate Windmill flow.\n * \n * @example\n * const pipeline = taskFlow(\"f/etl/pipeline\");\n * // inside workflow: await pipeline({ input: data })\n */\ntaskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike<any>\n\n/**\n * Mark an async function as a workflow-as-code entry point.\n * \n * The function must be **deterministic**: given the same inputs it must call\n * tasks in the same order on every replay. Branching on task results is fine\n * (results are replayed from checkpoint), but branching on external state\n * (current time, random values, external API calls) must use `step()` to\n * checkpoint the value so replays see the same result.\n */\nworkflow<T>(fn: (...args: any[]) => Promise<T>): void\n\n/**\n * Suspend the workflow and wait for an external approval.\n * \n * Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that\n * resume exactly this approval — route them through your own channel. Without a\n * key the steps are named `approval`, `approval_2`, ...\n * \n * `skin: \"minimal\"` shows approvers only the request (form and approve/reject)\n * instead of the detailed page with the workflow's details. `description` is\n * shown above the form: a string, or a rich value such as `{ markdown: \"...\" }`.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * const { value, approver } = await waitForApproval({ key: \"manager\", timeout: 3600 });\n */\nwaitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: \"detailed\" | \"minimal\"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>\n\n/**\n * Resume/cancel/approval-page URLs bound to one `waitForApproval` step.\n * \n * Unlike `getResumeUrls()`, which signs a random nonce, these address the very\n * `resume_job` record the step's built-in approval buttons use, so they are\n * stable across replays and safe to embed in a custom notification.\n * \n * `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique\n * within a workflow; reusing one throws rather than silently renaming it. The URL\n * only resumes while that step is awaiting approval; used at any other moment it is\n * rejected rather than banking a row a different approval would consume. Send it\n * ahead of time — approvers just cannot act before the workflow reaches the step.\n * \n * `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's\n * approval page, which acts on whichever approval is pending when it is used.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * await waitForApproval({ key: \"manager\" });\n */\nasync getApprovalUrls(stepKey: string = \"approval\", approver?: string): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Process items in parallel with optional concurrency control.\n * \n * Each item is processed by calling `fn(item)`, which should be a task().\n * Items are dispatched in batches of `concurrency` (default: all at once).\n * \n * @example\n * const process = task(async (item: string) => { ... });\n * const results = await parallel(items, process, { concurrency: 5 });\n */\nasync parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R> | R, options?: { concurrency?: number },): Promise<R[]>\n\n/**\n * Commit Kafka offsets for a trigger with auto_commit disabled.\n * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path)\n * @param topic - Kafka topic name (from event.topic)\n * @param partition - Partition number (from event.partition)\n * @param offset - Message offset to commit (from event.offset)\n */\nasync commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise<void>\n\n/**\n * Parse an S3 object from URI string or record format\n * @param s3Object - S3 object as URI string (`s3://storage/key`, `s3:///key`\n * for the default storage) or record. Any other string throws rather than\n * falling back to an auto-generated key: an auto key is requested by\n * omitting the object, and a fallback would silently misplace the upload\n * on any typo.\n * @returns S3 object record with storage and s3 key\n */\nparseS3Object(s3Object: S3Object): S3ObjectRecord\n\n/**\n * Create a SQL template function for PostgreSQL/datatable queries\n * @param name - Database/datatable name (default: \"main\")\n * @param opts.role - Connect as this data table role instead of the data table's default one.\n * Only meaningful on a data table under roles, and only for a role you are a tenant of.\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.datatable()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}::int\n * `.fetch()\n * @example\n * // Read through a restricted role\n * let sql = wmill.datatable(\"main\", { role: \"analytics\" })\n */\ndatatable(name: string = \"main\", opts?: DatatableOptions): DatatableSqlTemplateFunction\n\n/**\n * Create a SQL template function for DuckDB/ducklake queries\n * @param name - DuckDB database name, optionally with a schema as `name:schema` (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.ducklake()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}\n * `.fetch()\n * @example\n * // Target a specific schema within the ducklake\n * let sql = wmill.ducklake(\"my_lake:analytics\")\n */\nducklake(name: string = \"main\"): SqlTemplateFunction\n\n/**\n * Idempotently materialize `selectSql` into a ducklake table for one\n * partition (or the whole table when `partition` is omitted) — the client-side\n * equivalent of the `// materialize` engine.\n * With `uniqueKey` it upserts the slice (delete-by-key + insert); otherwise it\n * replaces it (whole table → `CREATE OR REPLACE`; partition → delete + insert).\n * Safe to re-run for the same partition (backfill / failure-recovery).\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.upsertPartition({ table, selectSql, partition }).execute()`.\n */\nupsertPartition(opts: DucklakeMaterializeOptions): SqlStatement<any>\n\n/**\n * INSERT-only materialization (no dedup/replace) for append-only tables.\n * Re-running the same partition duplicates rows — use only for immutable\n * event-log sources.\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.appendPartition({ table, selectSql, partition }).execute()`.\n */\nappendPartition(opts: Omit<DucklakeMaterializeOptions, \"uniqueKey\">,): SqlStatement<any>\n", - "app.python3": "# Global draft app instructions\n\n- Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy.\n- App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to `u/<current-user>/<name>` for bare names; only use `f/<folder>/<name>` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.\n- To create a new app, use `init_app` with a path, optional summary, a framework (`react19` / `react18` / `svelte5`) and, for an app that stores data, its `data` setup. \"Starting a new app\" below decides the framework and when the data setup is asked. Ask the user each of its questions, suggested answer first, before `init_app` and before any SQL. `init_app` only records `data`: a new schema is yours to create with the datatable SQL tool. `init_app` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.\n- `init_app` seeds the starter inline runnables `greet`, `word_stats`, `slow_task`, `stream_text` so the demo tour works on first render. Replace or remove them once you start building real backend runnables.\n- Frontend file paths start with `/` (e.g. `/index.tsx`, `/App.tsx`, `/styles.css`). Use `write_app_file` / `patch_app_file` / `delete_app_file`.\n- Backend inline runnables are addressed as `backend/<key>/main.{ts|py}` from the file tools, but you create or update them via `write_app_runnable` / `delete_app_runnable` (which take the runnable shape directly: `{ name, type, inlineScript?, path?, staticInputs? }`).\n- `/wmill.d.ts` (or `wmill.ts`) is generated automatically from the backend runnables — never write it directly.\n- Inline runnables only support `bun` or `python3` in chat. Path runnables (`script`/`flow`/`hubscript`) reference an existing item.\n- When a path runnable points at a draft (see \"Draft code vs deployed code\" below), offer to deploy just that flow/script with deploy_workspace_item, not through the compare page.\n- The authoring reference below carries the Python SDK, for `python3` inline runnables. For a `bun` runnable, call `get_instructions` again with `subject: \"app\"` and no language.\n- Use `deploy_workspace_item` after explicit user deploy intent. The deploy tool bundles JS/CSS before saving the raw app.\n- Use `read_workspace_item` with `type: 'app'` for a metadata summary (file paths and runnable list, no contents). Use `read_app_file` to read an individual file; large files are truncated to a head slice, so pass `offset`/`limit` to page through the rest rather than re-reading the whole file.\n- To find where a symbol or string lives across the app, call `search_app` (greps every frontend file and inline runnable, returns matching `file:line` rows) instead of reading files one by one — then `read_app_file` only the ranges you need. The loop is list (`read_workspace_item`) → locate (`search_app`) → inspect (`read_app_file` with `offset`/`limit`).\n\n# Windmill raw app authoring reference\n\n# Windmill Raw Apps\n\nRaw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables.\n\n## App shape\n\nA raw app has three logical parts:\n\n- **Frontend** — bundled with esbuild from `index.tsx` as the entrypoint. Files include the entrypoint, components (`App.tsx`), styles, etc.\n- **Backend runnables** — server-side scripts the frontend calls, each addressed by a unique key.\n- **Data** — optional whitelisted datatables (managed PostgreSQL) that the backend runnables can query. The frontend never queries the database directly; backend runnables are the only bridge.\n\n## Starting a new app\n\nA new app gets the setup Windmill's own new-app dialog gives one: a framework, then where its data lives.\n\n### Framework\n\nUse the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead.\n\n### Data setup\n\nWhen a new app has to store data and the user did not say where, **ask before creating anything**: no app, no schema, no table. Where the data lives is the user's decision, and tables created in the wrong place stay there. Look up the workspace's data tables first so every choice you offer exists, then ask, as proposed answers rather than open questions:\n\n1. **Data table**: which one to use, the default first (`main` when it exists, otherwise the first listed). Skip this one when the workspace has a single data table.\n2. **Schema**: where the app's new tables go.\n - A **new schema**, the usual choice, offered first. Suggest the first unused name among `app1`, `app2`, …\n - An **existing schema** of that data table, by name.\n - **None**: the tables go in `public`.\n3. **Tables**: only when that data table already holds tables the app could use, whether to **reuse** them (name them) or **create new ones**.\n\nDo not ask when:\n\n- the user already said where the data goes. A part they left out takes its default above: the default data table, a new `appN` schema, new tables;\n- the app, or its saved draft, already carries a data config (`data.datatable`, `data.schema` or `data.tables`). That config is the answer;\n- the app stores nothing.\n\nWith no data table in the workspace there is nothing to choose: say that one has to be configured in the workspace settings before the app can store data.\n\nA question left unanswered or dismissed is not a go-ahead for the defaults: stop there and say what is still needed.\n\nThen build what was chosen: the data table and schema become the app's `data.datatable` and `data.schema`, the tables it uses, reused or new, go in `data.tables`, and a new schema is created (`CREATE SCHEMA IF NOT EXISTS`) before the tables that go in it.\n\n### An app that arrives already set up\n\nAn app the user just started from the new-app dialog is already set up: its files are the framework template it was started with, and a `data` config it carries (`data.datatable`, `data.schema` or `data.tables`) is the data setup it was started with. Build that app in place, with that data config, and in that framework unless the user now names another. Do not ask for either, and do not create a second app. Only when it carries no data config and has to store data is the data setup still to ask.\n\n## Frontend\n\n### Entrypoint\n\nThe entrypoint is `index.tsx` for React and `index.ts` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty `<div id=\"root\">` and auto-renders nothing, so the entrypoint must mount a top-level `App` itself. Keep the UI in `App.tsx` / `App.svelte` and keep the entrypoint as the mount shim.\n\nReact (`index.tsx`):\n\n```tsx\nimport React from 'react'\nimport { createRoot } from 'react-dom/client'\nimport App from './App'\n\ncreateRoot(document.getElementById('root')!).render(<App />)\n```\n\nSvelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`.\n\n**Never replace the entrypoint with a bare component** (`export default function App() { ... }` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts `App` into `#root`.\n\n**Set the browser tab title with `document.title = '...'`** (or a `<title>` the framework renders into `<head>`). The app has no `index.html` of its own, so a `<title>` written there is never served; the deployed app's page shows whatever `document.title` holds, and follows it when it changes.\n\n**Always begin every React file (`.tsx`/`.jsx`) that uses JSX with `import React from 'react'`.** esbuild uses the classic JSX transform, so `React` must be in scope wherever JSX appears — a missing import compiles fine but throws `React is not defined` at runtime, leaving a blank screen.\n\n### Generated bindings (`wmill.d.ts` / `wmill.ts`)\n\nThe frontend imports a generated module that mirrors the backend runnables. **Never write to it directly** — it gets regenerated whenever backend runnables change. Modifying it by hand will be overwritten.\n\n### Calling backend runnables\n\nImport the generated bindings and call the runnable like a function. `./wmill` is the **only** way the frontend reaches anything server-side — datatables, workspace items, external services. Never `fetch` the Windmill API from frontend code: the bundle holds no token and builds no API URL.\n\n| Export | Resolves to | Use it for |\n|---|---|---|\n| `backend.<key>(args)` | the runnable's result | the default — run and wait |\n| `backendAsync.<key>(args)` | the **job id** (a string) | long-running work you want to track |\n| `waitJob(jobId)` | the job's **result** (rejects if the job failed) | awaiting a `backendAsync` job |\n| `getJob(jobId)` | a `Job` (`{ type, success, result, duration_ms, ... }`) | polling status without blocking |\n| `streamJob(jobId, onUpdate?)` | the final result, calling `onUpdate` per chunk | showing output as it is produced |\n\nA runnable is always called with **one object** whose keys are its `main` parameters — `main(user_id: string, limit: number)` is called as `backend.get_users({ user_id, limit })`, never with positional arguments. A runnable without parameters is called with no argument. Resource and variable ids handed to the `wmill` client are paths (`u/<user>/<name>` or `f/<folder>/<name>`).\n\nRun and wait — the common case:\n\n```tsx\nimport { backend } from './wmill';\n\nconst user = await backend.get_user({ user_id: '123' });\n```\n\nStart a long job, then await it:\n\n```tsx\nimport { backendAsync, waitJob } from './wmill';\n\nconst jobId = await backendAsync.run_report({ month: '2026-08' }); // a string\nconst report = await waitJob(jobId); // the result itself\n```\n\nOr poll it without blocking, to render progress:\n\n```tsx\nimport { getJob } from './wmill';\n\nconst job = await getJob(jobId);\nif (job.type === 'CompletedJob') setReport(job.result);\n```\n\n`backendAsync` resolves a job id and nothing else — guard on it before storing or polling. A poll loop started on an `undefined` id never completes and shows as a row stuck \"running\" forever:\n\n```tsx\nconst jobId = await backendAsync.run_report(args);\nif (!jobId) throw new Error('run_report did not start a job');\n```\n\n**Never hand-write a job-polling runnable.** A backend runnable that calls `jobs/list`, or that returns `getResultMaybe(...)` for the frontend to poll, reimplements `backendAsync` + `waitJob` / `getJob` / `streamJob` — and it is what leads to guessing at base URLs and tokens.\n\n### Keeping data out of recorded demos\n\nAn app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with `data-wm-no-record` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata:\n\n```tsx\n<label data-wm-no-record>\n Customer SSN <input value={ssn} onChange={onSsn} />\n</label>\n```\n\nApply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.\n\n### Chat UIs over a flow in chat mode\n\nA flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `@windmill-labs/chat-sdk` to `package.json` and use it directly, it detects the app's Windmill and credential.\n\n```tsx\nimport { useWindmillChat } from '@windmill-labs/chat-sdk/react'\n\nconst chat = useWindmillChat({ flowPath: 'f/support/assistant' })\n// chat.messages ({ role, content, pending, success, tool? }), chat.status, chat.sendMessage(text), chat.stop()\n```\n\n`@windmill-labs/chat-sdk/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `@windmill-labs/chat-sdk/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser.\n\n## Backend runnables\n\nEach runnable has a unique key (used to call it from the frontend) and one of four types:\n\n| Type | What it is |\n|---|---|\n| `inline` | Custom code stored on the app itself. Most common for app-specific logic. |\n| `script` | Reference to an existing workspace script by path. |\n| `flow` | Reference to an existing workspace flow by path. |\n| `hubscript` | Reference to a hub script by path. |\n\n### Inline runnables\n\nInline runnables carry their own source code, and must expose a `main` function as their entrypoint.\n\n**TypeScript example** (runnable `get_user`):\n\n```typescript\nimport * as wmill from 'windmill-client';\n\nexport async function main(user_id: string) {\n const sql = wmill.datatable();\n const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();\n return user;\n}\n```\n\n**Python example** (runnable `get_user`):\n\n```python\nimport wmill\n\ndef main(user_id: str):\n db = wmill.datatable()\n user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()\n return user\n```\n\n#### Sharing code between runnables\n\nAn inline runnable can import another inline runnable of the same app by its id, as a relative import: `import { getDb } from './shared'` (TypeScript, Bun) or `from .shared import get_db` (Python). This works across TypeScript (Bun) runnables and across Python runnables, not between the two. The imported runnable's own dependencies are picked up, and it can import further runnables in turn. A runnable that only holds helpers needs no `main`; the frontend just never calls it. A TypeScript runnable whose id is `main` cannot be imported, because that name is taken by the entrypoint file of every TypeScript job: give shared code any other id.\n\n#### The `wmill` client is already authenticated\n\nAn inline runnable runs as an ordinary Windmill job. `import * as wmill from 'windmill-client'` (TypeScript) and `import wmill` (Python) are already pointed at this instance and this workspace — there is nothing to configure.\n\n**Don't read `WM_TOKEN` or `BASE_INTERNAL_URL` and build an API URL to `fetch`.** The client's own `setClient` already reads exactly those, and it also sets the credentials mode a raw app needs (`WM_RAW_APP` suppresses credentials, because a sandboxed bundle calls the API from an opaque origin that can never pair with `Access-Control-Allow-Origin: *`). Rebuilding that by hand drops the parts you can't see. Use `wmill.*` for everything Windmill, and `fetch` only for third-party APIs.\n\nUse only `wmill` functions the SDK actually exports; for an endpoint none of them covers, the generated service classes (`JobService`, `ScriptService`, ...) are importable from `windmill-client`. What is not available is a name you guessed at: `getBaseUrl` and `getWorkspaceToken` are inventions, not API.\n\n### Path runnables (script / flow / hubscript)\n\nWhen `type` is `script`, `flow`, or `hubscript`, the runnable just stores a `path` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface.\n\nBefore writing an inline runnable, look for a workspace script or flow that already does the job, or a Hub script (a prebuilt integration at `hub/<version>/<app>/<name>`) for a third-party service, and reference it instead of copying its logic.\n\n### Draft code vs deployed code\n\nThis decides whether an app works before anything is deployed:\n\n- **Inline runnables run the app's current code.** The editor sends the runnable's source with each request, so an inline runnable works in the preview with nothing deployed.\n- **Path runnables (`script` / `flow` / `hubscript`) run the DEPLOYED item at that path.** So do `wmill.runFlow`, `wmill.runFlowAsync` and `wmill.runScriptByPath` called from inside a runnable. A draft — including a draft you just created — does not exist for them.\n\nSo an app wired to a flow you just wrote does nothing until **that flow is deployed**. The app itself does NOT have to be deployed for this: the preview runs the app's draft, so the referenced flow is the only thing that has to exist deployed.\n\nThat makes the fix a one-item deploy, not a release. Offer to deploy exactly the referenced flow or script and leave the app a draft the user keeps testing in the preview — do not push the whole change set through the review-and-deploy page, and do not ask the user to deploy the app, unless they said they want to ship it.\n\nDo NOT quietly reimplement the flow inside an inline runnable to dodge the deployment: that leaves the user with two copies of the same logic and an app that ignores the flow they asked for. Inline the logic only when the user actually wants it inline.\n\nPrefer a **path runnable of type `flow`** over an inline runnable that calls `wmill.runFlowAsync`. The path runnable gives the frontend the flow's real input schema and works with `backend` / `backendAsync` / `waitJob` like any other runnable; a hand-written wrapper gives up all of that.\n\n### Static inputs\n\n`staticInputs` is an optional `Record<string, any>` for arguments not overridable from the frontend. Useful with path runnables to pre-fill some args while leaving the rest to the frontend caller.\n\n## Who can open a deployed app\n\nA draft app is reachable by nobody; deploying is what exposes it, and its backend runnables with it. Otherwise only users with access to the app can open it, unless the app is opened to:\n\n- **anonymous** users: anyone with the URL, without logging in;\n- **guests**: anyone the instance's identity provider authenticates, whether or not they belong to the workspace.\n\nEither one lets those people run the app's backend runnables, so never open an app up unless the user asks.\nWhen a deploy widens who may open the app, tell the user in plain words.\n\n## Sandbox isolation and frontend API access\n\nNew apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in `window.ctx`, and whoami works with any of them.\n\nInside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry `Origin: null`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or `localStorage`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app.\nYou cannot turn isolation off; the user does it in the app's deploy settings.\n\n## Reading data as the viewer\n\nA backend runnable reads data as the app's identity (the publisher in publisher mode), the same for every viewer. An inline runnable can instead read data as the person using the app: its data table connections that name no role hold that viewer's roles, and object storage permission rules (which paths may be listed, read, written) are checked against the viewer. Resources, variables and everything else stay the app's, and an anonymous viewer is refused. Turn it on only when what the runnable reads should differ per viewer: row-level security on a data table, or per-user paths in object storage. Both are Enterprise Edition features.\nSet it with `viewerDataAccess: true` on the backend runnable.\n\n## Data Tables\n\nData tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the `wmill` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table.\n\n### Critical rules\n\n1. **Check what exists first**: look up the workspace's data tables and their tables before designing storage, and reuse a suitable table rather than creating another. Never assume a `main` data table exists.\n2. **Whitelisted tables only**: a runnable can only query tables listed in the app's `data.tables` config. Queries against unlisted tables fail at runtime, so register a new table there before using it.\n3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable.\n4. **Qualify table names**: an unqualified name means the `public` schema, so write every other table as `schema.table`, in table creation and queries alike. The app's `data` config sets the default datatable and schema its tables go in; use them consistently across runnables.\n5. **Pass the role**: when the app's `data.roles` gives a data table a role, every `wmill.datatable` call on it passes that role (`wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python). The role only reaches what it was granted, so a query outside it fails with `permission denied`.\n6. **Per-viewer rows**: a data table with no entry in `data.roles` is called with no role. Unless its default role is `admin`, the connection then holds the default role, if it may use it, plus every role it is a tenant of, and is refused when that is none. Whose roles those are follows the runnable's identity (see \"Reading data as the viewer\"). Row-level security policies written as `pg_has_role(current_user, '<role>', 'member')` then return the right rows with nothing added to the code. A role pins the connection to that one role either way, so a data table whose rows should differ per viewer gets no `data.roles` entry. Data table roles are an Enterprise Edition feature, self-hosted only.\n\n### Querying in TypeScript (Bun/Deno)\n\n```typescript\nimport * as wmill from 'windmill-client';\n\nexport async function main(user_id: string) {\n const sql = wmill.datatable(); // Or: wmill.datatable('other_datatable')\n\n // Parameterized queries (safe from SQL injection)\n const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();\n const users = await sql`SELECT * FROM users WHERE active = ${true}`.fetch();\n\n // Insert/Update\n await sql`INSERT INTO users (name, email) VALUES (${name}, ${email})`.execute();\n await sql`UPDATE users SET name = ${newName} WHERE id = ${user_id}`.execute();\n\n return user;\n}\n```\n\n### Querying in Python\n\n```python\nimport wmill\n\ndef main(user_id: str):\n db = wmill.datatable() # Or: wmill.datatable('other_datatable')\n\n # Use $1, $2, etc. for parameters\n user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()\n users = db.query('SELECT * FROM users WHERE active = $1', True).fetch()\n\n # Insert/Update\n db.query('INSERT INTO users (name, email) VALUES ($1, $2)', name, email).execute()\n db.query('UPDATE users SET name = $1 WHERE id = $2', new_name, user_id).execute()\n\n return user\n```\n\n## Best Practices\n\n1. **Check existing tables** before creating new ones — reuse beats schema growth.\n2. **Use parameterized queries** — never concatenate user input into SQL.\n3. **Terminate every datatable statement** — the tagged template and `db.query(...)` only build a statement. It runs when you call `fetch` / `fetchOne` / `fetchOneScalar` / `execute` (`fetch` / `fetch_one` / `fetch_one_scalar` / `execute` in Python). An INSERT or UPDATE without one writes nothing and raises nothing. Awaiting the statement itself is a no-op — it is not a promise.\n4. **Keep runnables focused** — one function per runnable; small surface area.\n5. **Use descriptive keys** — `get_user`, not `a`.\n6. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in `data.tables` first.\n7. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you.\n8. **Reach for `backendAsync` + `waitJob`** for long work — never a hand-written job-polling runnable.\n9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying.\n10. **Use `@windmill-labs/chat-sdk` for a chat over a chat-mode flow** — never a runnable that runs the flow and polls its stream.\n\n\n# Python SDK (wmill)\n\nImport: import wmill\n\nThe client configures itself from the job's environment — base URL, token and credentials mode\nare all set before your code runs, so there is nothing to initialize and no reason to read\nWM_TOKEN or BASE_INTERNAL_URL and build an API URL yourself. Reconstructing that by hand only\nreintroduces details the client already handles. Call the SDK for anything Windmill, and use raw\nHTTP for third-party APIs.\n\nThe functions below are the surface to prefer. For an endpoint none of them covers,\nwmill.Windmill().get(endpoint) and .post(endpoint) issue an authenticated request against this\ninstance. What does not exist is a function name you guessed at: if it is not listed below, do\nnot call it.\n\nTo know who is running the script, read the contextual variables rather than calling the API:\n`os.environ.get(\"WM_END_USER_EMAIL\") or os.environ.get(\"WM_EMAIL\")`. WM_END_USER_EMAIL is the app\nviewer when the run was triggered from an app and empty otherwise (both variables are always\ndefined), WM_EMAIL is the user the job is permissioned as. WM_USERNAME is the matching username.\n\ndef worker_has_internal_server() -> bool\n\ndef get_mocked_api() -> Optional[dict]\n\n# Get the HTTP client instance.\n# \n# Returns:\n# Configured httpx.Client for API requests\ndef get_client() -> httpx.Client\n\n# Make an HTTP GET request to the Windmill API.\n# \n# Args:\n# endpoint: API endpoint path\n# raise_for_status: Whether to raise an exception on HTTP errors\n# **kwargs: Additional arguments passed to httpx.get\n# \n# Returns:\n# HTTP response object\ndef get(endpoint, raise_for_status = True, **kwargs) -> httpx.Response\n\n# Make an HTTP POST request to the Windmill API.\n# \n# Args:\n# endpoint: API endpoint path\n# raise_for_status: Whether to raise an exception on HTTP errors\n# **kwargs: Additional arguments passed to httpx.post\n# \n# Returns:\n# HTTP response object\ndef post(endpoint, raise_for_status = True, **kwargs) -> httpx.Response\n\n# Create a new authentication token.\n# \n# Args:\n# duration: Token validity duration (default: 1 day)\n# \n# Returns:\n# New authentication token string\ndef create_token(duration = dt.timedelta(days=1)) -> str\n\n# Create a script job by path and return its job id.\n# \n# ``retry`` re-runs the job when it fails, with the policy a schedule takes,\n# e.g. ``{\"constant\": {\"attempts\": 3, \"seconds\": 10}}``. The returned id is\n# the first attempt's; each retry is a job of its own, recorded as a child of it.\ndef run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str\n\n# Create a script job by hash and return its job id. See ``run_script_by_path_async`` for ``retry``.\ndef run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str\n\n# Create a flow job and return its job id.\ndef run_flow_async(path: str, args: dict = None, scheduled_in_secs: int = None, do_not_track_in_parent: bool = True, tag: str = None) -> str\n\n# Run script by path synchronously and return its result.\ndef run_script_by_path(path: str, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False, tag: str = None) -> Any\n\n# Run script by hash synchronously and return its result.\ndef run_script_by_hash(hash_: str, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False, tag: str = None) -> Any\n\n# Run a script on the current worker without creating a job.\n# \n# On agent workers (no internal server), falls back to running a normal\n# preview job and waiting for the result.\ndef run_inline_script_preview(content: str, language: str, args: dict = None) -> Any\n\n# Wait for a job to complete and return its result.\n# \n# Args:\n# job_id: ID of the job to wait for\n# timeout: Maximum time to wait (seconds or timedelta)\n# verbose: Enable verbose logging\n# cleanup: Register cleanup handler to cancel job on exit\n# assert_result_is_not_none: Raise exception if result is None\n# \n# Returns:\n# Job result when completed\n# \n# Raises:\n# TimeoutError: If timeout is reached\n# Exception: If job fails\ndef wait_job(job_id, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False)\n\n# Cancel a specific job by ID.\n# \n# Args:\n# job_id: UUID of the job to cancel\n# reason: Optional reason for cancellation\n# \n# Returns:\n# Response message from the cancel endpoint\ndef cancel_job(job_id: str, reason: str = None) -> str\n\n# Cancel currently running executions of the same script.\ndef cancel_running() -> dict\n\n# Get job details by ID.\n# \n# Args:\n# job_id: UUID of the job\n# \n# Returns:\n# Job details dictionary\ndef get_job(job_id: str) -> dict\n\n# Get the root job ID for a flow hierarchy.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# Root job ID\ndef get_root_job_id(job_id: str | None = None) -> dict\n\n# Get an OIDC JWT token for authentication to external services.\n# \n# Args:\n# audience: Token audience (e.g., \"vault\", \"aws\")\n# expires_in: Optional expiration time in seconds\n# \n# Returns:\n# JWT token string\ndef get_id_token(audience: str, expires_in: int | None = None) -> str\n\n# Get the status of a job.\n# \n# Args:\n# job_id: UUID of the job\n# \n# Returns:\n# Job status: \"RUNNING\", \"WAITING\", or \"COMPLETED\"\ndef get_job_status(job_id: str) -> JobStatus\n\n# Get the result of a completed job.\n# \n# Args:\n# job_id: UUID of the completed job\n# assert_result_is_not_none: Raise exception if result is None\n# \n# Returns:\n# Job result\ndef get_result(job_id: str, assert_result_is_not_none: bool = True) -> Any\n\n# Get a variable value by path.\n# \n# Args:\n# path: Variable path in Windmill\n# \n# Returns:\n# Variable value as string\ndef get_variable(path: str) -> str\n\n# Set a variable value by path, creating it if it doesn't exist.\n# \n# Args:\n# path: Variable path in Windmill\n# value: Variable value to set\n# is_secret: Whether the variable should be secret (default: False)\ndef set_variable(path: str, value: str, is_secret: bool = False) -> None\n\n# Get a resource value by path.\n# \n# Args:\n# path: Resource path in Windmill\n# none_if_undefined: Return None instead of raising if not found\n# interpolated: if variables and resources are fully unrolled\n# \n# Returns:\n# Resource value dictionary or None\ndef get_resource(path: str, none_if_undefined: bool = False, interpolated: bool = True) -> dict | None\n\n# Set a resource value by path, creating it if it doesn't exist.\n# \n# Args:\n# value: Resource value to set\n# path: Resource path in Windmill\n# resource_type: Resource type for creation\ndef set_resource(value: Any, path: str, resource_type: str)\n\n# List resources from Windmill workspace.\n# \n# Args:\n# resource_type: Optional resource type to filter by (e.g., \"postgresql\", \"mysql\", \"s3\")\n# page: Optional page number for pagination\n# per_page: Optional number of results per page\n# \n# Returns:\n# List of resource dictionaries\ndef list_resources(resource_type: str = None, page: int = None, per_page: int = None) -> list[dict]\n\n# Set the workflow state.\n# \n# Args:\n# value: State value to set\n# path: Optional state resource path override.\ndef set_state(value: Any, path: str | None = None) -> None\n\n# Get the workflow state.\n# \n# Args:\n# path: Optional state resource path override.\n# \n# Returns:\n# State value or None if not set\ndef get_state(path: str | None = None) -> Any\n\n# Set job progress percentage (0-99).\n# \n# Args:\n# value: Progress percentage\n# job_id: Job ID (defaults to current WM_JOB_ID)\ndef set_progress(value: int, job_id: Optional[str] = None)\n\n# Get job progress percentage.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# Progress value (0-100) or None if not set\ndef get_progress(job_id: Optional[str] = None) -> Any\n\n# Whether the worker running the job has received its shutdown signal.\n# \n# A draining worker waits for its current job to finish before exiting. A long-running\n# script can poll this and return early so the worker can be replaced.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# True if the worker running the job is draining\ndef worker_is_draining(job_id: Optional[str] = None) -> bool\n\n# Set the user state of a flow at a given key\ndef set_flow_user_state(key: str, value: Any) -> None\n\n# Get the user state of a flow at a given key\ndef get_flow_user_state(key: str) -> Any\n\n# Get the Windmill server version.\n# \n# Returns:\n# Version string\ndef version()\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from DuckDB.\n# \n# Without a resource path, the connection goes to the workspace storage through Windmill's\n# S3 proxy when that storage is S3: address it as ``s3://<bucket>/<key>`` with the\n# storage's own bucket, returned as ``s3_bucket``. Pass the resource path to connect with\n# the storage's own credentials instead.\ndef get_duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings | None\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from Polars.\n# \n# Without a resource path, the connection goes to the workspace storage through Windmill's\n# S3 proxy when that storage is S3: address it as ``s3://<bucket>/<key>`` with the\n# storage's own bucket, returned as ``s3_bucket``. Pass the resource path to connect with\n# the storage's own credentials instead.\ndef get_polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection using boto3.\n# \n# Without a resource path, the connection goes to the workspace storage through Windmill's\n# S3 proxy when that storage is S3: pass the storage's own bucket as ``Bucket``. The proxy\n# serves object reads, writes, deletes, listing and multipart uploads of that bucket only;\n# presigned URLs, other buckets and bucket-level APIs need the storage's own credentials,\n# which passing the resource path returns.\ndef get_boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings\n\n# Load a file from the workspace s3 bucket and returns its content as bytes.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# my_obj_content = client.load_s3_file(s3_obj)\n# file_content = my_obj_content.decode(\"utf-8\")\n# '''\ndef load_s3_file(s3object: S3Object | str, s3_resource_path: str | None) -> bytes\n\n# Load a file from the workspace s3 bucket and returns the bytes stream.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# with wmill.load_s3_file_reader(s3object, s3_resource_path) as file_reader:\n# print(file_reader.read())\n# '''\ndef load_s3_file_reader(s3object: S3Object | str, s3_resource_path: str | None) -> BufferedReader\n\n# Write a file to the workspace S3 bucket\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# \n# # for an in memory bytes array:\n# file_content = b'Hello Windmill!'\n# client.write_s3_file(s3_obj, file_content)\n# \n# # for a file:\n# with open(\"my_file.txt\", \"rb\") as my_file:\n# client.write_s3_file(s3_obj, my_file)\n# '''\ndef write_s3_file(s3object: S3Object | str | None, file_content: BufferedReader | bytes, s3_resource_path: str | None, content_type: str | None = None, content_disposition: str | None = None) -> S3Object\n\n# Permanently delete a file from the workspace S3 bucket.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# client.delete_s3_object(s3_obj)\n# '''\ndef delete_s3_object(s3object: S3Object | str, s3_resource_path: str | None = None) -> None\n\n# Sign S3 objects for use by anonymous users in public apps.\n# \n# Args:\n# s3_objects: List of S3 objects to sign\n# expiry_secs: How long the signature stays valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# List of signed S3 objects\ndef sign_s3_objects(s3_objects: list[S3Object | str], expiry_secs: int | None = None) -> list[S3Object]\n\n# Sign a single S3 object for use by anonymous users in public apps.\n# \n# Args:\n# s3_object: S3 object to sign\n# expiry_secs: How long the signature stays valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# Signed S3 object\ndef sign_s3_object(s3_object: S3Object | str, expiry_secs: int | None = None) -> S3Object\n\n# Generate presigned public URLs for an array of S3 objects.\n# If an S3 object is not signed yet, it will be signed first.\n# \n# Args:\n# s3_objects: List of S3 objects to sign\n# base_url: Optional base URL for the presigned URLs (defaults to WM_BASE_URL)\n# expiry_secs: How long the signatures stay valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# List of signed public URLs\n# \n# Example:\n# >>> s3_objs = [S3Object(s3=\"/path/to/file1.txt\"), S3Object(s3=\"/path/to/file2.txt\")]\n# >>> urls = client.get_presigned_s3_public_urls(s3_objs)\ndef get_presigned_s3_public_urls(s3_objects: list[S3Object | str], base_url: str | None = None, expiry_secs: int | None = None) -> list[str]\n\n# Generate a presigned public URL for an S3 object.\n# If the S3 object is not signed yet, it will be signed first.\n# \n# Args:\n# s3_object: S3 object to sign\n# base_url: Optional base URL for the presigned URL (defaults to WM_BASE_URL)\n# expiry_secs: How long the signature stays valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# Signed public URL\n# \n# Example:\n# >>> s3_obj = S3Object(s3=\"/path/to/file.txt\")\n# >>> url = client.get_presigned_s3_public_url(s3_obj)\ndef get_presigned_s3_public_url(s3_object: S3Object | str, base_url: str | None = None, expiry_secs: int | None = None) -> str\n\n# Get the current user information.\n# \n# Returns:\n# User details dictionary\ndef whoami() -> dict\n\n# Get the current user information (alias for whoami).\n# \n# Returns:\n# User details dictionary\ndef user() -> dict\n\n# Get the state resource path from environment.\n# \n# Returns:\n# State path string\ndef state_path() -> str\n\n# Get the workflow state.\n# \n# Returns:\n# State value or None if not set\ndef state() -> Any\n\n# Set the state in the shared folder using pickle\ndef set_shared_state_pickle(value: Any, path: str = 'state.pickle') -> None\n\n# Get the state in the shared folder using pickle\ndef get_shared_state_pickle(path: str = 'state.pickle') -> Any\n\n# Set the state in the shared folder using pickle\ndef set_shared_state(value: Any, path: str = 'state.json') -> None\n\n# Get the state in the shared folder using pickle\ndef get_shared_state(path: str = 'state.json') -> None\n\n# Get URLs needed for resuming a flow after suspension.\n# \n# Args:\n# approver: Optional approver name\n# flow_level: If True, generate resume URLs for the parent flow instead of the\n# specific step. This allows pre-approvals that can be consumed by any later\n# suspend step in the same flow.\n# \n# Returns:\n# Dictionary with approvalPage, resume, and cancel URLs\ndef get_resume_urls(approver: str = None, flow_level: bool = None) -> dict\n\n# Get the resume URLs bound to one ``wait_for_approval`` step of this workflow.\n# \n# Args:\n# step_key: Checkpoint key of the approval step, as passed to\n# ``wait_for_approval(key=...)``\n# approver: Optional approver name\n# \n# Returns:\n# Dictionary with approvalPage, resume, and cancel URLs\ndef get_approval_urls(step_key: str = 'approval', approver: str = None) -> dict\n\n# Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n# \n# **[Enterprise Edition Only]** To include form fields in the Slack approval request, use the \"Advanced -> Suspend -> Form\" functionality.\n# Learn more at: https://www.windmill.dev/docs/flows/flow_approval#form\n# \n# :param slack_resource_path: The path to the Slack resource in Windmill.\n# :type slack_resource_path: str\n# :param channel_id: The Slack channel ID where the approval request will be sent.\n# :type channel_id: str\n# :param message: Optional custom message to include in the Slack approval request.\n# :type message: str, optional\n# :param approver: Optional user ID or name of the approver for the request.\n# :type approver: str, optional\n# :param default_args_json: Optional dictionary defining or overriding the default arguments for form fields.\n# :type default_args_json: dict, optional\n# :param dynamic_enums_json: Optional dictionary overriding the enum default values of enum form fields.\n# :type dynamic_enums_json: dict, optional\n# \n# :raises Exception: If the function is not called within a flow or flow preview.\n# :raises Exception: If the required flow job or flow step environment variables are not set.\n# \n# :return: None\n# \n# **Usage Example:**\n# >>> client.request_interactive_slack_approval(\n# ... slack_resource_path=\"/u/alex/my_slack_resource\",\n# ... channel_id=\"admins-slack-channel\",\n# ... message=\"Please approve this request\",\n# ... approver=\"approver123\",\n# ... default_args_json={\"key1\": \"value1\", \"key2\": 42},\n# ... dynamic_enums_json={\"foo\": [\"choice1\", \"choice2\"], \"bar\": [\"optionA\", \"optionB\"]},\n# ... )\n# \n# **Notes:**\n# - This function must be executed within a Windmill flow or flow preview.\n# - The function checks for required environment variables (`WM_FLOW_JOB_ID`, `WM_FLOW_STEP_ID`) to ensure it is run in the appropriate context.\ndef request_interactive_slack_approval(slack_resource_path: str, channel_id: str, message: str = None, approver: str = None, default_args_json: dict = None, dynamic_enums_json: dict = None) -> None\n\n# Send a message to a Microsoft Teams conversation with conversation_id, where success is used to style the message\ndef send_teams_message(conversation_id: str, text: str, success: bool = True, card_block: dict = None)\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# role: Connect as this data table role instead of the data table's default one.\n# Only meaningful on a data table under roles, and only for a role you are a\n# tenant of.\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main', *, role: Optional[str] = None)\n\n# Get a DuckLake client for DuckDB queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# \n# Returns:\n# DucklakeClient instance\ndef ducklake(name: str = 'main')\n\ndef init_global_client(f)\n\ndef deprecate(in_favor_of: str)\n\n# Get the current workspace ID.\n# \n# Returns:\n# Workspace ID string\ndef get_workspace() -> str\n\ndef get_version() -> str\n\n# Create a script job and return its job ID.\n# \n# Args:\n# hash_or_path: Script hash or path (determined by presence of '/')\n# args: Script arguments\n# scheduled_in_secs: Delay before execution in seconds\n# tag: Override the worker tag the job runs on\n# \n# Returns:\n# Job ID string\ndef run_script_async(hash_or_path: str, args: Dict[str, Any] = None, scheduled_in_secs: int = None, tag: str = None) -> str\n\n# Run a script synchronously by hash and return its result.\n# \n# Args:\n# hash: Script hash\n# args: Script arguments\n# verbose: Enable verbose logging\n# assert_result_is_not_none: Raise exception if result is None\n# cleanup: Register cleanup handler to cancel job on exit\n# timeout: Maximum time to wait\n# tag: Override the worker tag the job runs on\n# \n# Returns:\n# Script result\ndef run_script_sync(hash: str, args: Dict[str, Any] = None, verbose: bool = False, assert_result_is_not_none: bool = True, cleanup: bool = True, timeout: dt.timedelta = None, tag: str = None) -> Any\n\n# Run a script synchronously by path and return its result.\n# \n# Args:\n# path: Script path\n# args: Script arguments\n# verbose: Enable verbose logging\n# assert_result_is_not_none: Raise exception if result is None\n# cleanup: Register cleanup handler to cancel job on exit\n# timeout: Maximum time to wait\n# tag: Override the worker tag the job runs on\n# \n# Returns:\n# Script result\ndef run_script_by_path_sync(path: str, args: Dict[str, Any] = None, verbose: bool = False, assert_result_is_not_none: bool = True, cleanup: bool = True, timeout: dt.timedelta = None, tag: str = None) -> Any\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from DuckDB\ndef duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from Polars\ndef polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection using boto3\ndef boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings\n\n# Get the state resource path from environment.\n# \n# Returns:\n# State path string\ndef get_state_path() -> str\n\n# Parse resource syntax from string.\ndef parse_resource_syntax(s: str) -> Optional[str]\n\n# Parse S3 object from a `s3://<storage>/<key>` URI string (`s3:///<key>`\n# for the default storage) or S3Object format. Any other string raises\n# rather than falling back to an auto-generated key: an auto key is\n# requested by omitting the object, and a fallback would silently misplace\n# the upload on any typo.\ndef parse_s3_object(s3_object: S3Object | str) -> S3Object\n\n# Parse variable syntax from string.\ndef parse_variable_syntax(s: str) -> Optional[str]\n\n# Append a text to the result stream.\n# \n# Args:\n# text: text to append to the result stream\ndef append_to_result_stream(text: str) -> None\n\n# Stream to the result stream.\n# \n# Args:\n# stream: stream to stream to the result stream\ndef stream_result(stream) -> None\n\n# Execute a SQL query against the DataTable.\n# \n# Args:\n# sql: SQL query string with $1, $2, etc. placeholders\n# *args: Positional arguments to bind to query placeholders\n# \n# Returns:\n# SqlQuery instance for fetching results\ndef query(sql: str, *args) -> SqlQuery\n\n# Idempotently materialize the rows of `select_sql` into ducklake\n# `table` for one `partition` (or the whole table when `partition` is\n# None). Client-side equivalent of the `// materialize` engine: with\n# `unique_key` it upserts within the slice (delete-by-key + insert);\n# without it, it replaces (whole table → CREATE OR REPLACE; partition →\n# delete the partition + insert). Re-running the same slice is safe — the\n# backfill / failure-recovery contract.\n# \n# The partition value is bound as a DuckDB arg (never string-interpolated)\n# so it cannot inject SQL. `select_sql` is trusted (your own query).\ndef upsert_partition(table: str, select_sql: str, partition: str = None, unique_key: str = None, partition_col: str = '_wm_partition', schema: str = None)\n\n# INSERT-only materialization (no dedup / no replace) for an immutable\n# event-log table — for one `partition`, or the whole table when\n# `partition` is None. NOTE: unlike `upsert_partition`, re-running the same\n# slice duplicates rows — use only for append-only sources.\ndef append_partition(table: str, select_sql: str, partition: str = None, partition_col: str = '_wm_partition', schema: str = None)\n\n# Read a materialized ducklake table, optionally a single partition.\ndef read(table: str, partition: str = None, partition_col: str = '_wm_partition', schema: str = None)\n\n# Execute query and fetch results.\n# \n# Args:\n# result_collection: Optional result collection mode\n# \n# Returns:\n# Query results\ndef fetch(result_collection: str | None = None)\n\n# Execute query and fetch first row of results.\n# \n# Returns:\n# First row of query results\ndef fetch_one()\n\n# Execute query and fetch first row of results. Return result as a scalar value.\n# \n# Returns:\n# First row of query result as a scalar value\ndef fetch_one_scalar()\n\n# Execute query and don't return any results.\n# \ndef execute()\n\n# DuckDB executor requires explicit argument types at declaration\n# These types exist in both DuckDB and Postgres\n# Check that the types exist if you plan to extend this function for other SQL engines.\ndef infer_sql_type(value) -> str\n\ndef parse_sql_client_name(name: str) -> tuple[str, Optional[str]]\n\n# Decorator that marks a function as a workflow task.\n# \n# Works in both WAC v1 (sync, HTTP-based dispatch) and WAC v2\n# (async, checkpoint/replay) modes:\n# \n# - **v2 (inside @workflow)**: dispatches as a checkpoint step.\n# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.\n# - **Standalone, or called from inside another v2 task**: executes the\n# function body directly. A task runs as a single job, so the tasks it\n# calls run in that same job, without their own options.\n# \n# A task runs as its own job, so its result is always encoded as JSON and\n# decoded back before the caller sees it: a ``datetime`` comes back as a\n# string, a tuple as a list.\n# \n# ``retry`` re-dispatches the task after a failure, inside ``@workflow`` only.\n# Every attempt is a step of its own (``call_api``, ``call_api#2``, ...) and\n# the wait between two of them is a durable sleep, so a retrying task holds no\n# worker while it backs off. Keys: ``attempts`` (retries after the first\n# failure, a whole number from 0 to 100), ``delay`` (seconds before the first\n# retry, sub-second delays dropped), ``multiplier`` (applied to the delay\n# after each attempt, 1 keeps it constant), ``max_delay`` (ceiling in\n# seconds). ``attempts`` is required, and an out-of-range or unknown key is\n# rejected where the policy is written.\n# \n# A workflow sleeps once per round, so tasks backing off in the same fan-out\n# wait one after another rather than together: the delay before a fan-out\n# retries is the sum of every backoff pending in it, not the longest one, and\n# it grows with both the width of the fan-out and ``attempts``. Retries with\n# no ``delay`` all go out in a single round.\n# \n# ``cache_ttl`` serves a previous result of the task for that many seconds\n# instead of running it again. A task is keyed on its step key (its name and\n# call order) and the workflow's input, not on the arguments it is called\n# with, so cache one only when whether it runs, and what it receives, follow\n# from the workflow's input alone. A ``task_script`` target is keyed on the\n# arguments it is called with. It has no effect on a ``task_flow`` target,\n# which keeps its flow's own cache policy.\n# \n# Usage::\n# \n# @task\n# async def extract_data(url: str): ...\n# \n# @task(path=\"f/external_script\", timeout=600, tag=\"gpu\")\n# async def run_external(x: int): ...\n# \n# @task(retry={\"attempts\": 3, \"delay\": 30, \"multiplier\": 2})\n# async def call_api(payload: dict): ...\ndef task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)\n\n# Create a task that dispatches to a separate Windmill script.\n# \n# ``retry`` takes the same policy as :func:`task`.\n# \n# Usage::\n# \n# extract = task_script(\"f/data/extract\", timeout=600)\n# \n# @workflow\n# async def main():\n# data = await extract(url=\"https://...\")\ndef task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)\n\n# Create a task that dispatches to a separate Windmill flow.\n# \n# ``retry`` takes the same policy as :func:`task`.\n# \n# Usage::\n# \n# pipeline = task_flow(\"f/etl/pipeline\", priority=10)\n# \n# @workflow\n# async def main():\n# result = await pipeline(input=data)\ndef task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)\n\n# Decorator marking an async function as a workflow-as-code entry point.\n# \n# The function must be **deterministic**: given the same inputs it must call\n# tasks in the same order on every replay. Branching on task results is fine\n# (results are replayed from checkpoint), but branching on external state\n# (current time, random values, external API calls) must use ``step()`` to\n# checkpoint the value so replays see the same result.\ndef workflow(func)\n\n# Execute ``fn`` inline and checkpoint the result.\n# \n# On replay the cached value is returned without re-executing ``fn``.\n# Use for lightweight deterministic operations (timestamps, random IDs,\n# config reads) that should not incur the overhead of a child job.\n# \n# ``fn``'s result is encoded as JSON and decoded back before it is returned,\n# so the round that runs the body sees the same types every replay sees:\n# a ``datetime`` comes back as a string, a tuple as a list.\n# \n# Called from inside a task body, ``fn`` runs in place and nothing is\n# checkpointed: a task is a single job and is never replayed.\nasync def step(name: str, fn)\n\n# Server-side sleep — suspend the workflow for the given duration without holding a worker.\n# \n# Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``.\n# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``.\nasync def sleep(seconds: int)\n\n# Suspend the workflow and wait for an external approval.\n# \n# Pass ``key`` to name the step, then ``get_approval_urls(key)`` yields the URLs\n# that resume exactly this approval — route them through your own channel.\n# Without a key the steps are named ``approval``, ``approval_2``, ...\n# \n# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.\n# \n# Args:\n# timeout: Approval timeout in seconds (default 1800).\n# form: Optional form schema for the approval page.\n# self_approval: Whether the user who triggered the flow can approve it (default True).\n# key: Optional checkpoint key naming this approval step.\n# skin: ``\"minimal\"`` shows approvers only the request (form and approve/reject)\n# instead of the detailed page with the workflow's details.\n# description: Shown to approvers above the form: a string, or a rich value such as\n# ``{\"markdown\": \"...\"}``.\n# \n# Example::\n# \n# urls = await step(\"urls\", lambda: get_approval_urls(\"manager\"))\n# await step(\"notify\", lambda: send_email(urls[\"resume\"], urls[\"cancel\"]))\n# result = await wait_for_approval(key=\"manager\", timeout=3600)\nasync def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None, skin: Literal['detailed', 'minimal'] | None = None, description: str | dict | None = None) -> dict\n\n# Process items in parallel with optional concurrency control.\n# \n# Each item is processed by calling ``fn(item)``, which should be a @task.\n# Items are dispatched in batches of ``concurrency`` (default: all at once).\n# \n# Example::\n# \n# @task\n# async def process(item: str):\n# ...\n# \n# results = await parallel(items, process, concurrency=5)\nasync def parallel(items, fn, *, concurrency: Optional[int] = None)\n\n# Commit Kafka offsets for a trigger with auto_commit disabled.\n# \n# Args:\n# trigger_path: Path to the Kafka trigger (from event['wm_trigger']['trigger_path'])\n# topic: Kafka topic name (from event['topic'])\n# partition: Partition number (from event['partition'])\n# offset: Message offset to commit (from event['offset'])\ndef commit_kafka_offsets(trigger_path: str, topic: str, partition: int, offset: int) -> None\n\n", + "resource": "# Global draft resource & variable instructions\n\n- Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata.\n- A resource draft is a workspace item: `{ type: 'resource', path, summary?, value, isDraft }`. `value` is a CreateResource body: `{ path, value, description?, resource_type, labels? }` where the inner `value` is the resource type's data shape.\n- Reading a variable returns `{ type: 'variable', path, summary?, isSecret, isDraft }` — never its value, secret or not. `isSecret` tells you whether the value is encrypted.\n- `write_variable` takes `{ path, value?, is_secret?, description?, is_oauth?, expires_at?, value_valid_until?, labels? }`. Creating a variable needs `value` and `is_secret`; editing one needs only the fields you are changing. Omitting `value` keeps the stored value, which is the only way to edit a secret variable — you cannot read its value, so passing any `value` you did not get from the user destroys it.\n- For secret fields in a resource value, create the variable with `write_variable` and `is_secret: true`, and deploy it before the resource (see \"Secrets\" in the reference below).\n- Reference formats inside resource values: `$var:g/all/name` (global), `$var:u/user/name` (user), `$var:f/folder/name` (folder). Reference another resource with `$res:path/to/resource`. The same strings are also how a resource or variable is passed as a run argument (see the run-argument rule in the resource reference below); what they are never valid as is a variable's own value.\n- Use `search_resource_types` to discover valid `resource_type` names and their JSON Schemas. Match the resource value to that schema.\n- For OAuth resources, the `is_oauth: true` flag is managed by Windmill's OAuth flow; global mode generally creates manual resources, not OAuth ones.\n\n# Windmill resource & variable reference\n\n# Windmill Resources\n\nResources store credentials and configuration for external services.\n\n## Resource Structure\n\n```json\n{\n \"value\": {\n \"host\": \"db.example.com\",\n \"port\": 5432,\n \"user\": \"admin\",\n \"password\": \"$var:g/all/db_password\",\n \"dbname\": \"production\"\n },\n \"description\": \"Production PostgreSQL database\",\n \"resource_type\": \"postgresql\"\n}\n```\n\n## Required Fields\n\n- `value` - Object containing the resource configuration\n- `resource_type` - Name of the resource type (e.g., \"postgresql\", \"slack\")\n\n## Agents\n\nAn agent, as the Agents page lists it, is a resource of type `ai_agent`. Its value holds the\nagent's provider, system prompt, tools and memory settings, and a flow step uses a saved agent by\nlinking its path.\n\n## Variable References\n\nReference variables in resource values:\n\n```json\n{\n \"value\": {\n \"api_key\": \"$var:g/all/api_key\",\n \"secret\": \"$var:u/admin/secret\"\n }\n}\n```\n\n**Reference formats:**\n- `$var:g/all/name` - Global variable\n- `$var:u/username/name` - User variable\n- `$var:f/folder/name` - Folder variable\n\n## Secrets\n\nNever put a secret (password, API key, token) inline in a resource value. Store it in a secret variable and reference that variable as `$var:<path>`.\n\n- `$var:<path>` is a reference, not a value: it resolves to the variable's value at run time. Never invent a value for a variable.\n- A resource that references a variable needs the variable to exist first, so create or deploy the variable before the resource.\n\n## Resource References\n\nReference other resources:\n\n```json\n{\n \"value\": {\n \"database\": \"$res:f/databases/postgres\"\n }\n}\n```\n\n## Passing a Resource or Variable as a Run Argument\n\nA script or flow argument typed as a resource (schema `format: resource-<type>`) is passed as\nthe **bare string** `$res:<path>` — the whole argument value. Same for a variable, with\n`$var:<path>`. This applies everywhere job arguments are supplied: `wmill script run/preview`,\n`wmill flow run/preview`, the `runScriptByPath` / `runFlowByPath` API, a schedule's `args`, a\ntrigger's configured static args.\n\n```json\n{\n \"db\": \"$res:f/databases/postgres_prod\",\n \"api_token\": \"$var:g/all/api_token\"\n}\n```\n\nThe reference is resolved when the job runs, under the job's run-as identity — the caller for an\nordinary run, but the configured principal for a schedule, a trigger, or a runnable set to run on\nbehalf of someone else. The run fails if that identity cannot read the referenced resource or\nvariable.\n\n**Never wrap it in an object.** The resolver only rewrites a JSON value that *is* a string\nstarting with `$res:` / `$var:`; keys are never inspected. These are all wrong and are passed\nthrough to the script unchanged:\n\n```json\n{ \"db\": { \"$res\": \"f/databases/postgres_prod\" } }\n{ \"db\": { \"resource\": \"f/databases/postgres_prod\" } }\n{ \"db\": \"f/databases/postgres_prod\" }\n```\n\nThe string may sit anywhere a string can — a top-level argument, a nested object field\n(`{ \"gh_auth\": { \"token\": \"$var:g/all/gh_token\" } }`), or an array element (array elements are\nwalked only while nested at most two levels deep, and only for arrays of at most 1000 items).\nThe prefix must be on the string itself.\n\n## Common Resource Types\n\n### PostgreSQL\n```json\n{\n \"resource_type\": \"postgresql\",\n \"value\": {\n \"host\": \"localhost\",\n \"port\": 5432,\n \"user\": \"postgres\",\n \"password\": \"$var:g/all/pg_password\",\n \"dbname\": \"windmill\",\n \"sslmode\": \"prefer\"\n }\n}\n```\n\n### MySQL\n```json\n{\n \"resource_type\": \"mysql\",\n \"value\": {\n \"host\": \"localhost\",\n \"port\": 3306,\n \"user\": \"root\",\n \"password\": \"$var:g/all/mysql_password\",\n \"database\": \"myapp\"\n }\n}\n```\n\n### Slack\n```json\n{\n \"resource_type\": \"slack\",\n \"value\": {\n \"token\": \"$var:g/all/slack_token\"\n }\n}\n```\n\n### AWS S3\n```json\n{\n \"resource_type\": \"s3\",\n \"value\": {\n \"bucket\": \"my-bucket\",\n \"region\": \"us-east-1\",\n \"accessKeyId\": \"$var:g/all/aws_access_key\",\n \"secretAccessKey\": \"$var:g/all/aws_secret_key\"\n }\n}\n```\n\n### HTTP/API\n```json\n{\n \"resource_type\": \"http\",\n \"value\": {\n \"baseUrl\": \"https://api.example.com\",\n \"headers\": {\n \"Authorization\": \"Bearer $var:g/all/api_token\"\n }\n }\n}\n```\n\n### Kafka\n```json\n{\n \"resource_type\": \"kafka\",\n \"value\": {\n \"brokers\": \"broker1:9092,broker2:9092\",\n \"sasl_mechanism\": \"PLAIN\",\n \"security_protocol\": \"SASL_SSL\",\n \"username\": \"$var:g/all/kafka_user\",\n \"password\": \"$var:g/all/kafka_password\"\n }\n}\n```\n\n### NATS\n```json\n{\n \"resource_type\": \"nats\",\n \"value\": {\n \"servers\": [\"nats://localhost:4222\"],\n \"user\": \"$var:g/all/nats_user\",\n \"password\": \"$var:g/all/nats_password\"\n }\n}\n```\n\n### MQTT\n```json\n{\n \"resource_type\": \"mqtt\",\n \"value\": {\n \"host\": \"mqtt.example.com\",\n \"port\": 8883,\n \"username\": \"$var:g/all/mqtt_user\",\n \"password\": \"$var:g/all/mqtt_password\",\n \"tls\": true\n }\n}\n```\n\n## When No Resource Type Fits\n\nThe Windmill Hub publishes resource types for most services, and an instance gets them by syncing with the hub. When none fits the service:\n\n- If the instance is reported as not having synced the hub's types, mention that a superadmin can sync them: the hub most likely has one for the service. That holds for a type a hub script takes too: it is missing here until the sync, so say so before asking for a resource of it. Create one of your own only if the user still wants it, since after a sync the workspace would hold both.\n- Otherwise, or when the user prefers it to syncing, propose a custom resource type named `c_<name>` holding the service's connection fields (base URL, account or region identifiers, credentials), as in \"Custom Resource Types\" below, and ask the user before creating it: every resource of that type and every script taking one depends on its fields.\n- A plain variable instead of a resource is fine when the user asks for it; without that, default to the resource, its secret in a variable it references.\n\n## Custom Resource Types\n\nA custom type's name starts with `c_`: the hub's types reach every instance under their plain names, so the prefix keeps a custom type from clashing with one synced later. Scripts and flows refer to the full name: `format: resource-c_custom_api`, `RT.CCustomApi` in TypeScript, a `c_custom_api` TypedDict in Python.\n\nCreate custom resource types with JSON Schema:\n\n```json\n{\n \"name\": \"c_custom_api\",\n \"schema\": {\n \"type\": \"object\",\n \"properties\": {\n \"base_url\": {\"type\": \"string\", \"format\": \"uri\"},\n \"api_key\": {\"type\": \"string\"},\n \"timeout\": {\"type\": \"integer\", \"default\": 30}\n },\n \"required\": [\"base_url\", \"api_key\"]\n },\n \"description\": \"Custom API connection\"\n}\n```\n\nSave as: `c_custom_api.resource-type.json`\n\n## OAuth Resources\n\nOAuth resources are managed through the Windmill UI and marked:\n\n```json\n{\n \"is_oauth\": true,\n \"account\": 123\n}\n```\n\nOAuth tokens are automatically refreshed by Windmill.\n\n## Using Resources in Scripts\n\n### TypeScript (Bun/Deno)\n```typescript\nexport async function main(db: RT.Postgresql) {\n // db contains the resource values\n const { host, port, user, password, dbname } = db;\n}\n```\n\n### Python\n```python\nclass postgresql(TypedDict):\n host: str\n port: int\n user: str\n password: str\n dbname: str\n\ndef main(db: postgresql):\n # db contains the resource values\n pass\n```\n\n", + "app": "# Global draft app instructions\n\n- Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy.\n- App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to `u/<current-user>/<name>` for bare names; only use `f/<folder>/<name>` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.\n- To create a new app, use `init_app` with a path, optional summary, a framework (`react19` / `react18` / `svelte5`) and, for an app that stores data, its `data` setup. \"Starting a new app\" below decides the framework and when the data setup is asked. Ask the user each of its questions, suggested answer first, before `init_app` and before any SQL. `init_app` only records `data`: a new schema is yours to create with the datatable SQL tool. `init_app` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.\n- `init_app` seeds the starter inline runnables `greet`, `word_stats`, `slow_task`, `stream_text` so the demo tour works on first render. Replace or remove them once you start building real backend runnables.\n- Frontend file paths start with `/` (e.g. `/index.tsx`, `/App.tsx`, `/styles.css`). Use `write_app_file` / `patch_app_file` / `delete_app_file`.\n- Backend inline runnables are addressed as `backend/<key>/main.{ts|py}` from the file tools, but you create or update them via `write_app_runnable` / `delete_app_runnable` (which take the runnable shape directly: `{ name, type, inlineScript?, path?, staticInputs? }`).\n- `/wmill.d.ts` (or `wmill.ts`) is generated automatically from the backend runnables — never write it directly.\n- Inline runnables only support `bun` or `python3` in chat. Path runnables (`script`/`flow`/`hubscript`) reference an existing item.\n- When a path runnable points at a draft (see \"Draft code vs deployed code\" below), offer to deploy just that flow/script with deploy_workspace_item, not through the compare page.\n- The authoring reference below carries the TypeScript SDK. For a `python3` runnable, call `get_instructions` again with `subject: \"app\"` and `language: \"python3\"`.\n- Use `deploy_workspace_item` after explicit user deploy intent. The deploy tool bundles JS/CSS before saving the raw app.\n- Use `read_workspace_item` with `type: 'app'` for a metadata summary (file paths and runnable list, no contents). Use `read_app_file` to read an individual file; large files are truncated to a head slice, so pass `offset`/`limit` to page through the rest rather than re-reading the whole file.\n- To find where a symbol or string lives across the app, call `search_app` (greps every frontend file and inline runnable, returns matching `file:line` rows) instead of reading files one by one — then `read_app_file` only the ranges you need. The loop is list (`read_workspace_item`) → locate (`search_app`) → inspect (`read_app_file` with `offset`/`limit`).\n\n# Windmill raw app authoring reference\n\n# Windmill Raw Apps\n\nRaw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables.\n\n## App shape\n\nA raw app has three logical parts:\n\n- **Frontend** — bundled with esbuild from `index.tsx` as the entrypoint. Files include the entrypoint, components (`App.tsx`), styles, etc.\n- **Backend runnables** — server-side scripts the frontend calls, each addressed by a unique key.\n- **Data** — optional whitelisted datatables (managed PostgreSQL) that the backend runnables can query. The frontend never queries the database directly; backend runnables are the only bridge.\n\n## Starting a new app\n\nA new app gets the setup Windmill's own new-app dialog gives one: a framework, then where its data lives.\n\n### Framework\n\nUse the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead.\n\n### Data setup\n\nWhen a new app has to store data and the user did not say where, **ask before creating anything**: no app, no schema, no table. Where the data lives is the user's decision, and tables created in the wrong place stay there. Look up the workspace's data tables first so every choice you offer exists, then ask, as proposed answers rather than open questions:\n\n1. **Data table**: which one to use, the default first (`main` when it exists, otherwise the first listed). Skip this one when the workspace has a single data table.\n2. **Schema**: where the app's new tables go.\n - A **new schema**, the usual choice, offered first. Suggest the first unused name among `app1`, `app2`, …\n - An **existing schema** of that data table, by name.\n - **None**: the tables go in `public`.\n3. **Tables**: only when that data table already holds tables the app could use, whether to **reuse** them (name them) or **create new ones**.\n\nDo not ask when:\n\n- the user already said where the data goes. A part they left out takes its default above: the default data table, a new `appN` schema, new tables;\n- the app, or its saved draft, already carries a data config (`data.datatable`, `data.schema` or `data.tables`). That config is the answer;\n- the app stores nothing.\n\nWith no data table in the workspace there is nothing to choose: say that one has to be configured in the workspace settings before the app can store data.\n\nA question left unanswered or dismissed is not a go-ahead for the defaults: stop there and say what is still needed.\n\nThen build what was chosen: the data table and schema become the app's `data.datatable` and `data.schema`, the tables it uses, reused or new, go in `data.tables`, and a new schema is created (`CREATE SCHEMA IF NOT EXISTS`) before the tables that go in it.\n\n### An app that arrives already set up\n\nAn app the user just started from the new-app dialog is already set up: its files are the framework template it was started with, and a `data` config it carries (`data.datatable`, `data.schema` or `data.tables`) is the data setup it was started with. Build that app in place, with that data config, and in that framework unless the user now names another. Do not ask for either, and do not create a second app. Only when it carries no data config and has to store data is the data setup still to ask.\n\n## Frontend\n\n### Entrypoint\n\nThe entrypoint is `index.tsx` for React and `index.ts` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty `<div id=\"root\">` and auto-renders nothing, so the entrypoint must mount a top-level `App` itself. Keep the UI in `App.tsx` / `App.svelte` and keep the entrypoint as the mount shim.\n\nReact (`index.tsx`):\n\n```tsx\nimport React from 'react'\nimport { createRoot } from 'react-dom/client'\nimport App from './App'\n\ncreateRoot(document.getElementById('root')!).render(<App />)\n```\n\nSvelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`.\n\n**Never replace the entrypoint with a bare component** (`export default function App() { ... }` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts `App` into `#root`.\n\n**Set the browser tab title with `document.title = '...'`** (or a `<title>` the framework renders into `<head>`). The app has no `index.html` of its own, so a `<title>` written there is never served; the deployed app's page shows whatever `document.title` holds, and follows it when it changes.\n\n**Always begin every React file (`.tsx`/`.jsx`) that uses JSX with `import React from 'react'`.** esbuild uses the classic JSX transform, so `React` must be in scope wherever JSX appears — a missing import compiles fine but throws `React is not defined` at runtime, leaving a blank screen.\n\n### Generated bindings (`wmill.d.ts` / `wmill.ts`)\n\nThe frontend imports a generated module that mirrors the backend runnables. **Never write to it directly** — it gets regenerated whenever backend runnables change. Modifying it by hand will be overwritten.\n\n### Calling backend runnables\n\nImport the generated bindings and call the runnable like a function. `./wmill` is the **only** way the frontend reaches anything server-side — datatables, workspace items, external services. Never `fetch` the Windmill API from frontend code: the bundle holds no token and builds no API URL.\n\n| Export | Resolves to | Use it for |\n|---|---|---|\n| `backend.<key>(args)` | the runnable's result | the default — run and wait |\n| `backendAsync.<key>(args)` | the **job id** (a string) | long-running work you want to track |\n| `waitJob(jobId)` | the job's **result** (rejects if the job failed) | awaiting a `backendAsync` job |\n| `getJob(jobId)` | a `Job` (`{ type, success, result, duration_ms, ... }`) | polling status without blocking |\n| `streamJob(jobId, onUpdate?)` | the final result, calling `onUpdate` per chunk | showing output as it is produced |\n\nA runnable is always called with **one object** whose keys are its `main` parameters — `main(user_id: string, limit: number)` is called as `backend.get_users({ user_id, limit })`, never with positional arguments. A runnable without parameters is called with no argument. Resource and variable ids handed to the `wmill` client are paths (`u/<user>/<name>` or `f/<folder>/<name>`).\n\nRun and wait — the common case:\n\n```tsx\nimport { backend } from './wmill';\n\nconst user = await backend.get_user({ user_id: '123' });\n```\n\nStart a long job, then await it:\n\n```tsx\nimport { backendAsync, waitJob } from './wmill';\n\nconst jobId = await backendAsync.run_report({ month: '2026-08' }); // a string\nconst report = await waitJob(jobId); // the result itself\n```\n\nOr poll it without blocking, to render progress:\n\n```tsx\nimport { getJob } from './wmill';\n\nconst job = await getJob(jobId);\nif (job.type === 'CompletedJob') setReport(job.result);\n```\n\n`backendAsync` resolves a job id and nothing else — guard on it before storing or polling. A poll loop started on an `undefined` id never completes and shows as a row stuck \"running\" forever:\n\n```tsx\nconst jobId = await backendAsync.run_report(args);\nif (!jobId) throw new Error('run_report did not start a job');\n```\n\n**Never hand-write a job-polling runnable.** A backend runnable that calls `jobs/list`, or that returns `getResultMaybe(...)` for the frontend to poll, reimplements `backendAsync` + `waitJob` / `getJob` / `streamJob` — and it is what leads to guessing at base URLs and tokens.\n\n### Keeping data out of recorded demos\n\nAn app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with `data-wm-no-record` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata:\n\n```tsx\n<label data-wm-no-record>\n Customer SSN <input value={ssn} onChange={onSsn} />\n</label>\n```\n\nApply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.\n\n### Chat UIs over a flow in chat mode or a saved agent\n\nA flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `@windmill-labs/chat-sdk` to `package.json` and use it directly, it detects the app's Windmill and credential.\n\n```tsx\nimport { useWindmillChat } from '@windmill-labs/chat-sdk/react'\n\nconst chat = useWindmillChat({ flowPath: 'f/support/assistant' })\n// chat.messages ({ role, content, pending, success, tool? }), chat.status, chat.sendMessage(text), chat.stop()\n```\n\n`@windmill-labs/chat-sdk/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `@windmill-labs/chat-sdk/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser.\n\n#### A saved agent: an `agent` runnable\n\nChat with a saved AI agent (an `ai_agent` resource) through an `agent` runnable, a path runnable whose `path` is the agent's. Each call runs one turn of the agent's chat as the app, so a sandboxed app declares no frontend SDK scope, viewers get no consent prompt and need no access to the agent. Prefer it in every app, sandboxed or not:\n\n```json\n{ \"chat\": { \"type\": \"agent\", \"path\": \"f/support/agent\" } }\n```\n\n```tsx\nimport { useWindmillChat } from '@windmill-labs/chat-sdk/react'\nimport * as wmill from './wmill'\n\nconst chat = useWindmillChat({ wmill, runnable: 'chat' })\n```\n\n`runnable` is the runnable's key; each runnable keeps its own conversations. The runnable takes `user_message` and `memory_id`, the conversation id, which the chat fills in: the agent remembers earlier turns of a conversation, and a signed-in viewer's conversations are theirs alone. Called directly as `backend.<key>({ user_message })` without `memory_id`, it is a single run with no conversation, for a one-off question. The chat needs an agent with managed memory. An agent whose memory is off keeps none: its runnable takes `previous_messages` (chat messages) instead of `memory_id`, and the app keeps the history itself. The chat keeps its transcript in the app's own storage. `chat.stop()` stops following without cancelling the turn, and a message sent while it still runs is refused with `TurnRunningError`. Attachments are not supported this way.\n\n## Backend runnables\n\nEach runnable has a unique key (used to call it from the frontend) and one of five types:\n\n| Type | What it is |\n|---|---|\n| `inline` | Custom code stored on the app itself. Most common for app-specific logic. |\n| `script` | Reference to an existing workspace script by path. |\n| `flow` | Reference to an existing workspace flow by path. |\n| `hubscript` | Reference to a hub script by path. |\n| `agent` | Reference to a saved AI agent by path. Each call is one turn of its chat (see the chat section above). |\n\n### Inline runnables\n\nInline runnables carry their own source code, and must expose a `main` function as their entrypoint.\n\n**TypeScript example** (runnable `get_user`):\n\n```typescript\nimport * as wmill from 'windmill-client';\n\nexport async function main(user_id: string) {\n const sql = wmill.datatable();\n const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();\n return user;\n}\n```\n\n**Python example** (runnable `get_user`):\n\n```python\nimport wmill\n\ndef main(user_id: str):\n db = wmill.datatable()\n user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()\n return user\n```\n\n#### Sharing code between runnables\n\nAn inline runnable can import another inline runnable of the same app by its id, as a relative import: `import { getDb } from './shared'` (TypeScript, Bun) or `from .shared import get_db` (Python). This works across TypeScript (Bun) runnables and across Python runnables, not between the two. The imported runnable's own dependencies are picked up, and it can import further runnables in turn. A runnable that only holds helpers needs no `main`; the frontend just never calls it. A TypeScript runnable whose id is `main` cannot be imported, because that name is taken by the entrypoint file of every TypeScript job: give shared code any other id.\n\n#### The `wmill` client is already authenticated\n\nAn inline runnable runs as an ordinary Windmill job. `import * as wmill from 'windmill-client'` (TypeScript) and `import wmill` (Python) are already pointed at this instance and this workspace — there is nothing to configure.\n\n**Don't read `WM_TOKEN` or `BASE_INTERNAL_URL` and build an API URL to `fetch`.** The client's own `setClient` already reads exactly those, and it also sets the credentials mode a raw app needs (`WM_RAW_APP` suppresses credentials, because a sandboxed bundle calls the API from an opaque origin that can never pair with `Access-Control-Allow-Origin: *`). Rebuilding that by hand drops the parts you can't see. Use `wmill.*` for everything Windmill, and `fetch` only for third-party APIs.\n\nUse only `wmill` functions the SDK actually exports; for an endpoint none of them covers, the generated service classes (`JobService`, `ScriptService`, ...) are importable from `windmill-client`. What is not available is a name you guessed at: `getBaseUrl` and `getWorkspaceToken` are inventions, not API.\n\n### Path runnables (script / flow / hubscript / agent)\n\nWhen `type` is `script`, `flow`, `hubscript` or `agent`, the runnable just stores a `path` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface; an agent's is `user_message` and `user_attachments`, plus `memory_id` when the agent keeps managed memory or `previous_messages` when its memory is off.\n\nBefore writing an inline runnable, look for a workspace script or flow that already does the job, or a Hub script (a prebuilt integration at `hub/<version>/<app>/<name>`) for a third-party service, and reference it instead of copying its logic.\n\n### Draft code vs deployed code\n\nThis decides whether an app works before anything is deployed:\n\n- **Inline runnables run the app's current code.** The editor sends the runnable's source with each request, so an inline runnable works in the preview with nothing deployed.\n- **Path runnables (`script` / `flow` / `hubscript` / `agent`) run the DEPLOYED item at that path.** So do `wmill.runFlow`, `wmill.runFlowAsync` and `wmill.runScriptByPath` called from inside a runnable. A draft — including a draft you just created — does not exist for them.\n\nSo an app wired to a flow you just wrote does nothing until **that flow is deployed**. The app itself does NOT have to be deployed for this: the preview runs the app's draft, so the referenced flow is the only thing that has to exist deployed.\n\nThat makes the fix a one-item deploy, not a release. Offer to deploy exactly the referenced flow or script and leave the app a draft the user keeps testing in the preview — do not push the whole change set through the review-and-deploy page, and do not ask the user to deploy the app, unless they said they want to ship it.\n\nDo NOT quietly reimplement the flow inside an inline runnable to dodge the deployment: that leaves the user with two copies of the same logic and an app that ignores the flow they asked for. Inline the logic only when the user actually wants it inline.\n\nPrefer a **path runnable of type `flow`** over an inline runnable that calls `wmill.runFlowAsync`. The path runnable gives the frontend the flow's real input schema and works with `backend` / `backendAsync` / `waitJob` like any other runnable; a hand-written wrapper gives up all of that.\n\n### Static inputs\n\n`staticInputs` is an optional `Record<string, any>` for arguments not overridable from the frontend. Useful with path runnables to pre-fill some args while leaving the rest to the frontend caller.\n\n## Who can open a deployed app\n\nA draft app is reachable by nobody; deploying is what exposes it, and its backend runnables with it. Otherwise only users with access to the app can open it, unless the app is opened to:\n\n- **anonymous** users: anyone with the URL, without logging in;\n- **guests**: anyone the instance's identity provider authenticates, whether or not they belong to the workspace.\n\nEither one lets those people run the app's backend runnables, so never open an app up unless the user asks.\nWhen a deploy widens who may open the app, tell the user in plain words.\n\n## Sandbox isolation and frontend API access\n\nNew apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in `window.ctx`, and whoami works with any of them.\n\nInside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry `Origin: null`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or `localStorage`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app.\nYou cannot turn isolation off; the user does it in the app's deploy settings.\n\n## Reading data as the viewer\n\nA backend runnable reads data as the app's identity (the publisher in publisher mode), the same for every viewer. An inline runnable can instead read data as the person using the app: its data table connections that name no role hold that viewer's roles, and object storage permission rules (which paths may be listed, read, written) are checked against the viewer. Resources, variables and everything else stay the app's, and an anonymous viewer is refused. Turn it on only when what the runnable reads should differ per viewer: row-level security on a data table, or per-user paths in object storage. Both are Enterprise Edition features.\nSet it with `viewerDataAccess: true` on the backend runnable.\n\n## Data Tables\n\nData tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the `wmill` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table.\n\n### Critical rules\n\n1. **Check what exists first**: look up the workspace's data tables and their tables before designing storage, and reuse a suitable table rather than creating another. Never assume a `main` data table exists.\n2. **Whitelisted tables only**: a runnable can only query tables listed in the app's `data.tables` config. Queries against unlisted tables fail at runtime, so register a new table there before using it.\n3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable.\n4. **Qualify table names**: an unqualified name means the `public` schema, so write every other table as `schema.table`, in table creation and queries alike. The app's `data` config sets the default datatable and schema its tables go in; use them consistently across runnables.\n5. **Pass the role**: when the app's `data.roles` gives a data table a role, every `wmill.datatable` call on it passes that role (`wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python). The role only reaches what it was granted, so a query outside it fails with `permission denied`.\n6. **Per-viewer rows**: a data table with no entry in `data.roles` is called with no role. Unless its default role is `admin`, the connection then holds the default role, if it may use it, plus every role it is a tenant of, and is refused when that is none. Whose roles those are follows the runnable's identity (see \"Reading data as the viewer\"). Row-level security policies written as `pg_has_role(current_user, '<role>', 'member')` then return the right rows with nothing added to the code. A role pins the connection to that one role either way, so a data table whose rows should differ per viewer gets no `data.roles` entry. Data table roles are an Enterprise Edition feature, self-hosted only.\n\n### Querying in TypeScript (Bun/Deno)\n\n```typescript\nimport * as wmill from 'windmill-client';\n\nexport async function main(user_id: string) {\n const sql = wmill.datatable(); // Or: wmill.datatable('other_datatable')\n\n // Parameterized queries (safe from SQL injection)\n const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();\n const users = await sql`SELECT * FROM users WHERE active = ${true}`.fetch();\n\n // Insert/Update\n await sql`INSERT INTO users (name, email) VALUES (${name}, ${email})`.execute();\n await sql`UPDATE users SET name = ${newName} WHERE id = ${user_id}`.execute();\n\n return user;\n}\n```\n\n### Querying in Python\n\n```python\nimport wmill\n\ndef main(user_id: str):\n db = wmill.datatable() # Or: wmill.datatable('other_datatable')\n\n # Use $1, $2, etc. for parameters\n user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()\n users = db.query('SELECT * FROM users WHERE active = $1', True).fetch()\n\n # Insert/Update\n db.query('INSERT INTO users (name, email) VALUES ($1, $2)', name, email).execute()\n db.query('UPDATE users SET name = $1 WHERE id = $2', new_name, user_id).execute()\n\n return user\n```\n\n## Best Practices\n\n1. **Check existing tables** before creating new ones — reuse beats schema growth.\n2. **Use parameterized queries** — never concatenate user input into SQL.\n3. **Terminate every datatable statement** — the tagged template and `db.query(...)` only build a statement. It runs when you call `fetch` / `fetchOne` / `fetchOneScalar` / `execute` (`fetch` / `fetch_one` / `fetch_one_scalar` / `execute` in Python). An INSERT or UPDATE without one writes nothing and raises nothing. Awaiting the statement itself is a no-op — it is not a promise.\n4. **Keep runnables focused** — one function per runnable; small surface area.\n5. **Use descriptive keys** — `get_user`, not `a`.\n6. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in `data.tables` first.\n7. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you.\n8. **Reach for `backendAsync` + `waitJob`** for long work — never a hand-written job-polling runnable.\n9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying.\n10. **Use `@windmill-labs/chat-sdk` for a chat over a chat-mode flow or a saved agent** — never hand-roll a runnable that runs it and polls its stream; a saved agent is an `agent` runnable passed as `{ wmill, runnable }`.\n\n\n# TypeScript SDK (windmill-client)\n\nImport: import * as wmill from 'windmill-client'\n\nThe client configures itself from the job's environment — base URL, token and credentials mode\nare all set before your code runs, so there is nothing to initialize and no reason to read\nWM_TOKEN or BASE_INTERNAL_URL and build an API URL yourself. Reconstructing that by hand only\nreintroduces details the client already handles. Call the SDK for anything Windmill, and use raw\nHTTP for third-party APIs.\n\nThe helpers below are the surface to prefer. For an endpoint none of them covers, import the\ngenerated service classes (JobService, ScriptService, ...) from 'windmill-client' — they are not\nlisted here but they do exist. What does not exist is a helper name you guessed at: if it is\nneither listed below nor a service method, do not call it.\n\nTo know who is running the script, read the contextual variables rather than calling the API:\n`process.env.WM_END_USER_EMAIL || process.env.WM_EMAIL`. WM_END_USER_EMAIL is the app viewer when\nthe run was triggered from an app and empty otherwise (both variables are always defined), WM_EMAIL\nis the user the job is permissioned as. WM_USERNAME is the matching username.\n\nworkerHasInternalServer(): boolean\n\n/**\n * Initialize the Windmill client with authentication token and base URL\n * @param token - Authentication token (defaults to WM_TOKEN env variable)\n * @param baseUrl - API base URL (defaults to BASE_INTERNAL_URL or BASE_URL env variable)\n */\nsetClient(token?: string, baseUrl?: string): void\n\n/**\n * Create a client configuration from env variables\n * @returns client configuration\n */\ngetWorkspace(): string\n\n/**\n * Get a resource value by path\n * @param path path of the resource, default to internal state path\n * @param undefinedIfEmpty if the resource does not exist, return undefined instead of throwing an error\n * @returns resource value\n */\nasync getResource(path?: string, undefinedIfEmpty?: boolean): Promise<any>\n\n/**\n * Get the true root job id\n * @param jobId job id to get the root job id from (default to current job)\n * @returns root job id\n */\nasync getRootJobId(jobId?: string): Promise<string>\n\n/**\n * Run a script synchronously by its path and wait for the result\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByPath(path: string, args: Record<string, any> | null = null, verbose: boolean = false, tag: string | null = null): Promise<any>\n\n/**\n * Run a script synchronously by its hash and wait for the result\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Script execution result\n */\nasync runScriptByHash(hash_: string, args: Record<string, any> | null = null, verbose: boolean = false, tag: string | null = null): Promise<any>\n\n/**\n * Append a text to the result stream\n * @param text text to append to the result stream\n */\nappendToResultStream(text: string): void\n\n/**\n * Stream to the result stream\n * @param stream stream to stream to the result stream\n */\nasync streamResult(stream: AsyncIterable<string>): Promise<void>\n\n/**\n * Run a flow synchronously by its path and wait for the result\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param verbose - Enable verbose logging\n * @param tag - Override the worker tag the job runs on\n * @returns Flow execution result\n */\nasync runFlow(path: string | null = null, args: Record<string, any> | null = null, verbose: boolean = false, tag: string | null = null): Promise<any>\n\n/**\n * Wait for a job to complete and return its result\n * @param jobId - ID of the job to wait for\n * @param verbose - Enable verbose logging\n * @returns Job result when completed\n */\nasync waitJob(jobId: string, verbose: boolean = false): Promise<any>\n\n/**\n * Get the result of a completed job\n * @param jobId - ID of the completed job\n * @returns Job result\n */\nasync getResult(jobId: string): Promise<any>\n\n/**\n * Get the result of a job if completed, or its current status\n * @param jobId - ID of the job\n * @returns Object with started, completed, success, and result properties\n */\nasync getResultMaybe(jobId: string): Promise<any>\n\n/**\n * Cancel a queued or running job by ID.\n * @param jobId - UUID of the job to cancel\n * @param reason - Optional reason for cancellation\n * @returns Response message from the cancel endpoint\n */\nasync cancelJob(jobId: string, reason: string | undefined = undefined): Promise<string>\n\n/**\n * Run a script asynchronously by its path\n * @param path - Script path in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByPathAsync(path: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string>\n\n/**\n * Run a script asynchronously by its hash\n * @param hash_ - Script hash in Windmill\n * @param args - Arguments to pass to the script\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds), or {@link RunScriptAsyncOptions}\n * `{ scheduledInSeconds?, tag?, retry? }`, e.g. `{ retry: { constant: { attempts: 3, seconds: 10 } } }`\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runScriptByHashAsync(hash_: string, args: Record<string, any> | null = null, scheduledInSeconds: number | null | RunScriptAsyncOptions = null, tag: string | null = null): Promise<string>\n\n/**\n * Run a flow asynchronously by its path\n * @param path - Flow path in Windmill\n * @param args - Arguments to pass to the flow\n * @param scheduledInSeconds - Schedule execution for a future time (in seconds)\n * @param doNotTrackInParent - If false, tracks state in parent job (only use when fully awaiting the job)\n * @param tag - Override the worker tag the job runs on\n * @returns Job ID of the created job\n */\nasync runFlowAsync(path: string | null, args: Record<string, any> | null, scheduledInSeconds: number | null = null, // can only be set to false if this the job will be fully await and not concurrent with any other job // as otherwise the child flow and its own child will store their state in the parent job which will // lead to incorrectness and failures doNotTrackInParent: boolean = true, tag: string | null = null): Promise<string>\n\n/**\n * Resolve a resource value in case the default value was picked because the input payload was undefined\n * @param obj resource value or path of the resource under the format `$res:path`\n * @returns resource value\n */\nasync resolveDefaultResource(obj: any): Promise<any>\n\n/**\n * Get the state file path from environment variables\n * @returns State path string\n */\ngetStatePath(): string\n\n/**\n * Set a resource value by path\n * @param path path of the resource to set, default to state path\n * @param value new value of the resource to set\n * @param initializeToTypeIfNotExist if the resource does not exist, initialize it with this type\n */\nasync setResource(value: any, path?: string, initializeToTypeIfNotExist?: string): Promise<void>\n\n/**\n * Set the state\n * @param state state to set\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync setState(state: any, path?: string): Promise<void>\n\n/**\n * Set the progress\n * Progress cannot go back and limited to 0% to 99% range\n * @param percent Progress to set in %\n * @param jobId? Job to set progress for\n */\nasync setProgress(percent: number, jobId?: any): Promise<void>\n\n/**\n * Get the progress\n * @param jobId? Job to get progress from\n * @returns Optional clamped between 0 and 100 progress value\n */\nasync getProgress(jobId?: any): Promise<number | null>\n\n/**\n * Whether the worker running this job has received its shutdown signal.\n * A draining worker waits for its current job to finish before exiting, so a\n * long-running script can poll this and return early to let the worker be replaced.\n * @param jobId? Job to check, defaults to the current job\n */\nasync workerIsDraining(jobId?: string): Promise<boolean>\n\n/**\n * Set a flow user state\n * @param key key of the state\n * @param value value of the state\n */\nasync setFlowUserState(key: string, value: any, errorIfNotPossible?: boolean): Promise<void>\n\n/**\n * Get a flow user state\n * @param path path of the variable\n */\nasync getFlowUserState(key: string, errorIfNotPossible?: boolean): Promise<any>\n\n/**\n * Get the state shared across executions\n * @param path Optional state resource path override. Defaults to `getStatePath()`.\n */\nasync getState(path?: string): Promise<any>\n\n/**\n * Get a variable by path\n * @param path path of the variable\n * @returns variable value\n */\nasync getVariable(path: string): Promise<string>\n\n/**\n * Set a variable by path, create if not exist\n * @param path path of the variable\n * @param value value of the variable\n * @param isSecretIfNotExist if the variable does not exist, create it as secret or not (default: false)\n * @param descriptionIfNotExist if the variable does not exist, create it with this description (default: \"\")\n */\nasync setVariable(path: string, value: string, isSecretIfNotExist?: boolean, descriptionIfNotExist?: string): Promise<void>\n\n/**\n * Build a PostgreSQL connection URL from a database resource\n * @param path - Path to the database resource\n * @returns PostgreSQL connection URL string\n */\nasync databaseUrlFromResource(path: string): Promise<string>\n\nasync polarsConnectionSettings(s3_resource_path: string | undefined): Promise<any>\n\nasync duckdbConnectionSettings(s3_resource_path: string | undefined): Promise<any>\n\n/**\n * Get S3 client settings from a resource or workspace default\n * @param s3_resource_path - Path to S3 resource (uses workspace default if undefined)\n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n * @returns S3 client configuration settings\n */\nasync denoS3LightClientSettings(s3_resource_path: string | undefined, workspace: string | undefined = undefined): Promise<DenoS3LightClientSettings>\n\n/**\n * Load the content of a file stored in S3. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContent = await wmill.loadS3FileContent(inputFile)\n * // if the file is a raw text file, it can be decoded and printed directly:\n * const text = new TextDecoder().decode(fileContentStream)\n * console.log(text);\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3File(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise<Uint8Array | undefined>\n\n/**\n * Load the content of a file stored in S3 as a stream. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * let fileContentBlob = await wmill.loadS3FileStream(inputFile)\n * // if the content is plain text, the blob can be read directly:\n * console.log(await fileContentBlob.text());\n * ```\n * \n * @param workspace - Workspace to read from (defaults to the `WM_WORKSPACE` env var)\n */\nasync loadS3FileStream(s3object: S3Object, s3ResourcePath: string | undefined = undefined, workspace: string | undefined = undefined): Promise<Blob | undefined>\n\n/**\n * Persist a file to the S3 bucket. If the s3ResourcePath is undefined, it will default to the workspace S3 resource.\n * \n * ```typescript\n * const s3object = await writeS3File(s3Object, \"Hello Windmill!\")\n * const fileContentAsUtf8Str = (await s3object.toArray()).toString('utf-8')\n * console.log(fileContentAsUtf8Str)\n * ```\n * \n * @param workspace - Workspace to write to (defaults to the `WM_WORKSPACE` env var)\n */\nasync writeS3File(s3object: S3Object | undefined, fileContent: string | Blob, s3ResourcePath: string | undefined = undefined, contentType: string | undefined = undefined, contentDisposition: string | undefined = undefined, workspace: string | undefined = undefined): Promise<S3Object>\n\n/**\n * Permanently delete a file from S3 by key.\n * \n * ```typescript\n * await wmill.deleteS3File({ s3: \"path/to/file.txt\" })\n * ```\n * \n * @param s3object - S3 object identifying the file to delete (must have `s3` set)\n * @param workspace - Workspace to delete from (defaults to the `WM_WORKSPACE` env var)\n */\nasync deleteS3File(s3object: S3Object, workspace: string | undefined = undefined): Promise<void>\n\n/**\n * Sign S3 objects to be used by anonymous users in public apps\n * @param s3objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 objects\n */\nasync signS3Objects(s3objects: S3Object[], { expirySecs }: { expirySecs?: number } = {}): Promise<S3Object[]>\n\n/**\n * Sign S3 object to be used by anonymous users in public apps\n * @param s3object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed s3 object\n */\nasync signS3Object(s3object: S3Object, { expirySecs }: { expirySecs?: number } = {}): Promise<S3Object>\n\n/**\n * Generate a presigned public URL for an array of S3 objects.\n * If an S3 object is not signed yet, it will be signed first.\n * @param s3Objects s3 objects to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns list of signed public URLs\n */\nasync getPresignedS3PublicUrls(s3Objects: S3Object[], { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise<string[]>\n\n/**\n * Generate a presigned public URL for an S3 object. If the S3 object is not signed yet, it will be signed first.\n * @param s3Object s3 object to sign\n * @param expirySecs how long the signature stays valid, in seconds (default 43200 = 12h, clamped to [60, 604800])\n * @returns signed public URL\n */\nasync getPresignedS3PublicUrl(s3Objects: S3Object, { baseUrl, expirySecs }: { baseUrl?: string; expirySecs?: number } = {}): Promise<string>\n\n/**\n * Get URLs needed for resuming a flow after this step\n * @param approver approver name\n * @param flowLevel if true, generate resume URLs for the parent flow instead of the specific step.\n * This allows pre-approvals that can be consumed by any later suspend step in the same flow.\n * @returns approval page UI URL, resume and cancel API URLs for resuming the flow\n */\nasync getResumeUrls(approver?: string, flowLevel?: boolean): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Get an OIDC jwt token for auth to external services (e.g: Vault, AWS) (ee only)\n * @param audience audience of the token\n * @param expiresIn Optional number of seconds until the token expires\n * @returns jwt token\n */\nasync getIdToken(audience: string, expiresIn?: number): Promise<string>\n\n/**\n * Convert a base64-encoded string to Uint8Array\n * @param data - Base64-encoded string\n * @returns Decoded Uint8Array\n */\nbase64ToUint8Array(data: string): Uint8Array\n\n/**\n * Convert a Uint8Array to base64-encoded string\n * @param arrayBuffer - Uint8Array to encode\n * @returns Base64-encoded string\n */\nuint8ArrayToBase64(arrayBuffer: Uint8Array): string\n\n/**\n * Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Slack approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Slack approval request.\n * @param {string} options.slackResourcePath - The path to the Slack resource in Windmill.\n * @param {string} options.channelId - The Slack channel ID where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Slack approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * @param {string} [options.resumeButtonText] - Optional text for the resume button.\n * @param {string} [options.cancelButtonText] - Optional text for the cancel button.\n * \n * @returns {Promise<void>} Resolves when the Slack approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getSlackApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveSlackApproval({\n * slackResourcePath: \"/u/alex/my_slack_resource\",\n * channelId: \"admins-slack-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * resumeButtonText: \"Resume\",\n * cancelButtonText: \"Cancel\",\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveSlackApproval({ slackResourcePath, channelId, message, approver, defaultArgsJson, dynamicEnumsJson, resumeButtonText, cancelButtonText, }: SlackApprovalOptions): Promise<void>\n\n/**\n * Sends an interactive approval request via Teams, allowing optional customization of the message, approver, and form fields.\n * \n * **[Enterprise Edition Only]** To include form fields in the Teams approval request, go to **Advanced -> Suspend -> Form**\n * and define a form. Learn more at [Windmill Documentation](https://www.windmill.dev/docs/flows/flow_approval#form).\n * \n * @param {Object} options - The configuration options for the Teams approval request.\n * @param {string} options.teamName - The Teams team name where the approval request will be sent.\n * @param {string} options.channelName - The Teams channel name where the approval request will be sent.\n * @param {string} [options.message] - Optional custom message to include in the Teams approval request.\n * @param {string} [options.approver] - Optional user ID or name of the approver for the request.\n * @param {DefaultArgs} [options.defaultArgsJson] - Optional object defining or overriding the default arguments to a form field.\n * @param {Enums} [options.dynamicEnumsJson] - Optional object overriding the enum default values of an enum form field.\n * \n * @returns {Promise<void>} Resolves when the Teams approval request is successfully sent.\n * \n * @throws {Error} If the function is not called within a flow or flow preview.\n * @throws {Error} If the `JobService.getTeamsApprovalPayload` call fails.\n * \n * **Usage Example:**\n * ```typescript\n * await requestInteractiveTeamsApproval({\n * teamName: \"admins-teams\",\n * channelName: \"admins-teams-channel\",\n * message: \"Please approve this request\",\n * approver: \"approver123\",\n * defaultArgsJson: { key1: \"value1\", key2: 42 },\n * dynamicEnumsJson: { foo: [\"choice1\", \"choice2\"], bar: [\"optionA\", \"optionB\"] },\n * });\n * ```\n * \n * **Note:** This function requires execution within a Windmill flow or flow preview.\n */\nasync requestInteractiveTeamsApproval({ teamName, channelName, message, approver, defaultArgsJson, dynamicEnumsJson, }: TeamsApprovalOptions): Promise<void>\n\nsetWorkflowCtx(ctx: WorkflowCtx | null): void\n\nasync sleep(seconds: number): Promise<void>\n\n/**\n * Execute `fn` inline and checkpoint the result. On replay the cached value is\n * returned without re-executing `fn`.\n * \n * `fn`'s result is encoded as JSON and decoded back before it is returned, so\n * the round that runs the body sees the same types every replay sees: a `Date`\n * comes back as a string, a `Map` as `{}`. {@link Jsonified} is that shape.\n * \n * Called from inside a task body, `fn` runs in place and nothing is\n * checkpointed: a task is a single job and is never replayed.\n */\nasync step<T>(name: string, fn: () => T | Promise<T>,): Promise<Jsonified<Awaited<T>>>\n\n/**\n * Create a task that dispatches to a separate Windmill script.\n * \n * @example\n * const extract = taskScript(\"f/data/extract\");\n * // inside workflow: await extract({ url: \"https://...\" })\n */\ntaskScript(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike<any>\n\n/**\n * Create a task that dispatches to a separate Windmill flow.\n * \n * @example\n * const pipeline = taskFlow(\"f/etl/pipeline\");\n * // inside workflow: await pipeline({ input: data })\n */\ntaskFlow(path: string, options?: TaskOptions): (...args: any[]) => PromiseLike<any>\n\n/**\n * Mark an async function as a workflow-as-code entry point.\n * \n * The function must be **deterministic**: given the same inputs it must call\n * tasks in the same order on every replay. Branching on task results is fine\n * (results are replayed from checkpoint), but branching on external state\n * (current time, random values, external API calls) must use `step()` to\n * checkpoint the value so replays see the same result.\n */\nworkflow<T>(fn: (...args: any[]) => Promise<T>): void\n\n/**\n * Suspend the workflow and wait for an external approval.\n * \n * Pass `key` to name the step, then `getApprovalUrls(key)` yields the URLs that\n * resume exactly this approval — route them through your own channel. Without a\n * key the steps are named `approval`, `approval_2`, ...\n * \n * `skin: \"minimal\"` shows approvers only the request (form and approve/reject)\n * instead of the detailed page with the workflow's details. `description` is\n * shown above the form: a string, or a rich value such as `{ markdown: \"...\" }`.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * const { value, approver } = await waitForApproval({ key: \"manager\", timeout: 3600 });\n */\nwaitForApproval(options?: { timeout?: number; form?: object; selfApproval?: boolean; key?: string; skin?: \"detailed\" | \"minimal\"; description?: string | object; }): PromiseLike<{ value: any; approver: string; approved: boolean }>\n\n/**\n * Resume/cancel/approval-page URLs bound to one `waitForApproval` step.\n * \n * Unlike `getResumeUrls()`, which signs a random nonce, these address the very\n * `resume_job` record the step's built-in approval buttons use, so they are\n * stable across replays and safe to embed in a custom notification.\n * \n * `stepKey` must match the `key` given to `waitForApproval`. Keys must be unique\n * within a workflow; reusing one throws rather than silently renaming it. The URL\n * only resumes while that step is awaiting approval; used at any other moment it is\n * rejected rather than banking a row a different approval would consume. Send it\n * ahead of time — approvers just cannot act before the workflow reaches the step.\n * \n * `resume` and `cancel` are step-bound; `approvalPage` is not — it opens the job's\n * approval page, which acts on whichever approval is pending when it is used.\n * \n * @example\n * const urls = await step(\"urls\", () => getApprovalUrls(\"manager\"));\n * await step(\"notify\", () => sendEmail(urls.resume, urls.cancel));\n * await waitForApproval({ key: \"manager\" });\n */\nasync getApprovalUrls(stepKey: string = \"approval\", approver?: string): Promise<{\n approvalPage: string;\n resume: string;\n cancel: string;\n}>\n\n/**\n * Process items in parallel with optional concurrency control.\n * \n * Each item is processed by calling `fn(item)`, which should be a task().\n * Items are dispatched in batches of `concurrency` (default: all at once).\n * \n * @example\n * const process = task(async (item: string) => { ... });\n * const results = await parallel(items, process, { concurrency: 5 });\n */\nasync parallel<T, R>(items: T[], fn: (item: T) => PromiseLike<R> | R, options?: { concurrency?: number },): Promise<R[]>\n\n/**\n * Commit Kafka offsets for a trigger with auto_commit disabled.\n * @param triggerPath - Path to the Kafka trigger (from event.wm_trigger.trigger_path)\n * @param topic - Kafka topic name (from event.topic)\n * @param partition - Partition number (from event.partition)\n * @param offset - Message offset to commit (from event.offset)\n */\nasync commitKafkaOffsets(triggerPath: string, topic: string, partition: number, offset: number,): Promise<void>\n\n/**\n * Parse an S3 object from URI string or record format\n * @param s3Object - S3 object as URI string (`s3://storage/key`, `s3:///key`\n * for the default storage) or record. Any other string throws rather than\n * falling back to an auto-generated key: an auto key is requested by\n * omitting the object, and a fallback would silently misplace the upload\n * on any typo.\n * @returns S3 object record with storage and s3 key\n */\nparseS3Object(s3Object: S3Object): S3ObjectRecord\n\n/**\n * Create a SQL template function for PostgreSQL/datatable queries\n * @param name - Database/datatable name (default: \"main\")\n * @param opts.role - Connect as this data table role instead of the data table's default one.\n * Only meaningful on a data table under roles, and only for a role you are a tenant of.\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.datatable()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}::int\n * `.fetch()\n * @example\n * // Read through a restricted role\n * let sql = wmill.datatable(\"main\", { role: \"analytics\" })\n */\ndatatable(name: string = \"main\", opts?: DatatableOptions): DatatableSqlTemplateFunction\n\n/**\n * Create a SQL template function for DuckDB/ducklake queries\n * @param name - DuckDB database name, optionally with a schema as `name:schema` (default: \"main\")\n * @returns SQL template function for building parameterized queries\n * @example\n * let sql = wmill.ducklake()\n * let name = 'Robin'\n * let age = 21\n * await sql`\n * SELECT * FROM friends\n * WHERE name = ${name} AND age = ${age}\n * `.fetch()\n * @example\n * // Target a specific schema within the ducklake\n * let sql = wmill.ducklake(\"my_lake:analytics\")\n */\nducklake(name: string = \"main\"): SqlTemplateFunction\n\n/**\n * Idempotently materialize `selectSql` into a ducklake table for one\n * partition (or the whole table when `partition` is omitted) — the client-side\n * equivalent of the `// materialize` engine.\n * With `uniqueKey` it upserts the slice (delete-by-key + insert); otherwise it\n * replaces it (whole table → `CREATE OR REPLACE`; partition → delete + insert).\n * Safe to re-run for the same partition (backfill / failure-recovery).\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.upsertPartition({ table, selectSql, partition }).execute()`.\n */\nupsertPartition(opts: DucklakeMaterializeOptions): SqlStatement<any>\n\n/**\n * INSERT-only materialization (no dedup/replace) for append-only tables.\n * Re-running the same partition duplicates rows — use only for immutable\n * event-log sources.\n * \n * Returns a lazy statement — call `.execute()` to run it:\n * `await wmill.appendPartition({ table, selectSql, partition }).execute()`.\n */\nappendPartition(opts: Omit<DucklakeMaterializeOptions, \"uniqueKey\">,): SqlStatement<any>\n", + "app.python3": "# Global draft app instructions\n\n- Global mode edits raw app drafts only; it does not save or deploy unless the user explicitly asks to deploy.\n- App drafts are addressed by workspace path. Follow the path conventions in the system prompt: default to `u/<current-user>/<name>` for bare names; only use `f/<folder>/<name>` when the folder is known to exist. The first write tool snapshots the workspace app onto the draft, and subsequent writes accumulate.\n- To create a new app, use `init_app` with a path, optional summary, a framework (`react19` / `react18` / `svelte5`) and, for an app that stores data, its `data` setup. \"Starting a new app\" below decides the framework and when the data setup is asked. Ask the user each of its questions, suggested answer first, before `init_app` and before any SQL. `init_app` only records `data`: a new schema is yours to create with the datatable SQL tool. `init_app` errors if an app already exists at the path or a draft is already in flight; in that case, edit the existing one rather than re-initializing.\n- `init_app` seeds the starter inline runnables `greet`, `word_stats`, `slow_task`, `stream_text` so the demo tour works on first render. Replace or remove them once you start building real backend runnables.\n- Frontend file paths start with `/` (e.g. `/index.tsx`, `/App.tsx`, `/styles.css`). Use `write_app_file` / `patch_app_file` / `delete_app_file`.\n- Backend inline runnables are addressed as `backend/<key>/main.{ts|py}` from the file tools, but you create or update them via `write_app_runnable` / `delete_app_runnable` (which take the runnable shape directly: `{ name, type, inlineScript?, path?, staticInputs? }`).\n- `/wmill.d.ts` (or `wmill.ts`) is generated automatically from the backend runnables — never write it directly.\n- Inline runnables only support `bun` or `python3` in chat. Path runnables (`script`/`flow`/`hubscript`) reference an existing item.\n- When a path runnable points at a draft (see \"Draft code vs deployed code\" below), offer to deploy just that flow/script with deploy_workspace_item, not through the compare page.\n- The authoring reference below carries the Python SDK, for `python3` inline runnables. For a `bun` runnable, call `get_instructions` again with `subject: \"app\"` and no language.\n- Use `deploy_workspace_item` after explicit user deploy intent. The deploy tool bundles JS/CSS before saving the raw app.\n- Use `read_workspace_item` with `type: 'app'` for a metadata summary (file paths and runnable list, no contents). Use `read_app_file` to read an individual file; large files are truncated to a head slice, so pass `offset`/`limit` to page through the rest rather than re-reading the whole file.\n- To find where a symbol or string lives across the app, call `search_app` (greps every frontend file and inline runnable, returns matching `file:line` rows) instead of reading files one by one — then `read_app_file` only the ranges you need. The loop is list (`read_workspace_item`) → locate (`search_app`) → inspect (`read_app_file` with `offset`/`limit`).\n\n# Windmill raw app authoring reference\n\n# Windmill Raw Apps\n\nRaw apps let you build custom frontends with React or Svelte that connect to Windmill backend runnables and datatables.\n\n## App shape\n\nA raw app has three logical parts:\n\n- **Frontend** — bundled with esbuild from `index.tsx` as the entrypoint. Files include the entrypoint, components (`App.tsx`), styles, etc.\n- **Backend runnables** — server-side scripts the frontend calls, each addressed by a unique key.\n- **Data** — optional whitelisted datatables (managed PostgreSQL) that the backend runnables can query. The frontend never queries the database directly; backend runnables are the only bridge.\n\n## Starting a new app\n\nA new app gets the setup Windmill's own new-app dialog gives one: a framework, then where its data lives.\n\n### Framework\n\nUse the framework the user named. When they named none, follow what there is to follow: the app being edited, or the apps the request points at. With nothing to follow, use **React 19**, the recommended default, **without asking**. React 18 and Svelte 5 are for when they are asked for. These three are the only frameworks the raw app bundler builds. When the user asks to build or convert an app in another framework, write no code in it: say it is not available and offer these three instead.\n\n### Data setup\n\nWhen a new app has to store data and the user did not say where, **ask before creating anything**: no app, no schema, no table. Where the data lives is the user's decision, and tables created in the wrong place stay there. Look up the workspace's data tables first so every choice you offer exists, then ask, as proposed answers rather than open questions:\n\n1. **Data table**: which one to use, the default first (`main` when it exists, otherwise the first listed). Skip this one when the workspace has a single data table.\n2. **Schema**: where the app's new tables go.\n - A **new schema**, the usual choice, offered first. Suggest the first unused name among `app1`, `app2`, …\n - An **existing schema** of that data table, by name.\n - **None**: the tables go in `public`.\n3. **Tables**: only when that data table already holds tables the app could use, whether to **reuse** them (name them) or **create new ones**.\n\nDo not ask when:\n\n- the user already said where the data goes. A part they left out takes its default above: the default data table, a new `appN` schema, new tables;\n- the app, or its saved draft, already carries a data config (`data.datatable`, `data.schema` or `data.tables`). That config is the answer;\n- the app stores nothing.\n\nWith no data table in the workspace there is nothing to choose: say that one has to be configured in the workspace settings before the app can store data.\n\nA question left unanswered or dismissed is not a go-ahead for the defaults: stop there and say what is still needed.\n\nThen build what was chosen: the data table and schema become the app's `data.datatable` and `data.schema`, the tables it uses, reused or new, go in `data.tables`, and a new schema is created (`CREATE SCHEMA IF NOT EXISTS`) before the tables that go in it.\n\n### An app that arrives already set up\n\nAn app the user just started from the new-app dialog is already set up: its files are the framework template it was started with, and a `data` config it carries (`data.datatable`, `data.schema` or `data.tables`) is the data setup it was started with. Build that app in place, with that data config, and in that framework unless the user now names another. Do not ask for either, and do not create a second app. Only when it carries no data config and has to store data is the data setup still to ask.\n\n## Frontend\n\n### Entrypoint\n\nThe entrypoint is `index.tsx` for React and `index.ts` for Svelte. It is both the bundling entrypoint (the bundler is esbuild) and the **mount** entrypoint: the preview executes the bundle against an empty `<div id=\"root\">` and auto-renders nothing, so the entrypoint must mount a top-level `App` itself. Keep the UI in `App.tsx` / `App.svelte` and keep the entrypoint as the mount shim.\n\nReact (`index.tsx`):\n\n```tsx\nimport React from 'react'\nimport { createRoot } from 'react-dom/client'\nimport App from './App'\n\ncreateRoot(document.getElementById('root')!).render(<App />)\n```\n\nSvelte (`index.ts`): `mount(App, { target: document.getElementById('root')! })`.\n\n**Never replace the entrypoint with a bare component** (`export default function App() { ... }` and no mount call). A component that is defined but never mounted renders a blank screen with **no error thrown** — it never executes, so nothing reaches the console or the error overlay. If an app renders blank, check that the entrypoint still mounts `App` into `#root`.\n\n**Set the browser tab title with `document.title = '...'`** (or a `<title>` the framework renders into `<head>`). The app has no `index.html` of its own, so a `<title>` written there is never served; the deployed app's page shows whatever `document.title` holds, and follows it when it changes.\n\n**Always begin every React file (`.tsx`/`.jsx`) that uses JSX with `import React from 'react'`.** esbuild uses the classic JSX transform, so `React` must be in scope wherever JSX appears — a missing import compiles fine but throws `React is not defined` at runtime, leaving a blank screen.\n\n### Generated bindings (`wmill.d.ts` / `wmill.ts`)\n\nThe frontend imports a generated module that mirrors the backend runnables. **Never write to it directly** — it gets regenerated whenever backend runnables change. Modifying it by hand will be overwritten.\n\n### Calling backend runnables\n\nImport the generated bindings and call the runnable like a function. `./wmill` is the **only** way the frontend reaches anything server-side — datatables, workspace items, external services. Never `fetch` the Windmill API from frontend code: the bundle holds no token and builds no API URL.\n\n| Export | Resolves to | Use it for |\n|---|---|---|\n| `backend.<key>(args)` | the runnable's result | the default — run and wait |\n| `backendAsync.<key>(args)` | the **job id** (a string) | long-running work you want to track |\n| `waitJob(jobId)` | the job's **result** (rejects if the job failed) | awaiting a `backendAsync` job |\n| `getJob(jobId)` | a `Job` (`{ type, success, result, duration_ms, ... }`) | polling status without blocking |\n| `streamJob(jobId, onUpdate?)` | the final result, calling `onUpdate` per chunk | showing output as it is produced |\n\nA runnable is always called with **one object** whose keys are its `main` parameters — `main(user_id: string, limit: number)` is called as `backend.get_users({ user_id, limit })`, never with positional arguments. A runnable without parameters is called with no argument. Resource and variable ids handed to the `wmill` client are paths (`u/<user>/<name>` or `f/<folder>/<name>`).\n\nRun and wait — the common case:\n\n```tsx\nimport { backend } from './wmill';\n\nconst user = await backend.get_user({ user_id: '123' });\n```\n\nStart a long job, then await it:\n\n```tsx\nimport { backendAsync, waitJob } from './wmill';\n\nconst jobId = await backendAsync.run_report({ month: '2026-08' }); // a string\nconst report = await waitJob(jobId); // the result itself\n```\n\nOr poll it without blocking, to render progress:\n\n```tsx\nimport { getJob } from './wmill';\n\nconst job = await getJob(jobId);\nif (job.type === 'CompletedJob') setReport(job.result);\n```\n\n`backendAsync` resolves a job id and nothing else — guard on it before storing or polling. A poll loop started on an `undefined` id never completes and shows as a row stuck \"running\" forever:\n\n```tsx\nconst jobId = await backendAsync.run_report(args);\nif (!jobId) throw new Error('run_report did not start a job');\n```\n\n**Never hand-write a job-polling runnable.** A backend runnable that calls `jobs/list`, or that returns `getResultMaybe(...)` for the frontend to poll, reimplements `backendAsync` + `waitJob` / `getJob` / `streamJob` — and it is what leads to guessing at base URLs and tokens.\n\n### Keeping data out of recorded demos\n\nAn app can be demoed by recording a session: every interaction becomes a step carrying a snapshot of the page, replayed publicly or on the Hub. Password inputs are masked automatically. Mark anything else that must not appear with `data-wm-no-record` — the whole marked subtree is dropped from every snapshot, along with its values and the step's own metadata:\n\n```tsx\n<label data-wm-no-record>\n Customer SSN <input value={ssn} onChange={onSsn} />\n</label>\n```\n\nApply it to customer data, internal notes and anything else a viewer of the demo should not see. It costs nothing when the app is never recorded.\n\n### Chat UIs over a flow in chat mode or a saved agent\n\nA flow deployed with chat mode on is a chat backend (streaming answer, tool calls, memory, conversation history). Do not drive it through a runnable: add `@windmill-labs/chat-sdk` to `package.json` and use it directly, it detects the app's Windmill and credential.\n\n```tsx\nimport { useWindmillChat } from '@windmill-labs/chat-sdk/react'\n\nconst chat = useWindmillChat({ flowPath: 'f/support/assistant' })\n// chat.messages ({ role, content, pending, success, tool? }), chat.status, chat.sendMessage(text), chat.stop()\n```\n\n`@windmill-labs/chat-sdk/ai-sdk` gives a `ChatTransport` for the Vercel AI SDK's `useChat`, `@windmill-labs/chat-sdk/assistant-ui` a runtime for assistant-ui. The flow must be deployed, not a draft. A sandboxed app needs `jobs:run` in its frontend SDK scopes, plus `flow_conversations:write` for the conversation sidebar; without them the chat keeps history in the browser.\n\n#### A saved agent: an `agent` runnable\n\nChat with a saved AI agent (an `ai_agent` resource) through an `agent` runnable, a path runnable whose `path` is the agent's. Each call runs one turn of the agent's chat as the app, so a sandboxed app declares no frontend SDK scope, viewers get no consent prompt and need no access to the agent. Prefer it in every app, sandboxed or not:\n\n```json\n{ \"chat\": { \"type\": \"agent\", \"path\": \"f/support/agent\" } }\n```\n\n```tsx\nimport { useWindmillChat } from '@windmill-labs/chat-sdk/react'\nimport * as wmill from './wmill'\n\nconst chat = useWindmillChat({ wmill, runnable: 'chat' })\n```\n\n`runnable` is the runnable's key; each runnable keeps its own conversations. The runnable takes `user_message` and `memory_id`, the conversation id, which the chat fills in: the agent remembers earlier turns of a conversation, and a signed-in viewer's conversations are theirs alone. Called directly as `backend.<key>({ user_message })` without `memory_id`, it is a single run with no conversation, for a one-off question. The chat needs an agent with managed memory. An agent whose memory is off keeps none: its runnable takes `previous_messages` (chat messages) instead of `memory_id`, and the app keeps the history itself. The chat keeps its transcript in the app's own storage. `chat.stop()` stops following without cancelling the turn, and a message sent while it still runs is refused with `TurnRunningError`. Attachments are not supported this way.\n\n## Backend runnables\n\nEach runnable has a unique key (used to call it from the frontend) and one of five types:\n\n| Type | What it is |\n|---|---|\n| `inline` | Custom code stored on the app itself. Most common for app-specific logic. |\n| `script` | Reference to an existing workspace script by path. |\n| `flow` | Reference to an existing workspace flow by path. |\n| `hubscript` | Reference to a hub script by path. |\n| `agent` | Reference to a saved AI agent by path. Each call is one turn of its chat (see the chat section above). |\n\n### Inline runnables\n\nInline runnables carry their own source code, and must expose a `main` function as their entrypoint.\n\n**TypeScript example** (runnable `get_user`):\n\n```typescript\nimport * as wmill from 'windmill-client';\n\nexport async function main(user_id: string) {\n const sql = wmill.datatable();\n const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();\n return user;\n}\n```\n\n**Python example** (runnable `get_user`):\n\n```python\nimport wmill\n\ndef main(user_id: str):\n db = wmill.datatable()\n user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()\n return user\n```\n\n#### Sharing code between runnables\n\nAn inline runnable can import another inline runnable of the same app by its id, as a relative import: `import { getDb } from './shared'` (TypeScript, Bun) or `from .shared import get_db` (Python). This works across TypeScript (Bun) runnables and across Python runnables, not between the two. The imported runnable's own dependencies are picked up, and it can import further runnables in turn. A runnable that only holds helpers needs no `main`; the frontend just never calls it. A TypeScript runnable whose id is `main` cannot be imported, because that name is taken by the entrypoint file of every TypeScript job: give shared code any other id.\n\n#### The `wmill` client is already authenticated\n\nAn inline runnable runs as an ordinary Windmill job. `import * as wmill from 'windmill-client'` (TypeScript) and `import wmill` (Python) are already pointed at this instance and this workspace — there is nothing to configure.\n\n**Don't read `WM_TOKEN` or `BASE_INTERNAL_URL` and build an API URL to `fetch`.** The client's own `setClient` already reads exactly those, and it also sets the credentials mode a raw app needs (`WM_RAW_APP` suppresses credentials, because a sandboxed bundle calls the API from an opaque origin that can never pair with `Access-Control-Allow-Origin: *`). Rebuilding that by hand drops the parts you can't see. Use `wmill.*` for everything Windmill, and `fetch` only for third-party APIs.\n\nUse only `wmill` functions the SDK actually exports; for an endpoint none of them covers, the generated service classes (`JobService`, `ScriptService`, ...) are importable from `windmill-client`. What is not available is a name you guessed at: `getBaseUrl` and `getWorkspaceToken` are inventions, not API.\n\n### Path runnables (script / flow / hubscript / agent)\n\nWhen `type` is `script`, `flow`, `hubscript` or `agent`, the runnable just stores a `path` to an existing workspace or hub item — no inline code. The referenced item's input/output schema becomes the runnable's surface; an agent's is `user_message` and `user_attachments`, plus `memory_id` when the agent keeps managed memory or `previous_messages` when its memory is off.\n\nBefore writing an inline runnable, look for a workspace script or flow that already does the job, or a Hub script (a prebuilt integration at `hub/<version>/<app>/<name>`) for a third-party service, and reference it instead of copying its logic.\n\n### Draft code vs deployed code\n\nThis decides whether an app works before anything is deployed:\n\n- **Inline runnables run the app's current code.** The editor sends the runnable's source with each request, so an inline runnable works in the preview with nothing deployed.\n- **Path runnables (`script` / `flow` / `hubscript` / `agent`) run the DEPLOYED item at that path.** So do `wmill.runFlow`, `wmill.runFlowAsync` and `wmill.runScriptByPath` called from inside a runnable. A draft — including a draft you just created — does not exist for them.\n\nSo an app wired to a flow you just wrote does nothing until **that flow is deployed**. The app itself does NOT have to be deployed for this: the preview runs the app's draft, so the referenced flow is the only thing that has to exist deployed.\n\nThat makes the fix a one-item deploy, not a release. Offer to deploy exactly the referenced flow or script and leave the app a draft the user keeps testing in the preview — do not push the whole change set through the review-and-deploy page, and do not ask the user to deploy the app, unless they said they want to ship it.\n\nDo NOT quietly reimplement the flow inside an inline runnable to dodge the deployment: that leaves the user with two copies of the same logic and an app that ignores the flow they asked for. Inline the logic only when the user actually wants it inline.\n\nPrefer a **path runnable of type `flow`** over an inline runnable that calls `wmill.runFlowAsync`. The path runnable gives the frontend the flow's real input schema and works with `backend` / `backendAsync` / `waitJob` like any other runnable; a hand-written wrapper gives up all of that.\n\n### Static inputs\n\n`staticInputs` is an optional `Record<string, any>` for arguments not overridable from the frontend. Useful with path runnables to pre-fill some args while leaving the rest to the frontend caller.\n\n## Who can open a deployed app\n\nA draft app is reachable by nobody; deploying is what exposes it, and its backend runnables with it. Otherwise only users with access to the app can open it, unless the app is opened to:\n\n- **anonymous** users: anyone with the URL, without logging in;\n- **guests**: anyone the instance's identity provider authenticates, whether or not they belong to the workspace.\n\nEither one lets those people run the app's backend runnables, so never open an app up unless the user asks.\nWhen a deploy widens who may open the app, tell the user in plain words.\n\n## Sandbox isolation and frontend API access\n\nNew apps are sandboxed: the frontend is isolated from the viewer's own Windmill session and reaches Windmill only through its backend runnables, plus whatever frontend API scopes the app declares, which each viewer approves before the app runs. With isolation turned off, the frontend runs with the viewer's full session. The scopes are `jobs:run`, `jobs:read`, `resources:read`, `variables:read`, `flow_conversations:read` and `flow_conversations:write`; grant only the ones the frontend calls. The viewer's identity needs no scope: it is in `window.ctx`, and whoami works with any of them.\n\nInside the sandbox, IndexedDB is unavailable, third-party sign-in and OAuth redirects fail, and requests to other services carry `Origin: null`, which an API that checks the origin rejects. Work around these first: call the service from a backend runnable, keep state in a data table or `localStorage`, or grant the frontend API scope the code needs. Suggest turning isolation off only when none of that works, and say plainly that it is a security risk: the app's code, and any XSS bug in it, then runs with the full Windmill permissions of everyone who opens the app.\nYou cannot turn isolation off; the user does it in the app's deploy settings.\n\n## Reading data as the viewer\n\nA backend runnable reads data as the app's identity (the publisher in publisher mode), the same for every viewer. An inline runnable can instead read data as the person using the app: its data table connections that name no role hold that viewer's roles, and object storage permission rules (which paths may be listed, read, written) are checked against the viewer. Resources, variables and everything else stay the app's, and an anonymous viewer is refused. Turn it on only when what the runnable reads should differ per viewer: row-level security on a data table, or per-user paths in object storage. Both are Enterprise Edition features.\nSet it with `viewerDataAccess: true` on the backend runnable.\n\n## Data Tables\n\nData tables are PostgreSQL databases managed by Windmill. Backend runnables query them via the `wmill` client; the frontend never queries them directly. **When the app needs to store or persist data** (user data, settings, application state, records, logs), use a data table.\n\n### Critical rules\n\n1. **Check what exists first**: look up the workspace's data tables and their tables before designing storage, and reuse a suitable table rather than creating another. Never assume a `main` data table exists.\n2. **Whitelisted tables only**: a runnable can only query tables listed in the app's `data.tables` config. Queries against unlisted tables fail at runtime, so register a new table there before using it.\n3. **No DDL inside runnables**: runnables only read and write rows (SELECT, INSERT, UPDATE, DELETE) on existing tables. Never CREATE, ALTER or DROP a table from a runnable.\n4. **Qualify table names**: an unqualified name means the `public` schema, so write every other table as `schema.table`, in table creation and queries alike. The app's `data` config sets the default datatable and schema its tables go in; use them consistently across runnables.\n5. **Pass the role**: when the app's `data.roles` gives a data table a role, every `wmill.datatable` call on it passes that role (`wmill.datatable('main', { role: 'analyst' })` in TypeScript, `wmill.datatable('main', role='analyst')` in Python). The role only reaches what it was granted, so a query outside it fails with `permission denied`.\n6. **Per-viewer rows**: a data table with no entry in `data.roles` is called with no role. Unless its default role is `admin`, the connection then holds the default role, if it may use it, plus every role it is a tenant of, and is refused when that is none. Whose roles those are follows the runnable's identity (see \"Reading data as the viewer\"). Row-level security policies written as `pg_has_role(current_user, '<role>', 'member')` then return the right rows with nothing added to the code. A role pins the connection to that one role either way, so a data table whose rows should differ per viewer gets no `data.roles` entry. Data table roles are an Enterprise Edition feature, self-hosted only.\n\n### Querying in TypeScript (Bun/Deno)\n\n```typescript\nimport * as wmill from 'windmill-client';\n\nexport async function main(user_id: string) {\n const sql = wmill.datatable(); // Or: wmill.datatable('other_datatable')\n\n // Parameterized queries (safe from SQL injection)\n const user = await sql`SELECT * FROM users WHERE id = ${user_id}`.fetchOne();\n const users = await sql`SELECT * FROM users WHERE active = ${true}`.fetch();\n\n // Insert/Update\n await sql`INSERT INTO users (name, email) VALUES (${name}, ${email})`.execute();\n await sql`UPDATE users SET name = ${newName} WHERE id = ${user_id}`.execute();\n\n return user;\n}\n```\n\n### Querying in Python\n\n```python\nimport wmill\n\ndef main(user_id: str):\n db = wmill.datatable() # Or: wmill.datatable('other_datatable')\n\n # Use $1, $2, etc. for parameters\n user = db.query('SELECT * FROM users WHERE id = $1', user_id).fetch_one()\n users = db.query('SELECT * FROM users WHERE active = $1', True).fetch()\n\n # Insert/Update\n db.query('INSERT INTO users (name, email) VALUES ($1, $2)', name, email).execute()\n db.query('UPDATE users SET name = $1 WHERE id = $2', new_name, user_id).execute()\n\n return user\n```\n\n## Best Practices\n\n1. **Check existing tables** before creating new ones — reuse beats schema growth.\n2. **Use parameterized queries** — never concatenate user input into SQL.\n3. **Terminate every datatable statement** — the tagged template and `db.query(...)` only build a statement. It runs when you call `fetch` / `fetchOne` / `fetchOneScalar` / `execute` (`fetch` / `fetch_one` / `fetch_one_scalar` / `execute` in Python). An INSERT or UPDATE without one writes nothing and raises nothing. Awaiting the statement itself is a no-op — it is not a promise.\n4. **Keep runnables focused** — one function per runnable; small surface area.\n5. **Use descriptive keys** — `get_user`, not `a`.\n6. **Always whitelist tables** — adding a runnable that queries a new table requires the table to be in `data.tables` first.\n7. **Mark sensitive UI with `data-wm-no-record`** — it is what keeps that data out of a recorded demo; passwords are handled for you.\n8. **Reach for `backendAsync` + `waitJob`** for long work — never a hand-written job-polling runnable.\n9. **Deploy what a path runnable points at** — a path runnable aimed at a draft fails at runtime; tell the user what needs deploying.\n10. **Use `@windmill-labs/chat-sdk` for a chat over a chat-mode flow or a saved agent** — never hand-roll a runnable that runs it and polls its stream; a saved agent is an `agent` runnable passed as `{ wmill, runnable }`.\n\n\n# Python SDK (wmill)\n\nImport: import wmill\n\nThe client configures itself from the job's environment — base URL, token and credentials mode\nare all set before your code runs, so there is nothing to initialize and no reason to read\nWM_TOKEN or BASE_INTERNAL_URL and build an API URL yourself. Reconstructing that by hand only\nreintroduces details the client already handles. Call the SDK for anything Windmill, and use raw\nHTTP for third-party APIs.\n\nThe functions below are the surface to prefer. For an endpoint none of them covers,\nwmill.Windmill().get(endpoint) and .post(endpoint) issue an authenticated request against this\ninstance. What does not exist is a function name you guessed at: if it is not listed below, do\nnot call it.\n\nTo know who is running the script, read the contextual variables rather than calling the API:\n`os.environ.get(\"WM_END_USER_EMAIL\") or os.environ.get(\"WM_EMAIL\")`. WM_END_USER_EMAIL is the app\nviewer when the run was triggered from an app and empty otherwise (both variables are always\ndefined), WM_EMAIL is the user the job is permissioned as. WM_USERNAME is the matching username.\n\ndef worker_has_internal_server() -> bool\n\ndef get_mocked_api() -> Optional[dict]\n\n# Get the HTTP client instance.\n# \n# Returns:\n# Configured httpx.Client for API requests\ndef get_client() -> httpx.Client\n\n# Make an HTTP GET request to the Windmill API.\n# \n# Args:\n# endpoint: API endpoint path\n# raise_for_status: Whether to raise an exception on HTTP errors\n# **kwargs: Additional arguments passed to httpx.get\n# \n# Returns:\n# HTTP response object\ndef get(endpoint, raise_for_status = True, **kwargs) -> httpx.Response\n\n# Make an HTTP POST request to the Windmill API.\n# \n# Args:\n# endpoint: API endpoint path\n# raise_for_status: Whether to raise an exception on HTTP errors\n# **kwargs: Additional arguments passed to httpx.post\n# \n# Returns:\n# HTTP response object\ndef post(endpoint, raise_for_status = True, **kwargs) -> httpx.Response\n\n# Create a new authentication token.\n# \n# Args:\n# duration: Token validity duration (default: 1 day)\n# \n# Returns:\n# New authentication token string\ndef create_token(duration = dt.timedelta(days=1)) -> str\n\n# Create a script job by path and return its job id.\n# \n# ``retry`` re-runs the job when it fails, with the policy a schedule takes,\n# e.g. ``{\"constant\": {\"attempts\": 3, \"seconds\": 10}}``. The returned id is\n# the first attempt's; each retry is a job of its own, recorded as a child of it.\ndef run_script_by_path_async(path: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str\n\n# Create a script job by hash and return its job id. See ``run_script_by_path_async`` for ``retry``.\ndef run_script_by_hash_async(hash_: str, args: dict = None, scheduled_in_secs: int = None, tag: str = None, retry: dict = None) -> str\n\n# Create a flow job and return its job id.\ndef run_flow_async(path: str, args: dict = None, scheduled_in_secs: int = None, do_not_track_in_parent: bool = True, tag: str = None) -> str\n\n# Run script by path synchronously and return its result.\ndef run_script_by_path(path: str, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False, tag: str = None) -> Any\n\n# Run script by hash synchronously and return its result.\ndef run_script_by_hash(hash_: str, args: dict = None, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False, tag: str = None) -> Any\n\n# Run a script on the current worker without creating a job.\n# \n# On agent workers (no internal server), falls back to running a normal\n# preview job and waiting for the result.\ndef run_inline_script_preview(content: str, language: str, args: dict = None) -> Any\n\n# Wait for a job to complete and return its result.\n# \n# Args:\n# job_id: ID of the job to wait for\n# timeout: Maximum time to wait (seconds or timedelta)\n# verbose: Enable verbose logging\n# cleanup: Register cleanup handler to cancel job on exit\n# assert_result_is_not_none: Raise exception if result is None\n# \n# Returns:\n# Job result when completed\n# \n# Raises:\n# TimeoutError: If timeout is reached\n# Exception: If job fails\ndef wait_job(job_id, timeout: dt.timedelta | int | float | None = None, verbose: bool = False, cleanup: bool = True, assert_result_is_not_none: bool = False)\n\n# Cancel a specific job by ID.\n# \n# Args:\n# job_id: UUID of the job to cancel\n# reason: Optional reason for cancellation\n# \n# Returns:\n# Response message from the cancel endpoint\ndef cancel_job(job_id: str, reason: str = None) -> str\n\n# Cancel currently running executions of the same script.\ndef cancel_running() -> dict\n\n# Get job details by ID.\n# \n# Args:\n# job_id: UUID of the job\n# \n# Returns:\n# Job details dictionary\ndef get_job(job_id: str) -> dict\n\n# Get the root job ID for a flow hierarchy.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# Root job ID\ndef get_root_job_id(job_id: str | None = None) -> dict\n\n# Get an OIDC JWT token for authentication to external services.\n# \n# Args:\n# audience: Token audience (e.g., \"vault\", \"aws\")\n# expires_in: Optional expiration time in seconds\n# \n# Returns:\n# JWT token string\ndef get_id_token(audience: str, expires_in: int | None = None) -> str\n\n# Get the status of a job.\n# \n# Args:\n# job_id: UUID of the job\n# \n# Returns:\n# Job status: \"RUNNING\", \"WAITING\", or \"COMPLETED\"\ndef get_job_status(job_id: str) -> JobStatus\n\n# Get the result of a completed job.\n# \n# Args:\n# job_id: UUID of the completed job\n# assert_result_is_not_none: Raise exception if result is None\n# \n# Returns:\n# Job result\ndef get_result(job_id: str, assert_result_is_not_none: bool = True) -> Any\n\n# Get a variable value by path.\n# \n# Args:\n# path: Variable path in Windmill\n# \n# Returns:\n# Variable value as string\ndef get_variable(path: str) -> str\n\n# Set a variable value by path, creating it if it doesn't exist.\n# \n# Args:\n# path: Variable path in Windmill\n# value: Variable value to set\n# is_secret: Whether the variable should be secret (default: False)\ndef set_variable(path: str, value: str, is_secret: bool = False) -> None\n\n# Get a resource value by path.\n# \n# Args:\n# path: Resource path in Windmill\n# none_if_undefined: Return None instead of raising if not found\n# interpolated: if variables and resources are fully unrolled\n# \n# Returns:\n# Resource value dictionary or None\ndef get_resource(path: str, none_if_undefined: bool = False, interpolated: bool = True) -> dict | None\n\n# Set a resource value by path, creating it if it doesn't exist.\n# \n# Args:\n# value: Resource value to set\n# path: Resource path in Windmill\n# resource_type: Resource type for creation\ndef set_resource(value: Any, path: str, resource_type: str)\n\n# List resources from Windmill workspace.\n# \n# Args:\n# resource_type: Optional resource type to filter by (e.g., \"postgresql\", \"mysql\", \"s3\")\n# page: Optional page number for pagination\n# per_page: Optional number of results per page\n# \n# Returns:\n# List of resource dictionaries\ndef list_resources(resource_type: str = None, page: int = None, per_page: int = None) -> list[dict]\n\n# Set the workflow state.\n# \n# Args:\n# value: State value to set\n# path: Optional state resource path override.\ndef set_state(value: Any, path: str | None = None) -> None\n\n# Get the workflow state.\n# \n# Args:\n# path: Optional state resource path override.\n# \n# Returns:\n# State value or None if not set\ndef get_state(path: str | None = None) -> Any\n\n# Set job progress percentage (0-99).\n# \n# Args:\n# value: Progress percentage\n# job_id: Job ID (defaults to current WM_JOB_ID)\ndef set_progress(value: int, job_id: Optional[str] = None)\n\n# Get job progress percentage.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# Progress value (0-100) or None if not set\ndef get_progress(job_id: Optional[str] = None) -> Any\n\n# Whether the worker running the job has received its shutdown signal.\n# \n# A draining worker waits for its current job to finish before exiting. A long-running\n# script can poll this and return early so the worker can be replaced.\n# \n# Args:\n# job_id: Job ID (defaults to current WM_JOB_ID)\n# \n# Returns:\n# True if the worker running the job is draining\ndef worker_is_draining(job_id: Optional[str] = None) -> bool\n\n# Set the user state of a flow at a given key\ndef set_flow_user_state(key: str, value: Any) -> None\n\n# Get the user state of a flow at a given key\ndef get_flow_user_state(key: str) -> Any\n\n# Get the Windmill server version.\n# \n# Returns:\n# Version string\ndef version()\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from DuckDB.\n# \n# Without a resource path, the connection goes to the workspace storage through Windmill's\n# S3 proxy when that storage is S3: address it as ``s3://<bucket>/<key>`` with the\n# storage's own bucket, returned as ``s3_bucket``. Pass the resource path to connect with\n# the storage's own credentials instead.\ndef get_duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings | None\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from Polars.\n# \n# Without a resource path, the connection goes to the workspace storage through Windmill's\n# S3 proxy when that storage is S3: address it as ``s3://<bucket>/<key>`` with the\n# storage's own bucket, returned as ``s3_bucket``. Pass the resource path to connect with\n# the storage's own credentials instead.\ndef get_polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection using boto3.\n# \n# Without a resource path, the connection goes to the workspace storage through Windmill's\n# S3 proxy when that storage is S3: pass the storage's own bucket as ``Bucket``. The proxy\n# serves object reads, writes, deletes, listing and multipart uploads of that bucket only;\n# presigned URLs, other buckets and bucket-level APIs need the storage's own credentials,\n# which passing the resource path returns.\ndef get_boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings\n\n# Load a file from the workspace s3 bucket and returns its content as bytes.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# my_obj_content = client.load_s3_file(s3_obj)\n# file_content = my_obj_content.decode(\"utf-8\")\n# '''\ndef load_s3_file(s3object: S3Object | str, s3_resource_path: str | None) -> bytes\n\n# Load a file from the workspace s3 bucket and returns the bytes stream.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# with wmill.load_s3_file_reader(s3object, s3_resource_path) as file_reader:\n# print(file_reader.read())\n# '''\ndef load_s3_file_reader(s3object: S3Object | str, s3_resource_path: str | None) -> BufferedReader\n\n# Write a file to the workspace S3 bucket\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# \n# # for an in memory bytes array:\n# file_content = b'Hello Windmill!'\n# client.write_s3_file(s3_obj, file_content)\n# \n# # for a file:\n# with open(\"my_file.txt\", \"rb\") as my_file:\n# client.write_s3_file(s3_obj, my_file)\n# '''\ndef write_s3_file(s3object: S3Object | str | None, file_content: BufferedReader | bytes, s3_resource_path: str | None, content_type: str | None = None, content_disposition: str | None = None) -> S3Object\n\n# Permanently delete a file from the workspace S3 bucket.\n# \n# '''python\n# from wmill import S3Object\n# \n# s3_obj = S3Object(s3=\"/path/to/my_file.txt\")\n# client.delete_s3_object(s3_obj)\n# '''\ndef delete_s3_object(s3object: S3Object | str, s3_resource_path: str | None = None) -> None\n\n# Sign S3 objects for use by anonymous users in public apps.\n# \n# Args:\n# s3_objects: List of S3 objects to sign\n# expiry_secs: How long the signature stays valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# List of signed S3 objects\ndef sign_s3_objects(s3_objects: list[S3Object | str], expiry_secs: int | None = None) -> list[S3Object]\n\n# Sign a single S3 object for use by anonymous users in public apps.\n# \n# Args:\n# s3_object: S3 object to sign\n# expiry_secs: How long the signature stays valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# Signed S3 object\ndef sign_s3_object(s3_object: S3Object | str, expiry_secs: int | None = None) -> S3Object\n\n# Generate presigned public URLs for an array of S3 objects.\n# If an S3 object is not signed yet, it will be signed first.\n# \n# Args:\n# s3_objects: List of S3 objects to sign\n# base_url: Optional base URL for the presigned URLs (defaults to WM_BASE_URL)\n# expiry_secs: How long the signatures stay valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# List of signed public URLs\n# \n# Example:\n# >>> s3_objs = [S3Object(s3=\"/path/to/file1.txt\"), S3Object(s3=\"/path/to/file2.txt\")]\n# >>> urls = client.get_presigned_s3_public_urls(s3_objs)\ndef get_presigned_s3_public_urls(s3_objects: list[S3Object | str], base_url: str | None = None, expiry_secs: int | None = None) -> list[str]\n\n# Generate a presigned public URL for an S3 object.\n# If the S3 object is not signed yet, it will be signed first.\n# \n# Args:\n# s3_object: S3 object to sign\n# base_url: Optional base URL for the presigned URL (defaults to WM_BASE_URL)\n# expiry_secs: How long the signature stays valid, in seconds\n# (defaults to 43200 = 12h, clamped to [60, 604800])\n# \n# Returns:\n# Signed public URL\n# \n# Example:\n# >>> s3_obj = S3Object(s3=\"/path/to/file.txt\")\n# >>> url = client.get_presigned_s3_public_url(s3_obj)\ndef get_presigned_s3_public_url(s3_object: S3Object | str, base_url: str | None = None, expiry_secs: int | None = None) -> str\n\n# Get the current user information.\n# \n# Returns:\n# User details dictionary\ndef whoami() -> dict\n\n# Get the current user information (alias for whoami).\n# \n# Returns:\n# User details dictionary\ndef user() -> dict\n\n# Get the state resource path from environment.\n# \n# Returns:\n# State path string\ndef state_path() -> str\n\n# Get the workflow state.\n# \n# Returns:\n# State value or None if not set\ndef state() -> Any\n\n# Set the state in the shared folder using pickle\ndef set_shared_state_pickle(value: Any, path: str = 'state.pickle') -> None\n\n# Get the state in the shared folder using pickle\ndef get_shared_state_pickle(path: str = 'state.pickle') -> Any\n\n# Set the state in the shared folder using pickle\ndef set_shared_state(value: Any, path: str = 'state.json') -> None\n\n# Get the state in the shared folder using pickle\ndef get_shared_state(path: str = 'state.json') -> None\n\n# Get URLs needed for resuming a flow after suspension.\n# \n# Args:\n# approver: Optional approver name\n# flow_level: If True, generate resume URLs for the parent flow instead of the\n# specific step. This allows pre-approvals that can be consumed by any later\n# suspend step in the same flow.\n# \n# Returns:\n# Dictionary with approvalPage, resume, and cancel URLs\ndef get_resume_urls(approver: str = None, flow_level: bool = None) -> dict\n\n# Get the resume URLs bound to one ``wait_for_approval`` step of this workflow.\n# \n# Args:\n# step_key: Checkpoint key of the approval step, as passed to\n# ``wait_for_approval(key=...)``\n# approver: Optional approver name\n# \n# Returns:\n# Dictionary with approvalPage, resume, and cancel URLs\ndef get_approval_urls(step_key: str = 'approval', approver: str = None) -> dict\n\n# Sends an interactive approval request via Slack, allowing optional customization of the message, approver, and form fields.\n# \n# **[Enterprise Edition Only]** To include form fields in the Slack approval request, use the \"Advanced -> Suspend -> Form\" functionality.\n# Learn more at: https://www.windmill.dev/docs/flows/flow_approval#form\n# \n# :param slack_resource_path: The path to the Slack resource in Windmill.\n# :type slack_resource_path: str\n# :param channel_id: The Slack channel ID where the approval request will be sent.\n# :type channel_id: str\n# :param message: Optional custom message to include in the Slack approval request.\n# :type message: str, optional\n# :param approver: Optional user ID or name of the approver for the request.\n# :type approver: str, optional\n# :param default_args_json: Optional dictionary defining or overriding the default arguments for form fields.\n# :type default_args_json: dict, optional\n# :param dynamic_enums_json: Optional dictionary overriding the enum default values of enum form fields.\n# :type dynamic_enums_json: dict, optional\n# \n# :raises Exception: If the function is not called within a flow or flow preview.\n# :raises Exception: If the required flow job or flow step environment variables are not set.\n# \n# :return: None\n# \n# **Usage Example:**\n# >>> client.request_interactive_slack_approval(\n# ... slack_resource_path=\"/u/alex/my_slack_resource\",\n# ... channel_id=\"admins-slack-channel\",\n# ... message=\"Please approve this request\",\n# ... approver=\"approver123\",\n# ... default_args_json={\"key1\": \"value1\", \"key2\": 42},\n# ... dynamic_enums_json={\"foo\": [\"choice1\", \"choice2\"], \"bar\": [\"optionA\", \"optionB\"]},\n# ... )\n# \n# **Notes:**\n# - This function must be executed within a Windmill flow or flow preview.\n# - The function checks for required environment variables (`WM_FLOW_JOB_ID`, `WM_FLOW_STEP_ID`) to ensure it is run in the appropriate context.\ndef request_interactive_slack_approval(slack_resource_path: str, channel_id: str, message: str = None, approver: str = None, default_args_json: dict = None, dynamic_enums_json: dict = None) -> None\n\n# Send a message to a Microsoft Teams conversation with conversation_id, where success is used to style the message\ndef send_teams_message(conversation_id: str, text: str, success: bool = True, card_block: dict = None)\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# role: Connect as this data table role instead of the data table's default one.\n# Only meaningful on a data table under roles, and only for a role you are a\n# tenant of.\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main', *, role: Optional[str] = None)\n\n# Get a DuckLake client for DuckDB queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# \n# Returns:\n# DucklakeClient instance\ndef ducklake(name: str = 'main')\n\ndef init_global_client(f)\n\ndef deprecate(in_favor_of: str)\n\n# Get the current workspace ID.\n# \n# Returns:\n# Workspace ID string\ndef get_workspace() -> str\n\ndef get_version() -> str\n\n# Create a script job and return its job ID.\n# \n# Args:\n# hash_or_path: Script hash or path (determined by presence of '/')\n# args: Script arguments\n# scheduled_in_secs: Delay before execution in seconds\n# tag: Override the worker tag the job runs on\n# \n# Returns:\n# Job ID string\ndef run_script_async(hash_or_path: str, args: Dict[str, Any] = None, scheduled_in_secs: int = None, tag: str = None) -> str\n\n# Run a script synchronously by hash and return its result.\n# \n# Args:\n# hash: Script hash\n# args: Script arguments\n# verbose: Enable verbose logging\n# assert_result_is_not_none: Raise exception if result is None\n# cleanup: Register cleanup handler to cancel job on exit\n# timeout: Maximum time to wait\n# tag: Override the worker tag the job runs on\n# \n# Returns:\n# Script result\ndef run_script_sync(hash: str, args: Dict[str, Any] = None, verbose: bool = False, assert_result_is_not_none: bool = True, cleanup: bool = True, timeout: dt.timedelta = None, tag: str = None) -> Any\n\n# Run a script synchronously by path and return its result.\n# \n# Args:\n# path: Script path\n# args: Script arguments\n# verbose: Enable verbose logging\n# assert_result_is_not_none: Raise exception if result is None\n# cleanup: Register cleanup handler to cancel job on exit\n# timeout: Maximum time to wait\n# tag: Override the worker tag the job runs on\n# \n# Returns:\n# Script result\ndef run_script_by_path_sync(path: str, args: Dict[str, Any] = None, verbose: bool = False, assert_result_is_not_none: bool = True, cleanup: bool = True, timeout: dt.timedelta = None, tag: str = None) -> Any\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from DuckDB\ndef duckdb_connection_settings(s3_resource_path: str = '') -> DuckDbConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection from Polars\ndef polars_connection_settings(s3_resource_path: str = '') -> PolarsConnectionSettings\n\n# Convenient helpers that takes an S3 resource as input and returns the settings necessary to\n# initiate an S3 connection using boto3\ndef boto3_connection_settings(s3_resource_path: str = '') -> Boto3ConnectionSettings\n\n# Get the state resource path from environment.\n# \n# Returns:\n# State path string\ndef get_state_path() -> str\n\n# Parse resource syntax from string.\ndef parse_resource_syntax(s: str) -> Optional[str]\n\n# Parse S3 object from a `s3://<storage>/<key>` URI string (`s3:///<key>`\n# for the default storage) or S3Object format. Any other string raises\n# rather than falling back to an auto-generated key: an auto key is\n# requested by omitting the object, and a fallback would silently misplace\n# the upload on any typo.\ndef parse_s3_object(s3_object: S3Object | str) -> S3Object\n\n# Parse variable syntax from string.\ndef parse_variable_syntax(s: str) -> Optional[str]\n\n# Append a text to the result stream.\n# \n# Args:\n# text: text to append to the result stream\ndef append_to_result_stream(text: str) -> None\n\n# Stream to the result stream.\n# \n# Args:\n# stream: stream to stream to the result stream\ndef stream_result(stream) -> None\n\n# Execute a SQL query against the DataTable.\n# \n# Args:\n# sql: SQL query string with $1, $2, etc. placeholders\n# *args: Positional arguments to bind to query placeholders\n# \n# Returns:\n# SqlQuery instance for fetching results\ndef query(sql: str, *args) -> SqlQuery\n\n# Idempotently materialize the rows of `select_sql` into ducklake\n# `table` for one `partition` (or the whole table when `partition` is\n# None). Client-side equivalent of the `// materialize` engine: with\n# `unique_key` it upserts within the slice (delete-by-key + insert);\n# without it, it replaces (whole table → CREATE OR REPLACE; partition →\n# delete the partition + insert). Re-running the same slice is safe — the\n# backfill / failure-recovery contract.\n# \n# The partition value is bound as a DuckDB arg (never string-interpolated)\n# so it cannot inject SQL. `select_sql` is trusted (your own query).\ndef upsert_partition(table: str, select_sql: str, partition: str = None, unique_key: str = None, partition_col: str = '_wm_partition', schema: str = None)\n\n# INSERT-only materialization (no dedup / no replace) for an immutable\n# event-log table — for one `partition`, or the whole table when\n# `partition` is None. NOTE: unlike `upsert_partition`, re-running the same\n# slice duplicates rows — use only for append-only sources.\ndef append_partition(table: str, select_sql: str, partition: str = None, partition_col: str = '_wm_partition', schema: str = None)\n\n# Read a materialized ducklake table, optionally a single partition.\ndef read(table: str, partition: str = None, partition_col: str = '_wm_partition', schema: str = None)\n\n# Execute query and fetch results.\n# \n# Args:\n# result_collection: Optional result collection mode\n# \n# Returns:\n# Query results\ndef fetch(result_collection: str | None = None)\n\n# Execute query and fetch first row of results.\n# \n# Returns:\n# First row of query results\ndef fetch_one()\n\n# Execute query and fetch first row of results. Return result as a scalar value.\n# \n# Returns:\n# First row of query result as a scalar value\ndef fetch_one_scalar()\n\n# Execute query and don't return any results.\n# \ndef execute()\n\n# DuckDB executor requires explicit argument types at declaration\n# These types exist in both DuckDB and Postgres\n# Check that the types exist if you plan to extend this function for other SQL engines.\ndef infer_sql_type(value) -> str\n\ndef parse_sql_client_name(name: str) -> tuple[str, Optional[str]]\n\n# Decorator that marks a function as a workflow task.\n# \n# Works in both WAC v1 (sync, HTTP-based dispatch) and WAC v2\n# (async, checkpoint/replay) modes:\n# \n# - **v2 (inside @workflow)**: dispatches as a checkpoint step.\n# - **v1 (WM_JOB_ID set, no @workflow)**: dispatches via HTTP API.\n# - **Standalone, or called from inside another v2 task**: executes the\n# function body directly. A task runs as a single job, so the tasks it\n# calls run in that same job, without their own options.\n# \n# A task runs as its own job, so its result is always encoded as JSON and\n# decoded back before the caller sees it: a ``datetime`` comes back as a\n# string, a tuple as a list.\n# \n# ``retry`` re-dispatches the task after a failure, inside ``@workflow`` only.\n# Every attempt is a step of its own (``call_api``, ``call_api#2``, ...) and\n# the wait between two of them is a durable sleep, so a retrying task holds no\n# worker while it backs off. Keys: ``attempts`` (retries after the first\n# failure, a whole number from 0 to 100), ``delay`` (seconds before the first\n# retry, sub-second delays dropped), ``multiplier`` (applied to the delay\n# after each attempt, 1 keeps it constant), ``max_delay`` (ceiling in\n# seconds). ``attempts`` is required, and an out-of-range or unknown key is\n# rejected where the policy is written.\n# \n# A workflow sleeps once per round, so tasks backing off in the same fan-out\n# wait one after another rather than together: the delay before a fan-out\n# retries is the sum of every backoff pending in it, not the longest one, and\n# it grows with both the width of the fan-out and ``attempts``. Retries with\n# no ``delay`` all go out in a single round.\n# \n# ``cache_ttl`` serves a previous result of the task for that many seconds\n# instead of running it again. A task is keyed on its step key (its name and\n# call order) and the workflow's input, not on the arguments it is called\n# with, so cache one only when whether it runs, and what it receives, follow\n# from the workflow's input alone. A ``task_script`` target is keyed on the\n# arguments it is called with. It has no effect on a ``task_flow`` target,\n# which keeps its flow's own cache policy.\n# \n# Usage::\n# \n# @task\n# async def extract_data(url: str): ...\n# \n# @task(path=\"f/external_script\", timeout=600, tag=\"gpu\")\n# async def run_external(x: int): ...\n# \n# @task(retry={\"attempts\": 3, \"delay\": 30, \"multiplier\": 2})\n# async def call_api(payload: dict): ...\ndef task(_func = None, *, path: Optional[str] = None, tag: Optional[str] = None, timeout: Optional[int] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)\n\n# Create a task that dispatches to a separate Windmill script.\n# \n# ``retry`` takes the same policy as :func:`task`.\n# \n# Usage::\n# \n# extract = task_script(\"f/data/extract\", timeout=600)\n# \n# @workflow\n# async def main():\n# data = await extract(url=\"https://...\")\ndef task_script(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)\n\n# Create a task that dispatches to a separate Windmill flow.\n# \n# ``retry`` takes the same policy as :func:`task`.\n# \n# Usage::\n# \n# pipeline = task_flow(\"f/etl/pipeline\", priority=10)\n# \n# @workflow\n# async def main():\n# result = await pipeline(input=data)\ndef task_flow(path: str, *, timeout: Optional[int] = None, tag: Optional[str] = None, cache_ttl: Optional[int] = None, priority: Optional[int] = None, concurrency_limit: Optional[int] = None, concurrency_key: Optional[str] = None, concurrency_time_window_s: Optional[int] = None, retry: Optional[dict] = None)\n\n# Decorator marking an async function as a workflow-as-code entry point.\n# \n# The function must be **deterministic**: given the same inputs it must call\n# tasks in the same order on every replay. Branching on task results is fine\n# (results are replayed from checkpoint), but branching on external state\n# (current time, random values, external API calls) must use ``step()`` to\n# checkpoint the value so replays see the same result.\ndef workflow(func)\n\n# Execute ``fn`` inline and checkpoint the result.\n# \n# On replay the cached value is returned without re-executing ``fn``.\n# Use for lightweight deterministic operations (timestamps, random IDs,\n# config reads) that should not incur the overhead of a child job.\n# \n# ``fn``'s result is encoded as JSON and decoded back before it is returned,\n# so the round that runs the body sees the same types every replay sees:\n# a ``datetime`` comes back as a string, a tuple as a list.\n# \n# Called from inside a task body, ``fn`` runs in place and nothing is\n# checkpointed: a task is a single job and is never replayed.\nasync def step(name: str, fn)\n\n# Server-side sleep — suspend the workflow for the given duration without holding a worker.\n# \n# Inside a @workflow, the parent job suspends and auto-resumes after ``seconds``.\n# Outside a workflow, or inside a task body, falls back to ``asyncio.sleep``.\nasync def sleep(seconds: int)\n\n# Suspend the workflow and wait for an external approval.\n# \n# Pass ``key`` to name the step, then ``get_approval_urls(key)`` yields the URLs\n# that resume exactly this approval — route them through your own channel.\n# Without a key the steps are named ``approval``, ``approval_2``, ...\n# \n# Returns a dict with ``value`` (form data), ``approver``, and ``approved``.\n# \n# Args:\n# timeout: Approval timeout in seconds (default 1800).\n# form: Optional form schema for the approval page.\n# self_approval: Whether the user who triggered the flow can approve it (default True).\n# key: Optional checkpoint key naming this approval step.\n# skin: ``\"minimal\"`` shows approvers only the request (form and approve/reject)\n# instead of the detailed page with the workflow's details.\n# description: Shown to approvers above the form: a string, or a rich value such as\n# ``{\"markdown\": \"...\"}``.\n# \n# Example::\n# \n# urls = await step(\"urls\", lambda: get_approval_urls(\"manager\"))\n# await step(\"notify\", lambda: send_email(urls[\"resume\"], urls[\"cancel\"]))\n# result = await wait_for_approval(key=\"manager\", timeout=3600)\nasync def wait_for_approval(timeout: int = 1800, form: dict | None = None, self_approval: bool = True, key: str | None = None, skin: Literal['detailed', 'minimal'] | None = None, description: str | dict | None = None) -> dict\n\n# Process items in parallel with optional concurrency control.\n# \n# Each item is processed by calling ``fn(item)``, which should be a @task.\n# Items are dispatched in batches of ``concurrency`` (default: all at once).\n# \n# Example::\n# \n# @task\n# async def process(item: str):\n# ...\n# \n# results = await parallel(items, process, concurrency=5)\nasync def parallel(items, fn, *, concurrency: Optional[int] = None)\n\n# Commit Kafka offsets for a trigger with auto_commit disabled.\n# \n# Args:\n# trigger_path: Path to the Kafka trigger (from event['wm_trigger']['trigger_path'])\n# topic: Kafka topic name (from event['topic'])\n# partition: Partition number (from event['partition'])\n# offset: Message offset to commit (from event['offset'])\ndef commit_kafka_offsets(trigger_path: str, topic: str, partition: int, offset: int) -> None\n\n", "datatable": "# Data tables: schema changes, migrations and permissions\n\nA data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, `main` being the default. It is configured in the workspace settings (Data Tables), never created by SQL.\n\n## Changing the schema\n\nA data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an `up` and an optional `down`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table:\n\n- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it.\n- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else.\n\nWriting a migration:\n\n- Wrap the statements in `BEGIN; ... END;` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as `CREATE INDEX CONCURRENTLY`: such a migration can fail half applied.\n- Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back.\n- Declare the schemas it changes on a leading line of its `up`, `-- schemas sales, billing`, and qualify every name with its schema (`sales.orders`). A migration waits only on earlier migrations whose schemas overlap its own. One that declares none applies to the whole database and every later migration waits for it, so leave the line out only for a change that really spans the database, such as an extension.\n- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration.\n- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not.\n\n## A migration is not a draft\n\nA migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use:\n\n- put the new tables in a schema of their own, created by the same migration (`CREATE SCHEMA IF NOT EXISTS`), and point what you are building at it. A raw app names its schema in its `data` config, which is why a new app gets a new schema by default;\n- or work in a workspace fork whose data table was cloned when the fork was created (\"Clone schema only\" or \"Clone schema and data\"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once.\n\nAltering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it.\n\n## Roles and permissions\n\nData table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise.\n\nA data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted:\n\n- roles are assigned, per data table, to users, groups or folders. `admin` is the connection the data table had before roles: it owns the existing objects and ignores grants;\n- a connection either names a role (`wmill.datatable('main', { role: 'analyst' })`, `role='analyst'` in Python, `datatable://main?role=analyst`) and holds that role alone, or names none and is resolved from the data table's default role;\n- `permission denied` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to `admin` to get past it;\n- a migration runs as the role its SQL declares on its first line, `-- role <name>`, the same one in its `down`. One that declares none runs as `admin`, which only those who may use `admin` can do.\n\nRead the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles.\n\n\n# Datatable SQL SDK reference\n\nDatatables are workspace-scoped managed PostgreSQL databases. In chat, explore and shape them with the datatable tools you were given. The reference below is for code you author inside runnables (inline app runnables, scripts, or flow rawscript modules) that reads or writes datatable data at runtime.\n\n- A runnable accesses a datatable via `wmill.datatable()` (the default \"main\") or `wmill.datatable('<name>')`, referencing tables as `schema.table`.\n- Use parameterized queries (the tagged template in TypeScript, `$1`/`$2` placeholders in Python) — never interpolate untrusted values into SQL strings.\n\n## TypeScript Datatable API (windmill-client)\n\nImport: `import * as wmill from 'windmill-client'`\n\nSQL statement object with query content, arguments, and execution methods\n```typescript\ntype SqlStatement<T> = {\n /** Raw SQL content with formatted arguments */\n content: string;\n\n /** Argument values keyed by parameter name */\n args: Record<string, any>;\n\n /**\n * Execute the SQL query and return results\n * @param params - Optional parameters including result collection mode\n * @returns Query results based on the result collection mode\n */\n fetch<ResultCollectionT extends ResultCollection = \"last_statement_all_rows\">(\n params?: FetchParams<ResultCollectionT | ResultCollection> // The union is for auto-completion\n ): Promise<SqlResult<T, ResultCollectionT>>;\n\n /**\n * Execute the SQL query and return only the first row\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOne(\n params?: Omit<FetchParams<\"last_statement_first_row\">, \"resultCollection\">\n ): Promise<SqlResult<T, \"last_statement_first_row\">>;\n\n /**\n * Execute the SQL query and return only the first row as a scalar value\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOneScalar(\n params?: Omit<\n FetchParams<\"last_statement_first_row_scalar\">,\n \"resultCollection\"\n >\n ): Promise<SqlResult<T, \"last_statement_first_row_scalar\">>;\n\n /**\n * Execute the SQL query without fetching rows\n * @param params - Optional parameters\n */\n execute(\n params?: Omit<FetchParams<\"last_statement_first_row\">, \"resultCollection\">\n ): Promise<void>;\n};\n```\n\n```typescript\n// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch()\ninterface DatatableSqlTemplateFunction {\n // Tagged template usage:\n <T = any>(strings: TemplateStringsArray, ...values: any[]): SqlStatement<T>;\n query<T = any>(sql: string, ...params: any[]): SqlStatement<T>;\n};\n```\n\nCreate a SQL template function for PostgreSQL/datatable queries\n@param name - Database/datatable name (default: \"main\")\n@param opts.role - Connect as this data table role instead of the data table's default one.\n Only meaningful on a data table under roles, and only for a role you are a tenant of.\n@returns SQL template function for building parameterized queries\n@example\nlet sql = wmill.datatable()\nlet name = 'Robin'\nlet age = 21\nawait sql`\n SELECT * FROM friends\n WHERE name = ${name} AND age = ${age}::int\n`.fetch()\n@example\n// Read through a restricted role\nlet sql = wmill.datatable(\"main\", { role: \"analytics\" })\n```typescript\nfunction datatable(name: string = \"main\",\n opts?: DatatableOptions): DatatableSqlTemplateFunction\n```\n", "datatable.python3": "# Data tables: schema changes, migrations and permissions\n\nA data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, `main` being the default. It is configured in the workspace settings (Data Tables), never created by SQL.\n\n## Changing the schema\n\nA data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an `up` and an optional `down`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table:\n\n- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it.\n- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else.\n\nWriting a migration:\n\n- Wrap the statements in `BEGIN; ... END;` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as `CREATE INDEX CONCURRENTLY`: such a migration can fail half applied.\n- Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back.\n- Declare the schemas it changes on a leading line of its `up`, `-- schemas sales, billing`, and qualify every name with its schema (`sales.orders`). A migration waits only on earlier migrations whose schemas overlap its own. One that declares none applies to the whole database and every later migration waits for it, so leave the line out only for a change that really spans the database, such as an extension.\n- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration.\n- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not.\n\n## A migration is not a draft\n\nA migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use:\n\n- put the new tables in a schema of their own, created by the same migration (`CREATE SCHEMA IF NOT EXISTS`), and point what you are building at it. A raw app names its schema in its `data` config, which is why a new app gets a new schema by default;\n- or work in a workspace fork whose data table was cloned when the fork was created (\"Clone schema only\" or \"Clone schema and data\"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once.\n\nAltering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it.\n\n## Roles and permissions\n\nData table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise.\n\nA data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted:\n\n- roles are assigned, per data table, to users, groups or folders. `admin` is the connection the data table had before roles: it owns the existing objects and ignores grants;\n- a connection either names a role (`wmill.datatable('main', { role: 'analyst' })`, `role='analyst'` in Python, `datatable://main?role=analyst`) and holds that role alone, or names none and is resolved from the data table's default role;\n- `permission denied` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to `admin` to get past it;\n- a migration runs as the role its SQL declares on its first line, `-- role <name>`, the same one in its `down`. One that declares none runs as `admin`, which only those who may use `admin` can do.\n\nRead the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles.\n\n\n# Datatable SQL SDK reference\n\nDatatables are workspace-scoped managed PostgreSQL databases. In chat, explore and shape them with the datatable tools you were given. The reference below is for code you author inside runnables (inline app runnables, scripts, or flow rawscript modules) that reads or writes datatable data at runtime.\n\n- A runnable accesses a datatable via `wmill.datatable()` (the default \"main\") or `wmill.datatable('<name>')`, referencing tables as `schema.table`.\n- Use parameterized queries (the tagged template in TypeScript, `$1`/`$2` placeholders in Python) — never interpolate untrusted values into SQL strings.\n\n## Python Datatable API (wmill)\n\nImport: `import wmill`\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# role: Connect as this data table role instead of the data table's default one.\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main', *, role: Optional[str] = None) -> DataTableClient\n\n# Client for executing SQL queries against Windmill DataTables.\nclass DataTableClient:\n # Initialize DataTableClient.\n # \n # Args:\n # client: Windmill client instance\n # name: DataTable name\n # role: Data table role to connect as, or None for the data table's default\n def __init__(client: Windmill, name: str, role: Optional[str] = None)\n\n # Execute a SQL query against the DataTable.\n # \n # Args:\n # sql: SQL query string with $1, $2, etc. placeholders\n # *args: Positional arguments to bind to query placeholders\n # \n # Returns:\n # SqlQuery instance for fetching results\n def query(sql: str, *args) -> SqlQuery\n\n\n# Query result handler for DataTable and DuckLake queries.\nclass SqlQuery:\n # Initialize SqlQuery.\n # \n # Args:\n # sql: SQL query string\n # fetch_fn: Function to execute the query\n def __init__(sql: str, fetch_fn)\n\n # Execute query and fetch results.\n # \n # Args:\n # result_collection: Optional result collection mode\n # \n # Returns:\n # Query results\n def fetch(result_collection: str | None = None)\n\n # Execute query and fetch first row of results.\n # \n # Returns:\n # First row of query results\n def fetch_one()\n\n # Execute query and fetch first row of results. Return result as a scalar value.\n # \n # Returns:\n # First row of query result as a scalar value\n def fetch_one_scalar()\n\n # Execute query and don't return any results.\n # \n def execute()\n\n\n", "datatable.go": "# Data tables: schema changes, migrations and permissions\n\nA data table is a PostgreSQL database managed by Windmill and shared by the whole workspace: scripts, flows and apps reach it by name, `main` being the default. It is configured in the workspace settings (Data Tables), never created by SQL.\n\n## Changing the schema\n\nA data table with migrations enabled keeps its schema as an ordered list of versioned SQL migrations, each an `up` and an optional `down`. That list is what git sync and a workspace merge carry, and what rebuilds the schema in another workspace. So first find out whether migrations are enabled on the data table:\n\n- **Enabled**: make every schema change (CREATE, ALTER, DROP, anything but row data) as a new migration. A change made with plain SQL exists in that one database only, and the history no longer describes it.\n- **Not enabled**: schema changes are plain SQL. Enabling migrations is a workspace admin's decision, made in the Data Tables settings, not a step to take on the way to something else.\n\nWriting a migration:\n\n- Wrap the statements in `BEGIN; ... END;` so it applies atomically. Leave the frame out only for a statement that cannot run in a transaction, such as `CREATE INDEX CONCURRENTLY`: such a migration can fail half applied.\n- Give it a `down` that undoes the `up` whenever the change is reversible. Without one it cannot be rolled back.\n- Declare the schemas it changes on a leading line of its `up`, `-- schemas sales, billing`, and qualify every name with its schema (`sales.orders`). A migration waits only on earlier migrations whose schemas overlap its own. One that declares none applies to the whole database and every later migration waits for it, so leave the line out only for a change that really spans the database, such as an extension.\n- Migrations apply oldest first. One that is applied is not edited: correct it with a new migration.\n- Row data is not schema. Insert, update and delete rows with plain SQL, migrations or not.\n\n## A migration is not a draft\n\nA migration is saved to the workspace and changes the database as soon as it is applied, for everything that reads those tables. To try a schema out without touching tables already in use:\n\n- put the new tables in a schema of their own, created by the same migration (`CREATE SCHEMA IF NOT EXISTS`), and point what you are building at it. A raw app names its schema in its `data` config, which is why a new app gets a new schema by default;\n- or work in a workspace fork whose data table was cloned when the fork was created (\"Clone schema only\" or \"Clone schema and data\"): migrations applied there change that copy alone, and reach the parent workspace when the fork is merged. A fork that kept the original data table points at the parent's database, so a migration applied in it changes the parent's tables at once.\n\nAltering or dropping a table other scripts or apps use is neither of those: say what depends on it before doing it.\n\n## Roles and permissions\n\nData table roles are a self-hosted Enterprise Edition feature, for data tables on managed Postgres (the instance database or the external managed instance). Without them, and always on a data table backed by a Postgres resource, everyone who can use the data table reaches all of it: there is no per-user, per-schema or per-row access to promise.\n\nA data table under roles is reached as a Postgres role, and Postgres enforces what that role was granted:\n\n- roles are assigned, per data table, to users, groups or folders. `admin` is the connection the data table had before roles: it owns the existing objects and ignores grants;\n- a connection either names a role (`wmill.datatable('main', { role: 'analyst' })`, `role='analyst'` in Python, `datatable://main?role=analyst`) and holds that role alone, or names none and is resolved from the data table's default role;\n- `permission denied` means the role in use lacks a grant. Say which role and which object: the grant is a data table admin's to give, in the roles drawer of the Database Manager. Do not switch to `admin` to get past it;\n- a migration runs as the role its SQL declares on its first line, `-- role <name>`, the same one in its `down`. One that declares none runs as `admin`, which only those who may use `admin` can do.\n\nRead the Permissions section of https://www.windmill.dev/docs/core_concepts/persistent_storage/data_tables before designing who reaches what: it covers tenants, grants, the default role and the roles a connection that names none ends up holding, rows that differ per viewer through row-level security, and how forks and raw apps behave under roles.\n\n\n# Datatable SQL SDK reference\n\nDatatables are workspace-scoped managed PostgreSQL databases. In chat, explore and shape them with the datatable tools you were given. The reference below is for code you author inside runnables (inline app runnables, scripts, or flow rawscript modules) that reads or writes datatable data at runtime.\n\n- A runnable accesses a datatable via `wmill.datatable()` (the default \"main\") or `wmill.datatable('<name>')`, referencing tables as `schema.table`.\n- Use parameterized queries (the tagged template in TypeScript, `$1`/`$2` placeholders in Python) — never interpolate untrusted values into SQL strings.\n\n## TypeScript Datatable API (windmill-client)\n\nImport: `import * as wmill from 'windmill-client'`\n\nSQL statement object with query content, arguments, and execution methods\n```typescript\ntype SqlStatement<T> = {\n /** Raw SQL content with formatted arguments */\n content: string;\n\n /** Argument values keyed by parameter name */\n args: Record<string, any>;\n\n /**\n * Execute the SQL query and return results\n * @param params - Optional parameters including result collection mode\n * @returns Query results based on the result collection mode\n */\n fetch<ResultCollectionT extends ResultCollection = \"last_statement_all_rows\">(\n params?: FetchParams<ResultCollectionT | ResultCollection> // The union is for auto-completion\n ): Promise<SqlResult<T, ResultCollectionT>>;\n\n /**\n * Execute the SQL query and return only the first row\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOne(\n params?: Omit<FetchParams<\"last_statement_first_row\">, \"resultCollection\">\n ): Promise<SqlResult<T, \"last_statement_first_row\">>;\n\n /**\n * Execute the SQL query and return only the first row as a scalar value\n * @param params - Optional parameters\n * @returns First row of the query result\n */\n fetchOneScalar(\n params?: Omit<\n FetchParams<\"last_statement_first_row_scalar\">,\n \"resultCollection\"\n >\n ): Promise<SqlResult<T, \"last_statement_first_row_scalar\">>;\n\n /**\n * Execute the SQL query without fetching rows\n * @param params - Optional parameters\n */\n execute(\n params?: Omit<FetchParams<\"last_statement_first_row\">, \"resultCollection\">\n ): Promise<void>;\n};\n```\n\n```typescript\n// Template tag function: sql`SELECT * FROM table WHERE id = ${id}`.fetch()\ninterface DatatableSqlTemplateFunction {\n // Tagged template usage:\n <T = any>(strings: TemplateStringsArray, ...values: any[]): SqlStatement<T>;\n query<T = any>(sql: string, ...params: any[]): SqlStatement<T>;\n};\n```\n\nCreate a SQL template function for PostgreSQL/datatable queries\n@param name - Database/datatable name (default: \"main\")\n@param opts.role - Connect as this data table role instead of the data table's default one.\n Only meaningful on a data table under roles, and only for a role you are a tenant of.\n@returns SQL template function for building parameterized queries\n@example\nlet sql = wmill.datatable()\nlet name = 'Robin'\nlet age = 21\nawait sql`\n SELECT * FROM friends\n WHERE name = ${name} AND age = ${age}::int\n`.fetch()\n@example\n// Read through a restricted role\nlet sql = wmill.datatable(\"main\", { role: \"analytics\" })\n```typescript\nfunction datatable(name: string = \"main\",\n opts?: DatatableOptions): DatatableSqlTemplateFunction\n```\n\n\n## Python Datatable API (wmill)\n\nImport: `import wmill`\n\n# Get a DataTable client for SQL queries.\n# \n# Args:\n# name: Database name (default: \"main\")\n# role: Connect as this data table role instead of the data table's default one.\n# \n# Returns:\n# DataTableClient instance\ndef datatable(name: str = 'main', *, role: Optional[str] = None) -> DataTableClient\n\n# Client for executing SQL queries against Windmill DataTables.\nclass DataTableClient:\n # Initialize DataTableClient.\n # \n # Args:\n # client: Windmill client instance\n # name: DataTable name\n # role: Data table role to connect as, or None for the data table's default\n def __init__(client: Windmill, name: str, role: Optional[str] = None)\n\n # Execute a SQL query against the DataTable.\n # \n # Args:\n # sql: SQL query string with $1, $2, etc. placeholders\n # *args: Positional arguments to bind to query placeholders\n # \n # Returns:\n # SqlQuery instance for fetching results\n def query(sql: str, *args) -> SqlQuery\n\n\n# Query result handler for DataTable and DuckLake queries.\nclass SqlQuery:\n # Initialize SqlQuery.\n # \n # Args:\n # sql: SQL query string\n # fetch_fn: Function to execute the query\n def __init__(sql: str, fetch_fn)\n\n # Execute query and fetch results.\n # \n # Args:\n # result_collection: Optional result collection mode\n # \n # Returns:\n # Query results\n def fetch(result_collection: str | None = None)\n\n # Execute query and fetch first row of results.\n # \n # Returns:\n # First row of query results\n def fetch_one()\n\n # Execute query and fetch first row of results. Return result as a scalar value.\n # \n # Returns:\n # First row of query result as a scalar value\n def fetch_one_scalar()\n\n # Execute query and don't return any results.\n # \n def execute()\n\n\n", "pipeline": "# Data pipeline authoring\n\nA **data pipeline** is NOT a flow. A flow is one runnable that orchestrates steps internally. A data pipeline is a set of **independent scripts**, each deployed on its own, that form a DAG by reading and writing shared **storage assets** (DuckLake tables, data tables, S3 objects, volumes, resources) and by declaring execution **triggers**. The pipeline is visualized and edited at `/pipeline/<folder>`; every node is a normal workspace script that happens to carry pipeline annotations. When the user asks for a \"data pipeline\" (or to \"ingest / transform / materialize\" data across steps), build pipeline-annotated scripts — do NOT build a flow.\n\n## Default to DuckDB + DuckLake\n\nA pipeline node that produces a table should almost always be a **`duckdb`** node that materializes its output into a **DuckLake** table with `-- materialize ducklake://<name>/<table>` (in a DuckDB node the annotation uses SQL `--` comment syntax; write the body as a bare `SELECT` and let the runtime do the write). DuckLake is the default lakehouse store for pipelines and is the shape the pipeline editor is built around, so prefer it unless the work specifically calls for something else:\n\n- `postgresql` / data tables — only for row-level, OLTP-style mutations against an existing Postgres data table (frequent single-row upserts/updates, transactional reads that an app queries live).\n- `bun` / `python3` — only for work that doesn't map to SQL: calling an external API, wrangling files, arbitrary glue. When such a node produces tabular data for downstream steps, write it **straight into DuckLake** (or a data table) from the same script — see \"Ingesting from Python / TypeScript\" below — rather than inventing a parallel store.\n\nDo not spread a pipeline across postgres, S3, and DuckLake when one DuckLake lake would do; a consistent DuckLake lakehouse is the goal.\n\n## Ingesting from Python / TypeScript\n\nA node that fetches data in code (an API, a SaaS export, a scraper) writes the rows **directly** into their DuckLake table with `wmill.ducklake()`, or into a data table with `wmill.datatable()`. DuckLake already stores every table as parquet files in the workspace object storage, so **never stage the raw payload first** — no intermediate JSON / CSV / parquet file in S3 and no second \"load the raw file\" node. One ingestion node, one table.\n\nPass the rows as a query argument (a list of dicts / array of objects is sent as JSON) and let SQL unnest them; declare the column types so the table gets a real schema:\n\n```python\n# pipeline\n# on schedule\nimport wmill\n\ndef main():\n rows = fetch_charges() # list[dict]\n lake = wmill.ducklake()\n lake.query(\"\"\"\n CREATE OR REPLACE TABLE stripe_charges AS\n SELECT unnest(from_json($rows, '[{\"id\":\"VARCHAR\",\"amount\":\"BIGINT\",\"created\":\"BIGINT\"}]'), recursive := true)\n \"\"\", rows=rows).fetch()\n```\n\n```ts\n// pipeline\n// on schedule\nimport * as wmill from \"windmill-client\"\n\nexport async function main() {\n const rows = await fetchCharges()\n const lake = wmill.ducklake()\n await lake`CREATE OR REPLACE TABLE stripe_charges AS\n SELECT unnest(from_json(${rows}, '[{\"id\":\"VARCHAR\",\"amount\":\"BIGINT\",\"created\":\"BIGINT\"}]'), recursive := true)`.fetch()\n}\n```\n\n- Use `INSERT INTO <table> SELECT ...` instead of `CREATE OR REPLACE` to append (an incremental pull), with a one-time `CREATE TABLE IF NOT EXISTS` first.\n- Assign the client to a variable (`lake = wmill.ducklake()`) and keep the SQL a **string literal** (Python) or a template without `sql.raw` (TS): that is what lets the graph record the node's write to `ducklake://main/<table>`. A chained `wmill.ducklake().query(...)` or SQL built in a variable records no output, so the downstream edge never forms.\n- `wmill.ducklake(\"<name>\")` targets a non-default catalog; `wmill.datatable(\"<name>\")` writes a Postgres data table the same way (Python `db.query(sql, *args)` with `$1` placeholders, TS the same template form) — use it only when the data belongs in Postgres (see above).\n- The downstream node triggers on the table itself: `-- on ducklake://main/stripe_charges`.\n\n## Storage prerequisites\n\nA DuckLake pipeline only runs once the workspace has **object storage** (S3 / Azure Blob / GCS) **and a DuckLake catalog** configured — DuckLake tables and `s3://` assets can't be materialized or read without it. Check which DuckLake catalogs the workspace has before you build.\nDrafting the annotated scripts does not require storage, but the pipeline can't ingest, materialize, or read its assets until it exists. So if there is none (or the user hits \"storage not configured\" errors), say so and give the right next step **by role**:\n\n- a workspace **admin** sets it up in Workspace settings → Object Storage (add an S3/Azure/GCS storage), then adds a DuckLake catalog on top of it;\n- anyone **without admin rights** should ask a workspace admin to configure object storage + a DuckLake catalog.\n\nNever hand back a DuckLake pipeline that cannot run without flagging the missing storage and pointing to who sets it up.\n\n## What makes a script a pipeline node\n\nA script joins the pipeline when its source begins with the `pipeline` annotation as a top-of-file comment, **written in the script's own comment syntax** — `//` for TS/JS (bun), `--` for SQL (DuckDB/Postgres), `#` for Python/Bash. So it's `-- pipeline` in a DuckDB node, `# pipeline` in a Python node, `// pipeline` in a bun node. Every annotation below uses that same prefix (the `//` shown is the TS form). All other wiring is expressed as annotation comments near the top of the file:\n\n- `// on <ref>` — declares an execution-DAG **input** (what triggers/feeds this node). `<ref>` is either:\n - an **asset URI** (the node runs when that asset is produced upstream): `ducklake://main/orders`, `datatable://main/users`, `$res:f/folder/my_resource`, `volume://name/path`, or an S3 object (see the S3 storage-form rule below).\n - a **native trigger kind**: `schedule`, `webhook`, `email`, `kafka`, `mqtt`, `amqp`, `nats`, `postgres`, `sqs`, `gcp`, or `data_upload` (a user-uploaded S3 file). For these the actual trigger row (cron, topic, …) is created separately; the annotation only declares the binding. **`data_upload` is special**: there is no trigger row — the node instead declares an **`S3Object` input parameter** fed by the auto-generated upload picker; it never hard-codes a key. Any language can be the `data_upload` node:\n - Python (has `import wmill`): `def main(file: wmill.S3Object):` then `wmill.load_s3_file(file)`; TS (has `import * as wmill from \"windmill-client\"`): `export async function main(file: wmill.S3Object)`. Qualify the type as `wmill.S3Object` (or add `from wmill import S3Object` / `import { S3Object } from \"windmill-client\"`) — a bare `S3Object` is undefined.\n - **DuckDB** takes the s3object arg via a `-- $<name> (s3object)` declaration and reads it directly, so a single DuckDB node can ingest **and** materialize:\n ```\n -- pipeline\n -- on data_upload\n -- materialize ducklake://main/raw_uploads\n -- $file (s3object)\n SELECT * FROM read_csv($file)\n ```\n- **Outputs** are inferred from what the body writes — a `CREATE TABLE`, a `wmill.writeS3File(...)`, a DuckLake/datatable write. To declare a managed output explicitly, use `// materialize <asset-uri>`.\n- Optional badges: `// partitioned <daily|hourly|weekly|monthly|dynamic>`, `// freshness <duration>` (e.g. `1h`), `// tag <worker-tag>`, `// retry <count> [delay]`, `// data_test <kind> ...` (managed DuckLake targets only — deploy rejects it beside a `dbt://` one), `// measure <name> = <agg> [where <pred>]` and `// dimension <name> = <expr>` (see \"Declared metrics\" below).\n\n## S3 object wiring (storage form matters)\n\nAn `s3://` URI's first slashes select the **storage**, not part of the key — get this wrong and the producer/consumer edge silently won't connect:\n\n- `s3:///<key>` (**triple** slash, empty first segment) = the **default** workspace storage. A downstream node reading or triggering on that object uses `s3:///<key>` — e.g. DuckDB `-- on s3:///orders/2024.parquet` and `read_parquet('s3:///orders/2024.parquet')`.\n- `s3://<storage>/<key>` (**double** slash, non-empty first segment) = a **named secondary** storage called `<storage>` — so `s3://ingest/x` means storage `ingest`, key `x`, NOT key `ingest/x`. Only use this when the object genuinely lives in a configured secondary storage; never invent a bucket/storage name for a default-storage object (it breaks the edge).\n\nTo make the producer side visible to lineage, a Python/TS node MUST pass the **`S3Object` form**, not a bare key string: Python `wmill.write_s3_file(wmill.S3Object(s3=\"<key>\"), data)` (or the import-free dict `{\"s3\": \"<key>\"}`), TS `wmill.writeS3File({ s3: \"<key>\" }, data)`. That records the default-storage asset `/<key>`, which a downstream `s3:///<key>` reader connects to (same key both sides). A bare `write_s3_file(\"<key>\", ...)` records **no** asset and produces **no** edge. Add `storage=\"<name>\"` only for a named secondary storage.\n\nThe key must be a **string literal** — the graph parser is static and cannot follow a variable, f-string, or computed path, so `write_s3_file(wmill.S3Object(s3=key_var), ...)` records no edge. Inline the literal (`s3=\"events/user_events.parquet\"`) on both the writing and reading node. The same rule applies to every asset URI in an annotation or SDK call (`ducklake://`, `datatable://`, `s3://`): write them literally, not via a variable.\n\n## Materialize (the managed output)\n\n> **A MANAGED `// materialize` is DuckDB-only**, and its target must be a DuckLake table (`ducklake://<name>/<table>`). Deploy **rejects** a `ducklake://` `// materialize` on any other language (`python3`, `bun`, `postgresql`). For a non-DuckDB node writing the lake, do **not** use `// materialize` — write the table itself with `wmill.ducklake()` (see \"Ingesting from Python / TypeScript\") and let the output be inferred. Use `duckdb` when a node should materialize a DuckLake table.\n>\n> The one target ANY language may declare (except a dbt script, whose writes come from its manifest) is a **warehouse relation**: `// materialize manual dbt://<warehouse>/<schema>/<name>`, where `<warehouse>` is a warehouse the workspace configures under Settings → dbt. `manual` is the only mode it has — nothing generates warehouse DDL, so the node issues its own write (a postgresql `CREATE TABLE` / `INSERT`, an SDK load, …) and the annotation records the outcome. Use it on an ingestion node whose output a dbt project reads as a `source`: the declared relation and the dbt model land on ONE graph node, and a downstream `// on dbt://<warehouse>/<schema>/<name>` fires when the ingestion node completes.\n\nA managed `// materialize ducklake://<name>/<table>` tells the runtime to write the node's output table **for you**: write the body as a single `SELECT` and the runtime wraps it in the create/replace — do **not** also write your own `CREATE TABLE` / `INSERT`. (The opposite holds for the `dbt://` target above: there the node writes its own DDL and the strategies below do not apply.) Write strategy:\n\n- no option → **replace** the whole table each run (full refresh; the only mode whose output columns may change);\n- `// materialize <uri> append` → INSERT-append rows (incremental);\n- `// materialize <uri> key=<col>` → merge/upsert on `<col>`.\n\n`// materialize manual <uri>` opts **out** of managed writes — the script writes its own DDL and the annotation only records the output asset for lineage.\n\n`materialize` pairs with partitioning for incremental pipelines: a `// partitioned <daily|hourly|weekly|monthly|dynamic>` node runs **once per partition** (append/merge into a fixed-schema table). The `{partition}` token, usable in any asset URI **and** in the body SQL, is replaced at run time by the current partition's **identity string**:\n\n- To filter the source to the active slice on a time grain, use the runtime-injected macro: `WHERE wm_partition(<ts_col>) = {partition}`. `wm_partition(ts)` buckets a timestamp in exactly the identity format the runtime uses for daily/hourly/weekly/monthly, so it always matches; never hand-write a `strftime` format.\n- Do NOT write `= TIMESTAMP {partition}`: the identity string is not a valid timestamp literal for hourly/weekly/monthly and errors at run time.\n- For `dynamic` partitioning the identity is the caller-supplied key (not a timestamp, no macro), so filter on it directly: `WHERE <your_key_col> = {partition}`.\n\n`materialize` is an output **declaration** on a node — not a command. There is no \"materialize run\".\n\n## Declared metrics (`measure` / `dimension`)\n\nOn a node that materializes a DuckLake table, `// measure <name> = <aggregate> [where <predicate>]` names the canonical way to aggregate that table (e.g. `// measure revenue = sum(amount) where not is_refund`), and `// dimension <name> = <expr>` names a way to slice it (e.g. `// dimension region = region`, `// dimension month = date_trunc('month', ordered_at)`). They execute nothing: they are catalogued at deploy so the editor and other agents reuse the definition instead of re-deriving it and silently disagreeing.\n\n- Keep the predicate in the `where` clause rather than folding it into the aggregate: it is rendered as `<agg> FILTER (WHERE <pred>)`, which is what lets two measures with different predicates share one GROUP BY.\n- DuckLake-only, and only meaningful next to `// materialize`.\n- Declare one when a number carries a judgement call someone else would get wrong (refunds excluded, test rows dropped, which column is the amount); do NOT blanket every table with measures — an obvious `count(*)` earns nothing.\n- To use a metric another node declares, read that node and reuse its exact expression rather than guessing it.\n\n## How to build one\n\n1. Put every node in the **same folder**: `f/<folder>/<name>`. The folder is the pipeline.\n2. Write each node as its own script. Default to `duckdb` materializing into DuckLake (see \"Default to DuckDB + DuckLake\" above); pick `postgresql`, `bun`, or `python3` only when that section says the work calls for it.\n3. Start each body with `// pipeline`, then the `// on` input declarations, then the transform that writes the output.\n4. **Chain nodes by asset URI**: read an upstream node's output asset, then `// on <that-same-uri>` in the downstream node so the edge forms. Reuse exact asset paths from existing nodes rather than inventing parallel ones.\n5. Don't deploy nodes unless the user asks to. A pipeline only \"runs\" once its scripts are deployed and their triggers exist.\n\n## Example (DuckDB → DuckLake, scheduled ingest + downstream transform)\n\nNode `f/sales/orders_ingest` (runs on a schedule, materializes a DuckLake table):\n\n```sql\n-- pipeline\n-- on schedule\n-- materialize ducklake://main/orders\nSELECT * FROM read_csv('s3:///raw/orders/*.csv')\n```\n\nNode `f/sales/orders_daily` (runs when `orders` is produced, writes a rollup):\n\n```sql\n-- pipeline\n-- on ducklake://main/orders\n-- materialize ducklake://main/orders_daily\nSELECT date_trunc('day', ts) AS day, count(*) AS n\nFROM ducklake.main.orders GROUP BY 1\n```\n", - "workspace_settings": "# Workspace settings\n\nEverything an admin sets on the Workspace settings page is one document: a mapping of top-level\nkeys, the same layout as the `settings.yaml` file `wmill sync` keeps. Only a workspace admin can\nread or change it.\n\n## Applying a document\n\n- A partial document changes only the keys it states; every key it omits is left as it is.\n- Each key is replaced whole, so read its current value and send it back edited: a nested object\n given with one field replaces the stored object with that one field.\n- A key set to `null` clears that setting; an on/off switch cleared this way reads as `false`.\n- Each setting is written through the same path the settings page uses, with the same validation\n and audit log. The writes are not one transaction: when one fails the apply stops, and the\n error says what was already written.\n- A dry run reports the changes, as `before` / `after` per key, without writing anything.\n- `name` is never applied: rename a workspace on the settings page. `color` is applied only when\n set to a value, so `null` does not clear it.\n- A key the server does not know is ignored and named in the warnings: check them, since a typo\n otherwise reads as \"nothing to change\".\n- A setting this edition does not offer (Enterprise only) is refused, by the dry run too.\n\n## Secrets\n\n`slack_oauth_client_secret` is the one secret. A read shows `__SENSITIVE_AND_UNCHANGED__` in place\nof a set secret; sending that placeholder back keeps the stored value. A value starting with\n`$encrypted:` is decrypted with the workspace key, and one encrypted with another workspace's key\nis left unchanged with a warning; any other value is taken as plaintext. Never\nwrite a secret you were not given: keep the placeholder.\n\n## Keys\n\n| Key | Value |\n|---|---|\n| `auto_invite` | `{ enabled, mode: invite \\| add, operator, instance_groups?, instance_groups_roles? }`. The instance groups are managed only when `instance_groups` is stated; clearing a group removes the members it added. |\n| `error_handler` / `success_handler` | `{ path, extra_args?, muted_on_cancel?, muted_on_user_path? }`, `path` being `script/<path>` or `flow/<path>`. |\n| `error_handler_fallback_to_instance_alerts` | Report failures to the instance critical alert channels when no error handler is set. |\n| `webhook` | URL POSTed a JSON event on every change to the workspace's items. |\n| `ai_config` | Providers, default and completion models, custom prompts, `copilot_disabled`. |\n| `large_file_storage` | Workspace object storage: `{ type, s3_resource_path \\| azure_blob_resource_path \\| gcs_resource_path, public_resource?, secondary_storage?, advanced_permissions? }`. |\n| `git_sync` | `{ repositories: [...] }`, each with `git_repo_resource_path`, `script_path` and `settings` (include/exclude paths and types). Server-owned fields (webhook secrets, sync state, credentials) are never read nor written. |\n| `datatable` | `{ datatables: { <name>: { database: { resource_type, resource_path? } } } }`. Role permissions are `datatable_permissions`. |\n| `datatable_permissions` | `{ <data table>: { default_role, roles: { <role>: [tenants] } } }`: who may connect to each data table as which role. Tenants are `u/<user>`, `g/<group>`, `f/<folder>` or `*`; `admin` is always a role, and `default_role: null` means no default. A data table the value omits is no longer under roles. Only a data table on a cluster Windmill manages can be. |\n| `protection_rules` | `{ <rule name>: { rules: [...], bypass_groups: [...], bypass_users: [...] } }`, rules among `DisableDirectDeployment`, `DisableWorkspaceForking`, `RestrictDeployToDeployers`, `RestrictAnonymousAppDeployment`, `RestrictPublicRunSharing`, `RestrictGuestAppDeployment`, `RequireIsolatedForks`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. |\n| `ducklake` | `{ ducklakes: { <name>: { catalog, storage, ... } } }`. |\n| `dbt_warehouses` | dbt warehouse connections. |\n| `default_app` | Path of the app shown as the workspace home. |\n| `default_scripts` | `{ order?, hidden?, default_script_content? }` for the script language picker. |\n| `deploy_ui` | `{ include_path?, include_type? }`: what the deploy-to-workspace UI offers. |\n| `operator_settings` | What operators see and do: page visibility flags (`runs`, `schedules`, `resources`, `variables`, `assets`, `audit_logs`, `groups`, `folders`, `workers`, `triggers`) and write rights (`manage_schedules`, `manage_triggers`, `builder_flows`, `builder_apps`). A write right the value omits keeps its stored value. |\n| `mute_critical_alerts` | Mute the workspace's critical alerts. |\n| `slack_team_id`, `slack_name` | Read only: set by connecting Slack on the settings page. |\n| `slack_command_script` / `teams_command_script` | Script or flow handling the Slack / Teams command. |\n| `slack_oauth_client_id` / `slack_oauth_client_secret` | The workspace's own Slack OAuth app. Clearing either removes both. |\n| `public_app_execution_limit_per_minute` | Rate limit for public apps, `null` for none. |\n| `guest_access_enabled`, `guest_jwt_public_key`, `guest_jwt_jwks_url` | Guest access to public apps and the key that signs guest JWTs. |\n| `add_admins_and_developers_to_forks` | Add the workspace's admins and developers to each fork. |\n| `color` | Workspace color, `#rrggbb`. |\n" + "workspace_settings": "# Workspace settings\n\nEverything an admin sets on the Workspace settings page is one document: a mapping of top-level\nkeys, the same layout as the `settings.yaml` file `wmill sync` keeps. Only a workspace admin can\nread or change it.\n\n## Applying a document\n\n- A partial document changes only the keys it states; every key it omits is left as it is.\n- Each key is replaced whole, so read its current value and send it back edited: a nested object\n given with one field replaces the stored object with that one field.\n- A key set to `null` clears that setting; an on/off switch cleared this way reads as `false`.\n- Each setting is written through the same path the settings page uses, with the same validation\n and audit log. The writes are not one transaction: when one fails the apply stops, and the\n error says what was already written.\n- A dry run reports the changes, as `before` / `after` per key, without writing anything.\n- `name` is never applied: rename a workspace on the settings page. `color` is applied only when\n set to a value, so `null` does not clear it.\n- A key the server does not know is ignored and named in the warnings: check them, since a typo\n otherwise reads as \"nothing to change\".\n- A setting this edition does not offer (Enterprise only) is refused, by the dry run too.\n\n## Secrets\n\n`slack_oauth_client_secret` is the one secret. A read shows `__SENSITIVE_AND_UNCHANGED__` in place\nof a set secret; sending that placeholder back keeps the stored value. A value starting with\n`$encrypted:` is decrypted with the workspace key, and one encrypted with another workspace's key\nis left unchanged with a warning; any other value is taken as plaintext. Never\nwrite a secret you were not given: keep the placeholder.\n\n## Keys\n\n| Key | Value |\n|---|---|\n| `auto_invite` | `{ enabled, mode: invite \\| add, operator, instance_groups?, instance_groups_roles? }`. The instance groups are managed only when `instance_groups` is stated; clearing a group removes the members it added. |\n| `error_handler` / `success_handler` | `{ path, extra_args?, muted_on_cancel?, muted_on_user_path? }`, `path` being `script/<path>` or `flow/<path>`. |\n| `error_handler_fallback_to_instance_alerts` | Report failures to the instance critical alert channels when no error handler is set. |\n| `webhook` | URL POSTed a JSON event on every change to the workspace's items. |\n| `ai_config` | Providers, default and completion models, custom prompts, `copilot_disabled`. |\n| `large_file_storage` | Workspace object storage: `{ type, s3_resource_path \\| azure_blob_resource_path \\| gcs_resource_path, public_resource?, secondary_storage?, advanced_permissions? }`. |\n| `git_sync` | `{ repositories: [...] }`, each with `git_repo_resource_path`, `script_path` and `settings` (include/exclude paths and types). Server-owned fields (webhook secrets, sync state, credentials) are never read nor written. |\n| `datatable` | `{ datatables: { <name>: { database: { resource_type, resource_path } } } }`, both required: `resource_type: postgresql` with `resource_path` a Postgres resource's path, or `instance`, or `external_instance` (Enterprise only), with `resource_path` the name of a database a superadmin set up. A fork's entry can instead be `{ reference: { workspace_id, datatable } }`, pointing at its parent's data table; keep it as is. `main` is the one scripts use by default. Role permissions are `datatable_permissions`. |\n| `datatable_permissions` | `{ <data table>: { default_role, roles: { <role>: [tenants] } } }`: who may connect to each data table as which role. Tenants are `u/<user>`, `g/<group>`, `f/<folder>` or `*`; `admin` is always a role, and `default_role: null` means no default. A data table the value omits is no longer under roles. Only a data table on a cluster Windmill manages can be. |\n| `protection_rules` | `{ <rule name>: { rules: [...], bypass_groups: [...], bypass_users: [...] } }`, rules among `DisableDirectDeployment`, `DisableWorkspaceForking`, `RestrictDeployToDeployers`, `RestrictAnonymousAppDeployment`, `RestrictPublicRunSharing`, `RestrictGuestAppDeployment`, `RequireIsolatedForks`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. |\n| `ducklake` | `{ ducklakes: { <name>: { catalog, storage, ... } } }`. |\n| `dbt_warehouses` | dbt warehouse connections. |\n| `default_app` | Path of the app shown as the workspace home. |\n| `default_scripts` | `{ order?, hidden?, default_script_content? }` for the script language picker. |\n| `deploy_ui` | `{ include_path?, include_type? }`: what the deploy-to-workspace UI offers. |\n| `operator_settings` | What operators see and do: page visibility flags (`runs`, `schedules`, `resources`, `variables`, `assets`, `audit_logs`, `groups`, `folders`, `workers`, `triggers`) and write rights (`manage_schedules`, `manage_triggers`, `builder_flows`, `builder_apps`). A write right the value omits keeps its stored value. |\n| `mute_critical_alerts` | Mute the workspace's critical alerts. |\n| `slack_team_id`, `slack_name` | Read only: set by connecting Slack on the settings page. |\n| `slack_command_script` / `teams_command_script` | Script or flow handling the Slack / Teams command. |\n| `slack_oauth_client_id` / `slack_oauth_client_secret` | The workspace's own Slack OAuth app. Clearing either removes both. |\n| `public_app_execution_limit_per_minute` | Rate limit for public apps, `null` for none. |\n| `guest_access_enabled`, `guest_jwt_public_key`, `guest_jwt_jwks_url` | Guest access to public apps and the key that signs guest JWTs. |\n| `add_admins_and_developers_to_forks` | Add the workspace's admins and developers to each fork. |\n| `color` | Workspace color, `#rrggbb`. |\n" } } diff --git a/backend/windmill-api/src/mcp/session/gen/tools.json b/backend/windmill-api/src/mcp/session/gen/tools.json index 4db0f3e4a4..da1d9cb32a 100644 --- a/backend/windmill-api/src/mcp/session/gen/tools.json +++ b/backend/windmill-api/src/mcp/session/gen/tools.json @@ -1372,7 +1372,7 @@ }, { "name": "rebase_draft", - "description": "Discard a stale script, flow, or app draft and return your changes as a diff to re-apply on the latest deployed version. Use when deploy_workspace_item reports the draft was started from an older deployed version.", + "description": "Discard a stale script, flow, or app draft and return your changes as a diff to re-apply on the latest deployed version. Use when a deploy reports the draft was started from an older deployed version.", "parameters": { "type": "object", "properties": { @@ -1670,7 +1670,7 @@ "type": "string" }, "account": { - "description": "The account identifier", + "description": "Ignored. A variable is linked to an OAuth account only when `createAccount` creates both.", "type": "integer", "minimum": -9007199254740991, "maximum": 9007199254740991 @@ -1680,11 +1680,24 @@ "type": "boolean" }, "expires_at": { - "description": "The expiration date of the variable", + "description": "When to garbage-collect the variable. Used for ephemeral secrets; the variable is deleted once this is reached. Not the value's expiry — see value_valid_until.\n", "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" }, + "value_valid_until": { + "description": "When the value stored in this variable stops working. Triggers the workspace's variable expiration handler ahead of it (1 day by default, set on the handler); never causes the variable to be deleted. Omit it to keep the current expiry, or pass null to clear one — for instance once the value has been rotated.", + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$" + }, + { + "type": "null" + } + ] + }, "labels": { "type": "array", "items": { @@ -2069,13 +2082,14 @@ "type": "string" }, "type": { - "description": "Runnable kind: \"inline\" for custom code stored on the app; \"script\"/\"flow\" for a workspace runnable; \"hubscript\" for a hub script.", + "description": "Runnable kind: \"inline\" for custom code stored on the app; \"script\"/\"flow\" for a workspace runnable; \"hubscript\" for a hub script; \"agent\" for a saved AI agent, run as one chat turn.", "type": "string", "enum": [ "inline", "script", "flow", - "hubscript" + "hubscript", + "agent" ] }, "staticInputs": { @@ -2110,7 +2124,7 @@ "additionalProperties": false }, "path": { - "description": "Required when type is \"script\", \"flow\", or \"hubscript\". Workspace path of the runnable.", + "description": "Required when type is \"script\", \"flow\", \"hubscript\" or \"agent\". Workspace path of the runnable.", "type": "string" }, "viewerDataAccess": { diff --git a/backend/windmill-api/src/mcp/session/items.rs b/backend/windmill-api/src/mcp/session/items.rs index 4f221180d7..c075586a40 100644 --- a/backend/windmill-api/src/mcp/session/items.rs +++ b/backend/windmill-api/src/mcp/session/items.rs @@ -363,7 +363,7 @@ fn variable_to_draft_state(variable: &Value) -> Value { "wsSpecific".into(), field("ws_specific").unwrap_or(json!(false)), ); - for key in ["account", "is_oauth", "expires_at"] { + for key in ["account", "is_oauth", "expires_at", "value_valid_until"] { put(&mut state, key, field(key)); } Value::Object(state) @@ -429,6 +429,13 @@ fn variable_draft(args: &Value, base: Option<Value>) -> Result<Value, String> { for (key, value) in extras { put(&mut draft, key, value); } + // Deploying a draft reads an absent expiry as cleared. The draft starts from the base, so a + // `null` has to remove its date rather than skip it. + if args.get("value_valid_until").is_some_and(Value::is_null) { + draft.remove("value_valid_until"); + } else { + put(&mut draft, "value_valid_until", arg("value_valid_until")); + } Ok(Value::Object(draft)) } @@ -703,6 +710,18 @@ mod tests { variable_draft(&json!({"path": "p", "is_secret": true}), Some(plain_empty)).is_err() ); + let dated = variable_to_draft_state(&json!({ + "path": "p", "value": "v", "is_secret": false, "value_valid_until": "2027-01-15T00:00:00Z", + })); + let edit = |args: Value| variable_draft(&args, Some(dated.clone())).unwrap(); + assert_eq!( + edit(json!({"path": "p", "description": "new"}))["value_valid_until"], + "2027-01-15T00:00:00Z" + ); + assert!(edit(json!({"path": "p", "value_valid_until": null})) + .get("value_valid_until") + .is_none()); + let oauth = variable_to_draft_state(&json!({ "path": "p", "value": "live-token", "is_secret": true, "account": 3, "description": null, "expires_at": null, diff --git a/backend/windmill-api/src/mcp/session/workspace.rs b/backend/windmill-api/src/mcp/session/workspace.rs index 3e3cf45459..43ab2c33ec 100644 --- a/backend/windmill-api/src/mcp/session/workspace.rs +++ b/backend/windmill-api/src/mcp/session/workspace.rs @@ -499,6 +499,7 @@ pub(super) fn draft_item(kind: &str, path: &str, draft: &Value) -> Option<Value> put(&mut value, "account", field("account")); put(&mut value, "is_oauth", field("is_oauth")); put(&mut value, "expires_at", field("expires_at")); + put(&mut value, "value_valid_until", field("value_valid_until")); put(&mut value, "labels", field("labels")); put(&mut value, "ws_specific", field("wsSpecific")); item.insert("value".into(), Value::Object(value)); @@ -1972,4 +1973,11 @@ mod tests { ); assert!(draft_item("app", "u/a/s", &draft).is_none()); } + + #[test] + fn draft_item_carries_the_variable_expiry() { + let draft = json!({ "path": "u/a/v", "variable": { "value": "", "is_secret": true, "description": "" }, "value_valid_until": "2027-01-15T00:00:00Z" }); + let item = draft_item("variable", "u/a/v", &draft).unwrap(); + assert_eq!(item["value"]["value_valid_until"], "2027-01-15T00:00:00Z"); + } } diff --git a/backend/windmill-api/src/mcp/utils.rs b/backend/windmill-api/src/mcp/utils.rs index dcf84e1851..6e125eefc6 100644 --- a/backend/windmill-api/src/mcp/utils.rs +++ b/backend/windmill-api/src/mcp/utils.rs @@ -183,8 +183,8 @@ fn items_query( let mut sqlb = SqlBuilder::select_from(&format!("{} as o", item_type)); if scope_type == "favorites" { sqlb.join("favorite") - .on("favorite.favorite_kind = ? AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = ?".bind(&item_type) - .bind(&authed.username)); + .on("favorite.favorite_kind = ? AND favorite.workspace_id = o.workspace_id AND favorite.path = o.path AND favorite.usr = ?" + .binds(&[&item_type, &authed.username])); } sqlb.and_where("o.workspace_id = ?".bind(&workspace_id)) .and_where("o.archived = false"); diff --git a/backend/windmill-api/src/offboarding.rs b/backend/windmill-api/src/offboarding.rs index defaa05234..59305d96e3 100644 --- a/backend/windmill-api/src/offboarding.rs +++ b/backend/windmill-api/src/offboarding.rs @@ -1075,7 +1075,7 @@ async fn offboard_user_from_workspace<'c>( let new_var_prefix = format!("{}/", reassign_to); let vault_secrets: Vec<(String, String)> = sqlx::query!( - r#"SELECT path, value FROM variable + r#"SELECT path, value::text AS "value!" FROM variable WHERE path LIKE ('u/' || $1 || '/%') AND workspace_id = $2 AND is_secret = true diff --git a/backend/windmill-api/src/raw_apps.rs b/backend/windmill-api/src/raw_apps.rs index 35e4f693a4..ad53872ca0 100644 --- a/backend/windmill-api/src/raw_apps.rs +++ b/backend/windmill-api/src/raw_apps.rs @@ -98,10 +98,10 @@ async fn list_apps( if let Some(label) = &lq.label { for l in label.split(',') { + // `binds` fills every `?` in one pass; chained `bind`s would re-scan a `?` in the bound value sqlb.and_where( "(app.labels @> ARRAY[?] OR folder_labels(app.workspace_id, app.path) @> ARRAY[?])" - .bind(&l.trim()) - .bind(&l.trim()), + .binds(&[&l.trim()]), ); } } diff --git a/backend/windmill-api/src/users.rs b/backend/windmill-api/src/users.rs index 8c742b6d64..ecf77b6d5f 100644 --- a/backend/windmill-api/src/users.rs +++ b/backend/windmill-api/src/users.rs @@ -632,7 +632,7 @@ async fn update_username_in_workpsace<'c>( // Fetch all externally-stored secret variables under this user's path let vault_secrets: Vec<(String, String)> = sqlx::query!( - r#"SELECT path, value FROM variable + r#"SELECT path, value::text AS "value!" FROM variable WHERE path LIKE ('u/' || $1 || '/%') AND workspace_id = $2 AND is_secret = true diff --git a/backend/windmill-api/src/workspace_settings_document.rs b/backend/windmill-api/src/workspace_settings_document.rs index f83ffcb4e8..d222188319 100644 --- a/backend/windmill-api/src/workspace_settings_document.rs +++ b/backend/windmill-api/src/workspace_settings_document.rs @@ -60,6 +60,7 @@ struct SettingsRow { webhook: Option<String>, error_handler: Option<Value>, success_handler: Option<Value>, + variable_expiration_handler: Option<Value>, ai_config: Option<Value>, large_file_storage: Option<Value>, git_sync: Option<Value>, @@ -298,7 +299,8 @@ pub async fn load_settings_document( ) -> Result<Map<String, Value>> { let row = sqlx::query_as::<_, SettingsRow>( r#"SELECT - auto_invite, webhook, error_handler, success_handler, ai_config, large_file_storage, + auto_invite, webhook, error_handler, success_handler, variable_expiration_handler, + ai_config, large_file_storage, git_sync, default_app, default_scripts, workspace.name AS name, mute_critical_alerts, color, operator_settings, datatable, slack_team_id, slack_name, slack_command_script, slack_oauth_client_id, slack_oauth_client_secret, ducklake, dbt_warehouses, deploy_ui, @@ -341,18 +343,28 @@ pub async fn load_settings_document( }; let datatable_permissions = datatable_permissions_document(row.datatable.as_ref(), &role_names); let protection_rules = protection_rules_document(&mut *conn, w_id).await?; + let git_sync = redact_git_sync(row.git_sync); + let git_sync = match fork_parent(&mut *conn, w_id).await? { + Some(parent) => fork_git_sync_document(git_sync, parent.git_sync), + None => git_sync, + }; // In the order `wmill sync pull` has always written settings.yaml. A key that predates the // document and is not marked `always` is written only when set, as it always was; a key // added since is always written, so a pulled file states it and from then on manages it. - let entries: [(&str, Option<Value>, bool); 31] = [ + let entries: [(&str, Option<Value>, bool); 32] = [ ("auto_invite", row.auto_invite, false), ("webhook", row.webhook.map(Value::String), false), ("error_handler", row.error_handler, true), ("success_handler", row.success_handler, true), + ( + "variable_expiration_handler", + row.variable_expiration_handler, + true, + ), ("ai_config", row.ai_config, false), ("large_file_storage", row.large_file_storage, false), - ("git_sync", redact_git_sync(row.git_sync), false), + ("git_sync", git_sync, false), ("default_app", row.default_app.map(Value::String), false), ("default_scripts", row.default_scripts, false), ("name", Some(json!(row.name.unwrap_or_default())), true), @@ -675,6 +687,7 @@ const KNOWN_KEYS: &[&str] = &[ "webhook", "error_handler", "success_handler", + "variable_expiration_handler", "ai_config", "large_file_storage", "git_sync", @@ -859,11 +872,36 @@ fn normalize_protection_rules(v: &Value, warnings: &mut Vec<String>) -> Result<V Ok(Value::Object(out)) } -async fn plan(db: &DB, w_id: &str, desired: Map<String, Value>, replace: bool) -> Result<Plan> { - let current = { +async fn plan(db: &DB, w_id: &str, mut desired: Map<String, Value>, replace: bool) -> Result<Plan> { + let (mut current, fork) = { let mut conn = db.acquire().await?; - load_settings_document(&mut conn, db, w_id, SecretMode::Plain).await? + let current = load_settings_document(&mut conn, db, w_id, SecretMode::Plain).await?; + (current, fork_parent(&mut conn, w_id).await?) }; + if let Some(fork) = fork { + // Diff against what the fork stores, not the parent's view the document shows it. + let own: Option<Value> = + sqlx::query_scalar("SELECT git_sync FROM workspace_settings WHERE workspace_id = $1") + .bind(w_id) + .fetch_optional(db) + .await? + .flatten(); + let own = redact_git_sync(own); + // A malformed value is left for the endpoint to refuse. + let stated = match desired.get("git_sync") { + Some(v) if v.is_object() || v.is_null() => Some(v), + Some(_) => None, + None => replace.then_some(&Value::Null), + }; + if let Some(stated) = stated { + let applied = fork_git_sync_to_apply(stated, own.as_ref(), fork.is_dev)?; + desired.insert("git_sync".to_string(), applied); + } + match own { + Some(own) => current.insert("git_sync".to_string(), own), + None => current.remove("git_sync"), + }; + } let mc = build_crypt(db, w_id).await?; let names = match desired.get("datatable_permissions") { Some(v) if !v.is_null() => DatatableNames::load(db, w_id, v).await?, @@ -872,6 +910,147 @@ async fn plan(db: &DB, w_id: &str, desired: Map<String, Value>, replace: bool) - plan_document(current, desired, replace, &mc, &names) } +/// A fork's parent, read for its `git_sync`. +struct ForkParent { + /// As the parent's own document states it: a fork of a fork shares the root's file too. + git_sync: Option<Value>, + /// Of the fork itself. + is_dev: bool, +} + +/// `Some` when `w_id` is a fork, by the same test as +/// `reject_parent_only_git_sync_settings_on_fork`. +async fn fork_parent(conn: &mut PgConnection, w_id: &str) -> Result<Option<ForkParent>> { + // The workspace itself, then its ancestors nearest first; the depth bound only guards + // against a cycle. + let mut chain: Vec<(bool, Option<Value>)> = sqlx::query_as( + "WITH RECURSIVE chain AS ( + SELECT id, parent_workspace_id, is_dev_workspace, 0 AS depth + FROM workspace WHERE id = $1 + UNION ALL + SELECT w.id, w.parent_workspace_id, w.is_dev_workspace, c.depth + 1 FROM workspace w + JOIN chain c ON w.id = c.parent_workspace_id WHERE c.depth < 32 + ) + SELECT c.is_dev_workspace, s.git_sync FROM chain c + LEFT JOIN workspace_settings s ON s.workspace_id = c.id + ORDER BY c.depth", + ) + .bind(w_id) + .fetch_all(&mut *conn) + .await?; + let is_dev = !chain.is_empty() && chain.remove(0).0; + if chain.is_empty() && !w_id.starts_with(windmill_common::workspaces::WM_FORK_PREFIX) { + return Ok(None); + } + let git_sync = chain.into_iter().rev().fold(None, |above, (_, own)| { + fork_git_sync_document(redact_git_sync(own), above) + }); + Ok(Some(ForkParent { git_sync, is_dev })) +} + +// A fork shares its parent's settings.yaml (the CLI maps `wm-fork/<branch>/<id>` to the parent's +// entry), so its `git_sync` there is the parent's. Of it, a fork applies only what selects the +// items synced, on the repos it carries: the keys listed here, and a repo's `settings` when +// neither side uses workspace-level filters. Auto-pull, fork PRs and promotion mode are not a +// fork's to take (`reject_parent_only_git_sync_settings_on_fork`), and any other field stays the +// fork's own. The fork's document is the parent's block with the fork's values for the same keys, +// the inverse of the apply, so a merge of the fork's branch changes only them in the parent. +const FORK_SYNCED_REPO_KEYS: [&str; 2] = ["exclude_types_override", "group_by_folder"]; + +fn repos(git_sync: Option<&Value>) -> &[Value] { + git_sync + .and_then(|g| g.get("repositories")) + .and_then(Value::as_array) + .map_or(&[], Vec::as_slice) +} + +fn same_repo<'a>(repos: &'a [Value], repo: &Value) -> Option<&'a Value> { + let path = repo.get("git_repo_resource_path")?; + repos + .iter() + .find(|r| r.get("git_repo_resource_path") == Some(path)) +} + +/// Whether repos fall back to the workspace-level filters, where an empty or missing `settings` +/// list means "use the workspace's". A repo's filters cross only when neither side does, as +/// that layout cannot state some of what the other can. Only these two are fallen back to on +/// their own (`exclude_path`/`extra_include_path` only beside `include_path`), and the save +/// leaves the other two behind once every repo has migrated. +fn uses_workspace_filters(git_sync: &Value) -> bool { + ["include_path", "include_type"] + .iter() + .any(|k| !is_null(git_sync.get(*k))) +} + +/// An unset key and a null one are left as they are: stored blobs differ in which they write, and +/// swapping one for the other would show in settings.yaml as a change. +fn copy_keys(onto: &mut Map<String, Value>, from: &Value, keys: &[&str]) { + for key in keys { + if is_null(from.get(*key)) && is_null(onto.get(*key)) { + continue; + } + match from.get(*key) { + Some(v) => onto.insert(key.to_string(), v.clone()), + None => onto.remove(*key), + }; + } +} + +/// Sets `onto` to `from`'s values for the synced keys, on each repo `from` also lists. +fn copy_synced_keys(onto: &mut Value, from: &Value) { + let filters = !uses_workspace_filters(onto) && !uses_workspace_filters(from); + let from_repos = repos(Some(from)); + let Some(repos) = onto.get_mut("repositories").and_then(Value::as_array_mut) else { + return; + }; + for repo in repos { + let Some(src) = same_repo(from_repos, repo) else { + continue; + }; + if let Some(repo) = repo.as_object_mut() { + copy_keys(repo, src, &FORK_SYNCED_REPO_KEYS); + if filters { + copy_keys(repo, src, &["settings"]); + } + } + } +} + +/// The fork's `git_sync` as its settings.yaml states it. +fn fork_git_sync_document(own: Option<Value>, parent: Option<Value>) -> Option<Value> { + let Some(mut doc) = parent else { + return own; + }; + if let Some(own) = &own { + copy_synced_keys(&mut doc, own); + } + Some(doc) +} + +/// What a fork applies of the `git_sync` its settings.yaml states. Only a dev workspace may hold +/// a promotion repo, and one detached from dev still stores its own: it is dropped so the write +/// passes the save's check. +fn fork_git_sync_to_apply(stated: &Value, own: Option<&Value>, is_dev: bool) -> Result<Value> { + if stated.get("repositories").is_some_and(|r| !r.is_array()) { + return Err(Error::BadRequest( + "git_sync.repositories must be a list".to_string(), + )); + } + let Some(mut applied) = own.cloned() else { + return Ok(Value::Null); + }; + copy_synced_keys(&mut applied, stated); + if !is_dev { + if let Some(repos) = applied + .get_mut("repositories") + .and_then(Value::as_array_mut) + { + repos.retain(|r| r.get("use_individual_branch") != Some(&Value::Bool(true))); + } + } + Ok(applied) +} + fn plan_document( current: Map<String, Value>, mut desired: Map<String, Value>, @@ -888,7 +1067,12 @@ fn plan_document( if !unknown.is_empty() { warnings.push(format!("Ignored unknown settings: {}", unknown.join(", "))); } - for key in ["auto_invite", "error_handler", "success_handler"] { + for key in [ + "auto_invite", + "error_handler", + "success_handler", + "variable_expiration_handler", + ] { if desired .get(key) .is_some_and(|v| !v.is_null() && !v.is_object()) @@ -951,6 +1135,24 @@ fn plan_document( Value::is_boolean, "a boolean", ), + ( + "variable_expiration_handler", + "muted_on_user_path", + Value::is_boolean, + "a boolean", + ), + ( + "variable_expiration_handler", + "notify_before_secs", + |v: &Value| v.as_u64().is_some_and(|n| n <= u32::MAX as u64), + "a non-negative number of seconds", + ), + ( + "variable_expiration_handler", + "remind_every_secs", + |v: &Value| v.as_u64().is_some_and(|n| n <= u32::MAX as u64), + "a non-negative number of seconds", + ), ] { let v = desired.get(parent).and_then(|p| p.get(key)); typed(&format!("{parent}.{key}"), v, ok, ty)?; @@ -964,14 +1166,19 @@ fn plan_document( let v = normalize_protection_rules(v, &mut warnings)?; desired.insert("protection_rules".to_string(), v); } - // The endpoint stores an omitted mute flag as `false`. - if let Some(handler) = desired - .get_mut("error_handler") - .and_then(Value::as_object_mut) - { - for key in ["muted_on_cancel", "muted_on_user_path"] { - if is_null(handler.get(key)) { - handler.insert(key.to_string(), Value::Bool(false)); + // The endpoints store an omitted mute flag as `false`. + for (handler, keys) in [ + ( + "error_handler", + &["muted_on_cancel", "muted_on_user_path"][..], + ), + ("variable_expiration_handler", &["muted_on_user_path"][..]), + ] { + if let Some(handler) = desired.get_mut(handler).and_then(Value::as_object_mut) { + for key in keys { + if is_null(handler.get(*key)) { + handler.insert(key.to_string(), Value::Bool(false)); + } } } } @@ -1118,6 +1325,23 @@ fn plan_document( |v| json!({ "path": v.get("path"), "extra_args": v.get("extra_args") }), ); + // Managed only when stated, like every key added after the document. + let variable_expiration_handler = p.managed("variable_expiration_handler"); + p.simple( + "variable_expiration_handler", + variable_expiration_handler, + Target::Edit(SettingsEdit::VariableExpirationHandler), + |v| { + json!({ + "path": v.get("path"), + "extra_args": v.get("extra_args"), + "muted_on_user_path": v.get("muted_on_user_path").and_then(Value::as_bool).unwrap_or(false), + "notify_before_secs": v.get("notify_before_secs"), + "remind_every_secs": v.get("remind_every_secs"), + }) + }, + ); + let lfs = p.legacy("large_file_storage"); p.simple( "large_file_storage", @@ -1839,6 +2063,29 @@ mod tests { assert!(p.changes.is_empty(), "{:?}", keys(&p)); } + #[test] + fn variable_expiration_handler_is_managed_only_when_stated() { + let mc = WorkspaceCrypt::new("k"); + let names = DatatableNames::default(); + let mut cur = current(); + cur.insert( + "variable_expiration_handler".into(), + json!({ "path": "script/f/x", "muted_on_user_path": false }), + ); + let omitted = doc(json!({ "webhook": "https://b" })); + let p = plan_document(cur.clone(), omitted, true, &mc, &names).unwrap(); + assert!(!keys(&p).contains(&"variable_expiration_handler")); + + let same = doc(json!({ "variable_expiration_handler": { "path": "script/f/x" } })); + let p = plan_document(cur.clone(), same, false, &mc, &names).unwrap(); + assert!(p.changes.is_empty(), "{:?}", keys(&p)); + + let cleared = doc(json!({ "variable_expiration_handler": null })); + let p = plan_document(cur, cleared, false, &mc, &names).unwrap(); + assert_eq!(keys(&p), ["variable_expiration_handler"]); + assert_eq!(p.writes[0].body["path"], Value::Null); + } + #[cfg(all(feature = "private", feature = "enterprise"))] #[test] fn datatable_roles_are_named_in_the_document_and_written_by_id() { @@ -1947,4 +2194,99 @@ mod tests { )); assert_eq!(p.warnings.len(), 1); } + + #[test] + fn a_fork_applies_only_the_synced_keys_of_the_repos_it_carries() { + let parent = json!({ "repositories": [ + { "git_repo_resource_path": "f/r", "settings": { "include_path": ["f/**"] }, + "auto_pull": { "enabled": true, "sync_forks": true }, "fork_open_prs": true }, + { "git_repo_resource_path": "f/promo", "use_individual_branch": true }, + ] }); + // A dev workspace's promotion repo is the repo the parent syncs normally. + let fork = json!({ "repositories": [ + { "git_repo_resource_path": "f/own" }, + { "git_repo_resource_path": "f/r", "settings": { "include_path": ["u/**"] }, + "use_individual_branch": true, "auto_pull": { "enabled": false } }, + ] }); + assert_eq!( + fork_git_sync_to_apply(&parent, Some(&fork), true).unwrap(), + json!({ "repositories": [ + { "git_repo_resource_path": "f/own" }, + { "git_repo_resource_path": "f/r", "settings": { "include_path": ["f/**"] }, + "use_individual_branch": true, "auto_pull": { "enabled": false } }, + ] }) + ); + + // The fork's document is the parent's block with the fork's synced keys, and applying it + // back to the fork is a no-op. + let shown = fork_git_sync_document(Some(fork.clone()), Some(parent.clone())).unwrap(); + assert_eq!( + shown, + json!({ "repositories": [ + { "git_repo_resource_path": "f/r", "settings": { "include_path": ["u/**"] }, + "auto_pull": { "enabled": true, "sync_forks": true }, "fork_open_prs": true }, + { "git_repo_resource_path": "f/promo", "use_individual_branch": true }, + ] }) + ); + assert_eq!( + fork_git_sync_to_apply(&shown, Some(&fork), true).unwrap(), + fork + ); + + // Detached from dev, the fork may no longer hold its promotion repo. + assert_eq!( + fork_git_sync_to_apply(&parent, Some(&fork), false).unwrap(), + json!({ "repositories": [{ "git_repo_resource_path": "f/own" }] }) + ); + + // Filters cross only between two sides on per-repo settings: with workspace-level ones on + // either side, the fork keeps its own and the document keeps the parent's. + let legacy_parent = json!({ "include_path": ["f/**"], "include_type": ["script"], + "repositories": [{ "git_repo_resource_path": "f/r" }] }); + let migrated = json!({ "repositories": [{ "git_repo_resource_path": "f/r", + "settings": { "include_path": ["u/**"], "include_type": [] } }] }); + assert_eq!( + fork_git_sync_to_apply(&legacy_parent, Some(&migrated), false).unwrap(), + migrated + ); + let shown = + fork_git_sync_document(Some(migrated.clone()), Some(legacy_parent.clone())).unwrap(); + assert_eq!(shown, legacy_parent); + assert_eq!( + fork_git_sync_to_apply(&shown, Some(&migrated), false).unwrap(), + migrated + ); + let legacy_fork = json!({ "include_path": ["u/**"], "include_type": ["flow"], + "repositories": [{ "git_repo_resource_path": "f/r" }] }); + let migrated_parent = json!({ "repositories": [{ "git_repo_resource_path": "f/r", + "settings": { "include_path": ["f/**"], "include_type": ["script"] } }] }); + assert_eq!( + fork_git_sync_document(Some(legacy_fork.clone()), Some(migrated_parent.clone())), + Some(migrated_parent.clone()) + ); + assert_eq!( + fork_git_sync_to_apply(&migrated_parent, Some(&legacy_fork), false).unwrap(), + legacy_fork + ); + assert_eq!( + fork_git_sync_to_apply(&Value::Null, Some(&legacy_fork), false).unwrap(), + legacy_fork + ); + // An `exclude_path` the save leaves behind after migrating is no fallback. + let mut leftover = migrated.clone(); + leftover["exclude_path"] = json!(["x/**"]); + assert_eq!( + fork_git_sync_to_apply(&migrated_parent, Some(&leftover), false).unwrap() + ["repositories"][0]["settings"], + migrated_parent["repositories"][0]["settings"] + ); + + assert!( + fork_git_sync_to_apply(&json!({ "repositories": null }), Some(&fork), true).is_err() + ); + assert_eq!( + fork_git_sync_to_apply(&parent, None, true).unwrap(), + Value::Null + ); + } } diff --git a/backend/windmill-api/src/workspaces_export.rs b/backend/windmill-api/src/workspaces_export.rs index 87496fed45..46b7a7a8dd 100644 --- a/backend/windmill-api/src/workspaces_export.rs +++ b/backend/windmill-api/src/workspaces_export.rs @@ -972,9 +972,9 @@ pub(crate) async fn tarball_workspace( // would never match the variable it created and every sync would report it changed. let variables = sqlx::query_as::<_, ExportableListableVariable>(if !skip_secrets.unwrap_or(false) { - "SELECT workspace_id, path, value, is_secret, description, extra_perms, NULL::int AS account, is_oauth, expires_at, labels FROM variable WHERE workspace_id = $1 AND expires_at IS NULL" + "SELECT workspace_id, path, value::text AS value, is_secret, description, extra_perms, NULL::int AS account, is_oauth, expires_at, value_valid_until, labels FROM variable WHERE workspace_id = $1 AND expires_at IS NULL" } else { - "SELECT workspace_id, path, value, is_secret, description, extra_perms, NULL::int AS account, is_oauth, expires_at, labels FROM variable WHERE workspace_id = $1 AND is_secret = false AND expires_at IS NULL" + "SELECT workspace_id, path, value::text AS value, is_secret, description, extra_perms, NULL::int AS account, is_oauth, expires_at, value_valid_until, labels FROM variable WHERE workspace_id = $1 AND is_secret = false AND expires_at IS NULL" }) .bind(&w_id) .fetch_all(&mut *tx) diff --git a/backend/windmill-common/src/client.rs b/backend/windmill-common/src/client.rs index 985d3bbbfc..54eb486da7 100644 --- a/backend/windmill-common/src/client.rs +++ b/backend/windmill-common/src/client.rs @@ -71,15 +71,32 @@ impl AuthedClient { } pub async fn get_id_token(&self, audience: &str) -> anyhow::Result<String> { + self.get_id_token_expiring_in(audience, None).await + } + + /// `expires_in` caps the token's lifetime below the instance default, for + /// audiences that refuse long-lived assertions. + pub async fn get_id_token_expiring_in( + &self, + audience: &str, + expires_in: Option<i64>, + ) -> anyhow::Result<String> { + // An audience can be a URL, whose slashes would otherwise split the path segment. let url = format!( "{}/api/w/{}/oidc/token/{}", - self.base_internal_url, self.workspace, audience + self.base_internal_url, + self.workspace, + urlencoding::encode(audience) ); - let response = self + let mut request = self .force_client .as_ref() .unwrap_or(&HTTP_CLIENT) - .post(&url) + .post(&url); + if let Some(expires_in) = expires_in { + request = request.query(&[("expires_in", expires_in)]); + } + let response = request .header( reqwest::header::AUTHORIZATION, reqwest::header::HeaderValue::from_str(&format!("Bearer {}", self.token))?, @@ -110,6 +127,30 @@ impl AuthedClient { make_basic_get_request(self, &url, None, Some("decoding resource value as json")).await } + /// The value of the `ai_agent` resource at `path`, refused when the resource there is of another + /// type. Only writes of an agent are checked for operators, so a step linking a path must not run + /// tools from a resource retyped, or recreated as another type, since the link was approved. + pub async fn get_agent_value(&self, path: &str) -> anyhow::Result<serde_json::Value> { + #[derive(serde::Deserialize)] + struct Resource { + resource_type: String, + value: Option<serde_json::Value>, + } + let url = format!( + "{}/api/w/{}/resources/get/{}", + self.base_internal_url, self.workspace, path + ); + let resource: Resource = + make_basic_get_request(self, &url, None, Some("decoding the agent resource")).await?; + if resource.resource_type != "ai_agent" { + anyhow::bail!( + "{path} is a {} resource, not an AI agent", + resource.resource_type + ); + } + Ok(resource.value.unwrap_or_default()) + } + pub async fn get_variable_value(&self, path: &str) -> anyhow::Result<String> { let url = format!( "{}/api/w/{}/variables/get_value/{}", diff --git a/backend/windmill-common/src/flows.rs b/backend/windmill-common/src/flows.rs index 222694aeac..500549c549 100644 --- a/backend/windmill-common/src/flows.rs +++ b/backend/windmill-common/src/flows.rs @@ -252,8 +252,8 @@ pub async fn resolve_modules( /// modules, every branch, and the `tools` of an AI agent step. /// /// Returns what the caller still has to authorize against its own permissions, which this -/// value-only walk cannot: every runnable the steps reference, the worker tags they pin, and the -/// `(path, hash)` pairs of version-pinned steps. See [`ComposedFlowRefs`] for why each one is not +/// value-only walk cannot: every runnable and linked agent the steps reference, the worker tags +/// they pin, and the `(path, hash)` pairs of version-pinned steps. See [`ComposedFlowRefs`] for why each one is not /// already settled by the walk. pub fn check_flow_is_composition_only(value: &FlowValue) -> Result<ComposedFlowRefs, Error> { let mut refs = ComposedFlowRefs::default(); @@ -276,6 +276,65 @@ pub struct ComposedFlowRefs { pub runnables: Vec<(bool, String)>, /// Version-pinned script steps, as `(path, hash)`. pub pinned_scripts: Vec<(String, ScriptHash)>, + /// The `ai_agent` resources that agent steps link. + pub agents: Vec<LinkedAgent>, +} + +/// An agent step's link, with the tool inputs the step binds, keyed by tool id. +pub struct LinkedAgent { + pub path: String, + pub bound_inputs: Vec<(String, Vec<String>)>, +} + +/// A builder's step may bind only the tool inputs its linked agent leaves open, passing them what +/// it could pass a deployed runnable's inputs. An input the agent fills is part of what its author +/// wrote, and the step's binding would replace it at run time. Every tool sharing an id is +/// checked, as the overlay binds them all. +pub fn check_bound_agent_inputs_are_open( + agent: &str, + tools: &[AgentTool], + bound_inputs: &[(String, Vec<String>)], +) -> Result<(), Error> { + for (tool_id, keys) in bound_inputs { + for tool in tools.iter().filter(|t| &t.id == tool_id) { + check_tool_inputs_are_open(agent, tool_id, &tool.value, keys)?; + } + } + Ok(()) +} + +fn check_tool_inputs_are_open( + agent: &str, + tool_id: &str, + value: &ToolValue, + keys: &[String], +) -> Result<(), Error> { + let ToolValue::FlowModule(value) = value else { + return Ok(()); + }; + let input_transforms = match value { + FlowModuleValue::Script { input_transforms, .. } + | FlowModuleValue::RawScript { input_transforms, .. } + | FlowModuleValue::FlowScript { input_transforms, .. } + | FlowModuleValue::AIAgent { input_transforms, .. } + | FlowModuleValue::AIDecision { input_transforms, .. } => input_transforms, + // Fail closed on any other kind, as the worker's `overlay_tool_inputs` skips them. + _ => { + return Err(Error::PermissionDenied(format!( + "Tool {tool_id} of agent {agent} takes no inputs a builder can set" + ))) + } + }; + match keys + .iter() + .find(|k| input_transforms.get(*k).is_some_and(|t| t.is_completed())) + { + Some(key) => Err(Error::PermissionDenied(format!( + "Input {key} of tool {tool_id} is set by agent {agent}, so a builder cannot \ + change it. Bind only the inputs the agent leaves to the model." + ))), + None => Ok(()), + } } fn check_module_is_composition_only( @@ -343,12 +402,20 @@ fn check_module_value_is_composition_only( FlowModuleValue::BranchAll { branches, .. } => { check_branches_are_composition_only(branches, id, refs)? } - FlowModuleValue::AIAgent { tools, tag, agent, .. } => { - // A linked agent resolves its tools from an `ai_agent` resource at run time, and - // operators may write resources, so those tools are outside this check: the list can - // be swapped for a raw script after the flow is deployed. - if agent.is_some() { - return refuse("links an AI agent resource, whose tools live outside the flow"); + FlowModuleValue::AIAgent { tools, tag, agent, tool_inputs, .. } => { + // A linked agent's tools are resolved from its resource at run time, outside this + // walk. That is safe only because an operator's every write of an agent passes this + // same check (`validate_operator_agent`), as a developer's agent is code they wrote. + if let Some(agent) = agent { + let path = agent + .trim_start_matches("$res:") + .trim_start_matches("res://"); + let bound_inputs = tool_inputs + .iter() + .map(|(tool, inputs)| (tool.clone(), inputs.keys().cloned().collect())) + .collect(); + refs.agents + .push(LinkedAgent { path: path.to_string(), bound_inputs }); } push_tag(tag); for tool in tools { @@ -477,15 +544,48 @@ mod tests { #[test] fn composition_check_rejects_code_reachable_through_an_ai_agent() { - for value in [ - serde_json::json!({"modules": [{"id": "a", "value": {"type": "aiagent", - "input_transforms": {}, "tools": [{"id": "t", "value": {"tool_type": "flowmodule", - "type": "rawscript", "content": "x", "language": "bun"}}]}}]}), - serde_json::json!({"modules": [{"id": "a", "value": {"type": "aiagent", - "input_transforms": {}, "tools": [], "agent": "$res:f/x/agent"}}]}), - ] { - assert!(check_flow_is_composition_only(&flow(value)).is_err()); - } + let value = serde_json::json!({"modules": [{"id": "a", "value": {"type": "aiagent", + "input_transforms": {}, "tools": [{"id": "t", "value": {"tool_type": "flowmodule", + "type": "rawscript", "content": "x", "language": "bun"}}]}}]}); + assert!(check_flow_is_composition_only(&flow(value)).is_err()); + } + + /// A builder may link only an agent they can read, which the walk cannot know: it has to + /// surface the path for the caller to check. + #[test] + fn composition_check_reports_linked_agents() { + let refs = check_flow_is_composition_only(&flow(serde_json::json!({"modules": [{ + "id": "a", "value": {"type": "aiagent", "input_transforms": {}, "tools": [], + "agent": "$res:f/x/agent", "tool_inputs": {"t": {}}}}]}))) + .unwrap(); + assert_eq!(refs.agents[0].path, "f/x/agent"); + assert_eq!(refs.agents[0].bound_inputs, vec![("t".to_string(), vec![])]); + } + + #[test] + fn bound_agent_inputs_must_be_left_to_the_model() { + // Two tools share the id: the overlay binds both, so the second's `query` counts. + let tools: Vec<AgentTool> = serde_json::from_value(serde_json::json!([ + {"id": "t", "value": {"tool_type": "flowmodule", "type": "rawscript", "content": "x", + "language": "bun", "input_transforms": {}}}, + {"id": "t", "value": {"tool_type": "flowmodule", "type": "rawscript", "content": "x", + "language": "bun", "input_transforms": { + "query": {"type": "static", "value": "SELECT 1"}, + "limit": {"type": "ai"}, + "note": {"type": "static", "value": null}}}}])) + .unwrap(); + let bind = |keys: &[&str]| { + check_bound_agent_inputs_are_open( + "f/x/agent", + &tools, + &[( + "t".to_string(), + keys.iter().map(|k| k.to_string()).collect(), + )], + ) + }; + assert!(bind(&["limit", "note", "absent"]).is_ok()); + assert!(bind(&["limit", "query"]).is_err()); } #[test] diff --git a/backend/windmill-common/src/jobs.rs b/backend/windmill-common/src/jobs.rs index d7190ff45f..5ca70d3b58 100644 --- a/backend/windmill-common/src/jobs.rs +++ b/backend/windmill-common/src/jobs.rs @@ -16,12 +16,77 @@ use crate::{ flows::get_full_hub_flow_by_path, get_latest_deployed_hash_for_path, get_latest_flow_version_info_for_path, scripts::{get_full_hub_script_by_path, ScriptHash, ScriptLang}, - utils::{StripPath, HTTP_CLIENT}, + utils::{strip_json_nul, StripPath, HTTP_CLIENT}, worker::{custom_tag_matches, to_raw_value, CUSTOM_TAGS_PER_WORKSPACE, WINDMILL_DIR}, workspaces::workspace_with_fork_ancestors, FlowVersionInfo, ScriptHashInfo, Tag, }; +/// Whether `args` already reads the way `push` will store it, i.e. holds no NUL escape in a +/// name or a value. Cheap (one SIMD substring scan per value) so the normalization below can +/// be a no-op on the overwhelmingly common clean case. +pub fn args_contain_nul(args: &HashMap<String, Box<RawValue>>) -> bool { + args.iter().any(|(k, v)| { + k.contains('\0') || memchr::memmem::find(v.get().as_bytes(), b"\\u0000").is_some() + }) +} + +/// Rewrite `args` the way `push` stores it: `push` runs [`strip_json_nul`] over the +/// serialized args just before the INSERT, which removes `\u0000` escapes from **names as +/// well as values** (jsonb rejects them with 22P05). Any decision taken on a name before +/// that — `push`'s own removal of a caller [`MODULES_ARG`], an app policy's `static_inputs` +/// and `one_of_inputs`, the `PushArgs` serializer's "`extra` wins this name" rule — would +/// otherwise compare a name nobody knows against the name that actually lands in the job: +/// an arg sent as `_MODULES\0` is stored as `_MODULES`, and a viewer's `tenant\0` is stored +/// as `tenant`, after the publisher's pinned value, which jsonb then discards as the earlier +/// of two equal keys. +/// +/// Normalizing first makes every such check see the stored name. Values are normalized too, +/// so a check on a value sees what the job will receive. +/// +/// Two names that collide once normalized keep a single value, deterministically: the +/// spelling without NULs wins, then the lexicographically smallest original name — never the +/// one that `HashMap` iteration happened to serialize last. +pub fn normalize_nul_in_args(args: &mut HashMap<String, Box<RawValue>>) { + if !args_contain_nul(args) { + return; + } + let mut entries = args.drain().collect::<Vec<_>>(); + entries.sort_by(|(a, _), (b, _)| { + a.contains('\0') + .cmp(&b.contains('\0')) + .then_with(|| a.cmp(b)) + }); + for (key, value) in entries { + let key = if key.contains('\0') { + key.replace('\0', "") + } else { + key + }; + let stripped = match strip_json_nul(value.get()) { + std::borrow::Cow::Owned(stripped) => RawValue::from_string(stripped).ok(), + std::borrow::Cow::Borrowed(_) => None, + }; + args.entry(key).or_insert(stripped.unwrap_or(value)); + } +} + +/// Drop every [`RESERVED_CONTROL_ARGS`] key from args that a third party supplied for a job +/// running under somebody else's identity (an HTTP route body or native trigger message run +/// as the trigger's owner, a public app viewer's inputs run as the app's publisher). Those +/// keys pick the function the worker calls instead of `main`, the module files written into +/// the job directory, and where workspace imports resolve from: the third party must not +/// choose any of them, while a caller running its own code under its own identity still may. +/// +/// Normalizes first, so a key spelled `_ENTRYPOINT_OVERRIDE\0` — which `push` stores as +/// `_ENTRYPOINT_OVERRIDE` — cannot slip past the comparison. +pub fn sanitize_untrusted_args(args: &mut HashMap<String, Box<RawValue>>) { + normalize_nul_in_args(args); + for key in RESERVED_CONTROL_ARGS { + args.remove(key); + } +} + pub fn get_has_preprocessor_from_content_and_lang( content: &str, language: &ScriptLang, @@ -633,6 +698,97 @@ pub async fn delete_jobs(conn: &mut sqlx::PgConnection, ids: &[uuid::Uuid]) -> e Ok(()) } +#[cfg(test)] +mod reserved_args_tests { + use super::{normalize_nul_in_args, sanitize_untrusted_args}; + use serde_json::value::RawValue; + use std::collections::HashMap; + + fn raw(json: &str) -> Box<RawValue> { + RawValue::from_string(json.to_string()).unwrap() + } + + fn args(entries: &[(&str, &str)]) -> HashMap<String, Box<RawValue>> { + entries + .iter() + .map(|(k, v)| (k.to_string(), raw(v))) + .collect() + } + + fn as_json(args: &HashMap<String, Box<RawValue>>) -> serde_json::Value { + serde_json::to_value(args).unwrap() + } + + /// A clean map must come out byte for byte the same: this runs on every push. + #[test] + fn normalization_leaves_clean_args_alone() { + let mut map = args(&[ + ("tenant", r#""acme""#), + // A legitimately escaped backslash followed by the literal text `u0000` + // (minified JS regexes contain these) is not a NUL and must survive. + ("src", r#""[^\\u0000]+""#), + ]); + let before = as_json(&map); + normalize_nul_in_args(&mut map); + assert_eq!(as_json(&map), before); + } + + /// `push` stores the args with their NUL escapes removed, names included, so a name + /// that only differs by NULs is the same name and must be compared as such. + #[test] + fn normalization_renames_nul_spelled_names_and_cleans_values() { + let mut map = args(&[ + ("_MODULES\u{0}", r#"{"m.py": "evil"}"#), + ("_MOD\u{0}ULES", r#"{"m.py": "evil"}"#), + ("greeting\u{0}", r#""anything""#), + ("note", r#""a\u0000b""#), + ]); + normalize_nul_in_args(&mut map); + let mut names = map.keys().cloned().collect::<Vec<_>>(); + names.sort(); + assert_eq!(names, vec!["_MODULES", "greeting", "note"]); + assert_eq!(map["note"].get(), r#""ab""#); + } + + /// Which of two names that collide once normalized survives may not depend on + /// `HashMap` iteration order: the spelling the publisher/server used wins. + #[test] + fn normalization_keeps_the_nul_free_spelling_on_collision() { + for _ in 0..16 { + let mut map = args(&[ + ("tenant", r#""acme""#), + ("tenant\u{0}", r#""globex""#), + ("\u{0}tenant", r#""initech""#), + ]); + normalize_nul_in_args(&mut map); + assert_eq!(map.len(), 1); + assert_eq!(map["tenant"].get(), r#""acme""#); + } + } + + /// Every reserved control key goes, in every spelling, and nothing else does. + #[test] + fn untrusted_args_lose_every_reserved_control_key() { + let mut map = args(&[ + ("_ENTRYPOINT_OVERRIDE", r#""apply""#), + ("_ENTRYPOINT_OVERRIDE\u{0}", r#""apply""#), + ("_ENTRY\u{0}POINT_OVERRIDE", r#""apply""#), + ("_MODULES", r#"{"m.py": "evil"}"#), + ("_MODULES\u{0}", r#"{"m.py": "evil"}"#), + ("_TEMP_SCRIPT_REFS", r#"{"f/a/b": "deadbeef"}"#), + ("_TEMP_SCRIPT_REFS\u{0}", r#"{"f/a/b": "deadbeef"}"#), + // Neighbours that must not be caught by a prefix/contains match. + ("_TEMP_SCRIPT_REFS_", r#"1"#), + ("entrypoint", r#""main""#), + ("action", r#""refund""#), + ]); + sanitize_untrusted_args(&mut map); + let mut names = map.keys().cloned().collect::<Vec<_>>(); + names.sort(); + assert_eq!(names, vec!["_TEMP_SCRIPT_REFS_", "action", "entrypoint"]); + } +} + #[cfg(test)] mod tests { use super::is_safe_log_file_path; diff --git a/backend/windmill-common/src/lib.rs b/backend/windmill-common/src/lib.rs index 1fdfe72de7..fac05d1fea 100644 --- a/backend/windmill-common/src/lib.rs +++ b/backend/windmill-common/src/lib.rs @@ -175,7 +175,13 @@ pub const DEFAULT_AI_SHARED_ARTIFACT_RETENTION_SECS: i64 = 60 * 60 * 24 * 30; pub const WM_DEPLOYERS_GROUP: &str = "wm_deployers"; /// Groups Windmill creates lazily (`ON CONFLICT DO NOTHING`) and grants permissions to before /// they exist; a member who created one first would inherit those grants. -pub const RESERVED_GROUP_NAMES: &[&str] = &["error_handler", "success_handler", "slack", "teams"]; +pub const RESERVED_GROUP_NAMES: &[&str] = &[ + "error_handler", + "success_handler", + "variable_expiration_handler", + "slack", + "teams", +]; /// A century. Every consumer has to survive `now - retention`, and the ceilings are much lower /// than an `i64`: `DateTime` subtraction panics past year 262143, and the `(<n> s)::interval` @@ -837,6 +843,50 @@ mod validate_dbname_tests { } } +#[cfg(test)] +mod validate_dbname_not_option_like_tests { + use super::validate_dbname_not_option_like; + + /// A source is any database a user already has, so everything `validate_dbname` refuses but + /// Postgres accepts has to keep working. + #[test] + fn accepts_every_name_that_is_not_an_option() { + for name in [ + "mydb", + "My-Db_1", + "2024_archive", + "my.db", + "db with spaces", + "Grüße", + "host=elsewhere port=5433", + "", + "a-dash-inside--it", + ] { + assert!( + validate_dbname_not_option_like(name).is_ok(), + "refused the legitimate name {name:?}" + ); + } + } + + #[test] + fn rejects_a_name_that_an_argument_vector_would_read_as_an_option() { + for name in [ + "--file=/tmp/x", + "--restrict-key=k", + "-f", + "-", + " --file=/tmp/x", + "\t-f", + ] { + let err = validate_dbname_not_option_like(name) + .expect_err(&format!("accepted the option-like name {name:?}")) + .to_string(); + assert!(err.contains("cannot start with '-'"), "{err}"); + } + } +} + #[cfg(test)] mod pg_tls_tests { use super::PgDatabase; @@ -963,6 +1013,13 @@ ta9ELulniZau8zUAtwqwecxodzl+KO8NYj0a9PGgAM64dMqkRtRA8P4UP350Nag3\n\ assert!(pg(None, None).to_uri().contains("sslmode=prefer")); } + #[test] + fn to_uri_sslmode_cannot_add_parameters() { + let uri = pg(Some("disable&hostaddr=127.0.0.2"), None).to_uri(); + assert!(uri.ends_with("?sslmode=disable%26hostaddr%3D127.0.0.2")); + assert!(uri.parse::<tokio_postgres::Config>().is_err()); + } + #[test] fn options_survive_to_uri() { let mut db = pg(Some("require"), None); @@ -1102,7 +1159,9 @@ impl PgDatabase { let sslmode = match self.sslmode.as_deref() { Some("allow") => "prefer".to_string(), Some("require") | Some("verify-ca") | Some("verify-full") => "require".to_string(), - Some(s) => s.to_string(), + // Encoded like the other fields: a raw '&' would append parameters (`hostaddr`, `host`, + // `port`) that send the connection somewhere else than the host this struct names. + Some(s) => urlencoding::encode(s).into_owned(), None => "prefer".to_string(), }; // Encode host/dbname too: an unencoded '@', '/', '?' or '&' would otherwise @@ -1157,6 +1216,14 @@ impl PgDatabase { let on_local_cluster = local.host == self.host && local.port.unwrap_or(5432) == self.port.unwrap_or(5432); if let Some(db) = main_db.filter(|_| on_local_cluster) { + // Only a connection Windmill built carries the stored password (and the + // role's has drifted from it). Anything else is a resource whose author + // named this role and host: retrying it would log them in with a + // credential they were never given, and rotate it on their say-so. + let stored_pwd = crate::utils::get_custom_pg_instance_password(db).await?; + if self.password.as_deref() != Some(stored_pwd.as_str()) { + return Err(e); + } tracing::warn!( "custom_instance_user password auth failed, refreshing and retrying..." ); @@ -1589,6 +1656,26 @@ pub fn validate_dbname(dbname: &str) -> error::Result<()> { Ok(()) } +/// The one restriction on the name of a database Windmill only *reads from*: it may not look like +/// a command-line option. +/// +/// [`validate_dbname`] is the rule for a name Windmill creates or interpolates into SQL, and is +/// far too narrow here: a source is any Postgres resource a user already has, and an existing +/// database may legitimately be named `2024_archive`, `my.db`, `Grüße` or anything else Postgres +/// accepts as a quoted identifier. Refusing those would break working resources. A name opening +/// with `-` is the one shape that is never a database and always a mistake or an attempt to smuggle +/// an option into a `pg_dump`/`psql` argument vector, so it is refused wherever a source is +/// resolved — the commands themselves pass the name through `PGDATABASE` and never on the command +/// line, and this keeps a future caller that forgets from re-opening that door. +pub fn validate_dbname_not_option_like(dbname: &str) -> error::Result<()> { + if dbname.trim_start().starts_with('-') { + return Err(error::Error::BadRequest( + "Database name cannot start with '-'".to_string(), + )); + } + Ok(()) +} + /// Lock the instance databases among `names` until `tx` ends, in a stable order. Taken by every /// settings save naming an instance database, and by [`drop_unused_instance_database`]: a save /// cannot start using a database between that drop's check that nothing does and the drop. diff --git a/backend/windmill-common/src/min_version.rs b/backend/windmill-common/src/min_version.rs index 3719abd31c..9005ab834a 100644 --- a/backend/windmill-common/src/min_version.rs +++ b/backend/windmill-common/src/min_version.rs @@ -27,9 +27,13 @@ pub const MIN_VERSION_SUPPORTS_JOB_TOKEN_SCOPES: VC = vc(1, 822, 0, "Restricted // stored secret variables. Reading it ships one release earlier (1.831), so a rollback to // that release still reads every secret; below it, secrets written after the switch stay // unreadable until rolled forward. Only worker versions are seen here, not servers. -// Also gates widening `variable.value`, which needs every process to retire the connections -// the widening poisons (`db::retire_connections_opened_before_now`): that also ships in 1.832. pub const MIN_VERSION_SUPPORTS_AEAD_SECRETS: VC = vc(1, 832, 0, "AES-GCM secret encryption"); +// Gates widening `variable.value`. Every query returning that column casts it to text from +// this release, so the widening changes no result type: before it, each statement prepared on +// the column fails once per connection, and behind PgBouncer until its server connection +// closes. Must name the release this ships in. +pub const MIN_VERSION_SUPPORTS_VARIABLE_VALUE_WIDENING: VC = + vc(1, 848, 0, "variable.value widening"); // A renewed license key replaces the one every process reads, and a worker that predates // Ed25519 support rejects an Ed25519-signed key and stops pulling jobs, so renewal only offers // to accept one once this is met. Only worker versions are seen here, not servers. Must name diff --git a/backend/windmill-common/src/oidc_oss.rs b/backend/windmill-common/src/oidc_oss.rs index 2945f8b87a..60ef4da687 100644 --- a/backend/windmill-common/src/oidc_oss.rs +++ b/backend/windmill-common/src/oidc_oss.rs @@ -98,6 +98,7 @@ pub enum OidcCaller { Vault, Sqs, Bedrock, + Anthropic, } #[cfg(not(feature = "private"))] diff --git a/backend/windmill-common/src/secret_backend/database.rs b/backend/windmill-common/src/secret_backend/database.rs index b450cc7e15..903ea85e24 100644 --- a/backend/windmill-common/src/secret_backend/database.rs +++ b/backend/windmill-common/src/secret_backend/database.rs @@ -39,7 +39,7 @@ impl DatabaseBackend { impl SecretBackend for DatabaseBackend { async fn get_secret(&self, workspace_id: &str, path: &str) -> Result<String> { let variable = sqlx::query!( - "SELECT value FROM variable WHERE path = $1 AND workspace_id = $2 AND is_secret = true", + "SELECT value::text AS \"value!\" FROM variable WHERE path = $1 AND workspace_id = $2 AND is_secret = true", path, workspace_id ) diff --git a/backend/windmill-common/src/utils.rs b/backend/windmill-common/src/utils.rs index c034edc203..074469893d 100644 --- a/backend/windmill-common/src/utils.rs +++ b/backend/windmill-common/src/utils.rs @@ -286,6 +286,13 @@ pub fn sanitize_db_error(e: sqlx::Error) -> Error { Some("23514") if db_err.constraint() == Some("variable_value_length") => { Error::BadRequest("A field exceeds its maximum length".to_string()) } + Some("23514") if db_err.constraint() == Some("variable_oauth_no_value_expiry") => { + Error::BadRequest( + "A variable linked to an OAuth account cannot have a value expiry: Windmill \ + refreshes its token" + .to_string(), + ) + } // check_violation — `proper_id` / `proper_name` and friends Some("23514") => Error::BadRequest( "Invalid path or name: it does not match the required format".to_string(), diff --git a/backend/windmill-common/src/variables.rs b/backend/windmill-common/src/variables.rs index 512bd555f0..f3ee17769b 100644 --- a/backend/windmill-common/src/variables.rs +++ b/backend/windmill-common/src/variables.rs @@ -260,6 +260,7 @@ pub struct ListableVariable { pub refresh_error: Option<String>, pub is_linked: Option<bool>, pub expires_at: Option<chrono::DateTime<Utc>>, + pub value_valid_until: Option<chrono::DateTime<Utc>>, #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option<Vec<String>>, /// Labels inherited from the parent folder, computed at read time. @@ -302,6 +303,8 @@ pub struct ExportableListableVariable { #[serde(skip_serializing_if = "Option::is_none")] pub expires_at: Option<chrono::DateTime<Utc>>, #[serde(skip_serializing_if = "Option::is_none")] + pub value_valid_until: Option<chrono::DateTime<Utc>>, + #[serde(skip_serializing_if = "Option::is_none")] pub labels: Option<Vec<String>>, } @@ -317,7 +320,11 @@ pub struct CreateVariable { pub description: String, pub account: Option<i32>, pub is_oauth: Option<bool>, + /// When the row itself is garbage-collected. pub expires_at: Option<chrono::DateTime<Utc>>, + /// When the value stops working. Drives the workspace's variable expiration handler; + /// never deletes the row. + pub value_valid_until: Option<chrono::DateTime<Utc>>, #[serde(default)] pub labels: Option<Vec<String>>, #[serde(default)] @@ -451,7 +458,7 @@ pub async fn get_secret_value_as_admin( path: &str, ) -> crate::error::Result<String> { let variable_o = sqlx::query!( - "SELECT value, is_secret, path from variable WHERE variable.path = $1 AND variable.workspace_id = $2", path, w_id + "SELECT value::text AS \"value!\", is_secret, path from variable WHERE variable.path = $1 AND variable.workspace_id = $2", path, w_id ) .fetch_optional(db) .await?; @@ -542,9 +549,8 @@ pub fn variable_value_widen_pending() -> bool { /// Widen `variable.value` to [`VARIABLE_VALUE_WIDTH`], or `false` when it is too early to. /// Not a migration: changing a column's type, even its length, fails every statement a live -/// process prepared on it until its connection closes. It waits for v2 writes, gated on a -/// worker version that retires such connections on the first failure, so that no process too -/// old to recover is still running. +/// process prepared on it until its connection closes. It waits until no process returns the +/// column uncast. Any new query returning `variable.value` must select `value::text`. async fn widen_variable_value(db: &DB) -> error::Result<bool> { if VARIABLE_VALUE_WIDENED.load(Ordering::Relaxed) { return Ok(true); @@ -556,7 +562,9 @@ async fn widen_variable_value(db: &DB) -> error::Result<bool> { .fetch_one(db) .await?; if typmod >= 0 && typmod - 4 < VARIABLE_VALUE_WIDTH { - if crate::db::process_uptime() < crate::db::PREVIOUS_RELEASE_ROLLOUT_UPTIME { + if !crate::min_version::MIN_VERSION_SUPPORTS_VARIABLE_VALUE_WIDENING.met_conservatively() + || crate::db::process_uptime() < crate::db::PREVIOUS_RELEASE_ROLLOUT_UPTIME + { return Ok(false); } tracing::info!("widening variable.value to VARCHAR({VARIABLE_VALUE_WIDTH})"); @@ -669,7 +677,7 @@ async fn upgrade_workspace_secret_variables(db: &DB, w_id: &str) -> error::Resul }; let mc = crypt_from_key_with_suffix(&key, ""); let rows: Vec<(String, String)> = sqlx::query_as( - "SELECT path, value FROM variable WHERE workspace_id = $1 AND is_secret AND path > $2 \ + "SELECT path, value::text FROM variable WHERE workspace_id = $1 AND is_secret AND path > $2 \ ORDER BY path LIMIT $3", ) .bind(w_id) @@ -1009,7 +1017,7 @@ pub async fn get_variable_or_self( let path = path.strip_prefix("$var:").unwrap().to_string(); let record = sqlx::query!( - "SELECT value, is_secret + "SELECT value::text AS \"value!\", is_secret FROM variable WHERE path = $1 AND workspace_id = $2", &path, @@ -1057,7 +1065,7 @@ pub async fn get_variable_or_self_as<T: Authable + Sync>( // Use an RLS-scoped transaction so the query respects row-level security let mut tx = user_db.clone().begin(authed).await?; let record = sqlx::query!( - "SELECT value, is_secret + "SELECT value::text AS \"value!\", is_secret FROM variable WHERE path = $1 AND workspace_id = $2", &var_path, diff --git a/backend/windmill-common/src/workspaces.rs b/backend/windmill-common/src/workspaces.rs index 3b5d78c5b7..9132c51950 100644 --- a/backend/windmill-common/src/workspaces.rs +++ b/backend/windmill-common/src/workspaces.rs @@ -3805,7 +3805,7 @@ async fn transform_json_unchecked( serde_json::Value::String(s) if s.starts_with("$var:") => { let path = &s[5..]; let (value, is_secret): (String, bool) = sqlx::query_as( - "SELECT value, is_secret FROM variable WHERE workspace_id = $1 AND path = $2", + "SELECT value::text, is_secret FROM variable WHERE workspace_id = $1 AND path = $2", ) .bind(&w_id) .bind(path) diff --git a/backend/windmill-native-triggers/src/lib.rs b/backend/windmill-native-triggers/src/lib.rs index c61a1ff335..4a8560d9f0 100644 --- a/backend/windmill-native-triggers/src/lib.rs +++ b/backend/windmill-native-triggers/src/lib.rs @@ -815,7 +815,7 @@ pub async fn decrypt_oauth_data<T: DeserializeOwned>( let mc = build_crypt(db, workspace_id).await?; let row = sqlx::query!( - "SELECT v.value, a.id AS account_id, a.refresh_token + "SELECT v.value::text AS \"value!\", a.id AS account_id, a.refresh_token FROM variable v JOIN account a ON a.workspace_id = v.workspace_id AND a.id = v.account WHERE v.workspace_id = $1 AND v.path = $2 diff --git a/backend/windmill-oauth/src/lib.rs b/backend/windmill-oauth/src/lib.rs index 3b32f0436f..57fe97eac2 100644 --- a/backend/windmill-oauth/src/lib.rs +++ b/backend/windmill-oauth/src/lib.rs @@ -66,6 +66,8 @@ pub struct ClientWithScopes { pub extra_params_callback: Option<HashMap<String, String>>, pub allowed_domains: Option<Vec<String>>, pub userinfo_url: Option<String>, + /// See [`OAuthConfig::connect_fields`]. + pub connect_fields: Option<HashMap<String, String>>, pub grant_types: Vec<String>, pub token_response_path: Option<String>, /// Resolved token endpoint, exposed so the connect dialog can prefill and @@ -88,6 +90,15 @@ pub struct OAuthConfig { #[serde(default = "empty_string")] pub token_url: String, pub userinfo_url: Option<String>, + /// Resource fields the connect callback fills from what the provider returns, + /// as field -> path (see [`connect_resource_fields`]). A path starts with its + /// source: `token.` (the raw token response, before `token_response_path` + /// unwraps it), `userinfo.` (`userinfo_url`'s response, fetched only when a + /// path needs it) or `callback.` (the callback URL's query params). The browser + /// relays `callback.` values, so they only prefill the user's own resource: + /// nothing server-side may trust them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub connect_fields: Option<HashMap<String, String>>, /// The registry JSON may also carry frontend-only keys for the connect /// dialog, deliberately not modelled here: `scope_options`, a scope pick /// list, `resource_fields`, the fields of the resource type the dialog @@ -353,6 +364,10 @@ pub struct SlackTeam { pub struct OAuthCallback { pub code: String, pub state: String, + /// The callback URL's other query params (QuickBooks' `realmId`), read by + /// `connect_fields` paths under `callback.`. + #[serde(default)] + pub params: HashMap<String, String>, } /// Build a basic OAuth client from configuration @@ -902,6 +917,48 @@ pub fn parse_token_response( }) } +/// What the connect callback returns: the token, and the resource fields its +/// `connect_fields` picked, which the connect dialog prefills. +#[derive(Debug, Serialize)] +pub struct ConnectCallbackResponse { + #[serde(flatten)] + pub token: TokenResponse, + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub resource_fields: HashMap<String, serde_json::Value>, +} + +/// Picks each field's value out of `sources` (`{token, userinfo, callback}`). A path +/// is dot-separated keys, and a `key[k=v]` segment takes the element of array `key` +/// whose `k` is `v` (`userinfo.accounts[is_default=true].base_uri`). Only the listed +/// paths leave the server, and fields whose path misses are left out. +pub fn connect_resource_fields( + sources: &serde_json::Value, + fields: &HashMap<String, String>, +) -> HashMap<String, serde_json::Value> { + fn get<'a>(v: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> { + path.split('.') + .try_fold(v, |v, seg| match seg.split_once('[') { + Some((key, filter)) => { + let (k, want) = filter.strip_suffix(']')?.split_once('=')?; + v.get(key)?.as_array()?.iter().find(|e| match e.get(k) { + Some(serde_json::Value::String(s)) => s == want, + Some(other) => other.to_string() == want, + None => false, + }) + } + None => v.get(seg), + }) + } + fields + .iter() + .filter_map(|(field, path)| { + get(sources, path) + .filter(|v| !v.is_null()) + .map(|v| (field.clone(), v.clone())) + }) + .collect() +} + /// Pre-fetched account fields needed for token refresh. pub struct OAuthAccountInfo { pub client: String, @@ -1260,11 +1317,50 @@ mod tests { assert_eq!(&*token.access_token, "xoxe.xoxp-new"); } + #[test] + fn connect_fields_pick_from_each_source() { + let registry: HashMap<String, OAuthConfig> = + serde_json::from_str(include_str!("../../oauth_connect.json")).unwrap(); + let fields = |p: &str| registry[p].connect_fields.clone().unwrap(); + + let docusign = serde_json::json!({"userinfo": {"accounts": [ + {"account_id": "a1", "is_default": false, "base_uri": "https://na2.docusign.net"}, + {"account_id": "a2", "is_default": true, "base_uri": "https://demo.docusign.net"} + ]}}); + let got = connect_resource_fields(&docusign, &fields("docusign")); + assert_eq!(got["account_id"], "a2"); + assert_eq!(got["base_uri"], "https://demo.docusign.net"); + let no_account = serde_json::json!({"userinfo": {"accounts": []}}); + assert!(connect_resource_fields(&no_account, &fields("docusign")).is_empty()); + + let salesforce = serde_json::json!({"token": { + "access_token": "t", "instance_url": "https://acme.my.salesforce.com", "id_token": "x" + }}); + let want = HashMap::from([( + "instance_url".to_string(), + serde_json::json!("https://acme.my.salesforce.com"), + )]); + assert_eq!( + connect_resource_fields(&salesforce, &fields("salesforce")), + want + ); + let sandbox = registry["salesforce"].as_sandbox().unwrap(); + assert_eq!(sandbox.connect_fields, registry["salesforce"].connect_fields); + + let quickbooks = serde_json::json!({"callback": {"realmId": "9130"}}); + let got = connect_resource_fields(&quickbooks, &fields("quickbooks")); + assert_eq!( + got, + HashMap::from([("realmId".to_string(), serde_json::json!("9130"))]) + ); + } + fn sample_oauth_config(with_sandbox: bool) -> OAuthConfig { OAuthConfig { auth_url: "https://account.example.com/oauth/auth".to_string(), token_url: "https://account.example.com/oauth/token".to_string(), userinfo_url: Some("https://account.example.com/userinfo".to_string()), + connect_fields: None, scopes: Some(vec!["signature".to_string()]), cc_scopes: None, extra_params: None, diff --git a/backend/windmill-queue/src/jobs.rs b/backend/windmill-queue/src/jobs.rs index c51f057f16..b78da75edf 100644 --- a/backend/windmill-queue/src/jobs.rs +++ b/backend/windmill-queue/src/jobs.rs @@ -42,8 +42,8 @@ use windmill_common::auth::JobPerms; #[cfg(feature = "benchmark")] use windmill_common::bench::BenchmarkIter; use windmill_common::jobs::{ - script_path_to_payload, JobTriggerKind, TriggerKindLabel, EMAIL_ERROR_HANDLER_USER_EMAIL, - MODULES_ARG, + args_contain_nul, normalize_nul_in_args, sanitize_untrusted_args, script_path_to_payload, + JobTriggerKind, TriggerKindLabel, EMAIL_ERROR_HANDLER_USER_EMAIL, MODULES_ARG, }; use windmill_common::min_version::{ MIN_VERSION_SUPPORTS_DEBOUNCING, MIN_VERSION_SUPPORTS_DEBOUNCING_V2, @@ -170,12 +170,15 @@ const ERROR_HANDLER_USERNAME: &str = "error_handler"; pub const SCHEDULE_ERROR_HANDLER_USERNAME: &str = "schedule_error_handler"; const GLOBAL_ERROR_HANDLER_USERNAME: &str = "global"; const SUCCESS_HANDLER_USERNAME: &str = "success_handler"; +const VARIABLE_EXPIRATION_HANDLER_USERNAME: &str = "variable_expiration_handler"; pub const ERROR_HANDLER_USER_GROUP: &str = "g/error_handler"; pub const ERROR_HANDLER_USER_EMAIL: &str = "error_handler@windmill.dev"; pub const SCHEDULE_ERROR_HANDLER_USER_EMAIL: &str = "schedule_error_handler@windmill.dev"; pub const SUCCESS_HANDLER_USER_GROUP: &str = "g/success_handler"; pub const SUCCESS_HANDLER_USER_EMAIL: &str = "success_handler@windmill.dev"; +pub const VARIABLE_EXPIRATION_HANDLER_USER_GROUP: &str = "g/variable_expiration_handler"; +pub const VARIABLE_EXPIRATION_HANDLER_USER_EMAIL: &str = "variable_expiration_handler@windmill.dev"; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CanceledBy { @@ -480,6 +483,9 @@ pub const PERIODIC_SCRIPT_TAG: &str = "periodic_bash_script"; pub const INIT_SCRIPT_TAG: &str = "init_script"; pub const INIT_SCRIPT_PATH_PREFIX: &str = "init_script_"; pub const PERIODIC_SCRIPT_PATH_PREFIX: &str = "periodic_script_"; +/// The email the worker's own init/periodic scripts are pushed under by [`push_init_job`] +/// and [`push_periodic_bash_job`]. +pub const WORKER_SCRIPT_EMAIL: &str = "worker@windmill.dev"; pub async fn push_init_job<'c>( db: &Pool<Postgres>, @@ -508,7 +514,7 @@ pub async fn push_init_job<'c>( }), PushArgs::from(&ehm), worker_name, - "worker@windmill.dev", + WORKER_SCRIPT_EMAIL, SUPERADMIN_SECRET_EMAIL.to_string(), Some("worker_init_job"), None, @@ -569,7 +575,7 @@ pub async fn push_periodic_bash_job<'c>( }), PushArgs::from(&ehm), worker_name, - "worker@windmill.dev", + WORKER_SCRIPT_EMAIL, SUPERADMIN_SECRET_EMAIL.to_string(), Some("worker_periodic_script_job"), None, @@ -3599,6 +3605,100 @@ pub async fn push_error_handler<'a, 'c, T: Serialize + Send + Sync>( return Ok(uuid); } +/// Handler arguments, so the field names are wire format. Never add the value: job args and +/// results are stored in cleartext and shown in Runs. +#[derive(Serialize)] +pub struct ExpiringVariable { + pub workspace_id: String, + pub variable_path: String, + pub description: String, + pub value_valid_until: DateTime<Utc>, + pub is_secret: bool, +} + +/// Takes and returns the caller's transaction so the claim and the push commit together. +pub(crate) async fn push_variable_expiration_handler<'c>( + db: &Pool<Postgres>, + tx: Transaction<'c, Postgres>, + w_id: &str, + (payload, tag, on_behalf_of): ( + JobPayload, + Option<String>, + Option<windmill_common::jobs::OnBehalfOf>, + ), + expiring: &ExpiringVariable, + extra_args: Option<Json<Box<RawValue>>>, +) -> windmill_common::error::Result<(Uuid, Transaction<'c, Postgres>)> { + // Serialized whole so a new `ExpiringVariable` field reaches handlers. Any writer of the + // variable controls these, so only they are neutralized: the admin's `extra_args` keep their + // `$res:` references, which is how the handler gets its token. + let mut extra = omit_untrusted_interpolation( + serde_json::from_value(serde_json::to_value(expiring)?)?, + "The following resource has been omitted for security reasons: ", + ); + + if let Some(args_v) = extra_args { + if let Ok(args_m) = serde_json::from_str::<HashMap<String, Box<RawValue>>>(args_v.get()) { + extra.extend(args_m); + } else { + return Err(error::Error::ExecutionErr( + "args of scripts needs to be dict".to_string(), + )); + } + } + + let (email, permissioned_as) = if let Some(on_behalf_of) = on_behalf_of.as_ref() { + ( + on_behalf_of.email.as_str(), + on_behalf_of.permissioned_as.clone(), + ) + } else { + ( + VARIABLE_EXPIRATION_HANDLER_USER_EMAIL, + VARIABLE_EXPIRATION_HANDLER_USER_GROUP.to_string(), + ) + }; + + let empty: HashMap<String, Box<RawValue>> = HashMap::new(); + let args = PushArgs { extra: Some(extra), args: &empty }; + push( + db, + PushIsolationLevel::Transaction(tx), + w_id, + payload, + args, + VARIABLE_EXPIRATION_HANDLER_USERNAME, + email, + permissioned_as, + Some(&format!( + "variable.expiration.{}", + expiring.variable_path.replace('/', ".") + )), + None, + None, + None, + None, + None, + None, + None, + false, + false, + None, + true, + tag, + None, + None, + None, + None, + false, + None, + None, + None, + None, + ) + .await +} + pub async fn push_success_handler<'a, 'c, T: Serialize + Send + Sync>( db: &Pool<Postgres>, job_id: Uuid, @@ -3758,6 +3858,25 @@ pub struct MiniPulledJob { } impl MiniPulledJob { + /// Whether this is an init or periodic script a worker queued for itself + /// ([`push_init_job`], [`push_periodic_bash_job`]). These run outside any sandbox, so every + /// executor that skips isolation uses this check. + pub fn is_worker_own_script(&self) -> bool { + let own_script = |own_tag: &str, path_prefix: &str| { + self.tag == own_tag + && self + .runnable_path + .as_ref() + .is_some_and(|p| p.starts_with(path_prefix)) + }; + self.workspace_id == "admins" + && self.permissioned_as == SUPERADMIN_SECRET_EMAIL + && self.permissioned_as_email == WORKER_SCRIPT_EMAIL + && self.parent_job.is_none() + && (own_script(INIT_SCRIPT_TAG, INIT_SCRIPT_PATH_PREFIX) + || own_script(PERIODIC_SCRIPT_TAG, PERIODIC_SCRIPT_PATH_PREFIX)) + } + pub fn new_inline( workspace_id: String, args: Option<HashMap<String, Box<RawValue>>>, @@ -6034,6 +6153,18 @@ impl PushArgsOwned { pub fn empty() -> Self { PushArgsOwned { extra: None, args: HashMap::new() } } + + /// Mark these args as coming from an untrusted third party: see + /// [`windmill_common::jobs::sanitize_untrusted_args`]. Applied to both maps, because a + /// name in `extra` wins over the same name in `args` when the job is serialized, and no + /// source that builds args from a third party's payload sets a reserved control key of + /// its own. + pub fn sanitize_untrusted(&mut self) { + sanitize_untrusted_args(&mut self.args); + if let Some(extra) = self.extra.as_mut() { + sanitize_untrusted_args(extra); + } + } } pub fn empty_result() -> Box<RawValue> { @@ -6279,21 +6410,34 @@ async fn push_inner<'c, 'd>( suspended_mode: Option<bool>, scope_ceiling: Option<&[String]>, ) -> Result<(Uuid, Transaction<'c, Postgres>), Error> { - // The worker builds a preview's `_MODULES` arg into the job as its module code. Every - // caller-reachable value lands in `args` or `extra` (webhook query and headers go to - // `extra`, WAC children copy their parent's args), so it is dropped from both and only - // the `JobPayload::Code` arm below sets it, from the server-side `RawCode::modules`. - let args_without_modules; + // Normalize the names and values to what the INSERT below will actually store before + // anything here goes by a name: `strip_json_nul` runs over the serialized args just + // before that INSERT and removes `\u0000` escapes from keys as well as values, so a + // name compared as sent is not the name that lands in the job. Without this, `_MODULES` + // is dropped below while `_MODULES\0` is stored as `_MODULES` and built into a preview's + // job directory as module code; likewise the serializer's "`extra` wins this name" rule + // (which is how an app policy's `static_inputs` override a viewer's args) compares the + // name as sent, so `tenant\0` is written after the pinned `tenant` and jsonb keeps the + // viewer's, as the later of two equal keys. The final strip stays, harmless once this + // has run. + let normalized_args; let mut args = { let mut extra = args.extra; if let Some(extra) = extra.as_mut() { + normalize_nul_in_args(extra); + // The worker builds a preview's `_MODULES` arg into the job as its module code. + // Every caller-reachable value lands in `args` or `extra` (webhook query and + // headers go to `extra`, WAC children copy their parent's args), so it is + // dropped from both and only the `JobPayload::Code` arm below sets it, from the + // server-side `RawCode::modules`. extra.remove(MODULES_ARG); } - let args = if args.args.contains_key(MODULES_ARG) { + let args = if args_contain_nul(args.args) || args.args.contains_key(MODULES_ARG) { let mut stripped = args.args.clone(); + normalize_nul_in_args(&mut stripped); stripped.remove(MODULES_ARG); - args_without_modules = stripped; - &args_without_modules + normalized_args = stripped; + &normalized_args } else { args.args }; @@ -6357,6 +6501,7 @@ async fn push_inner<'c, 'd>( if !team_plan_status.premium && email != ERROR_HANDLER_USER_EMAIL && email != SCHEDULE_ERROR_HANDLER_USER_EMAIL + && email != VARIABLE_EXPIRATION_HANDLER_USER_EMAIL && email != recovery_email && email != "worker@windmill.dev" && email != SUPERADMIN_SECRET_EMAIL @@ -8861,6 +9006,111 @@ mod git_sync_concurrency_key_tests { } } +#[cfg(test)] +mod worker_own_script_tests { + use super::*; + + const WK: &str = "wk-default-host-a1b2c"; + + /// Built exactly as [`push_init_job`] and [`push_periodic_bash_job`] queue them. + fn own_script(path: &str, tag: &str) -> MiniPulledJob { + MiniPulledJob::new_inline( + "admins".to_string(), + None, + WK.to_string(), + SUPERADMIN_SECRET_EMAIL.to_string(), + WORKER_SCRIPT_EMAIL.to_string(), + Some(path.to_string()), + JobKind::Preview, + None, + tag.to_string(), + Some(ScriptLang::Bash), + ) + } + + /// The init and periodic scripts the worker queues for itself are recognised. + #[test] + fn what_the_worker_queues_for_itself_is_recognized() { + assert!( + own_script(&format!("{INIT_SCRIPT_PATH_PREFIX}{WK}"), INIT_SCRIPT_TAG) + .is_worker_own_script() + ); + assert!(own_script( + &format!("{PERIODIC_SCRIPT_PATH_PREFIX}{WK}_1700000000"), + PERIODIC_SCRIPT_TAG + ) + .is_worker_own_script()); + } + + /// Only the exact shape the worker queues is recognised. + #[test] + fn other_job_shapes_are_not_recognized() { + let init = || own_script(&format!("{INIT_SCRIPT_PATH_PREFIX}x"), INIT_SCRIPT_TAG); + let periodic = || { + own_script( + &format!("{PERIODIC_SCRIPT_PATH_PREFIX}x"), + PERIODIC_SCRIPT_TAG, + ) + }; + for (what, job) in [ + ( + "other workspace", + MiniPulledJob { workspace_id: "ws-a".to_string(), ..init() }, + ), + ( + "other permissioned_as", + MiniPulledJob { permissioned_as: "u/deva".to_string(), ..init() }, + ), + ( + "other email", + MiniPulledJob { permissioned_as_email: "deva@example.com".to_string(), ..init() }, + ), + ( + "flow step", + MiniPulledJob { parent_job: Some(Uuid::from_u128(1)), ..init() }, + ), + ( + "flow step", + MiniPulledJob { parent_job: Some(Uuid::from_u128(1)), ..periodic() }, + ), + ( + "user tag", + own_script(&format!("{INIT_SCRIPT_PATH_PREFIX}x"), "bash"), + ), + ( + "crossed tag", + own_script(&format!("{INIT_SCRIPT_PATH_PREFIX}x"), PERIODIC_SCRIPT_TAG), + ), + ( + "crossed tag", + own_script(&format!("{PERIODIC_SCRIPT_PATH_PREFIX}x"), INIT_SCRIPT_TAG), + ), + ("ordinary path", own_script("u/admin/s", INIT_SCRIPT_TAG)), + ( + "ordinary path", + own_script("u/admin/s", PERIODIC_SCRIPT_TAG), + ), + ] { + assert!( + !job.is_worker_own_script(), + "{what}: {:?}/{:?}/{} must not be recognised as a worker script", + job.workspace_id, + job.runnable_path, + job.tag + ); + } + } + + /// A job without a path is not a worker script. + #[test] + fn a_job_without_a_path_is_not_an_own_script() { + assert!( + !MiniPulledJob { runnable_path: None, ..own_script("x", INIT_SCRIPT_TAG) } + .is_worker_own_script() + ); + } +} + #[cfg(test)] mod preset_handler_path_tests { use super::is_preset_handler_path; @@ -8886,7 +9136,8 @@ mod preset_handler_path_tests { let hub_paths: std::collections::HashMap<String, String> = serde_json::from_str(include_str!("../../../frontend/src/lib/hubPaths.json")).unwrap(); for (name, path) in hub_paths { - if path.contains("-handler-") { + // Variable expiration handlers run as their own group whatever their path. + if path.contains("-handler-") && !path.contains("variable-expiration") { assert!( is_preset_handler_path(&path), "{name}: {path} is not pinned" diff --git a/backend/windmill-queue/src/lib.rs b/backend/windmill-queue/src/lib.rs index ff93ea2665..5358960635 100644 --- a/backend/windmill-queue/src/lib.rs +++ b/backend/windmill-queue/src/lib.rs @@ -34,6 +34,7 @@ pub mod jobs; pub mod jobs_ee; pub mod jobs_oss; pub mod schedule; +pub mod variable_expiration; pub use jobs::*; pub mod flow_status; pub mod tags; diff --git a/backend/windmill-queue/src/variable_expiration.rs b/backend/windmill-queue/src/variable_expiration.rs new file mode 100644 index 0000000000..74c0eaf393 --- /dev/null +++ b/backend/windmill-queue/src/variable_expiration.rs @@ -0,0 +1,309 @@ +/* + * Author: Ruben Fiszel + * Copyright: Windmill Labs, Inc 2022 + * This file and its contents are licensed under the AGPLv3 License. + * Please see the included NOTICE for copyright information and + * LICENSE-AGPL for a copy of the license. + */ + +//! Dispatches the workspace variable expiration handler for variables coming due. + +use std::collections::{HashMap, HashSet}; + +use serde_json::value::RawValue; +use sqlx::types::Json; +use windmill_common::jobs::get_payload_tag_from_prefixed_path; +use windmill_common::utils::{report_critical_error, report_recovered_critical_error}; +use windmill_common::DB; + +use crate::jobs::{push_variable_expiration_handler, ExpiringVariable}; + +/// Lead time when the handler setting has no `notify_before_secs`. +const VARIABLE_EXPIRATION_DEFAULT_NOTIFY_BEFORE_SECS: i64 = 24 * 3600; + +/// Subtracted from `remind_every_secs` so a reminder whose interval is a multiple of the +/// sweep's period is not skipped by tick jitter. +const VARIABLE_EXPIRATION_REMINDER_SLACK_SECS: i64 = 30; + +/// Advisory lock id ensuring only one server replica sweeps at a time. Must not reuse one of +/// the other monitor pass locks (737_483_920..=737_483_925): both sides try-lock and skip. +const VARIABLE_EXPIRATION_LOCK_ID: i64 = 737_483_926; + +/// System caller only: dispatches across every workspace. A handler job that fails is not +/// re-dispatched before its next reminder, as re-running it would repeat its side effects +/// against the third party it rotates; retrying is the handler's job. +pub async fn dispatch_expiring_variables(db: &DB) { + // Transaction-scoped, not session-scoped: a session lock would go back to the pool still + // held if this future were dropped mid-pass, wedging the sweep on every replica. + let mut lock_tx = match db.begin().await { + Ok(tx) => tx, + Err(e) => { + tracing::error!("variable expiration: failed to begin lock tx: {e:#}"); + return; + } + }; + let locked: bool = match sqlx::query_scalar("SELECT pg_try_advisory_xact_lock($1)") + .bind(VARIABLE_EXPIRATION_LOCK_ID) + .fetch_one(&mut *lock_tx) + .await + { + Ok(v) => v, + Err(e) => { + tracing::error!("variable expiration: advisory lock failed: {e:#}"); + return; + } + }; + if !locked { + return; + } + + dispatch_expiring_variables_inner(db).await; + + if let Err(e) = lock_tx.rollback().await { + tracing::error!("variable expiration: releasing lock failed: {e:#}"); + } +} + +async fn dispatch_expiring_variables_inner(db: &DB) { + resolve_removed_handlers(db).await; + + // No lower bound on `value_valid_until`, so a downed instance catches up. No cap either: + // everything due is tried every pass, so no workspace or kind of notice can be crowded out. + let due = sqlx::query!( + r#"SELECT v.workspace_id, v.path, v.description, v.value_valid_until AS "value_valid_until!", + v.is_secret, v.expiration_dispatched_for, v.expiration_notified_at, + ws.variable_expiration_handler->>'path' AS "handler_path!", + (ws.variable_expiration_handler->'extra_args')::text::json + AS "extra_args: Json<Box<RawValue>>" + FROM variable v + JOIN workspace_settings ws ON ws.workspace_id = v.workspace_id + JOIN workspace w ON w.id = v.workspace_id + WHERE NOT w.deleted + AND v.value_valid_until IS NOT NULL + AND (v.value_valid_until IS DISTINCT FROM v.expiration_dispatched_for + OR (v.value_valid_until > now() + AND v.expiration_notified_at <= now() - make_interval(secs => + NULLIF((ws.variable_expiration_handler->>'remind_every_secs')::bigint, 0) - $2))) + AND v.value_valid_until <= now() + make_interval(secs => + COALESCE((ws.variable_expiration_handler->>'notify_before_secs')::bigint, $1)) + AND ws.variable_expiration_handler->>'path' IS NOT NULL + AND (NOT COALESCE((ws.variable_expiration_handler->>'muted_on_user_path')::boolean, false) + OR v.path NOT LIKE 'u/%')"#, + VARIABLE_EXPIRATION_DEFAULT_NOTIFY_BEFORE_SECS, + VARIABLE_EXPIRATION_REMINDER_SLACK_SECS, + ) + .fetch_all(db) + .await; + + let due = match due { + Ok(due) => due, + Err(e) => { + tracing::error!("Error listing expiring variables: {e:#}"); + return; + } + }; + + let mut dispatched = 0usize; + // A handler fails alike for every variable of its workspace: try it once per pass. + let mut failed_this_pass: HashSet<String> = HashSet::new(); + // Hub handlers are shared by every workspace using them, so a hub outage is fetched once + // per pass rather than once per workspace. + let mut failed_hub_handlers: HashMap<String, String> = HashMap::new(); + + for row in due { + if failed_this_pass.contains(&row.workspace_id) { + continue; + } + // Resolved before the claim's transaction opens, so a pass never holds one connection + // while waiting on another. + let handler = match failed_hub_handlers.get(&row.handler_path) { + Some(error) => Err(error.clone()), + None => get_payload_tag_from_prefixed_path(&row.handler_path, db, &row.workspace_id) + .await + .map_err(|e| { + let error = format!("{e:#}"); + if row.handler_path.starts_with("script/hub/") + || row.handler_path.starts_with("flow/hub/") + { + failed_hub_handlers.insert(row.handler_path.clone(), error.clone()); + } + error + }), + }; + let handler = match handler { + Ok(handler) => handler, + Err(error) => { + failed_this_pass.insert(row.workspace_id.clone()); + record_failure(db, &row.workspace_id, &row.handler_path, &error).await; + continue; + } + }; + + // Claim and push in one transaction. The claim matches the row as it was read, so it + // loses both to a concurrent re-arm and to whichever replica got there first. + let mut tx = match db.begin().await { + Ok(tx) => tx, + Err(e) => { + tracing::error!("Error opening tx for expiring variable {}: {e:#}", row.path); + continue; + } + }; + + let claimed = sqlx::query_scalar!( + "UPDATE variable SET expiration_dispatched_for = $3, expiration_notified_at = now() + WHERE workspace_id = $1 AND path = $2 AND value_valid_until = $3 + AND expiration_dispatched_for IS NOT DISTINCT FROM $4 + AND expiration_notified_at IS NOT DISTINCT FROM $5 + RETURNING 1", + row.workspace_id, + row.path, + row.value_valid_until, + row.expiration_dispatched_for, + row.expiration_notified_at, + ) + .fetch_optional(&mut *tx) + .await; + + match claimed { + Ok(Some(_)) => {} + Ok(None) => continue, + Err(e) => { + tracing::error!("Error claiming expiring variable {}: {e:#}", row.path); + continue; + } + } + + let expiring = ExpiringVariable { + workspace_id: row.workspace_id.clone(), + variable_path: row.path.clone(), + description: row.description, + value_valid_until: row.value_valid_until, + is_secret: row.is_secret, + }; + + let pushed = push_variable_expiration_handler( + db, + tx, + &row.workspace_id, + handler, + &expiring, + row.extra_args, + ) + .await; + + let tx = match pushed { + Ok((_job_id, tx)) => tx, + Err(e) => { + // Dropping the tx un-claims the variable, so the next pass retries it. + failed_this_pass.insert(row.workspace_id.clone()); + record_failure(db, &row.workspace_id, &row.handler_path, &format!("{e:#}")).await; + continue; + } + }; + + if let Err(e) = tx.commit().await { + tracing::error!("Error committing dispatch for {}: {e:#}", row.path); + continue; + } + windmill_common::feature_usage::log_feature_usage( + "variable_expiration", + "dispatched", + if row.is_secret { "secret" } else { "plain" }, + ); + dispatched += 1; + recover(db, &row.workspace_id, &row.handler_path).await; + } + + if dispatched > 0 { + tracing::info!("Dispatched {dispatched} variable expiration handler job(s)"); + } +} + +/// Alerts once per failure, not per pass: the alert is resolved when the handler next starts. +async fn record_failure(db: &DB, w_id: &str, handler_path: &str, error: &str) { + let newly_failing = sqlx::query_scalar!( + "UPDATE workspace_settings SET variable_expiration_failed_at = now() + WHERE workspace_id = $1 AND variable_expiration_failed_at IS NULL + RETURNING 1", + w_id, + ) + .fetch_optional(db) + .await; + match newly_failing { + Ok(Some(_)) => { + report_critical_error( + format!( + "The variable expiration handler {handler_path} of workspace {w_id} failed to \ + start, so variables in this workspace are not being notified. It is retried \ + on every check, and this alert is resolved once it starts: {error}" + ), + db.clone(), + Some(w_id), + Some(&alert_resource(w_id)), + ) + .await + } + Ok(None) => {} + Err(e) => { + tracing::error!("Error recording variable expiration handler failure of {w_id}: {e:#}") + } + } +} + +/// Tags a failing handler's critical alerts, so they can be resolved together. +fn alert_resource(w_id: &str) -> String { + format!("variable_expiration_handler:{w_id}") +} + +/// A removed handler has nothing left to retry, so its failure would never be resolved. +async fn resolve_removed_handlers(db: &DB) { + let removed = sqlx::query_scalar!( + "UPDATE workspace_settings SET variable_expiration_failed_at = NULL + WHERE variable_expiration_failed_at IS NOT NULL AND variable_expiration_handler IS NULL + RETURNING workspace_id", + ) + .fetch_all(db) + .await; + match removed { + Ok(removed) => { + for w_id in removed { + report_recovered_critical_error( + format!("The variable expiration handler of workspace {w_id} was removed"), + db.clone(), + Some(&w_id), + Some(&alert_resource(&w_id)), + ) + .await; + } + } + Err(e) => tracing::error!("Error resolving removed variable expiration handlers: {e:#}"), + } +} + +async fn recover(db: &DB, w_id: &str, handler_path: &str) { + let cleared = sqlx::query_scalar!( + "UPDATE workspace_settings SET variable_expiration_failed_at = NULL + WHERE workspace_id = $1 AND variable_expiration_failed_at IS NOT NULL + RETURNING 1", + w_id, + ) + .fetch_optional(db) + .await; + match cleared { + Ok(Some(_)) => { + report_recovered_critical_error( + format!( + "The variable expiration handler {handler_path} of workspace {w_id} started again" + ), + db.clone(), + Some(w_id), + Some(&alert_resource(w_id)), + ) + .await + } + Ok(None) => {} + Err(e) => { + tracing::error!("Error clearing variable expiration handler failure of {w_id}: {e:#}") + } + } +} diff --git a/backend/windmill-store/src/oauth_refresh_oss.rs b/backend/windmill-store/src/oauth_refresh_oss.rs index 3d55b05167..a0d0926510 100644 --- a/backend/windmill-store/src/oauth_refresh_oss.rs +++ b/backend/windmill-store/src/oauth_refresh_oss.rs @@ -182,7 +182,7 @@ where async fn current_token(db: &DB, w_id: &str, path: &str) -> error::Result<String> { let value = sqlx::query_scalar!( - "SELECT value FROM variable WHERE workspace_id = $1 AND path = $2", + "SELECT value::text AS \"value!\" FROM variable WHERE workspace_id = $1 AND path = $2", w_id, path ) diff --git a/backend/windmill-store/src/resources.rs b/backend/windmill-store/src/resources.rs index a384351911..541369f327 100644 --- a/backend/windmill-store/src/resources.rs +++ b/backend/windmill-store/src/resources.rs @@ -439,9 +439,10 @@ async fn list_resources( if let Some(broad_filter) = &lq.broad_filter { let pat = format!("%{}%", escape_ilike_pattern(broad_filter)); + // `binds` fills every `?` in one pass; chained `bind`s would re-scan a `?` in the bound value sqlb.and_where( "(resource.path ILIKE ? OR resource.description ILIKE ? OR resource_type ILIKE ? OR resource.value::text ILIKE ?)" - .bind(&pat).bind(&pat).bind(&pat).bind(&pat) + .binds(&[&pat]), ); } @@ -455,10 +456,10 @@ async fn list_resources( if let Some(label) = &lq.label { for l in label.split(',') { + // `binds` fills every `?` in one pass; chained `bind`s would re-scan a `?` in the bound value sqlb.and_where( "(resource.labels @> ARRAY[?] OR folder_labels(resource.workspace_id, resource.path) @> ARRAY[?])" - .bind(&l.trim()) - .bind(&l.trim()), + .binds(&[&l.trim()]), ); } } @@ -2189,7 +2190,7 @@ async fn update_resource( // Handle Vault secret rename if the linked variable is a Vault-stored secret let linked_var = sqlx::query!( - "SELECT value, is_secret FROM variable WHERE path = $1 AND workspace_id = $2", + "SELECT value::text AS \"value!\", is_secret FROM variable WHERE path = $1 AND workspace_id = $2", path, w_id ) @@ -2489,6 +2490,9 @@ async fn update_resource_value( /// `run_on_behalf_of` is set: then the writer's own, unless one allowed to keep the one the value /// names asks to, and a folder's default for a new agent. The value is written by anyone who can /// write the agent, so the key it arrives with is only a request. +/// +/// Every write of an agent passes here, so this is also where an operator's is held to the +/// composed-flow rules: a flow step linking the agent runs its tools unchecked. async fn resolve_agent_on_behalf_of( tx: &mut Transaction<'_, Postgres>, authed: &ApiAuthed, @@ -2499,6 +2503,9 @@ async fn resolve_agent_on_behalf_of( preserve: bool, created: bool, ) -> Result<()> { + if authed.is_operator { + windmill_api_auth::composed::validate_operator_agent(value, authed, db, tx, w_id).await?; + } let Some(obj) = value.as_object_mut() else { return Ok(()); }; diff --git a/backend/windmill-store/src/variables.rs b/backend/windmill-store/src/variables.rs index 352a411214..e9aa3ab60d 100644 --- a/backend/windmill-store/src/variables.rs +++ b/backend/windmill-store/src/variables.rs @@ -149,6 +149,7 @@ async fn list_variables( "resource.path IS NOT NULL as is_linked", "account.refresh_token != '' as is_refreshed", "variable.expires_at", + "variable.value_valid_until", "variable.labels", "folder_labels(variable.workspace_id, variable.path) as inherited_labels", "ws_specific.path IS NOT NULL as ws_specific", @@ -199,18 +200,19 @@ async fn list_variables( if let Some(broad_filter) = &lq.broad_filter { let pat = format!("%{}%", escape_ilike_pattern(broad_filter)); + // `binds` fills every `?` in one pass; chained `bind`s would re-scan a `?` in the bound value sqlb.and_where( "(variable.path ILIKE ? OR variable.description ILIKE ? OR (is_secret = FALSE AND variable.value ILIKE ?))" - .bind(&pat).bind(&pat).bind(&pat) + .binds(&[&pat]), ); } if let Some(label) = &lq.label { for l in label.split(',') { + // `binds` fills every `?` in one pass; chained `bind`s would re-scan a `?` in the bound value sqlb.and_where( "(variable.labels @> ARRAY[?] OR folder_labels(variable.workspace_id, variable.path) @> ARRAY[?])" - .bind(&l.trim()) - .bind(&l.trim()), + .binds(&[&l.trim()]), ); } } @@ -245,7 +247,7 @@ async fn list_variables( for row in draft_only_rows { let v: serde_json::Value = serde_json::from_str(row.value.0.get()).unwrap_or(serde_json::Value::Null); - // VariableEditor's `VariableState`: { path, variable: { value, is_secret, description }, labels?, wsSpecific } + // VariableEditor's `VariableState`: { path, variable: { value, is_secret, description }, labels?, wsSpecific, value_valid_until? } let path = v .get("path") .and_then(|s| s.as_str()) @@ -284,6 +286,11 @@ async fn list_variables( }) }); let ws_specific = v.get("wsSpecific").and_then(|x| x.as_bool()); + let value_valid_until = v + .get("value_valid_until") + .and_then(|x| x.as_str()) + .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&chrono::Utc)); rows.push(ListableVariable { workspace_id: w_id.clone(), @@ -299,6 +306,7 @@ async fn list_variables( refresh_error: None, is_linked: None, expires_at: None, + value_valid_until, labels, // No deployed row to inherit folder labels from. inherited_labels: None, @@ -337,9 +345,9 @@ async fn get_variable( let mut tx = user_db.begin(&authed).await?; let variable_o = sqlx::query_as::<_, ListableVariable>( - "SELECT variable.workspace_id, variable.path, variable.value, variable.is_secret, + "SELECT variable.workspace_id, variable.path, variable.value::text AS value, variable.is_secret, variable.description, variable.extra_perms, variable.account, variable.is_oauth, - variable.expires_at, variable.labels, + variable.expires_at, variable.value_valid_until, variable.labels, folder_labels(variable.workspace_id, variable.path) as inherited_labels, variable.edited_at, variable.edited_by, (now() > account.expires_at) as is_expired, account.refresh_error, @@ -712,8 +720,8 @@ pub async fn create_variable_with_account( sqlx::query!( "INSERT INTO variable - (workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, labels, edited_by) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + (workspace_id, path, value, is_secret, description, account, is_oauth, expires_at, value_valid_until, labels, edited_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", &w_id, variable.path, value, @@ -722,6 +730,7 @@ pub async fn create_variable_with_account( account_id, variable.is_oauth.unwrap_or(false), variable.expires_at, + variable.value_valid_until, variable.labels.as_deref() as Option<&[String]>, &authed.username ) @@ -1122,6 +1131,13 @@ struct EditVariable { description: Option<String>, labels: Option<Vec<String>>, ws_specific: Option<bool>, + /// Absent leaves the stored expiry alone and `null` clears it, so a partial update + /// never clears it by omission. + #[serde( + default, + deserialize_with = "windmill_common::more_serde::double_option" + )] + value_valid_until: Option<Option<chrono::DateTime<chrono::Utc>>>, } #[derive(Deserialize)] @@ -1216,6 +1232,16 @@ async fn update_variable( has_sql_updates = true; } + if let Some(nvalue_valid_until) = ns.value_valid_until { + // Nothing to reset: the sweep compares the date with `expiration_dispatched_for`, so + // moving it re-arms the handler and rewriting the same date does not. + match nvalue_valid_until { + Some(ts) => sqlb.set("value_valid_until", "?".bind(&ts.to_rfc3339())), + None => sqlb.set("value_valid_until", "NULL"), + }; + has_sql_updates = true; + } + if let Some(nbool) = ns.is_secret { let old_secret = sqlx::query_scalar!( "SELECT is_secret from variable WHERE path = $1 AND workspace_id = $2", @@ -1243,7 +1269,7 @@ async fn update_variable( // Handle Vault secret rename if the variable is a secret stored in Vault let current_var = sqlx::query!( - "SELECT value, is_secret FROM variable WHERE path = $1 AND workspace_id = $2", + "SELECT value::text AS \"value!\", is_secret FROM variable WHERE path = $1 AND workspace_id = $2", path, w_id ) @@ -1537,7 +1563,7 @@ pub async fn get_value_internal<'a>( let mut tx = db_with_opt_authed.begin().await?; let variable_o = sqlx::query!( - "SELECT value, account, (now() > account.expires_at) as is_expired, is_secret, path from variable + "SELECT value::text AS \"value!\", account, (now() > account.expires_at) as is_expired, is_secret, path from variable LEFT JOIN account ON variable.account = account.id WHERE variable.path = $1 AND variable.workspace_id = $2", path, w_id ) .fetch_optional(&mut *tx) diff --git a/backend/windmill-trigger/src/trigger_helpers.rs b/backend/windmill-trigger/src/trigger_helpers.rs index ae805d195e..4056ae85e6 100644 --- a/backend/windmill-trigger/src/trigger_helpers.rs +++ b/backend/windmill-trigger/src/trigger_helpers.rs @@ -512,7 +512,7 @@ pub async fn trigger_runnable_inner<'c>( workspace_id: &str, runnable_path: &str, is_flow: bool, - args: PushArgsOwned, + mut args: PushArgsOwned, retry: Option<&sqlx::types::Json<Retry>>, error_handler_path: Option<&str>, error_handler_args: Option<&sqlx::types::Json<HashMap<String, serde_json::Value>>>, @@ -527,6 +527,18 @@ pub async fn trigger_runnable_inner<'c>( bool, Option<sqlx::Transaction<'c, sqlx::Postgres>>, )> { + // A trigger's args are built from a third party's payload — for an HTTP route without + // preprocessor the request body's keys *are* the job's top-level args — and the job runs + // as the trigger's owner, not as whoever sent that payload. So the sender may not steer + // how the runnable runs: pick the function called instead of `main` (and skip whatever + // `main` authenticates, e.g. the signature check of a `custom_script` route), plant + // module files in the job directory, or redirect where its workspace imports resolve. + // Every trigger kind funnels through here on its way to `push`, and none of them sets + // one of those keys itself, so this is the single place to do it rather than each args + // builder. It does not restrict what the runnable is *given*: ordinary inputs pass + // through untouched. + args.sanitize_untrusted(); + let error_handler_args = error_handler_args.map(|args| { let args = args .0 diff --git a/backend/windmill-types/src/flows.rs b/backend/windmill-types/src/flows.rs index 890318b8aa..59f06ae4e9 100644 --- a/backend/windmill-types/src/flows.rs +++ b/backend/windmill-types/src/flows.rs @@ -843,6 +843,19 @@ impl InputTransform { pub fn new_javascript_expr(expr: &str) -> InputTransform { InputTransform::Javascript { expr: expr.to_owned() } } + + /// Whether this transform fills its input. On an agent tool, an input it does not fill is + /// left to the model, which sees only those. + pub fn is_completed(&self) -> bool { + match self { + InputTransform::Static { value } => { + let val = value.get().trim(); + !val.is_empty() && val != "null" + } + InputTransform::Javascript { expr } => !expr.trim().is_empty(), + InputTransform::Ai => false, + } + } } impl TryFrom<UntaggedInputTransform> for InputTransform { diff --git a/backend/windmill-types/src/jobs.rs b/backend/windmill-types/src/jobs.rs index 9768373b91..17e5235c5f 100644 --- a/backend/windmill-types/src/jobs.rs +++ b/backend/windmill-types/src/jobs.rs @@ -724,6 +724,26 @@ pub const ENTRYPOINT_OVERRIDE: &str = "_ENTRYPOINT_OVERRIDE"; /// `RawCode::modules`: a caller-supplied one is dropped there. pub const MODULES_ARG: &str = "_MODULES"; +/// Job-arg key mapping a runnable's workspace imports to contents of the temporary script +/// store (`scripts/raw_temp/store`, writable by any workspace member). The preview routes +/// set it server-side from their `temp_script_refs` field; the bun and python executors +/// honor it on every `JobKind::Preview` job. +pub const TEMP_SCRIPT_REFS_ARG: &str = "_TEMP_SCRIPT_REFS"; + +/// Job-arg keys that steer *how* a runnable runs rather than what it is given: which +/// function the worker calls instead of `main` ([`ENTRYPOINT_OVERRIDE`]), what module files +/// are written into the job directory ([`MODULES_ARG`]) and where its workspace imports +/// resolve from ([`TEMP_SCRIPT_REFS_ARG`]). +/// +/// Each is legitimate for a caller that runs code it authored under its own identity (the +/// script editor's preprocessor test, dynamic-select helpers, the CLI, workflow-as-code), so +/// they are not dropped for every job. None of them may come from a third party whose +/// payload runs under somebody else's identity — an HTTP route body, a native trigger +/// message, a public app viewer's inputs — which is what `sanitize_untrusted_args` in +/// `windmill_common::jobs` drops. +pub const RESERVED_CONTROL_ARGS: [&str; 3] = + [ENTRYPOINT_OVERRIDE, MODULES_ARG, TEMP_SCRIPT_REFS_ARG]; + /// Reserved job-arg key holding the inbound W3C `traceparent` captured from the /// request that enqueued the job (run endpoints). It rides the `args` jsonb like /// [`ENTRYPOINT_OVERRIDE`]; normal scripts never see it because args are bound by diff --git a/backend/windmill-worker/nsjail/download.py.config.proto b/backend/windmill-worker/nsjail/download.py.config.proto index 18957bb4a5..a68d96eb92 100644 --- a/backend/windmill-worker/nsjail/download.py.config.proto +++ b/backend/windmill-worker/nsjail/download.py.config.proto @@ -30,7 +30,8 @@ clone_newnet: false clone_newuser: {CLONE_NEWUSER} skip_setsid: true -keep_caps: true +# No capability needed: writes go to the rw-mounted dirs as the jail's own uid, like the run.* profiles. +keep_caps: false keep_env: true mount_proc: true diff --git a/backend/windmill-worker/nsjail/download.ruby.config.proto b/backend/windmill-worker/nsjail/download.ruby.config.proto index 31eefae766..3a3a6f3b74 100644 --- a/backend/windmill-worker/nsjail/download.ruby.config.proto +++ b/backend/windmill-worker/nsjail/download.ruby.config.proto @@ -23,7 +23,8 @@ clone_newnet: false clone_newuser: {CLONE_NEWUSER} skip_setsid: true -keep_caps: true +# No capability needed: writes go to the rw-mounted dirs as the jail's own uid, like the run.* profiles. +keep_caps: false keep_env: true mount_proc: true diff --git a/backend/windmill-worker/nsjail/download_deps.py.sh b/backend/windmill-worker/nsjail/download_deps.py.sh index 6b4cd8cfd8..8f0ebd1cd0 100755 --- a/backend/windmill-worker/nsjail/download_deps.py.sh +++ b/backend/windmill-worker/nsjail/download_deps.py.sh @@ -20,20 +20,22 @@ then echo "\$TRUSTED_HOST is set to $TRUSTED_HOST" fi -CMD="/usr/local/bin/uv pip install -\"$REQ\" ---target \"$TARGET\" ---no-cache ---no-config ---no-color ---no-deps ---link-mode=copy -$PY_PATH -$INDEX_URL_ARG $EXTRA_INDEX_URL_ARG $TRUSTED_HOST_ARG ---system ---reinstall ---compile-bytecode -" +# Build the argv directly so each value reaches uv as a single argument, quoting +# intact (e.g. PEP 508 markers). The unquoted expansions are worker configuration +# and split into separate arguments on purpose. +set -- /usr/local/bin/uv pip install \ + "$REQ" \ + --target "$TARGET" \ + --no-cache \ + --no-config \ + --no-color \ + --no-deps \ + --link-mode=copy \ + $PY_PATH \ + $INDEX_URL_ARG $EXTRA_INDEX_URL_ARG $TRUSTED_HOST_ARG \ + --system \ + --reinstall \ + --compile-bytecode -echo $CMD -eval $CMD +echo "$@" +exec "$@" diff --git a/backend/windmill-worker/nsjail/install.r.config.proto b/backend/windmill-worker/nsjail/install.r.config.proto index dff9acde15..e844b2e720 100644 --- a/backend/windmill-worker/nsjail/install.r.config.proto +++ b/backend/windmill-worker/nsjail/install.r.config.proto @@ -16,7 +16,8 @@ clone_newnet: false clone_newuser: {CLONE_NEWUSER} skip_setsid: true -keep_caps: true +# No capability needed: writes go to the rw-mounted dirs as the jail's own uid, like the run.* profiles. +keep_caps: false keep_env: true mount_proc: true diff --git a/backend/windmill-worker/nsjail/lock.py.config.proto b/backend/windmill-worker/nsjail/lock.py.config.proto index 14119c4089..60c5573f40 100644 --- a/backend/windmill-worker/nsjail/lock.py.config.proto +++ b/backend/windmill-worker/nsjail/lock.py.config.proto @@ -23,7 +23,8 @@ clone_newnet: false clone_newuser: {CLONE_NEWUSER} skip_setsid: true -keep_caps: true +# No capability needed: writes go to the rw-mounted dirs as the jail's own uid, like the run.* profiles. +keep_caps: false keep_env: true mount_proc: true diff --git a/backend/windmill-worker/nsjail/lock.ruby.config.proto b/backend/windmill-worker/nsjail/lock.ruby.config.proto index 6f82f3b748..bcca74e77e 100644 --- a/backend/windmill-worker/nsjail/lock.ruby.config.proto +++ b/backend/windmill-worker/nsjail/lock.ruby.config.proto @@ -19,7 +19,8 @@ clone_newnet: false clone_newuser: {CLONE_NEWUSER} skip_setsid: true -keep_caps: true +# No capability needed: writes go to the rw-mounted dirs as the jail's own uid, like the run.* profiles. +keep_caps: false keep_env: true mount_proc: true diff --git a/backend/windmill-worker/src/ai/compaction.rs b/backend/windmill-worker/src/ai/compaction.rs index b933348a2e..82a79985e8 100644 --- a/backend/windmill-worker/src/ai/compaction.rs +++ b/backend/windmill-worker/src/ai/compaction.rs @@ -610,12 +610,24 @@ async fn summarize_prefix( request .query_builder .get_endpoint(base_url, request.model, &OutputType::Text); - let auth_headers = retain_effective_credentials( + // Summaries are rare enough that exchanging afresh costs less than threading + // the agent loop's cached token through. + let mut federated_token = None; + let auth_headers = match windmill_ai::providers::anthropic::refresh_federated_access_token( request.credentials, - request - .query_builder - .get_auth_headers(api_key, base_url, &OutputType::Text), - ); + &mut federated_token, + request.client, + ) + .await? + { + Some(token) => windmill_ai::providers::anthropic::federated_auth_headers(token), + None => retain_effective_credentials( + request.credentials, + request + .query_builder + .get_auth_headers(api_key, base_url, &OutputType::Text), + ), + }; // As in the agent loop, `endpoint` derives from the user-controlled provider // base_url, so DNS is pinned to the SSRF-validated address. diff --git a/backend/windmill-worker/src/ai/tools.rs b/backend/windmill-worker/src/ai/tools.rs index 01b90922ff..35075566b7 100644 --- a/backend/windmill-worker/src/ai/tools.rs +++ b/backend/windmill-worker/src/ai/tools.rs @@ -1,8 +1,8 @@ use crate::ai::stream_event_processor::StreamEventProcessor; use crate::ai::utils::{ add_message_to_conversation, execute_mcp_tool, get_step_name_from_flow, - is_completed_input_transform, update_flow_status_module_with_actions, - update_flow_status_module_with_actions_success, FlowContext, + update_flow_status_module_with_actions, update_flow_status_module_with_actions_success, + FlowContext, }; use crate::common::{get_root_job_id, OccupancyMetrics}; use crate::result_processor::handle_non_flow_job_error; @@ -444,7 +444,7 @@ async fn enqueue_windmill_tool( // Evaluate each input transform and merge with AI-provided args for (key, transform) in input_transforms.iter() { // We skip static empty / null values, those are the one the AI will fill in - if !is_completed_input_transform(transform) { + if !transform.is_completed() { continue; } let result = evaluate_input_transform::<Box<RawValue>>( diff --git a/backend/windmill-worker/src/ai/utils.rs b/backend/windmill-worker/src/ai/utils.rs index 44513c806e..ccf2e0ef88 100644 --- a/backend/windmill-worker/src/ai/utils.rs +++ b/backend/windmill-worker/src/ai/utils.rs @@ -62,17 +62,6 @@ pub async fn parse_raw_script_schema( Ok(to_raw_value(&schema)) } -pub fn is_completed_input_transform(transform: &InputTransform) -> bool { - match transform { - InputTransform::Static { value } => { - let val = value.get().trim(); - !val.is_empty() && val != "null" - } - InputTransform::Javascript { expr } => !expr.trim().is_empty(), - InputTransform::Ai => false, - } -} - /// Filters out properties from a JSON schema that have completed input transforms. /// This allows AI agents to only see and fill parameters that don't have user-configured values. pub fn filter_schema_by_input_transforms( @@ -88,7 +77,7 @@ pub fn filter_schema_by_input_transforms( let keys_to_remove: HashSet<String> = input_transforms .iter() .filter_map(|(key, transform)| { - let is_completed = is_completed_input_transform(transform); + let is_completed = transform.is_completed(); if is_completed { Some(key.clone()) } else { @@ -779,7 +768,12 @@ mod tests { .unwrap(); let preset = ["a", "c", "f"] .iter() - .map(|k| (k.to_string(), InputTransform::Static { value: to_raw_value(&1) })) + .map(|k| { + ( + k.to_string(), + InputTransform::Static { value: to_raw_value(&1) }, + ) + }) .collect(); let filtered = filter_schema_by_input_transforms(schema, &preset).unwrap(); diff --git a/backend/windmill-worker/src/ai_executor.rs b/backend/windmill-worker/src/ai_executor.rs index 6456a85bdf..6c37f67f2e 100644 --- a/backend/windmill-worker/src/ai_executor.rs +++ b/backend/windmill-worker/src/ai_executor.rs @@ -467,14 +467,11 @@ async fn find_ai_agent_tool_module_in_parent_agent( .trim_start_matches("$res:") .trim_start_matches("res://"); // Definitions only: resolving their defaults here would hit the same inaccessible resources. - let resource_value = client - .get_resource_value::<serde_json::Value>(agent_path) - .await - .map_err(|e| { - Error::internal_err(format!( - "failed to load ai_agent resource {agent_path}: {e}" - )) - })?; + let resource_value = client.get_agent_value(agent_path).await.map_err(|e| { + Error::internal_err(format!( + "failed to load ai_agent resource {agent_path}: {e}" + )) + })?; match resource_value { serde_json::Value::Object(mut map) => match map.remove("tools") { Some(t) => serde_json::from_value::<Vec<AgentTool>>(t).map_err(|e| { @@ -819,14 +816,11 @@ pub async fn handle_ai_agent_job( // Read raw and interpolate only the brain below. Interpolating the whole resource would also // resolve each tool's default `$res:`/`$var:`, which a host flow may be overriding and which // may be unreadable to whoever runs this flow — an unused tool could then fail the agent. - let resource_value = client - .get_resource_value::<serde_json::Value>(agent_path) - .await - .map_err(|e| { - Error::internal_err(format!( - "failed to load ai_agent resource {agent_path}: {e}" - )) - })?; + let resource_value = client.get_agent_value(agent_path).await.map_err(|e| { + Error::internal_err(format!( + "failed to load ai_agent resource {agent_path}: {e}" + )) + })?; let mut config = match resource_value { serde_json::Value::Object(map) => map, _ => { @@ -1672,6 +1666,9 @@ pub async fn run_agent( // re-assumed only near expiry, rather than on every model turn. #[cfg(feature = "bedrock")] let mut bedrock_assumed_role: Option<windmill_ai::ai_bedrock::AssumedRoleCredentials> = None; + let mut anthropic_federated_token: Option< + windmill_ai::providers::anthropic::FederatedAccessToken, + > = None; // Main agent loop for i in 0..max_iterations { @@ -1810,6 +1807,15 @@ pub async fn run_agent( ))); } + let federated_token = + windmill_ai::providers::anthropic::refresh_federated_access_token( + &credentials, + &mut anthropic_federated_token, + client, + ) + .await? + .map(str::to_string); + let timeout = resolve_job_timeout(conn, &job.workspace_id, job.id, job.timeout) .await .0; @@ -1861,10 +1867,15 @@ pub async fn run_agent( }; let endpoint = query_builder.get_endpoint(base_url, args.provider.get_model(), output_type); - let auth_headers = retain_effective_credentials( - &credentials, - query_builder.get_auth_headers(api_key, base_url, output_type), - ); + let auth_headers = match federated_token.as_deref() { + Some(token) => { + windmill_ai::providers::anthropic::federated_auth_headers(token) + } + None => retain_effective_credentials( + &credentials, + query_builder.get_auth_headers(api_key, base_url, output_type), + ), + }; let resp = build_http_request(&endpoint, &auth_headers, request_body) .send() diff --git a/backend/windmill-worker/src/bash_executor.rs b/backend/windmill-worker/src/bash_executor.rs index 7ec2f4fd33..217d1927f8 100644 --- a/backend/windmill-worker/src/bash_executor.rs +++ b/backend/windmill-worker/src/bash_executor.rs @@ -21,9 +21,7 @@ use windmill_common::{ #[cfg(feature = "dind")] use windmill_common::error::to_anyhow; -use windmill_queue::{ - append_logs, CanceledBy, MiniPulledJob, INIT_SCRIPT_PATH_PREFIX, PERIODIC_SCRIPT_PATH_PREFIX, -}; +use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; lazy_static::lazy_static! { pub static ref BIN_BASH: String = std::env::var("BASH_PATH").unwrap_or_else(|_| { @@ -148,14 +146,9 @@ pub async fn handle_bash_job( )); } - // Init/periodic scripts need full system access without isolation - let is_regular_job = job - .runnable_path - .as_ref() - .map(|x| { - !x.starts_with(INIT_SCRIPT_PATH_PREFIX) && !x.starts_with(PERIODIC_SCRIPT_PATH_PREFIX) - }) - .unwrap_or(true); + // Init/periodic scripts need full system access without isolation; see + // `MiniPulledJob::is_worker_own_script`. + let is_regular_job = !job.is_worker_own_script(); // Use nsjail if globally enabled OR if script has #sandbox annotation let nsjail = (is_sandboxing_enabled() || annotation.sandbox) && is_regular_job; diff --git a/backend/windmill-worker/src/bun_executor.rs b/backend/windmill-worker/src/bun_executor.rs index 0094328927..f2d683bcbf 100644 --- a/backend/windmill-worker/src/bun_executor.rs +++ b/backend/windmill-worker/src/bun_executor.rs @@ -1961,6 +1961,32 @@ async fn pull_codebase(w_id: &str, id: &str, job_dir: &str) -> Result<PulledCode Ok(PulledCodebase { is_esm }) } +/// Unpack a codebase tar into the job dir, refusing anything but regular files and +/// directories. +/// +/// The archive comes from the caller (`preview_bundle`, `create_snapshot`) and lands next to +/// the files the worker writes and nsjail bind-mounts by name (`args.json`, `result.json`, +/// `node_modules`, ...). A symlink or hard link entry with one of those names would redirect +/// the write, or the mount, to an arbitrary host path: `tar::Archive::unpack` keeps entry +/// names inside the destination but creates links with whatever target they carry. The +/// archives the CLI and the frontend build contain regular files only. +fn extract_codebase_tar(tar: &[u8], job_dir: &str) -> Result<()> { + let mut ar = tar::Archive::new(tar); + for entry in ar.entries()? { + let mut entry = entry?; + let kind = entry.header().entry_type(); + if !(kind.is_file() || kind.is_dir()) { + return Err(error::Error::ExecutionErr(format!( + "codebase archive entry {} is a {:?}, only regular files and directories are allowed", + entry.path()?.display(), + kind + ))); + } + entry.unpack_in(job_dir)?; + } + Ok(()) +} + fn extract_saved_codebase( job_dir: &str, bun_cache_path: &String, @@ -1968,10 +1994,8 @@ fn extract_saved_codebase( dst: &str, copy: bool, ) -> Result<()> { - use crate::global_cache::extract_tar; - Ok(if is_tar { - extract_tar(fs::read(bun_cache_path)?.into(), job_dir)?; + extract_codebase_tar(&fs::read(bun_cache_path)?, job_dir)?; } else { if copy { std::fs::copy(bun_cache_path, dst)?; @@ -3146,7 +3170,8 @@ try {{ None, has_stream, ) - .await?; + .await + .map_err(|e| crate::js_eval::with_step_context(e, &reserved_variables))?; let preprocessed: HashMap<String, Box<RawValue>> = serde_json::from_str(pre_result.get()).map_err(|e| { @@ -3178,7 +3203,8 @@ try {{ stream_notifier, has_stream, ) - .await?; + .await + .map_err(|e| crate::js_eval::with_step_context(e, &reserved_variables))?; tracing::info!( "Executed native code (with preprocessor) in {}ms", started_at.elapsed().as_millis() @@ -3206,7 +3232,8 @@ try {{ stream_notifier, has_stream, ) - .await?; + .await + .map_err(|e| crate::js_eval::with_step_context(e, &reserved_variables))?; tracing::info!( "Executed native code in {}ms", started_at.elapsed().as_millis() @@ -4166,8 +4193,7 @@ pub async fn handle_wac_v2_output( let resume_id: u32 = windmill_common::wac::approval_resume_id(&key); // Read before the tx: it holds the queue row lock, and a pool checkout under it // waits on the connections every other suspending parent is holding. - let wkey = - windmill_common::variables::get_workspace_key(&job.workspace_id, db).await?; + let wkey = windmill_common::variables::get_workspace_key(&job.workspace_id, db).await?; let approval_token = windmill_common::variables::approval_token_from_key(&wkey, job.id)?; @@ -5517,4 +5543,56 @@ export function main(x: number) { return x; }"#; assert_eq!(key_for(None).await, key_for(Some(&HashMap::new())).await); assert_ne!(key_for(None).await, attacker); } + + /// Build a codebase tar in memory; `link` adds a symlink entry named after a file the + /// worker writes and nsjail bind-mounts. + fn codebase_tar(link: Option<(&str, &str)>) -> Vec<u8> { + let mut builder = tar::Builder::new(Vec::new()); + let main = b"export async function main() {}\n"; + let mut header = tar::Header::new_gnu(); + header.set_size(main.len() as u64); + header.set_mode(0o644); + header.set_path("main.js").unwrap(); + header.set_cksum(); + builder.append(&header, &main[..]).unwrap(); + if let Some((name, target)) = link { + let mut header = tar::Header::new_gnu(); + header.set_size(0); + header.set_mode(0o777); + header.set_entry_type(tar::EntryType::Symlink); + header.set_path(name).unwrap(); + header.set_link_name(target).unwrap(); + header.set_cksum(); + builder.append(&header, &[][..]).unwrap(); + } + builder.into_inner().unwrap() + } + + #[test] + fn extract_codebase_tar_unpacks_regular_files() { + let dir = tempfile::tempdir().unwrap(); + let job_dir = dir.path().to_str().unwrap(); + + extract_codebase_tar(&codebase_tar(None), job_dir).unwrap(); + + assert_eq!( + std::fs::read_to_string(dir.path().join("main.js")).unwrap(), + "export async function main() {}\n" + ); + } + + #[test] + fn extract_codebase_tar_refuses_links() { + let dir = tempfile::tempdir().unwrap(); + let job_dir = dir.path().to_str().unwrap(); + + let err = extract_codebase_tar(&codebase_tar(Some(("args.json", "/etc/passwd"))), job_dir) + .unwrap_err(); + + assert!(err.to_string().contains("args.json"), "{err}"); + assert!( + std::fs::symlink_metadata(dir.path().join("args.json")).is_err(), + "the link must not be created" + ); + } } diff --git a/backend/windmill-worker/src/deno_executor.rs b/backend/windmill-worker/src/deno_executor.rs index 881923f299..de2098392e 100644 --- a/backend/windmill-worker/src/deno_executor.rs +++ b/backend/windmill-worker/src/deno_executor.rs @@ -244,6 +244,40 @@ pub async fn generate_deno_lock( } } +/// SECURITY: a sandboxed deno job is not nsjail-wrapped; its confinement is +/// `--allow-read=./` / `--allow-write=./`, which Deno checks against the path +/// as written and then hands to the kernel, which follows symlinks. `./shared` +/// is the worker's own link to the flow's shared directory, and every other +/// step of a `same_worker` flow gets that directory bind-mounted read-write +/// inside its jail. A link planted there (`ln -s / /tmp/shared/root` from a +/// bash step) would let the following deno step read and write the whole +/// worker host through `./shared/root/...`. Links pointing inside the +/// directory are not safe either: Deno collapses `..` textually before the +/// check while the kernel applies it to the resolved target. So nothing but +/// regular files and directories may be in there when the job starts. +/// `DirEntry::file_type` reports a link's own type without following it. +async fn refuse_links_in_shared_dir(job_dir: &str) -> error::Result<()> { + let shared = format!("{job_dir}/shared"); + let mut dirs = vec![std::path::PathBuf::from(&shared)]; + while let Some(dir) = dirs.pop() { + let mut entries = tokio::fs::read_dir(&dir).await?; + while let Some(entry) = entries.next_entry().await? { + let file_type = entry.file_type().await?; + if file_type.is_dir() { + dirs.push(entry.path()); + } else if !file_type.is_file() { + let path = entry.path(); + return Err(error::Error::ExecutionErr(format!( + "the flow's shared directory contains ./{}, which is not a regular file \ + or directory: a sandboxed deno step cannot run with it", + path.strip_prefix(job_dir).unwrap_or(&path).display() + ))); + } + } + } + Ok(()) +} + #[tracing::instrument(level = "trace", skip_all)] pub async fn handle_deno_job( requirements_o: Option<&String>, @@ -265,6 +299,10 @@ pub async fn handle_deno_job( ensure_deno_installed()?; let annotations = TypeScriptAnnotations::parse(inner_content); + if job.same_worker && (is_sandboxing_enabled() || annotations.sandbox) { + refuse_links_in_shared_dir(job_dir).await?; + } + // let mut start = Instant::now(); let mut logs1 = "\n\n--- DENO CODE EXECUTION ---\n".to_string(); if annotations.sandbox { @@ -453,6 +491,12 @@ try {{ conn, ) .await; + // The only deno.json deno may honour is the one the worker writes here. Script modules + // are written verbatim into the job dir, so without an explicit choice deno would + // discover a `deno.json(c)` the job shipped as a module, and a config is applied by + // the runtime itself, outside the --allow-* permissions: its `lock` key alone makes + // deno write a file wherever it points, past --allow-write. + let mut deno_config_path: Option<String> = None; if let Some(ref npmrc_content) = npmrc { if !npmrc_content.trim().is_empty() { write_file(job_dir, ".npmrc", npmrc_content)?; @@ -461,8 +505,16 @@ try {{ // routinely serve just-published versions, so the guard would break them. // Older Deno ignores the unknown field, so this is safe across versions. write_file(job_dir, "deno.json", r#"{"minimumDependencyAge":"0"}"#)?; + deno_config_path = Some(format!("{job_dir}/deno.json")); } } + // An admin may pass a config of their own through DENO_FLAGS; deno refuses it next + // to --no-config or a second --config, so it then stands on its own. + let config_in_deno_flags = DENO_FLAGS.as_ref().is_some_and(|flags| { + flags + .iter() + .any(|f| f == "-c" || f == "--config" || f.starts_with("--config=")) + }); //do not cache local dependencies let child = { @@ -472,6 +524,15 @@ try {{ let mut args = Vec::with_capacity(12); args.push("run"); args.push("--no-check"); + if !config_in_deno_flags { + match deno_config_path.as_deref() { + Some(config) => { + args.push("--config"); + args.push(config); + } + None => args.push("--no-config"), + } + } args.push("--import-map"); args.push(&import_map_path); args.push(&reload); diff --git a/backend/windmill-worker/src/global_cache.rs b/backend/windmill-worker/src/global_cache.rs index 1c3bbf870f..622f021fcb 100644 --- a/backend/windmill-worker/src/global_cache.rs +++ b/backend/windmill-worker/src/global_cache.rs @@ -1,3 +1,6 @@ +// Only the enterprise object-store tar helpers use this; `load_cache` below qualifies +// `std::time::Instant` directly, so the import is dead in other builds. +#[cfg(all(feature = "enterprise", feature = "parquet"))] use tokio::time::Instant; use windmill_common::error; @@ -199,6 +202,10 @@ pub async fn pull_from_tar( Ok(()) } +// Only `pull_from_tar` (the enterprise object-store cache path) still unpacks a tar +// through this helper; the codebase-bundle path uses `extract_codebase_tar` instead. +// Gate it with the caller's cfg so it is not dead code in other builds. +#[cfg(all(feature = "enterprise", feature = "parquet"))] pub fn extract_tar(tar: bytes::Bytes, folder: &str) -> error::Result<()> { use bytes::Buf; diff --git a/backend/windmill-worker/src/js_eval.rs b/backend/windmill-worker/src/js_eval.rs index bc8b33a5f2..2e78f0aae8 100644 --- a/backend/windmill-worker/src/js_eval.rs +++ b/backend/windmill-worker/src/js_eval.rs @@ -107,6 +107,31 @@ pub async fn eval_timeout( } } +/// Adds `step_id`, `job_id` and `flow_path` to a native script's thrown error, as the +/// subprocess wrappers do from the same env vars. +pub fn with_step_context( + e: windmill_common::error::Error, + reserved_variables: &HashMap<String, String>, +) -> windmill_common::error::Error { + use windmill_common::error::Error; + let Error::ExecutionRawError(raw) = &e else { + return e; + }; + let Ok(serde_json::Value::Object(mut err)) = serde_json::from_str(raw.get()) else { + return e; + }; + for (key, var) in [ + ("step_id", "WM_FLOW_STEP_ID"), + ("job_id", "WM_JOB_ID"), + ("flow_path", "WM_FLOW_PATH"), + ] { + if let Some(v) = reserved_variables.get(var).filter(|v| !v.is_empty()) { + err.insert(key.to_string(), serde_json::Value::String(v.clone())); + } + } + Error::ExecutionRawError(windmill_common::worker::to_raw_value(&err)) +} + #[cfg(feature = "deno_core")] pub fn transpile_ts(expr: String) -> anyhow::Result<String> { windmill_runtime_nativets::transpile_ts(expr) diff --git a/backend/windmill-worker/src/nsjail_compat.rs b/backend/windmill-worker/src/nsjail_compat.rs index 3069dcee52..e3b1f7656f 100644 --- a/backend/windmill-worker/src/nsjail_compat.rs +++ b/backend/windmill-worker/src/nsjail_compat.rs @@ -46,4 +46,90 @@ mod tests { assert_eq!(twice.matches("gvisor_compat").count(), 1); assert!(twice.ends_with("\nclone_newcgroup: false\ngvisor_compat: true\n")); } + + /// Every nsjail profile drops its capabilities. + #[test] + fn every_nsjail_config_drops_capabilities() { + let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/nsjail"); + let mut checked = 0; + for entry in std::fs::read_dir(dir).expect("nsjail config dir") { + let path = entry.expect("dir entry").path(); + if path.extension().and_then(|e| e.to_str()) != Some("proto") { + continue; + } + let content = std::fs::read_to_string(&path).expect("read config"); + let settings = content + .lines() + .map(str::trim) + .filter(|l| l.starts_with("keep_caps")) + .collect::<Vec<_>>(); + assert_eq!( + settings, + vec!["keep_caps: false"], + "{} must set keep_caps: false exactly once", + path.display() + ); + checked += 1; + } + assert!( + checked >= 20, + "only {checked} nsjail configs found in {dir}, expected all of them" + ); + } + + /// `REQ` and `TARGET` reach uv as single, unaltered argv words. + #[cfg(unix)] + #[test] + fn download_deps_passes_the_requirement_as_a_literal_argv_word() { + use std::os::unix::fs::PermissionsExt; + + let dir = std::env::temp_dir().join(format!("wm_download_deps_{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("scratch dir"); + + // Stand-in for uv that prints each argument it receives on its own line. + let uv = dir.join("uv_argv_dump"); + std::fs::write( + &uv, + "#!/bin/sh\nfor a in \"$@\"; do printf '<%s>\\n' \"$a\"; done\n", + ) + .expect("write stub"); + std::fs::set_permissions(&uv, std::fs::Permissions::from_mode(0o755)).expect("chmod stub"); + + let script = dir.join("download_deps.sh"); + std::fs::write( + &script, + include_str!("../nsjail/download_deps.py.sh") + .replace("/usr/local/bin/uv", uv.to_str().unwrap()), + ) + .expect("write script"); + + let marker = dir.join("marker"); + let req = format!("x==$(touch {})", marker.display()); + let target = dir.join("target dir with spaces"); + let out = std::process::Command::new("/bin/sh") + .arg(&script) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("REQ", &req) + .env("TARGET", &target) + .output() + .expect("run download_deps.sh"); + let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); + + assert!( + !marker.exists(), + "REQ was not passed through verbatim; stdout was:\n{stdout}" + ); + assert!( + stdout.contains(&format!("<{req}>\n")), + "the requirement must be one literal argv word; stdout was:\n{stdout}" + ); + assert!( + stdout.contains(&format!("<--target>\n<{}>\n", target.display())), + "--target must be followed by the target dir as one argv word; stdout was:\n{stdout}" + ); + + std::fs::remove_dir_all(&dir).expect("cleanup scratch dir"); + } } diff --git a/backend/windmill-worker/src/pwsh_executor.rs b/backend/windmill-worker/src/pwsh_executor.rs index f1baf9d5aa..4572a868a8 100644 --- a/backend/windmill-worker/src/pwsh_executor.rs +++ b/backend/windmill-worker/src/pwsh_executor.rs @@ -9,9 +9,7 @@ use windmill_common::error::Error; use windmill_common::scripts::ScriptLang; use windmill_common::worker::{to_raw_value, write_file, Connection}; use windmill_common::workspace_dependencies::clean_lock_from_annotations; -use windmill_queue::{ - append_logs, CanceledBy, MiniPulledJob, INIT_SCRIPT_PATH_PREFIX, PERIODIC_SCRIPT_PATH_PREFIX, -}; +use windmill_queue::{append_logs, CanceledBy, MiniPulledJob}; #[cfg(windows)] use crate::SYSTEM_ROOT; @@ -690,15 +688,9 @@ $env:PSModulePath = \"{};$PSModulePathBackup\"", let _ = write_file(job_dir, "result.out", "")?; let _ = write_file(job_dir, "result2.out", "")?; - // Check if this is a regular job (not init or periodic script) - // Init/periodic scripts need full system access without isolation - let is_regular_job = job - .runnable_path - .as_ref() - .map(|x| { - !x.starts_with(INIT_SCRIPT_PATH_PREFIX) && !x.starts_with(PERIODIC_SCRIPT_PATH_PREFIX) - }) - .unwrap_or(true); + // Init/periodic scripts need full system access without isolation; see + // `MiniPulledJob::is_worker_own_script`. + let is_regular_job = !job.is_worker_own_script(); let nsjail = is_sandboxing_enabled() && is_regular_job; let child = if nsjail { diff --git a/backend/windmill-worker/src/rust_executor.rs b/backend/windmill-worker/src/rust_executor.rs index fabea34499..bba1be8ad4 100644 --- a/backend/windmill-worker/src/rust_executor.rs +++ b/backend/windmill-worker/src/rust_executor.rs @@ -87,6 +87,8 @@ lazy_static::lazy_static! { static ref CARGO_PATH: String = find_cargo_path(); static ref SWEEP_MAXSIZE: String = std::env::var("CARGO_SWEEP_MAXSIZE").unwrap_or("25GB".to_owned()); static ref NO_SHARED_BUILD_DIR: bool = std::env::var("RUST_NO_SHARED_BUILD_DIR").ok().map(|flag| flag == "true").unwrap_or(false); + /// Parent of the worker-owned working directories of host-side cargo invocations, see [`HostCargoCwd`]. + static ref HOST_CARGO_CWD_DIR: String = format!("{}/host_cargo_cwd", *RUST_CACHE_DIR); } #[cfg(windows)] @@ -242,13 +244,10 @@ pub fn __WINDMILL_RUN__(_args: __WINDMILL_ARGS__) -> Result<String, Box<dyn std: Ok(()) } -async fn write_cargo_config( - job_dir: &str, - job_id: &Uuid, - w_id: &str, - conn: &Connection, -) -> anyhow::Result<()> { - if let Some(cargo_registries) = read_ee_registry_with_workspace_override( +/// The instance/workspace cargo registry configuration, if any is set, as the body of a +/// `.cargo/config.toml`. +async fn cargo_registries_config(job_id: &Uuid, w_id: &str, conn: &Connection) -> Option<String> { + read_ee_registry_with_workspace_override( CARGO_REGISTRIES.read().await.clone(), "cargo_registries", "cargo registries", @@ -257,16 +256,150 @@ async fn write_cargo_config( conn, ) .await - { - if !cargo_registries.trim().is_empty() { - let cargo_dir = format!("{job_dir}/.cargo"); - create_dir_all(&cargo_dir).await?; - write_file(&cargo_dir, "config.toml", &cargo_registries)?; - } + .filter(|cargo_registries| !cargo_registries.trim().is_empty()) +} + +/// Write `{dir}/.cargo/config.toml`. cargo only reads it when `dir` is the working +/// directory of the invocation (or an ancestor of it), never because `dir` holds the +/// manifest the invocation was pointed at. +async fn write_cargo_registries_config(dir: &str, registries: &str) -> anyhow::Result<()> { + let cargo_dir = format!("{dir}/.cargo"); + create_dir_all(&cargo_dir).await?; + write_file(&cargo_dir, "config.toml", registries)?; + Ok(()) +} + +async fn write_cargo_config( + job_dir: &str, + job_id: &Uuid, + w_id: &str, + conn: &Connection, +) -> anyhow::Result<()> { + if let Some(cargo_registries) = cargo_registries_config(job_id, w_id, conn).await { + write_cargo_registries_config(job_dir, &cargo_registries).await?; } Ok(()) } +/// Stub manifest for a [`HostCargoCwd`]. Nothing is ever built from it; `cargo sweep` +/// only needs *a* manifest in its working directory to ask `cargo metadata` where the +/// target directory is, and `[workspace]` keeps cargo from walking further up. +const HOST_CARGO_CWD_MANIFEST: &str = r#"# Worker-owned stub crate. Host-side cargo invocations run from here so that their +# configuration discovery never reaches a job directory. Nothing is built from it. +[workspace] + +[package] +name = "windmill_host_cargo_cwd" +version = "0.0.0" + +[lib] +path = "src/lib.rs" +"#; + +/// Working directory for cargo invocations that run outside the build jail. cargo reads +/// `.cargo/config.toml` from its working directory and that directory's ancestors (not +/// from the manifest's directory), so this is a worker-owned directory under the rust +/// cache dir, outside any job directory and outside the build dirs. +struct HostCargoCwd { + path: String, +} + +impl HostCargoCwd { + /// `registries` is the worker's own cargo registry configuration (never the job's), to + /// be made visible to the invocation as this directory's `.cargo/config.toml`. + async fn new(kind: &str, registries: Option<&str>) -> anyhow::Result<Self> { + let path = format!("{}/{kind}-{}", *HOST_CARGO_CWD_DIR, Uuid::new_v4()); + create_dir_all(format!("{path}/src")).await.map_err(|e| { + anyhow::anyhow!("Could not create host cargo working dir {path}.\ne: {e}") + })?; + let cwd = HostCargoCwd { path }; + write_file(&cwd.path, "Cargo.toml", HOST_CARGO_CWD_MANIFEST)?; + write_file(&format!("{}/src", cwd.path), "lib.rs", "")?; + if let Some(registries) = registries { + write_cargo_registries_config(&cwd.path, registries).await?; + } + Ok(cwd) + } + + fn path(&self) -> &str { + &self.path + } +} + +impl Drop for HostCargoCwd { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } +} + +/// `cargo generate-lockfile` for the crate in `job_dir`, run from `cwd`. +/// +/// `cwd` is a [`HostCargoCwd`] and not `job_dir`, so the crate is named with +/// `--manifest-path`; cargo writes `Cargo.lock` next to that manifest, i.e. still at +/// `{job_dir}/Cargo.lock`. +fn build_generate_lockfile_command(cwd: &str, job_dir: &str) -> Command { + let mut gen_lockfile_cmd = Command::new(CARGO_PATH.as_str()); + gen_lockfile_cmd + .current_dir(cwd) + .env_clear() + .env("PATH", PATH_ENV.as_str()) + .env("HOME", HOME_ENV.as_str()) + .env("CARGO_HOME", CARGO_HOME.as_str()) + .env("RUSTUP_HOME", RUSTUP_HOME.as_str()) + .envs(PROXY_ENVS.clone()) + .args([ + "generate-lockfile", + "--manifest-path", + &format!("{job_dir}/Cargo.toml"), + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(windows)] + { + gen_lockfile_cmd.env("SystemRoot", SYSTEM_ROOT.as_str()); + gen_lockfile_cmd.env("USERPROFILE", crate::USERPROFILE_ENV.as_str()); + gen_lockfile_cmd.env( + "TMP", + std::env::var("TMP").unwrap_or_else(|_| "C:\\tmp".to_string()), + ); + } + gen_lockfile_cmd +} + +/// `cargo sweep` over the shared build dir's target dir, run from `cwd`. +/// +/// `cwd` is a [`HostCargoCwd`] and not `job_dir`. `cargo sweep` resolves the target +/// directory to prune by running `cargo metadata` for the manifest in its working +/// directory, and `CARGO_TARGET_DIR` overrides what that resolves to — which is how this +/// invocation has always reached `{build_dir}/target`, the stub manifest in `cwd` standing +/// in for the job's crate. +fn build_cargo_sweep_command(cwd: &str, build_dir: &str) -> Command { + let mut sweep_cmd = Command::new(CARGO_PATH.as_str()); + let sweep_path = format!("{}/bin:{}", CARGO_HOME.as_str(), PATH_ENV.as_str()); + sweep_cmd + .current_dir(cwd) + .env_clear() + .env("PATH", &sweep_path) + .env("CARGO_HOME", CARGO_HOME.as_str()) + .env("HOME", HOME_ENV.as_str()) + .env("CARGO_TARGET_DIR", &format!("{build_dir}/target")) + .env("RUSTUP_HOME", RUSTUP_HOME.as_str()) + .args(["sweep", "--maxsize", SWEEP_MAXSIZE.as_str()]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + #[cfg(windows)] + { + sweep_cmd.env("SystemRoot", SYSTEM_ROOT.as_str()); + sweep_cmd.env( + "TMP", + std::env::var("TMP").unwrap_or_else(|_| "C:\\tmp".to_string()), + ); + sweep_cmd.env("USERPROFILE", crate::USERPROFILE_ENV.as_str()); + } + sweep_cmd +} + pub async fn generate_cargo_lockfile( job_id: &Uuid, code: &str, @@ -282,29 +415,14 @@ pub async fn generate_cargo_lockfile( check_executor_binary_exists("cargo", CARGO_PATH.as_str(), "rust")?; gen_cargo_crate(code, job_dir)?; - write_cargo_config(job_dir, job_id, w_id, conn).await?; - - let mut gen_lockfile_cmd = Command::new(CARGO_PATH.as_str()); - gen_lockfile_cmd - .current_dir(job_dir) - .env_clear() - .env("PATH", PATH_ENV.as_str()) - .env("HOME", HOME_ENV.as_str()) - .env("CARGO_HOME", CARGO_HOME.as_str()) - .env("RUSTUP_HOME", RUSTUP_HOME.as_str()) - .envs(PROXY_ENVS.clone()) - .args(vec!["generate-lockfile"]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - #[cfg(windows)] - { - gen_lockfile_cmd.env("SystemRoot", SYSTEM_ROOT.as_str()); - gen_lockfile_cmd.env("USERPROFILE", crate::USERPROFILE_ENV.as_str()); - gen_lockfile_cmd.env( - "TMP", - std::env::var("TMP").unwrap_or_else(|_| "C:\\tmp".to_string()), - ); + let registries = cargo_registries_config(job_id, w_id, conn).await; + if let Some(registries) = registries.as_deref() { + write_cargo_registries_config(job_dir, registries).await?; } + + // Run outside `job_dir`; the registry configuration is carried over explicitly. + let cwd = HostCargoCwd::new("lockfile", registries.as_deref()).await?; + let gen_lockfile_cmd = build_generate_lockfile_command(cwd.path(), job_dir); let gen_lockfile_process = start_child_process(gen_lockfile_cmd, CARGO_PATH.as_str(), false).await?; handle_child( @@ -334,7 +452,6 @@ pub async fn generate_cargo_lockfile( async fn get_build_dir( job: &MiniPulledJob, - job_dir: &str, conn: &Connection, worker_name: &str, is_preview: bool, @@ -387,38 +504,29 @@ async fn get_build_dir( if run_sweep { // Also run sweep to make sure target isn't using too much disk - let mut sweep_cmd = Command::new(CARGO_PATH.as_str()); - let sweep_path = format!("{}/bin:{}", CARGO_HOME.as_str(), PATH_ENV.as_str()); - sweep_cmd - .current_dir(job_dir) - .env_clear() - .env("PATH", &sweep_path) - .env("CARGO_HOME", CARGO_HOME.as_str()) - .env("HOME", HOME_ENV.as_str()) - .env("CARGO_TARGET_DIR", &(bd.clone() + "/target")) - .env("RUSTUP_HOME", RUSTUP_HOME.as_str()) - .args(["sweep", "--maxsize", SWEEP_MAXSIZE.as_str()]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - - #[cfg(windows)] - { - sweep_cmd.env("SystemRoot", SYSTEM_ROOT.as_str()); - sweep_cmd.env( - "TMP", - std::env::var("TMP").unwrap_or_else(|_| "C:\\tmp".to_string()), - ); - sweep_cmd.env("USERPROFILE", crate::USERPROFILE_ENV.as_str()); - } - - let (job_id, conn, w_id, wk_name) = ( + let (job_id, conn, w_id, wk_name, build_dir) = ( job.id.clone(), conn.clone(), job.workspace_id.clone(), worker_name.to_owned(), + bd.clone(), ); tokio::spawn(async move { + // Run from a dedicated directory rather than the job directory. `cwd` is held + // for the whole sweep; dropping it removes the directory. + let cwd = match HostCargoCwd::new("sweep", None).await { + Ok(cwd) => cwd, + Err(e) => { + tracing::warn!( + workspace_id = %w_id, + job_id = %job_id, + "Failed to prepare working directory for `cargo sweep`. Rust cache may grow over time, cargo sweep is meant to clean up unused cache.\ne: {e}\n" + ); + return; + } + }; + let sweep_cmd = build_cargo_sweep_command(cwd.path(), &build_dir); if let Err(e) = match start_child_process(sweep_cmd, CARGO_PATH.as_str(), false).await { Ok(sweep_process) => { handle_child( @@ -467,7 +575,7 @@ pub async fn build_rust_crate( ensure_rust_runtime_dirs(); let bin_path = format!("{}/{hash}", *RUST_CACHE_DIR); - let build_dir = get_build_dir(job, job_dir, conn, worker_name, is_preview).await?; + let build_dir = get_build_dir(job, conn, worker_name, is_preview).await?; let child = if is_sandboxing_enabled() { let _ = write_file( @@ -851,3 +959,153 @@ pub async fn handle_rust_job( .await?; read_result(job_dir, None).await } + +#[cfg(test)] +mod host_cargo_cwd_tests { + use super::*; + use std::ffi::OsStr; + use std::path::Path; + + /// A plausible job directory: `{TMP}/windmill/{worker}/{job_id}`. + const JOB_DIR: &str = "/tmp/windmill/wk-default-xyz-AbCdE/01a12c99-6620-74b9-a4ca-189c60de272c"; + + fn args(cmd: &Command) -> Vec<String> { + cmd.as_std() + .get_args() + .map(|a| a.to_string_lossy().into_owned()) + .collect() + } + + fn env(cmd: &Command, key: &str) -> Option<String> { + cmd.as_std() + .get_envs() + .find(|(k, _)| *k == OsStr::new(key)) + .map(|(_, v)| v.unwrap_or(OsStr::new("")).to_string_lossy().into_owned()) + } + + /// The working directory is neither inside the job directory nor an ancestor of it. + fn assert_cwd_is_isolated_from_job_dir(cmd: &Command, job_dir: &str) { + let cwd = cmd + .as_std() + .get_current_dir() + .expect("host-side cargo must set an explicit working directory"); + let job_dir = Path::new(job_dir); + assert!( + cwd.ancestors().all(|a| a != job_dir), + "cwd {cwd:?} is inside the job dir {job_dir:?}" + ); + assert!( + job_dir.ancestors().all(|a| a != cwd), + "cwd {cwd:?} is an ancestor of the job dir {job_dir:?}" + ); + assert!( + cwd.starts_with(Path::new(HOST_CARGO_CWD_DIR.as_str())), + "cwd {cwd:?} is not one of the worker-owned dirs under {}", + *HOST_CARGO_CWD_DIR + ); + } + + #[test] + fn generate_lockfile_runs_outside_the_job_dir_and_names_the_manifest() { + let cwd = format!("{}/lockfile-deadbeef", *HOST_CARGO_CWD_DIR); + let cmd = build_generate_lockfile_command(&cwd, JOB_DIR); + + assert_eq!(cmd.as_std().get_current_dir(), Some(Path::new(&cwd))); + assert_cwd_is_isolated_from_job_dir(&cmd, JOB_DIR); + // Cargo writes Cargo.lock next to the manifest it is given, which is where + // `generate_cargo_lockfile` reads it back from. + assert_eq!( + args(&cmd), + vec![ + "generate-lockfile".to_string(), + "--manifest-path".to_string(), + format!("{JOB_DIR}/Cargo.toml"), + ] + ); + } + + #[test] + fn cargo_sweep_runs_outside_the_job_dir_and_still_targets_the_build_dir() { + let cwd = format!("{}/sweep-deadbeef", *HOST_CARGO_CWD_DIR); + let build_dir = format!("{}/build/acme@u.dev.script@dev", *RUST_CACHE_DIR); + let cmd = build_cargo_sweep_command(&cwd, &build_dir); + + assert_eq!(cmd.as_std().get_current_dir(), Some(Path::new(&cwd))); + assert_cwd_is_isolated_from_job_dir(&cmd, JOB_DIR); + assert_eq!( + args(&cmd), + vec![ + "sweep".to_string(), + "--maxsize".to_string(), + SWEEP_MAXSIZE.to_string(), + ] + ); + // `cargo sweep` prunes the target dir that `cargo metadata` reports, and + // CARGO_TARGET_DIR is what decides that -- not the cwd's manifest. + assert_eq!( + env(&cmd, "CARGO_TARGET_DIR").as_deref(), + Some(format!("{build_dir}/target").as_str()) + ); + } + + #[tokio::test] + async fn host_cargo_cwd_is_a_worker_owned_stub_crate_removed_on_drop() { + let registries = "[registries.acme]\nindex = \"sparse+https://acme.test/index/\"\n"; + let path = { + let cwd = HostCargoCwd::new("unittest", Some(registries)) + .await + .expect("could not create host cargo cwd"); + let path = cwd.path().to_string(); + + assert!( + path.starts_with(&format!("{}/unittest-", *HOST_CARGO_CWD_DIR)), + "unexpected host cargo cwd {path}" + ); + assert!( + !path.starts_with(&format!("{}/build/", *RUST_CACHE_DIR)), + "host cargo cwd {path} is under the build dirs" + ); + + // `cargo sweep` needs a manifest in its cwd to ask `cargo metadata` for the + // target dir; `[workspace]` keeps cargo from walking further up. + let manifest = std::fs::read_to_string(format!("{path}/Cargo.toml")) + .expect("stub Cargo.toml missing"); + assert!( + manifest.contains("[workspace]"), + "stub manifest is not its own workspace root: {manifest}" + ); + assert!( + manifest.contains("path = \"src/lib.rs\""), + "stub manifest does not point at its stub target: {manifest}" + ); + assert_eq!( + std::fs::read_to_string(format!("{path}/src/lib.rs")).expect("stub lib.rs missing"), + "" + ); + // The worker's own registry configuration has to follow the invocation out + // of the job dir, since cargo only reads it from the cwd and CARGO_HOME. + assert_eq!( + std::fs::read_to_string(format!("{path}/.cargo/config.toml")) + .expect("registry config missing"), + registries + ); + path + }; + + assert!( + !Path::new(&path).exists(), + "host cargo cwd {path} outlived its guard" + ); + } + + #[tokio::test] + async fn host_cargo_cwd_has_no_config_when_no_registries_are_set() { + let cwd = HostCargoCwd::new("unittest", None) + .await + .expect("could not create host cargo cwd"); + assert!( + !Path::new(&format!("{}/.cargo", cwd.path())).exists(), + "host cargo cwd has a .cargo dir although no registries are configured" + ); + } +} diff --git a/backend/windmill-worker/src/worker.rs b/backend/windmill-worker/src/worker.rs index aa742bfdf5..beeac41a5c 100644 --- a/backend/windmill-worker/src/worker.rs +++ b/backend/windmill-worker/src/worker.rs @@ -2890,14 +2890,6 @@ pub async fn run_worker( killpill_tx: KillpillSender, base_internal_url: &str, ) { - #[cfg(not(feature = "enterprise"))] - if is_sandboxing_enabled() { - tracing::warn!( - worker = %worker_name, hostname = %hostname, - "NSJAIL to sandbox process in untrusted environments is an enterprise feature but allowed to be used for testing purposes" - ); - } - // Force UNSHARE_PATH and NSJAIL_AVAILABLE initialization now for clear startup logging let _ = &*UNSHARE_PATH; let _ = &*NSJAIL_AVAILABLE; @@ -6703,7 +6695,8 @@ pub async fn run_language_executor( occupancy_metrics, has_stream, )) - .await?; + .await + .map_err(|e| crate::js_eval::with_step_context(e, &reserved_variables))?; return Ok(result); } diff --git a/benchmarks/lib.ts b/benchmarks/lib.ts index 6be8a4523e..41d2fe87c0 100644 --- a/benchmarks/lib.ts +++ b/benchmarks/lib.ts @@ -2,7 +2,7 @@ import { sleep } from "https://deno.land/x/sleep@v1.2.1/mod.ts"; import * as windmill from "https://deno.land/x/windmill@v1.174.0/mod.ts"; import * as api from "https://deno.land/x/windmill@v1.174.0/windmill-api/index.ts"; -export const VERSION = "v1.847.0"; +export const VERSION = "v1.849.0"; export async function login(email: string, password: string): Promise<string> { return await windmill.UserService.login({ diff --git a/chat-sdk/package-lock.json b/chat-sdk/package-lock.json index 56e888035e..61ed2382a8 100644 --- a/chat-sdk/package-lock.json +++ b/chat-sdk/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/chat-sdk", - "version": "1.847.0", + "version": "1.849.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/chat-sdk", - "version": "1.847.0", + "version": "1.849.0", "license": "Apache-2.0", "devDependencies": { "@ai-sdk/react": "^4.0.102", diff --git a/chat-sdk/package.json b/chat-sdk/package.json index 74fb87fc20..992d52b207 100644 --- a/chat-sdk/package.json +++ b/chat-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@windmill-labs/chat-sdk", "description": "Build chat interfaces on Windmill flows deployed in chat mode, from any frontend or raw app", - "version": "1.847.0", + "version": "1.849.0", "author": "Ruben Fiszel", "license": "Apache-2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/chat-sdk#readme", diff --git a/cli/src/commands/variable/variable.ts b/cli/src/commands/variable/variable.ts index 88ad108a1a..c79630d1de 100644 --- a/cli/src/commands/variable/variable.ts +++ b/cli/src/commands/variable/variable.ts @@ -98,6 +98,7 @@ export interface VariableFile { description: string; account?: number; is_oauth?: boolean; + value_valid_until?: string; extra_perms?: Record<string, boolean>; } @@ -162,7 +163,14 @@ export async function pushVariable( const { extra_perms: localPerms, ...localVariableBody } = localVariable; if (variable) { - if (isSuperset(localVariableBody, variable)) { + // An expiry removed from the file is sent as `null` (absent keeps the stored date), and + // `isSuperset` cannot see a removed key, so the clear is detected here. + const clearsValueValidUntil = + localVariableBody.value_valid_until === undefined && + variable.value_valid_until !== undefined && + variable.value_valid_until !== null; + + if (!clearsValueValidUntil && isSuperset(localVariableBody, variable)) { log.debug(`Variable ${remotePath} is up-to-date`); } else { log.debug(`Variable ${remotePath} is not up-to-date, updating`); @@ -188,6 +196,7 @@ export async function pushVariable( ...localVariableBody, is_secret: nextIsSecret, ...(wsSpecific !== undefined ? { ws_specific: wsSpecific } : {}), + ...(clearsValueValidUntil ? { value_valid_until: null } : {}), }, }); } diff --git a/cli/src/core/constants.ts b/cli/src/core/constants.ts index e02c5a2217..bf083042d9 100644 --- a/cli/src/core/constants.ts +++ b/cli/src/core/constants.ts @@ -10,4 +10,4 @@ export const WM_FORK_PREFIX = "wm-fork"; // (e.g. utils.ts) can read it without importing main.ts and creating a circular // dependency (main → workspace → utils → main) that triggers a TDZ. // Re-exported from main.ts for backwards compatibility. -export const VERSION = "1.847.0"; +export const VERSION = "1.849.0"; diff --git a/cli/src/core/settings.ts b/cli/src/core/settings.ts index 502d0037bb..94875d2659 100644 --- a/cli/src/core/settings.ts +++ b/cli/src/core/settings.ts @@ -34,12 +34,21 @@ export interface SuccessHandlerConfig { extra_args?: any; } +export interface VariableExpirationHandlerConfig { + path?: string; + extra_args?: any; + muted_on_user_path?: boolean; + notify_before_secs?: number; + remind_every_secs?: number; +} + export interface SimplifiedSettings { // Grouped format (current). Explicit `null` on error_handler / success_handler // signals "clear the remote value"; `undefined` means "not managed by git". auto_invite?: AutoInviteConfig; error_handler?: ErrorHandlerConfig | null; success_handler?: SuccessHandlerConfig | null; + variable_expiration_handler?: VariableExpirationHandlerConfig | null; // Other fields webhook?: string; @@ -85,6 +94,7 @@ export const SETTINGS_MANAGED_ONLY_WHEN_PRESENT = [ "guest_jwt_jwks_url", "error_handler_fallback_to_instance_alerts", "add_admins_and_developers_to_forks", + "variable_expiration_handler", ] as const; // Legacy settings interface for reading old settings.yaml files diff --git a/cli/src/guidance/skills.gen.ts b/cli/src/guidance/skills.gen.ts index d932957a20..6a0d6f47f4 100644 --- a/cli/src/guidance/skills.gen.ts +++ b/cli/src/guidance/skills.gen.ts @@ -8364,7 +8364,7 @@ write a secret you were not given: keep the placeholder. | \`ai_config\` | Providers, default and completion models, custom prompts, \`copilot_disabled\`. | | \`large_file_storage\` | Workspace object storage: \`{ type, s3_resource_path \\| azure_blob_resource_path \\| gcs_resource_path, public_resource?, secondary_storage?, advanced_permissions? }\`. | | \`git_sync\` | \`{ repositories: [...] }\`, each with \`git_repo_resource_path\`, \`script_path\` and \`settings\` (include/exclude paths and types). Server-owned fields (webhook secrets, sync state, credentials) are never read nor written. | -| \`datatable\` | \`{ datatables: { <name>: { database: { resource_type, resource_path? } } } }\`. Role permissions are \`datatable_permissions\`. | +| \`datatable\` | \`{ datatables: { <name>: { database: { resource_type, resource_path } } } }\`, both required: \`resource_type: postgresql\` with \`resource_path\` a Postgres resource's path, or \`instance\`, or \`external_instance\` (Enterprise only), with \`resource_path\` the name of a database a superadmin set up. A fork's entry can instead be \`{ reference: { workspace_id, datatable } }\`, pointing at its parent's data table; keep it as is. \`main\` is the one scripts use by default. Role permissions are \`datatable_permissions\`. | | \`datatable_permissions\` | \`{ <data table>: { default_role, roles: { <role>: [tenants] } } }\`: who may connect to each data table as which role. Tenants are \`u/<user>\`, \`g/<group>\`, \`f/<folder>\` or \`*\`; \`admin\` is always a role, and \`default_role: null\` means no default. A data table the value omits is no longer under roles. Only a data table on a cluster Windmill manages can be. | | \`protection_rules\` | \`{ <rule name>: { rules: [...], bypass_groups: [...], bypass_users: [...] } }\`, rules among \`DisableDirectDeployment\`, \`DisableWorkspaceForking\`, \`RestrictDeployToDeployers\`, \`RestrictAnonymousAppDeployment\`, \`RestrictPublicRunSharing\`, \`RestrictGuestAppDeployment\`, \`RequireIsolatedForks\`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. | | \`ducklake\` | \`{ ducklakes: { <name>: { catalog, storage, ... } } }\`. | diff --git a/cli/test/deploy_variable_expiry_unit.test.ts b/cli/test/deploy_variable_expiry_unit.test.ts new file mode 100644 index 0000000000..5569883121 --- /dev/null +++ b/cli/test/deploy_variable_expiry_unit.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test"; +import { deployItem } from "../windmill-utils-internal/src/deploy.ts"; + +// Workspace deploy builds the variable body field by field, so a forgotten field is +// dropped silently on promotion, and a source with no expiry must send `null`. +function recordingProvider( + captured: [string, any][], + exists: boolean, + value_valid_until: string | null, +) { + return { + existsVariable: async () => exists, + getVariable: async () => ({ + path: "f/x/v", + value: "secret", + is_secret: true, + description: "d", + value_valid_until, + }), + createVariable: async (p: any) => + void captured.push(["createVariable", p.requestBody]), + updateVariable: async (p: any) => + void captured.push(["updateVariable", p.requestBody]), + } as any; +} + +test("deployItem: carries value_valid_until, and clears it explicitly", async () => { + const captured: [string, any][] = []; + + // Written once per branch: create and update. + for (const exists of [false, true]) { + await deployItem( + recordingProvider(captured, exists, "2027-03-15T08:00:00Z"), + "variable" as any, + "f/x/v", + "src", + "dst", + ); + await deployItem( + recordingProvider(captured, exists, null), + "variable" as any, + "f/x/v", + "src", + "dst", + ); + } + + expect(captured.map(([fn]) => fn)).toEqual([ + "createVariable", + "createVariable", + "updateVariable", + "updateVariable", + ]); + expect(captured.map(([, body]) => body.value_valid_until)).toEqual([ + "2027-03-15T08:00:00Z", + null, + "2027-03-15T08:00:00Z", + null, + ]); +}); diff --git a/cli/test/variable_resource_push.test.ts b/cli/test/variable_resource_push.test.ts index b77ed48b50..67be8bf3b7 100644 --- a/cli/test/variable_resource_push.test.ts +++ b/cli/test/variable_resource_push.test.ts @@ -133,6 +133,70 @@ describe("variable", () => { }); }); + test("push clears value_valid_until when the key is dropped from the file", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend); + + const uniqueId = Date.now(); + const path = `f/test/expiry_var_${uniqueId}`; + + const createResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/variables/create`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + path, + value: "original_value", + is_secret: false, + description: "Expiring", + value_valid_until: "2027-03-15T08:00:00Z", + }), + } + ); + expect(createResp.status).toBeLessThan(300); + await createResp.text(); + const created = await backend.apiRequest!( + `/api/w/${backend.workspace}/variables/get/${path}` + ); + expect((await created.json()).value_valid_until).toBe("2027-03-15T08:00:00Z"); + + await writeFile( + join(tempDir, "wmill.yaml"), + `defaultTs: bun\nincludes:\n - "**"\nexcludes: []\n`, + "utf-8" + ); + await mkdir(join(tempDir, "f", "test"), { recursive: true }); + // No value_valid_until: the CLI must send an explicit null to clear it. + await writeFile( + join(tempDir, `${path}.variable.yaml`), + `value: "original_value"\nis_secret: false\ndescription: "Expiring"\n`, + "utf-8" + ); + + const pushResult = await backend.runCLICommand( + ["sync", "push", "--yes", "--includes", `${path}**`], + tempDir + ); + expect(pushResult.code).toEqual(0); + + const apiResp = await backend.apiRequest!( + `/api/w/${backend.workspace}/variables/get/${path}` + ); + expect(apiResp.status).toEqual(200); + const varData = await apiResp.json(); + expect(varData.value_valid_until ?? null).toBeNull(); + + // A clear that did not land leaves the diff pending, so push success is not proof. + const secondPush = await backend.runCLICommand( + ["sync", "push", "--yes", "--includes", `${path}**`], + tempDir + ); + expect(secondPush.code).toEqual(0); + expect(secondPush.stdout).toContain("0 changes to apply"); + }); + }); + test("add creates secret by default and preserves fields on update", async () => { await withTestBackend(async (backend, tempDir) => { await setupWorkspaceProfile(backend); diff --git a/cli/windmill-utils-internal/package-lock.json b/cli/windmill-utils-internal/package-lock.json index b9508332f0..3babfd3631 100644 --- a/cli/windmill-utils-internal/package-lock.json +++ b/cli/windmill-utils-internal/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-utils-internal", - "version": "1.8.3", + "version": "1.8.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-utils-internal", - "version": "1.8.3", + "version": "1.8.4", "license": "Apache 2.0", "devDependencies": { "@types/node": "^24.2.0", diff --git a/cli/windmill-utils-internal/package.json b/cli/windmill-utils-internal/package.json index cf4138e6fc..4b3c96903d 100644 --- a/cli/windmill-utils-internal/package.json +++ b/cli/windmill-utils-internal/package.json @@ -1,6 +1,6 @@ { "name": "windmill-utils-internal", - "version": "1.8.3", + "version": "1.8.4", "description": "Internal utility functions for Windmill", "main": "dist/cjs/index.js", "module": "dist/esm/index.js", diff --git a/cli/windmill-utils-internal/src/deploy.ts b/cli/windmill-utils-internal/src/deploy.ts index b2c11253af..3478fa8b0b 100644 --- a/cli/windmill-utils-internal/src/deploy.ts +++ b/cli/windmill-utils-internal/src/deploy.ts @@ -617,6 +617,8 @@ export async function deployItem( is_secret: variable.is_secret, description: variable.description ?? "", labels: variable.labels, + // `null` clears; absent would keep the target's stored date. + value_valid_until: variable.value_valid_until ?? null, }, alreadyEncrypted: false, }); @@ -629,6 +631,7 @@ export async function deployItem( is_secret: variable.is_secret, description: variable.description ?? "", labels: variable.labels, + value_valid_until: variable.value_valid_until ?? null, }, }); } @@ -930,6 +933,7 @@ export async function getItemValue( value: variable.value, is_secret: variable.is_secret, description: variable.description, + value_valid_until: variable.value_valid_until ?? undefined, }; } else if (kind === "resource") { const resource = await provider.getResource({ workspace, path }); diff --git a/docs/feature-telemetry.md b/docs/feature-telemetry.md index 8e658eda3f..0862453020 100644 --- a/docs/feature-telemetry.md +++ b/docs/feature-telemetry.md @@ -4,10 +4,12 @@ anonymous usage-stats payload. It answers "does anyone use this, and which variant do they pick" without any identifying data leaving the instance. -It currently carries 62 registered actions across twenty-one features (`ai_session`, `ai_chat`, +It currently carries 78 registered actions across twenty-nine features (`ai_session`, `ai_chat`, `ai_fix`, `ai_agent`, `ai_decision`, `ai_agent_eval`, `app_sandbox`, `datatable`, `db_manager`, `flow_editor`, -`flow_run`, `flow_step`, `home`, `run_form`, `debugger`, `trigger`, `command_script`, -`hub_script`, `usage_meter`, `sso_groups_claim`, `cloud_trial_offer`). Nearly all of the +`flow_run`, `flow_step`, `home`, `header_nav`, `run_form`, `debugger`, `trigger`, `command_script`, +`hub_script`, `usage_meter`, `sso_groups_claim`, `cloud_trial_offer`, `job_token_scopes`, +`worker_draining`, `worker_group`, `worker_resource_stats`, `dev_workspace_isolation`, +`slack_agent`, `variable_expiration`). Nearly all of the product is uninstrumented, so new user-facing work is the opportunity to change that. ## When to instrument diff --git a/docs/operator-builder-rights.md b/docs/operator-builder-rights.md index 8445fa0dde..d4dc42b2d3 100644 --- a/docs/operator-builder-rights.md +++ b/docs/operator-builder-rights.md @@ -1,8 +1,8 @@ # Operator builder rights Two workspace settings, `operator_settings.builder_flows` and `operator_settings.builder_apps`, -that let every operator of that workspace compose flows and full-code apps out of runnables that -already exist. Neither lets them author backend code or run arbitrary code on a worker, the +that let every operator of that workspace compose flows (and AI agents, under the flow right) and +full-code apps out of runnables that already exist. Neither lets them author backend code or run arbitrary code on a worker, the boundary the operator role draws. The app right does let them write the app's frontend code, which runs in the viewer's browser and is why that right rests on sandbox isolation. @@ -32,13 +32,24 @@ anything that carries code. Three of its rules exist because the obvious walk mi past it. - **An AI agent step's `tools`.** `ToolValue::FlowModule` wraps a whole `FlowModuleValue`, so a tool can be a raw script. -- **An AI agent step's `agent` link.** A linked agent resolves its tools from an `ai_agent` - resource at run time, and operators may write resources, so the tool list is outside this check - and can be swapped for a raw script after the flow is approved. - It also returns what a value-only walk cannot authorize, for the caller to check against its own permissions: +- **every agent a step links**. A linked agent resolves its tools from its `ai_agent` resource at + run time, outside this walk, so the link is allowed only to an agent the builder can read. That + is safe because of the agent rule below: an agent's tools are either a developer's code or + passed this same check when an operator saved them. Two things keep it safe: + - The step's `tool_inputs` replace a tool's inputs at run time, including the ones the agent's + author set. A builder's step may bind only the inputs the agent leaves open + (`check_bound_agent_inputs_are_open`, on `InputTransform::is_completed`), and the flow editor + lets a builder edit only those, showing the rest read-only. What it binds there, a `$var:` or + a JavaScript expression included, is what its own steps may already pass a deployed runnable; an input the author set + stays theirs. The check runs on the flow's write, so a tool or input the agent gains later is + open to a binding written before it, like the retyped path below. + - The worker refuses a linked path whose resource is no longer an `ai_agent` + (`get_agent_value`). Only writes of an agent are checked, so a path retyped, or recreated as + another type, after the link was approved would otherwise run whatever `tools` it now holds. + - **the worker tags the steps pin**, or a builder routes a job onto a privileged worker group; - **every runnable a step references**. `script_to_payload` resolves a step's path with the root DB handle (`db_authed = None`) and returns the referenced runnable's `on_behalf_of`, which @@ -61,6 +72,20 @@ Call it on every write **and** every preview: `run_preview_flow_job` and it the way to run what the write path refuses. A flow draft goes through `validate_operator_flow` too: a developer who loads a builder's draft in the editor runs its code as themselves. +## Agents + +An agent is an `ai_agent` resource, and operators may write resources, so the `flows` capability +is also the right to build agents. Without it a restricted role writes no agent at all. Its tools run in the +agent's own runs, which take only inputs and run what is stored, and in every flow step linking +it. So `validate_operator_agent` holds an operator's agent to the composed-flow rules by checking +its tools as the tools of a one-step flow. It is called from `resolve_agent_on_behalf_of`, which +every write of an agent's value passes through: create, update, retype, update-value and version +restore. An operator's resource draft is checked the same way when it names `ai_agent` **or** an +agent is deployed at its path, whatever type the draft names: deploying a draft from the resource +editor keeps the row's type. A draft saved before an agent is created at its path is not +re-checked when someone else deploys it. A new route that writes an agent's value must go through +that function. + The same reasoning applies to a builder-authored app, with one extra step. `execute_component` resolves the runnable it runs on the root handle, so `validate_operator_composed_app` checks every referenced path under the caller's RLS and refuses hub ones. For a script it checks the version a @@ -104,12 +129,19 @@ ignored into `inlineScript`. `update_app` takes a builder too, but settings only: the deploy panel saves a deployed full-code app's access and frontend scopes through it with no value. It reaches the same check, so a builder -can no more switch the sandbox off there than on deploy. +can no more switch the sandbox off there than on deploy. A change of `execution_mode` alone runs +`check_operator_composed_app` on the deployed policy under the row lock, but not the readability +check, which resolves script versions on a second pool connection. It is therefore refused when +the app runs as anyone but the builder, `wm_deployers` or not: what an app running as its builder +invokes runs as that builder, who can already run it as themselves by opening the app. It is also +refused on an app whose stored policy is not sandboxed: the check would fill in the sandbox, and a +change of the mode alone keeps every other field of the policy. A builder's **draft** goes through the same `check_operator_composed_app` as its deploy, with `BuilderAppWrite::Draft`: an admin may open it in the editor or deploy it as it stands, so any rule added for deploys must hold for drafts too. Where the two differ, `write` decides: a deploy -fills in an omitted sandbox or mode, since it stores the policy it checked, while a draft skips +fills in an omitted sandbox, since it stores the policy it checked, and an omitted mode inherits the +deployed one, except that `Viewer` becomes `Publisher`, while a draft skips `triggerables_v2` and must not name another user's run identity. Every frontend writer of a builder draft must conform the policy first with `conformBuilderAppPolicy`, since a policy loaded from someone else's app breaks these rules: the raw-app editor component does, which covers both diff --git a/docs/workspace-roles.md b/docs/workspace-roles.md index 953b4c023c..9ce3294c54 100644 --- a/docs/workspace-roles.md +++ b/docs/workspace-roles.md @@ -16,7 +16,7 @@ code is `windmill_common::roles`. | Capability | Values | Enforced | |---|---|---| | `code` | bool | yes: scripts, previews, dependency jobs, dynamic selects, resolving jobs, data table migrations, eval datasets | -| `flows` | `none` / `compose` / `full` | yes | +| `flows` | `none` / `compose` / `full` | yes; also AI agents, whose tools are checked as composed steps | | `apps` | `none` / `compose` / `full` | yes; low-code apps and worker-built apps need `full` | | `manage_schedules`, `manage_triggers` | bool | yes, see `docs/operator-write-rights.md` | | `visibility.*` | bool per page | no, UI only | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e1fa01dbaa..206f8bb476 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "@windmill-labs/components", - "version": "1.847.0", + "version": "1.849.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@windmill-labs/components", - "version": "1.847.0", + "version": "1.849.0", "hasInstallScript": true, "license": "AGPL-3.0", "dependencies": { @@ -110,7 +110,7 @@ "windmill-parser-wasm-wac": "1.830.0", "windmill-parser-wasm-yaml": "1.770.0", "windmill-sql-datatype-parser-wasm": "1.512.0", - "windmill-utils-internal": "1.8.3", + "windmill-utils-internal": "1.8.4", "xterm": "^5.3.0", "xterm-readline": "^1.1.2", "y-monaco": "^0.1.4", @@ -14719,9 +14719,9 @@ "integrity": "sha512-uHNL8F72/Tf96xF3hOHnPDjkEyqXw7fNjcPJiUhth9sTQkcwUIoJMOdwm8/cs+j9kKVRJ4tgNYMHEBLylazp6g==" }, "node_modules/windmill-utils-internal": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/windmill-utils-internal/-/windmill-utils-internal-1.8.3.tgz", - "integrity": "sha512-Lp8fVM/uHUvO6q01rIODIR46hTQ9D9R9BEiKoZHQqw9NJoy1OxhcsvX4usBbkXff/GbHlkWiQfZXCOn0ozJeKQ==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/windmill-utils-internal/-/windmill-utils-internal-1.8.4.tgz", + "integrity": "sha512-s27Di5z/vonhMLd52P4i3iILQyty6ZIXYc89CCdkKZzseE6WiX/VMwdTF4nvNCjV3xN0WhAtXR+/PBhzarU2GQ==", "license": "Apache 2.0" }, "node_modules/word-wrap": { diff --git a/frontend/package.json b/frontend/package.json index d417cee0a4..86b73762fa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "@windmill-labs/components", - "version": "1.847.0", + "version": "1.849.0", "scripts": { "dev": "vite dev", "dev:ui-builder": "mv static/ui_builder static/ui_builder.dev-disabled 2>/dev/null || true ; trap 'mv static/ui_builder.dev-disabled static/ui_builder 2>/dev/null || true' EXIT ; vite dev", @@ -177,7 +177,7 @@ "windmill-parser-wasm-wac": "1.830.0", "windmill-parser-wasm-yaml": "1.770.0", "windmill-sql-datatype-parser-wasm": "1.512.0", - "windmill-utils-internal": "1.8.3", + "windmill-utils-internal": "1.8.4", "xterm": "^5.3.0", "xterm-readline": "^1.1.2", "y-monaco": "^0.1.4", diff --git a/frontend/src/lib/components/AIProviderPicker.svelte b/frontend/src/lib/components/AIProviderPicker.svelte index 67afad9be6..0d23ab55a3 100644 --- a/frontend/src/lib/components/AIProviderPicker.svelte +++ b/frontend/src/lib/components/AIProviderPicker.svelte @@ -9,6 +9,7 @@ } from './copilot/lib' import type { AIProvider, ProviderConfig } from '$lib/gen' import ResourcePicker from './ResourcePicker.svelte' + import { aiResourceProvider } from './copilot/aiResourceProvider' import Toggle from './Toggle.svelte' import { saveConfig, removeConfig, isSameAsStoredConfig } from './aiProviderStorage' import AIReasoningEffortPicker from './AIReasoningEffortPicker.svelte' @@ -57,7 +58,11 @@ const offeredProviders = Object.keys( untrack(() => decision) ? DECISION_AI_PROVIDERS : AI_PROVIDERS ) - const providerResourceTypes = offeredProviders.join(',') + // Federated Anthropic resources authenticate the anthropic provider without a key of their own. + const providerResourceTypes = [ + ...offeredProviders, + ...(offeredProviders.includes('anthropic') ? ['anthropic_federated'] : []) + ].join(',') if (!_uncheckedValue) { _uncheckedValue = { @@ -143,13 +148,14 @@ function onResourcePicked(_path: string | undefined, type: string | undefined) { // An empty type is the placeholder the picker keeps for a saved path it could not find. It // says nothing about the provider, so the kind stands. - if (!value || !type || !offeredProviders.includes(type)) { + const provider = aiResourceProvider(type) + if (!value || !provider || !offeredProviders.includes(provider)) { return } - if (value.kind === type) { + if (value.kind === provider) { return } - value.kind = type as AIProvider + value.kind = provider // Models are per provider, and a reasoning token is per model. value.model = '' value.reasoning_effort = undefined diff --git a/frontend/src/lib/components/AppConnectInner.svelte b/frontend/src/lib/components/AppConnectInner.svelte index d29d97a1dc..069965b4e7 100644 --- a/frontend/src/lib/components/AppConnectInner.svelte +++ b/frontend/src/lib/components/AppConnectInner.svelte @@ -54,6 +54,7 @@ import SchemaForm from './SchemaForm.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' import type { NativeConnect } from './triggers/native/utils' + import { AI_RESOURCE_TYPE_ALTERNATIVES } from './copilot/aiResourceProvider' const operatingWorkspace = useOperatingWorkspace() @@ -185,7 +186,6 @@ * toggling back from client-credentials can restore them. */ let instanceScopes: string[] = $state([]) let extra_params: [string, string][] = [] - let responseExtra: Record<string, string> = $state({}) let path: string = $state('') let description = $state('') let labels: string[] | undefined = $state(undefined) @@ -259,6 +259,16 @@ return registryEntryFor(connectClient, resourceType) } + let resourceTypeAlternative = $derived(AI_RESOURCE_TYPE_ALTERNATIVES[resourceType]) + + function switchToResourceType(rt: string) { + connectClient = rt + resourceType = rt + linkedSecrets = [] + args = {} + getResourceTypeInfo() + } + /** The static registry declares this provider supports client credentials */ function registryCcCapable(): boolean { return registryCcCapableFor(connectClient, resourceType) @@ -672,7 +682,7 @@ resourceType = stripSandboxSuffix(connectClient) value = data.res.access_token! valueToken = data.res - responseExtra = data.extra ?? {} + Object.assign(args, data.res.resource_fields ?? {}) fillGeneratedDescription() step = 4 // `fillPath` decides the path as surely as express does, so neither stops here. @@ -959,9 +969,6 @@ } } } - if (resourceType === 'quickbooks' && responseExtra['realmId']) { - args['realmId'] = responseExtra['realmId'] - } let accountData: any = undefined if (valueToken?.expires_in != undefined) { @@ -1481,6 +1488,14 @@ Acquire the token automatically via client credentials instead </button> {/if} + {#if resourceTypeAlternative} + <button + onclick={() => switchToResourceType(resourceTypeAlternative.resourceType)} + class="text-xs font-normal text-accent w-fit -mt-4" + > + {resourceTypeAlternative.label} + </button> + {/if} <!-- The form is a section of its own, not just the next field: it needs more of a break from the description than the uniform gap gives. --> <div class="mt-2"> diff --git a/frontend/src/lib/components/AutoscalingConfigEditor.svelte b/frontend/src/lib/components/AutoscalingConfigEditor.svelte index 885b6dd55c..a8884657cd 100644 --- a/frontend/src/lib/components/AutoscalingConfigEditor.svelte +++ b/frontend/src/lib/components/AutoscalingConfigEditor.svelte @@ -16,8 +16,12 @@ import ScriptPicker from './ScriptPicker.svelte' import { sendUserToast } from '$lib/toast' import { untrack } from 'svelte' + import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte' + import { createAsyncConfirmationModal } from './common/confirmationModal/asyncConfirmationModal.svelte' + import Portal from './Portal.svelte' interface Props { + worker_group: string config: AutoscalingConfig | undefined worker_tags: string[] | undefined /** Whether the draft config gives the group any tags (worker_tags or dedicated workers). */ @@ -29,6 +33,7 @@ } let { + worker_group, config = $bindable(), worker_tags, group_has_tags, @@ -66,6 +71,54 @@ } } + const confirmationModal = createAsyncConfirmationModal() + + async function testScaling() { + const integration = config?.integration + if (!integration) return + if (integration.type === 'script' && !integration['path']) { + sendUserToast('Please select a script before testing scaling', true) + return + } + const desired_workers = test_input + let job_id: string | null | undefined + let failed = false + const confirmed = await confirmationModal.ask({ + title: `Scale ${worker_group} to ${desired_workers} workers?`, + confirmationText: 'Scale now', + type: 'reload', + children: + integration.type === 'kubernetes' + ? `This sets the worker group's replica count to ${desired_workers} now. If autoscaling is enabled, it adjusts the count again on its next pass.` + : `This runs the integration script once with desired_workers set to ${desired_workers}, and it scales the group if the script does. If autoscaling is enabled, it adjusts the count again on its next pass.`, + onConfirmed: async () => { + try { + job_id = ( + await ConfigService.testAutoscaling({ + workerGroup: worker_group, + requestBody: { desired_workers, integration: $state.snapshot(integration) } + }) + ).job_id + } catch (e: any) { + failed = true + sendUserToast('Test scaling failed', true, [], e?.body ?? e?.message) + } + } + }) + if (!confirmed || failed) return + if (job_id) { + const id = job_id + sendUserToast('Autoscaling script queued in the admins workspace', false, [ + { + label: 'See job', + callback: () => window.open('/run/' + id + '?workspace=admins', '_blank') + } + ]) + } else { + sendUserToast(`Scaled ${worker_group} to ${desired_workers} workers`) + } + } + let noTagsToCount = $derived(!!config?.enabled && !group_has_tags && !config.custom_tags?.length) let collapsed: boolean = $state(untrack(() => !noTagsToCount)) @@ -266,22 +319,11 @@ /> <div class="flex flex-row gap-2 justify-end mt-4"> - <Button - variant="default" - unifiedSize="md" - onclick={() => { - if (!config?.integration?.['path']) { - sendUserToast('Please select a script before testing scaling', true) - return - } - if (!config?.integration?.['tag']) { - sendUserToast('Please select a custom tag before testing scaling', true) - return - } - }}>Test scaling</Button + <Button variant="default" unifiedSize="md" {disabled} onclick={testScaling} + >Test scaling</Button > <div class="flex text-xs flex-row gap-2 items-center"> - <input class="!w-16" type="number" bind:value={test_input} /> + <input class="!w-16" type="number" min="0" bind:value={test_input} /> workers </div> </div> @@ -317,9 +359,11 @@ </div> <div class="flex flex-row gap-2 justify-end"> - <Button unifiedSize="md" variant="default">Test scaling</Button> + <Button unifiedSize="md" variant="default" {disabled} onclick={testScaling} + >Test scaling</Button + > <div class="flex text-xs flex-row gap-2 items-center"> - <input class="!w-16" type="number" bind:value={test_input} /> + <input class="!w-16" type="number" min="0" bind:value={test_input} /> workers </div> </div> @@ -547,3 +591,7 @@ </Label> </Section> </Section> + +<Portal> + <ConfirmationModal {...confirmationModal.props} /> +</Portal> diff --git a/frontend/src/lib/components/CompareWorkspaces.svelte b/frontend/src/lib/components/CompareWorkspaces.svelte index 1c718287fc..096bc290c4 100644 --- a/frontend/src/lib/components/CompareWorkspaces.svelte +++ b/frontend/src/lib/components/CompareWorkspaces.svelte @@ -290,7 +290,8 @@ path, value: v.value ?? '', is_secret: v.is_secret ?? false, - description: v.description ?? '' + description: v.description ?? '', + value_valid_until: v.value_valid_until ?? undefined } }) } diff --git a/frontend/src/lib/components/ContextualVariableEditor.svelte b/frontend/src/lib/components/ContextualVariableEditor.svelte index 9adde76e74..f09b52b571 100644 --- a/frontend/src/lib/components/ContextualVariableEditor.svelte +++ b/frontend/src/lib/components/ContextualVariableEditor.svelte @@ -9,9 +9,14 @@ import autosize from '$lib/autosize' import Label from './Label.svelte' import TextInput from './text_input/TextInput.svelte' - import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' + import { + useOperatingWorkspace, + useReadOnlyWorkspace + } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() + // An editor opened in Build stays open when the session switches to Use. + const readOnly = useReadOnlyWorkspace() const dispatch = createEventDispatcher() @@ -36,6 +41,7 @@ let drawer: Drawer | undefined = $state() async function updateVariable(): Promise<void> { + if (readOnly($operatingWorkspace)) return await WorkspaceService.setEnvironmentVariable({ workspace: $operatingWorkspace!, requestBody: { @@ -79,7 +85,7 @@ {#snippet actions()} <Button on:click={() => updateVariable()} - disabled={value === '' || name === ''} + disabled={value === '' || name === '' || readOnly($operatingWorkspace)} startIcon={{ icon: Save }} variant="accent" unifiedSize="md" diff --git a/frontend/src/lib/components/DropdownV2.svelte b/frontend/src/lib/components/DropdownV2.svelte index 1d86519293..e9fbf71ab8 100644 --- a/frontend/src/lib/components/DropdownV2.svelte +++ b/frontend/src/lib/components/DropdownV2.svelte @@ -99,7 +99,10 @@ } = createDropdownMenu({ portal: untrack(() => hostPortal()), positioning: { - placement: untrack(() => placement) + placement: untrack(() => placement), + // Caps the menu at the room the viewport has left, so a list taller than that scrolls + // instead of flipping past the screen edge. A custom menu sizes itself. + fitViewport: untrack(() => !customMenu) }, closeOnItemClick: untrack(() => closeOnItemClick), loop: true, @@ -137,13 +140,12 @@ if (afterClose) setTimeout(afterClose, 10) } - async function computeItems(): Promise<Item[]> { - if (typeof items === 'function') { - const result = await items() - return Array.isArray(result) ? result.filter((item) => !item.hide) : [] - } else { - return items.filter((item) => !item.hide) - } + function visible(result: Item[] | undefined): Item[] { + return Array.isArray(result) ? result.filter((item) => !item.hide) : [] + } + function computeItems(): Item[] | Promise<Item[]> { + const result = typeof items === 'function' ? items() : items + return result instanceof Promise ? result.then(visible) : visible(result) } async function getMenuElements(): Promise<HTMLElement[]> { // Runs on every pointerdown anywhere, for every mounted dropdown. Skip the @@ -200,15 +202,15 @@ <div use:melt={$menuEl} data-menu - class="z-[6000] transition-all duration-100" + class="z-[6000] transition-all duration-100 flex flex-col" transition:placementFly={{ duration: enableFlyTransition ? 100 : 0, placement }} > {#if customMenu} {@render menu?.({ item, close, builders })} {:else} <div - class="bg-surface-tertiary dark:border w-56 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1" - style={`${customWidth ? `width: ${customWidth}px;` : ''} max-height: ${maxHeight || '50vh'};`} + class="bg-surface-tertiary dark:border w-56 min-h-0 origin-top-right rounded-lg shadow-lg focus:outline-none overflow-y-auto py-1" + style={`${customWidth ? `width: ${customWidth}px;` : ''} max-height: ${maxHeight || '80vh'};`} > <DropdownV2Inner {aiId} items={computeItems} meltItem={item} {builders} {close} /> </div> diff --git a/frontend/src/lib/components/DropdownV2Inner.svelte b/frontend/src/lib/components/DropdownV2Inner.svelte index 5a18a09157..138fd072b6 100644 --- a/frontend/src/lib/components/DropdownV2Inner.svelte +++ b/frontend/src/lib/components/DropdownV2Inner.svelte @@ -7,10 +7,11 @@ import type { MenubarMenuElements, createDropdownMenu } from '@melt-ui/svelte' import type { Item } from '$lib/utils' import { Tooltip } from './meltComponents' + import { untrack } from 'svelte' interface Props { aiId?: string - items?: Item[] | (() => Item[]) | (() => Promise<Item[]>) + items?: Item[] | (() => Item[] | Promise<Item[]>) meltItem: MenubarMenuElements['item'] builders?: ReturnType<typeof createDropdownMenu>['builders'] close?: (afterClose?: () => void) => void @@ -18,16 +19,17 @@ let { aiId, items = [], meltItem, builders, close }: Props = $props() - let computedItems: Item[] | undefined = $state(undefined) - async function computeItems() { - if (typeof items === 'function') { - computedItems = ((await items()) ?? []).filter((item) => !item.hide) - } else { - computedItems = items.filter((item) => !item.hide) - } + function visible(result: Item[] | undefined): Item[] { + return (result ?? []).filter((item) => !item.hide) } - computeItems() + // Items returned synchronously render on the first frame: awaiting them would show the + // spinner first, the popover would be placed for the spinner's height, then flip. + const initial = untrack(() => (typeof items === 'function' ? items() : items)) + let computedItems: Item[] | undefined = $state( + initial instanceof Promise ? undefined : visible(initial) + ) + if (initial instanceof Promise) initial.then((result) => (computedItems = visible(result))) </script> {#snippet menuItem(item: Item)} @@ -104,6 +106,11 @@ {#if item.separatorTop} <div class="my-1 border-t border-border-light"></div> {/if} + {#if item.sectionTitle} + <p class="px-4 pt-1.5 pb-1 text-2xs uppercase tracking-wide text-secondary"> + {item.sectionTitle} + </p> + {/if} {#if (item.submenuItems || item.customSubmenu) && builders} <DropdownSubmenuItem {item} {builders} {meltItem} close={close ?? (() => {})} /> {:else if item.disabled && item.tooltip} diff --git a/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte b/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte index 6d95ad0c82..bddf68fc0d 100644 --- a/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte +++ b/frontend/src/lib/components/ErrorOrRecoveryHandler.svelte @@ -16,6 +16,15 @@ 'trigger_path' ] + /** Arguments the variable expiration sweep always sends. */ + const variableExpirationHandlerArgs = [ + 'workspace_id', + 'variable_path', + 'description', + 'value_valid_until', + 'is_secret' + ] + export const slackErrorHandlerHubPathEnding = '/workspace-or-schedule-error-handler-slack' </script> @@ -78,6 +87,7 @@ /** Offer the instance critical alert channels as a destination. Workspace-level * error handling only: schedules and triggers have no such setting. */ showInstanceAlerts?: boolean + customRequiresEE?: boolean } let { @@ -90,9 +100,15 @@ customTabTooltip, noMargin = false, workspace = undefined, - showInstanceAlerts = false + showInstanceAlerts = false, + customRequiresEE = true }: Props = $props() + let isVariableExpiration = $derived(errorOrRecovery === 'variableExpiration') + let handlerArgs = $derived( + isVariableExpiration ? variableExpirationHandlerArgs : errorHandlerArgs + ) + let effectiveWorkspace = $derived(workspace ?? $operatingWorkspace) // Carry the workspace onto the "create from template" route, so the handler script is // created where this handler is saved. `customScriptTemplate` already has a query string. @@ -156,7 +172,11 @@ let submitted_job = await testJobFunction({ workspace: effectiveWorkspace!, requestBody: { - hub_script_path: draft[platform].path, + // Ours are not test-runnable presets; the error handler's checks the same + // token and channel. + hub_script_path: isVariableExpiration + ? builtinHubPath('error', platform) + : draft[platform].path, channel: channel, test_msg: `This is a notification to test the connection between ${platform} and Windmill workspace '${effectiveWorkspace!}'` } @@ -250,7 +270,7 @@ $effect(() => { const { path, kind } = draft.custom path && - loadHandlerScriptArgs(path, kind, errorHandlerArgs).then( + loadHandlerScriptArgs(path, kind, handlerArgs).then( (schema) => (customHandlerSchema = schema) ) }) @@ -258,7 +278,7 @@ $effect(() => { const path = draft.slack.path path && - loadHandlerScriptArgs(path, 'script', [...errorHandlerArgs, 'slack']).then( + loadHandlerScriptArgs(path, 'script', [...handlerArgs, 'slack']).then( (schema) => (slackHandlerSchema = schema) ) }) @@ -269,7 +289,9 @@ {#snippet children({ item })} <ToggleButton label="Slack" value="slack" {item} disabled={!isEditable} /> <ToggleButton label="Teams" value="teams" {item} disabled={!isEditable} /> - <ToggleButton label="Email" value="email" {item} disabled={!isEditable} /> + {#if !isVariableExpiration} + <ToggleButton label="Email" value="email" {item} disabled={!isEditable} /> + {/if} <ToggleButton label="Custom" value="custom" @@ -294,7 +316,7 @@ <div class="flex flex-col gap-1"> <div class="flex flex-row"> <ScriptPicker - disabled={!isEditable || !$enterpriseLicense} + disabled={!isEditable || (customRequiresEE && !$enterpriseLicense)} kinds={['script', 'failure']} allowFlow={true} bind:scriptPath={draft.custom.path} @@ -343,7 +365,7 @@ /> {/await} {#if customHandlerSchema && customHandlerSchema.properties && Object.keys(customHandlerSchema.properties).length === 0} - <div class="text-xs text-secondary">This error handler takes no extra arguments</div> + <div class="text-xs text-secondary">This handler takes no extra arguments</div> {/if} </div> {/if} @@ -381,6 +403,7 @@ }} bind:args={draft.slack.args} shouldHideNoInputs + compact className="text-xs" /> {/await} diff --git a/frontend/src/lib/components/FlowBuilder.svelte b/frontend/src/lib/components/FlowBuilder.svelte index a919fc82f5..a3d70a157c 100644 --- a/frontend/src/lib/components/FlowBuilder.svelte +++ b/frontend/src/lib/components/FlowBuilder.svelte @@ -135,8 +135,8 @@ let { initialPath = $bindable(''), /** The draft row's own path (the URL path). Unlike `initialPath`, which the - * route re-seeds from `draft_path` so the topbar shows the pending name, - * this stays where the item actually is. */ + * route re-seeds from a draft-only flow's `draft_path` so the topbar shows + * the pending name, this stays where the item actually is. */ userDraftPath = '', onTakeLatest = undefined, pathStoreInit = undefined, @@ -514,8 +514,7 @@ // the draft's `draft_path` so the topbar shows the pending name, which after // someone renames the item still names the old location. Comparing against // that fetches the row left behind there instead of the live one. - path: userDraftPath || initialPath, - withStarredInfo: true + path: userDraftPath || initialPath }) // A superseded opening must not write these: the current one would then render // and offer Take latest against the older head. @@ -1436,18 +1435,17 @@ readFieldsRecursively(flowStore.val) } }) - // Sync `$pathStore` from `flowStore.val.path` (which `initFlow` populates - // from the loaded flow — including the draft's rename, when there is one). - // This effect only tracks `flowStore.val.path`, so popover edits that go - // straight to `$pathStore` don't trigger it and aren't overwritten. - // Replaces the previous `$pathStore = initialPath` push (added in #2536 for - // the VSCode extension), which silently dropped any draft-renamed path - // because `initialPath` is the URL, not the loaded path. + // Sync `$pathStore` from the loaded flow: its staged `draft_path` when the + // draft has one, else its `path`. Seeding from `path` alone would make the + // effect below drop a staged move on every reload, and deploy would then + // create at a taken path. Only `flowStore.val.path` is tracked, so popover + // edits that go straight to `$pathStore` aren't overwritten. $effect.pre(() => { // `flowStore.val` is typed `OpenFlow` here but `initFlow` actually puts a // `Flow` (with `path`) in it. - const p = (flowStore.val as Flow | undefined)?.path - if (p) untrack(() => ($pathStore = p)) + const flow = flowStore.val as (Flow & { draft_path?: string }) | undefined + const p = flow?.path + if (p) untrack(() => ($pathStore = flow?.draft_path || p)) }) // Persist the user-typed path into the draft JSON as `draft_path` diff --git a/frontend/src/lib/components/NoDirectDeployAlert.svelte b/frontend/src/lib/components/NoDirectDeployAlert.svelte index 8ddf5c760a..1e1256241e 100644 --- a/frontend/src/lib/components/NoDirectDeployAlert.svelte +++ b/frontend/src/lib/components/NoDirectDeployAlert.svelte @@ -23,13 +23,15 @@ import { GitFork, Lock, ShieldOff } from 'lucide-svelte' import { useOperatingWorkspace, - useOperatingUser + useOperatingUser, + useReadOnlyWorkspace } from '$lib/components/operatingWorkspace.svelte' import { useRoleCapabilities } from '$lib/operatorWriteRights' const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() const roleCaps = useRoleCapabilities() + const readOnly = useReadOnlyWorkspace() // The shared rules state holds the navigation workspace's rules, so a host acting on another // workspace (an AI session's fork) has to ask for that one's. @@ -61,12 +63,16 @@ ? 'You will need to either fork the workspace, or make your changes locally and submit a PR to an authorized user.' : 'You will need to make your changes locally and submit a PR to an authorized user.' ) + // A session in Use edits nothing, so no rule or bypass opens the lists' create, edit and + // delete actions there. + let sessionReadOnly = $derived(readOnly($operatingWorkspace)) let overrideChecked = $state(false) // The toggle is only offered to a user who can bypass, but the answer can change under a // workspace switch, so the checked flag alone never grants the edit. - let bypassActive = $derived(canBypass && overrideChecked) + let bypassActive = $derived(canBypass && !sessionReadOnly && overrideChecked) let canEdit = $derived( - !isRuleActiveInRulesets(rulesets, 'DisableDirectDeployment') || bypassActive + !sessionReadOnly && + (!isRuleActiveInRulesets(rulesets, 'DisableDirectDeployment') || bypassActive) ) let { @@ -146,7 +152,7 @@ {editAdvice} </p> {/if} - {#if canBypass} + {#if canBypass && !sessionReadOnly} <Toggle size="xs" bind:checked={overrideChecked} diff --git a/frontend/src/lib/components/ResourcePicker.svelte b/frontend/src/lib/components/ResourcePicker.svelte index b396bd6dee..d766e5a51f 100644 --- a/frontend/src/lib/components/ResourcePicker.svelte +++ b/frontend/src/lib/components/ResourcePicker.svelte @@ -20,6 +20,9 @@ value?: string | undefined valueType?: string | undefined resourceType?: string | undefined + /** Further resource types whose resources are offered alongside `resourceType`'s. New + * resources are still created as `resourceType`. */ + additionalResourceTypes?: string[] disabled?: boolean disablePortal?: boolean showSchemaExplorer?: boolean @@ -46,6 +49,7 @@ value = $bindable(undefined), valueType = $bindable(undefined), resourceType = undefined, + additionalResourceTypes = [], disabled = false, disablePortal = false, showSchemaExplorer = false, @@ -114,8 +118,10 @@ async function loadResources(resourceType: string | undefined) { loading = true try { - const resourceTypesToQuery = - resourceType === 'snowflake' ? ['snowflake', 'snowflake_oauth'] : [resourceType] + const resourceTypesToQuery = [ + ...(resourceType === 'snowflake' ? ['snowflake', 'snowflake_oauth'] : [resourceType]), + ...additionalResourceTypes + ] const resources = await Promise.all( resourceTypesToQuery.map((rt) => diff --git a/frontend/src/lib/components/ScriptBuilder.svelte b/frontend/src/lib/components/ScriptBuilder.svelte index 3ee365be89..7c6db7b242 100644 --- a/frontend/src/lib/components/ScriptBuilder.svelte +++ b/frontend/src/lib/components/ScriptBuilder.svelte @@ -693,8 +693,7 @@ // the user has typed, so after someone renames the item it still names // the old location and this would compare against the archived row left // there. Resolving by the row is what lets the diff show the rename. - path: userDraftPath || initialPath, - withStarredInfo: true + path: userDraftPath || initialPath }) // A superseded opening must not write these: the current one would then render diff --git a/frontend/src/lib/components/ShareModal.svelte b/frontend/src/lib/components/ShareModal.svelte index dfb5892b03..082fbcce2e 100644 --- a/frontend/src/lib/components/ShareModal.svelte +++ b/frontend/src/lib/components/ShareModal.svelte @@ -25,13 +25,15 @@ import { useScheduleLock, useTriggerLock } from '$lib/operatorWriteRights' import { useOperatingWorkspace, - useOperatingUser + useOperatingUser, + useReadOnlyWorkspace } from '$lib/components/operatingWorkspace.svelte' const scheduleLock = useScheduleLock() const triggerLock = useTriggerLock() const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() + const readOnly = useReadOnlyWorkspace() const dispatch = createEventDispatcher() @@ -170,7 +172,9 @@ } } - let own = $state(false) + let ownsItem = $state(false) + // A session can switch to Use while the drawer is open, so read-only is read live. + const own = $derived(ownsItem && !readOnly($operatingWorkspace)) export async function openDrawer(newPath: string, kind_l: Kind, isOwnerOverride?: boolean) { path = newPath kind = kind_l @@ -181,7 +185,7 @@ loadLinkedVarPaths() loadDefaultPerms() if (isOwnerOverride !== undefined) { - own = isOwnerOverride + ownsItem = isOwnerOverride } else { loadOwner() } @@ -189,7 +193,7 @@ } async function loadOwner() { - own = isOwner(path, operatingUser.current!, $operatingWorkspace!) + ownsItem = isOwner(path, operatingUser.current!, $operatingWorkspace!) } async function loadAcls() { @@ -292,9 +296,15 @@ </div> {/if} {#if !own} - <Alert type="warning" title="Not owner" - >Since you do not own this item, you cannot modify its permission</Alert - > + {#if readOnly($operatingWorkspace)} + <Alert type="info" title="Use mode"> + This session cannot change permissions while it is in Use mode. + </Alert> + {:else} + <Alert type="warning" title="Not owner" + >Since you do not own this item, you cannot modify its permission</Alert + > + {/if} {/if} <div> {#if own && sharingDisabled} diff --git a/frontend/src/lib/components/VariableEditor.svelte b/frontend/src/lib/components/VariableEditor.svelte index d2af0187bc..a308237f5a 100644 --- a/frontend/src/lib/components/VariableEditor.svelte +++ b/frontend/src/lib/components/VariableEditor.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import { VariableService, WorkspaceService } from '$lib/gen' + import { VariableService, WorkspaceService, type ListableVariable } from '$lib/gen' import { createEventDispatcher, onDestroy, untrack } from 'svelte' import { Button } from './common' import Drawer from './common/drawer/Drawer.svelte' @@ -29,17 +29,37 @@ import { useDraftConflictSession } from '$lib/draftConflictSession.svelte' import { isEncryptedDraftValue } from '$lib/encryptedDraft' import { setLocalDraftHint } from '$lib/localDraftHints.svelte' + import { logFeatureUsage } from '$lib/utils/featureUsage' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() const dispatch = createEventDispatcher() + // Subset of VariableDraftState (copilot/chat/global/workspaceItems.ts) — the same + // localStorage rows back both, so a field this shape omits is dropped on save. type VariableState = { path: string variable: { value: string; is_secret: boolean; description: string } labels: string[] | undefined wsSpecific: boolean + value_valid_until?: string + } + + // The deployed baseline every dirty check and save diff runs against: a field it + // leaves out reads as unchanged, so its edits are never sent. + function deployedVariableState(v: ListableVariable): VariableState { + return { + path: v.path, + variable: { + value: v.value ?? '', + is_secret: v.is_secret, + description: v.description ?? '' + }, + labels: v.labels ?? undefined, + wsSpecific: v.ws_specific ?? false, + value_valid_until: v.value_valid_until ?? undefined + } } // The "current" workspace this editor defaults New/Edit actions to. Session @@ -79,6 +99,7 @@ let initialStates: Record<string, VariableState> = $state({}) let existedInitially: Record<string, boolean> = $state({}) let extraPerms: Record<string, Record<string, boolean>> = $state({}) + let oauthLinked: Record<string, boolean> = $state({}) let selected: string | undefined = $state(undefined) let pathError = $state('') const acting = useActingUser(() => selected) @@ -224,16 +245,7 @@ decryptSecret: false, getDraft: true }) - const deployedState: VariableState = { - path: v.path, - variable: { - value: v.value ?? '', - is_secret: v.is_secret, - description: v.description ?? '' - }, - labels: v.labels ?? undefined, - wsSpecific: v.ws_specific ?? false - } + const deployedState = deployedVariableState(v) // Everything below writes shared editor state, so first make sure it is still this // variable's: the drawer stays closable while the read is out, and another variable // opened meanwhile would otherwise get this one's baseline — and with it this one's @@ -257,6 +269,7 @@ // displayed would leave those deciding on the version the user just replaced. existedInitially[ws] = !(v as any).no_deployed extraPerms[ws] = v.extra_perms ?? {} + oauthLinked[ws] = v.account != null UserDraftDbSyncer.recordRemoteSync(query, (v as any).draft_saved_at) UserDraft.seed( 'variable', @@ -332,16 +345,7 @@ const savedDraftState = (v as any).draft as VariableState | undefined // Deployed baseline as the dirty-check reference, so the banner // compares draft-vs-deployed and fires immediately when a draft exists. - const deployedState: VariableState = { - path: v.path, - variable: { - value: v.value ?? '', - is_secret: v.is_secret, - description: v.description ?? '' - }, - labels: v.labels ?? undefined, - wsSpecific: v.ws_specific ?? false - } + const deployedState = deployedVariableState(v) // A refused save leaves this tab's own version parked. `.draft` is the version // that refused it, so opening on that would quietly drop the edit the alert is // about and leave "Keep mine" offering to keep the other one. @@ -363,6 +367,7 @@ // CREATE, not update (update 404s). existedInitially[ws] = !(v as any).no_deployed extraPerms[ws] = v.extra_perms ?? {} + oauthLinked[ws] = v.account != null }) }) }) @@ -376,6 +381,7 @@ initialStates = {} existedInitially = {} extraPerms = {} + oauthLinked = {} pathError = '' acting.forgetFailures() } @@ -435,6 +441,11 @@ async function save(): Promise<void> { const dirty = dirtyWorkspaces const savedPath = (curWs ? states[curWs]?.draft?.path : undefined) ?? editPath ?? '' + // Before the loop rewrites the baselines; once per save, not per workspace. + const expiryChanged = dirty.filter( + (ws) => initialStates[ws]?.value_valid_until !== states[ws].draft?.value_valid_until + ) + const expiryNowSet = expiryChanged.some((ws) => !!states[ws].draft?.value_valid_until) try { for (const ws of dirty) { const s = states[ws].draft! @@ -452,6 +463,11 @@ ini.variable.description != s.variable.description ? s.variable.description : undefined, + // Tri-state: absent leaves the stored date alone, `null` clears it. + value_valid_until: + ini.value_valid_until != s.value_valid_until + ? (s.value_valid_until ?? null) + : undefined, labels: s.labels, ws_specific: s.wsSpecific } @@ -464,6 +480,7 @@ value: s.variable.value, is_secret: s.variable.is_secret, description: s.variable.description, + value_valid_until: s.value_valid_until, labels: s.labels, ws_specific: s.wsSpecific } @@ -480,6 +497,11 @@ // it shows up immediately instead of after the 60s TTL. invalidateWorkspacePaths(ws) } + if (expiryChanged.length > 0) { + logFeatureUsage('variable_expiration', 'expiry_edited', { + key: expiryNowSet ? 'set' : 'cleared' + }) + } sendUserToast(edit ? `Updated variable in ${dirty.length} workspace(s)` : `Created variable`) dispatch('create') onSaved?.(savedPath) @@ -567,6 +589,7 @@ bind:variable={current.variable} bind:labels={current.labels} bind:wsSpecific={current.wsSpecific} + bind:valueValidUntil={current.value_valid_until} {initialPath} deployTo={deployTo.current} can_write={can_write === true} @@ -578,6 +601,7 @@ !!initialStates[selected]?.variable.is_secret} workspace={selected} actingUser={acting.in(selected) ?? null} + oauthLinked={!!selected && !!oauthLinked[selected]} /> {/key} {/if} diff --git a/frontend/src/lib/components/VariableForm.svelte b/frontend/src/lib/components/VariableForm.svelte index e3542b9b36..8bebd55953 100644 --- a/frontend/src/lib/components/VariableForm.svelte +++ b/frontend/src/lib/components/VariableForm.svelte @@ -3,7 +3,7 @@ import LabelsInput from './LabelsInput.svelte' import Toggle from './Toggle.svelte' import Alert from './common/alert/Alert.svelte' - import { Button } from './common' + import { Button, Section } from './common' import Tooltip from './Tooltip.svelte' import Label from './Label.svelte' import ToggleButtonGroup from './common/toggleButton-v2/ToggleButtonGroup.svelte' @@ -15,6 +15,7 @@ import { isOwner } from '$lib/utils' import { isEncryptedDraftValue } from '$lib/encryptedDraft' import EncryptedDraftField from './EncryptedDraftField.svelte' + import DateTimeInput from './DateTimeInput.svelte' import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() @@ -32,6 +33,7 @@ variable: Variable labels: string[] | undefined wsSpecific: boolean + valueValidUntil: string | undefined deployTo: string | undefined can_write: boolean edit: boolean @@ -47,6 +49,8 @@ * refuses, rather than answering with the navigation user's rights in another * workspace. */ actingUser: UserExt | null + /** Linked to an OAuth account, whose token Windmill refreshes on its own. */ + oauthLinked?: boolean } let { @@ -56,6 +60,7 @@ variable = $bindable(), labels = $bindable(), wsSpecific = $bindable(), + valueValidUntil = $bindable(), deployTo, can_write, edit, @@ -63,7 +68,8 @@ onSecretChange, deployedSecret = false, workspace = undefined, - actingUser + actingUser, + oauthLinked = false }: Props = $props() let ws = $derived(workspace ?? $operatingWorkspace) @@ -74,6 +80,15 @@ ) ) + // Enabling seeds a date: an empty DateTimeInput still shows 12:00, which reads as set. + const DEFAULT_EXPIRY_DAYS = 90 + + function defaultValueExpiry(): string { + const d = new Date() + d.setDate(d.getDate() + DEFAULT_EXPIRY_DAYS) + return d.toISOString() + } + // Loading the deployed secret overwrites the draft row this form shares with the AI // chat, so every path that would trigger it has to be blocked while that row stages a // value — otherwise the staged one is replaced and the next deploy carries the old one. @@ -243,3 +258,44 @@ stretch the hit area over the whole row and the alert. The toggle carries its na <textarea rows="4" use:autosize bind:value={variable.description} placeholder="Used for X" ></textarea> </label> + +{#if !oauthLinked} + <Section label="Advanced" small collapsable initiallyCollapsed={!valueValidUntil}> + <Label + label="Value expiration" + tooltip="When the value stored here stops working, such as the expiry date of an API key. Ahead of it (1 day by default), Windmill runs the workspace's variable expiration handler if one is set, so the value can be rotated. The variable itself is never deleted." + > + <Toggle + size="sm" + checked={Boolean(valueValidUntil)} + disabled={!can_write} + on:change={() => (valueValidUntil = valueValidUntil ? undefined : defaultValueExpiry())} + options={{ right: 'Set a date on which this value expires' }} + /> + {#if valueValidUntil} + <span class="text-xs font-semibold text-emphasis leading-none mt-2">Expires on</span> + <!-- Unbound: DateTimeInput writes `null` when emptied, which reads as an edit, + so empty states become `undefined`. Not `clearable`: the toggle means "no expiry". --> + <DateTimeInput + value={valueValidUntil} + useDropdown + disabled={!can_write} + on:change={(e) => (valueValidUntil = e.detail ?? undefined)} + /> + <span class="text-2xs font-normal text-hint"> + {#if new Date(valueValidUntil).getTime() <= Date.now()} + This date has passed. Set a new one once the value is rotated. + {:else} + Runs the workspace's <a + href="/workspace_settings?tab=variable_expiration&workspace={ws}" + >variable expiration handler</a + > + ahead of this date, if the workspace has one{path.startsWith('u/') + ? ' and does not skip u/ paths' + : ''}. + {/if} + </span> + {/if} + </Label> + </Section> +{/if} diff --git a/frontend/src/lib/components/WorkerGroup.svelte b/frontend/src/lib/components/WorkerGroup.svelte index 584d3bf2d5..3ee0807c50 100644 --- a/frontend/src/lib/components/WorkerGroup.svelte +++ b/frontend/src/lib/components/WorkerGroup.svelte @@ -1078,6 +1078,7 @@ <div class="mt-8"></div> <AutoscalingConfigEditor + worker_group={name} worker_tags={config?.worker_tags} group_has_tags={nconfig.worker_tags !== undefined ? nconfig.worker_tags.length > 0 diff --git a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte index e5613a71a1..b6e0134eff 100644 --- a/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte +++ b/frontend/src/lib/components/apps/editor/AppEditorHeader.svelte @@ -380,8 +380,7 @@ async function syncWithDeployed(opening?: number) { const deployedApp = await AppService.getAppByPath({ workspace: $workspaceStore!, - path: $appPath!, - withStarredInfo: true + path: $appPath! }) // A superseded opening must not write these: the current one would then render @@ -1326,7 +1325,7 @@ { label: 'Fork', onClick: () => { - window.open(`/apps/add?template=${appPath}`) + window.open(`/apps/add?template=${encodeURIComponent($appPath)}`) } }, ...(!isCloudHosted() && editInForkAllowed($workspaceStore, $userWorkspaces) diff --git a/frontend/src/lib/components/common/seconds/SecondsInput.svelte b/frontend/src/lib/components/common/seconds/SecondsInput.svelte index 9ca42379d4..ad4c0abee7 100644 --- a/frontend/src/lib/components/common/seconds/SecondsInput.svelte +++ b/frontend/src/lib/components/common/seconds/SecondsInput.svelte @@ -23,6 +23,10 @@ size?: ButtonType.UnifiedSize clearable?: boolean defaultValue?: number | undefined + /** For a value consumed at a coarser granularity, where seconds would have no effect. */ + hideSeconds?: boolean + /** Hides the "= N seconds" caption below the input. */ + hideTotal?: boolean onfocus?: (e: FocusEvent) => void } @@ -33,6 +37,8 @@ size = 'md', clearable = false, defaultValue = undefined, + hideSeconds = false, + hideTotal = false, onfocus }: Props = $props() @@ -193,30 +199,32 @@ ><span class="invisible">mins</span><span>{min && min > 1 ? 'mins' : 'min'}</span></span > </div> - <div class="flex items-baseline"> - <input - type="number" - class={segmentClass} - value={hasValue ? sec || 0 : ''} - placeholder="_ _" - min="0" - max="59" - {disabled} - oninput={(e) => handleInput(e, (v) => (sec = v), 59)} - onkeydown={(e) => - handleKeydown( - e, - () => sec, - (v) => (sec = v), - 59 - )} - onfocus={handleFocus} - onblur={handleBlur} - /> - <span class="text-secondary text-2xs inline-grid [&>*]:col-start-1 [&>*]:row-start-1" - ><span class="invisible">secs</span><span>{sec && sec > 1 ? 'secs' : 'sec'}</span></span - > - </div> + {#if !hideSeconds} + <div class="flex items-baseline"> + <input + type="number" + class={segmentClass} + value={hasValue ? sec || 0 : ''} + placeholder="_ _" + min="0" + max="59" + {disabled} + oninput={(e) => handleInput(e, (v) => (sec = v), 59)} + onkeydown={(e) => + handleKeydown( + e, + () => sec, + (v) => (sec = v), + 59 + )} + onfocus={handleFocus} + onblur={handleBlur} + /> + <span class="text-secondary text-2xs inline-grid [&>*]:col-start-1 [&>*]:row-start-1" + ><span class="invisible">secs</span><span>{sec && sec > 1 ? 'secs' : 'sec'}</span></span + > + </div> + {/if} </div> {#if clearable && !disabled && seconds != null && seconds !== defaultValue} <CloseButton @@ -229,11 +237,13 @@ /> {/if} </div> - {#if seconds != null} - <span class="text-2xs text-hint"> - = {seconds.toLocaleString()} second{seconds === 1 ? '' : 's'} - </span> - {:else} - <span class="text-2xs text-hint"> no time set </span> + {#if !hideTotal} + {#if seconds != null} + <span class="text-2xs text-hint"> + = {seconds.toLocaleString()} second{seconds === 1 ? '' : 's'} + </span> + {:else} + <span class="text-2xs text-hint"> no time set </span> + {/if} {/if} </div> diff --git a/frontend/src/lib/components/common/table/AgentRow.svelte b/frontend/src/lib/components/common/table/AgentRow.svelte index 20421ac514..061112dec4 100644 --- a/frontend/src/lib/components/common/table/AgentRow.svelte +++ b/frontend/src/lib/components/common/table/AgentRow.svelte @@ -14,7 +14,7 @@ import { keepsManagedMemory } from '$lib/components/flows/agentFormFields' import { agentMenuItems, deleteAgent } from '$lib/components/flows/agentActions' import AgentHistory from '$lib/components/flows/AgentHistory.svelte' - import { getDeployUiSettings } from '$lib/components/home/deploy_ui' + import { cachedDeployUiSettings, warmDeployUiSettings } from '$lib/components/home/deploy_ui' import Row from './Row.svelte' import { selectMenuItems, type RowSelection } from './rowSelection' @@ -48,6 +48,8 @@ const dispatch = createEventDispatcher() + warmDeployUiSettings() + async function remove(path: string) { if (await deleteAgent($workspaceStore!, path)) dispatch('change') } @@ -106,10 +108,10 @@ {/if} <Dropdown size="sm" - items={async () => [ + items={() => [ ...selectMenuItems(rowSelection), ...agentMenuItems({ - deployUiSettings: await getDeployUiSettings(), + deployUiSettings: cachedDeployUiSettings(), path: agent.path, canWrite: agent.canWrite, draftOnly: Boolean(agent.draft_only), diff --git a/frontend/src/lib/components/common/table/AppRow.svelte b/frontend/src/lib/components/common/table/AppRow.svelte index 812c06284b..7553b61870 100644 --- a/frontend/src/lib/components/common/table/AppRow.svelte +++ b/frontend/src/lib/components/common/table/AppRow.svelte @@ -35,7 +35,8 @@ import { copyToClipboard } from '$lib/utils' import AppDeploymentHistory from '$lib/components/apps/editor/AppDeploymentHistory.svelte' import { isDeployable } from '$lib/utils_deployable' - import { getDeployUiSettings } from '$lib/components/home/deploy_ui' + import { cachedDeployUiSettings, warmDeployUiSettings } from '$lib/components/home/deploy_ui' + import { menuGroups } from './menuGroups' import { editInForkAllowed, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork' import EditInForkButton from './EditInForkButton.svelte' import { isCloudHosted } from '$lib/cloud' @@ -69,6 +70,8 @@ }: Props = $props() const dispatch = createEventDispatcher() + + warmDeployUiSettings() // A compose-only app role authors full-code apps, never low-code ones. const canAuthorApp = $derived(roleCanAuthor(app.raw_app ? 'raw_app' : 'app', $roleCapabilities)) const fullApps = $derived($roleCapabilities.apps === 'full') @@ -167,27 +170,155 @@ size="sm" aiId={`app-row-dropdown-${app.summary?.length > 0 ? app.summary : app.path}`} aiDescription={`Open dropdown for app ${app.summary?.length > 0 ? app.summary : app.path} options`} - items={async () => { + items={() => { let { draft_only, canWrite, summary, execution_mode, path } = app const canEdit = canWrite && showEditButton if (draft_only) { - return [ - ...selectMenuItems(rowSelection), + return menuGroups( + selectMenuItems(rowSelection), + [ + { + displayName: fullApps ? 'View/Edit JSON' : 'View JSON', + icon: File, + action: () => { + loadAppJson() + } + }, + { + displayName: 'Move/Rename', + icon: FolderOpen, + action: () => { + // Addressed by the generated path its draft row sits at, but + // named by the path typed in the editor. + moveDrawer.openDrawer((app as any).draft_path ?? path, summary, 'app', { + storagePath: path, + rawApp: !!app.raw_app + }) + }, + disabled: !showEditButton, + hide: !fullApps + } + ], + [ + { + displayName: 'Delete', + icon: Trash, + action: async (event) => { + // TODO + // @ts-ignore + if (event?.shiftKey) { + await deleteApp(path) + dispatch('change') + } else { + deleteConfirmedCallback = async () => { + await deleteApp(path) + dispatch('change') + } + } + }, + type: 'delete', + // A draft-only row is always the authed user's own draft (the + // list endpoint only surfaces own/legacy draft-only rows), so + // discarding it never requires write permission on the path. The server + // lets one's own draft go without authoring rights; only a legacy one needs them. + disabled: !showEditButton, + hide: + !canAuthorApp && + app.draft_users?.length === 1 && + app.draft_users[0]?.username == null + } + ] + ) + } + return menuGroups( + selectMenuItems(rowSelection), + [ + { + displayName: fullApps ? 'View/Edit JSON' : 'View JSON', + icon: FileJson, + action: () => { + loadAppJson() + } + }, + { + displayName: 'Go to public page', + icon: ExternalLink, + action: async () => { + let secretUrl = await AppService.getPublicSecretOfApp({ + workspace: $workspaceStore ?? '', + path + }) + let url = + page.url.protocol + + '//' + + `${page.url.hostname}/public/${$workspaceStore}/${secretUrl}` + gotoUrl(url) + }, + hide: execution_mode != 'anonymous' + }, + { + displayName: 'Versions', + icon: History, + action: () => appDeploymentHistory?.open(), + hide: !canAuthorApp + } + ], + [ + { + displayName: 'Duplicate/Fork', + icon: GitFork, + href: `/apps${app.raw_app ? '_raw' : ''}/add?template=${path}`, + disabled: !showEditButton, + hide: !fullApps + }, + { + displayName: editInForkLabel($workspaceStore, $userWorkspaces), + icon: Pen, + // No `href`: the handler resolves the destination asynchronously, and a melt + // menu item's anchor navigates before a delegated onclick can preventDefault it. + action: (e) => onEditInForkClick(e, app.raw_app ? 'raw_app' : 'app', path), + hide: + !$roleCapabilities.code || + isCloudHosted() || + !editInForkAllowed($workspaceStore, $userWorkspaces) + }, + { + displayName: 'Copy path', + icon: Copy, + action: () => { + copyToClipboard(path) + } + } + ], + [ { displayName: 'Move/Rename', icon: FolderOpen, action: () => { - // Addressed by the generated path its draft row sits at, but - // named by the path typed in the editor. - moveDrawer.openDrawer((app as any).draft_path ?? path, summary, 'app', { - storagePath: path, - rawApp: !!app.raw_app - }) + moveDrawer.openDrawer(path, summary, 'app') }, - disabled: !showEditButton, + disabled: !canEdit, hide: !fullApps }, + { + displayName: 'Permissions', + icon: Shield, + action: () => { + shareModal.openDrawer && shareModal.openDrawer(path, 'app') + }, + hide: !canAuthorApp + }, + { + displayName: 'Deploy to staging/prod', + icon: ChevronUpSquare, + action: () => { + deploymentDrawer.openDrawer(path, 'app') + }, + hide: !fullApps || !isDeployable('app', path, cachedDeployUiSettings()) + } + ], + [ { displayName: 'Delete', icon: Trash, @@ -205,134 +336,11 @@ } }, type: 'delete', - // A draft-only row is always the authed user's own draft (the - // list endpoint only surfaces own/legacy draft-only rows), so - // discarding it never requires write permission on the path. The server - // lets one's own draft go without authoring rights; only a legacy one needs them. - disabled: !showEditButton, - hide: - !canAuthorApp && - app.draft_users?.length === 1 && - app.draft_users[0]?.username == null - }, - { - displayName: fullApps ? 'View/Edit JSON' : 'View JSON', - icon: File, - action: () => { - loadAppJson() - } + disabled: !canEdit, + hide: !canAuthorApp } ] - } - return [ - ...selectMenuItems(rowSelection), - { - displayName: 'Duplicate/Fork', - icon: GitFork, - href: `/apps${app.raw_app ? '_raw' : ''}/add?template=${path}`, - disabled: !showEditButton, - hide: !fullApps - }, - { - displayName: editInForkLabel($workspaceStore, $userWorkspaces), - icon: Pen, - // No `href`: the handler resolves the destination asynchronously, and a melt - // menu item's anchor navigates before a delegated onclick can preventDefault it. - action: (e) => onEditInForkClick(e, app.raw_app ? 'raw_app' : 'app', path), - hide: - !$roleCapabilities.code || - isCloudHosted() || - !editInForkAllowed($workspaceStore, $userWorkspaces) - }, - { - displayName: 'Move/Rename', - icon: FolderOpen, - action: () => { - moveDrawer.openDrawer(path, summary, 'app') - }, - disabled: !canEdit, - hide: !fullApps - }, - ...(isDeployable('app', path, await getDeployUiSettings()) - ? [ - { - displayName: 'Deploy to staging/prod', - icon: ChevronUpSquare, - action: () => { - deploymentDrawer.openDrawer(path, 'app') - }, - hide: !fullApps - } - ] - : []), - { - displayName: fullApps ? 'View/Edit JSON' : 'View JSON', - icon: FileJson, - action: () => { - loadAppJson() - } - }, - { - displayName: 'Deployments', - icon: History, - action: () => appDeploymentHistory?.open(), - hide: !canAuthorApp - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal.openDrawer && shareModal.openDrawer(path, 'app') - }, - hide: !canAuthorApp - }, - { - displayName: 'Copy path', - icon: Copy, - action: () => { - copyToClipboard(path) - } - }, - ...(execution_mode == 'anonymous' - ? [ - { - displayName: 'Go to public page', - icon: ExternalLink, - action: async () => { - let secretUrl = await AppService.getPublicSecretOfApp({ - workspace: $workspaceStore ?? '', - path - }) - let url = - page.url.protocol + - '//' + - `${page.url.hostname}/public/${$workspaceStore}/${secretUrl}` - gotoUrl(url) - } - } - ] - : []), - { - displayName: 'Delete', - icon: Trash, - action: async (event) => { - // TODO - // @ts-ignore - if (event?.shiftKey) { - await deleteApp(path) - dispatch('change') - } else { - deleteConfirmedCallback = async () => { - await deleteApp(path) - dispatch('change') - } - } - }, - type: 'delete', - disabled: !canEdit, - hide: !canAuthorApp - } - ] + ) }} on:open={() => { menuOpen = true diff --git a/frontend/src/lib/components/common/table/FlowRow.svelte b/frontend/src/lib/components/common/table/FlowRow.svelte index ccbdb9de3e..eb58ecbc3f 100644 --- a/frontend/src/lib/components/common/table/FlowRow.svelte +++ b/frontend/src/lib/components/common/table/FlowRow.svelte @@ -32,13 +32,15 @@ Archive, Copy, Eye, - HistoryIcon + HistoryIcon, + Clock } from 'lucide-svelte' import FlowHistory from '$lib/components/flows/FlowHistory.svelte' import ChatFlowBadge from '$lib/components/flows/ChatFlowBadge.svelte' import RowLabels from './RowLabels.svelte' import AskAiRowButton from './AskAiRowButton.svelte' - import { getDeployUiSettings } from '$lib/components/home/deploy_ui' + import { cachedDeployUiSettings, warmDeployUiSettings } from '$lib/components/home/deploy_ui' + import { menuGroups, submenu } from './menuGroups' import { editInForkAllowed, editInForkLabel, onEditInForkClick } from '$lib/utils/editInFork' import EditInForkButton from './EditInForkButton.svelte' import { isCloudHosted } from '$lib/cloud' @@ -86,6 +88,8 @@ const dispatch = createEventDispatcher() + warmDeployUiSettings() + async function archiveFlow(path: string, archived: boolean): Promise<void> { try { await FlowService.archiveFlowByPath({ @@ -199,24 +203,152 @@ size="sm" aiId={`flow-row-dropdown-${flow.summary?.length > 0 ? flow.summary : flow.path}`} aiDescription={`Open dropdown for flow ${flow.summary?.length > 0 ? flow.summary : flow.path} options`} - items={async () => { + items={() => { let { draft_only, path, archived } = flow let owner = isOwner(path, $userStore, $workspaceStore) const canEdit = flow.canWrite && showEditButton if (draft_only) { - return [ - ...selectMenuItems(rowSelection), + return menuGroups( + selectMenuItems(rowSelection), + [ + { + displayName: 'Move/Rename', + icon: FolderOpen, + action: () => { + // Addressed by the generated path its draft row sits at, but + // named by the path typed in the editor. + moveDrawer.openDrawer((flow as any).draft_path ?? path, flow.summary, 'flow', { + storagePath: path + }) + }, + disabled: !showEditButton, + hide: hideForOperator + } + ], + [ + { + displayName: 'Delete', + icon: Trash, + action: (event) => { + // @ts-ignore + if (event?.shiftKey) { + deleteFlow(path) + } else { + deleteConfirmedCallback = () => { + deleteFlow(path) + } + } + }, + type: 'delete', + // A draft-only row is always the authed user's own draft (the + // list endpoint only surfaces own/legacy draft-only rows), so + // discarding it never requires write permission on the path. + disabled: !showEditButton, + hide: hideForOperator + } + ] + ) + } + return menuGroups( + selectMenuItems(rowSelection), + [ + { + displayName: 'View runs', + icon: List, + href: `/runs/${path}` + }, + submenu('History', Clock, [ + { + displayName: 'Versions', + icon: HistoryIcon, + action: () => { + flowHistory?.open() + }, + hide: hideForOperator + }, + { + displayName: 'Audit logs', + icon: Eye, + href: `/audit_logs?resource=${path}`, + hide: !$roleCapabilities.code || !$roleCapabilities.visibility.audit_logs + } + ]) + ], + [ + { + displayName: 'Duplicate/Fork', + icon: GitFork, + href: `/flows/add?template=${path}`, + disabled: !showEditButton, + hide: hideForOperator + }, + { + displayName: editInForkLabel($workspaceStore, $userWorkspaces), + icon: Pen, + // No `href`: the handler resolves the destination asynchronously, and a melt + // menu item's anchor navigates before a delegated onclick can preventDefault it. + action: (e) => onEditInForkClick(e, 'flow', path), + hide: + !$roleCapabilities.code || + isCloudHosted() || + !editInForkAllowed($workspaceStore, $userWorkspaces) + }, + { + displayName: 'Copy path', + icon: Copy, + action: () => { + copyToClipboard(path) + } + } + ], + [ { displayName: 'Move/Rename', icon: FolderOpen, action: () => { - // Addressed by the generated path its draft row sits at, but - // named by the path typed in the editor. - moveDrawer.openDrawer((flow as any).draft_path ?? path, flow.summary, 'flow', { - storagePath: path - }) + moveDrawer.openDrawer(path, flow.summary, 'flow') }, - disabled: !showEditButton, + disabled: !owner || archived || !canEdit, + hide: hideForOperator + }, + { + displayName: 'Schedule', + icon: Calendar, + action: () => { + scheduleEditor?.openNew(true, path) + }, + disabled: archived, + hide: hideForOperator || !$roleCapabilities.manage_schedules + }, + { + displayName: 'Permissions', + icon: Shield, + action: () => { + shareModal.openDrawer && shareModal.openDrawer(path, 'flow') + }, + hide: hideForOperator + }, + { + displayName: 'Deploy to staging/prod', + icon: ChevronUpSquare, + action: () => { + deploymentDrawer.openDrawer(path, 'flow') + }, + disabled: archived, + hide: + $roleCapabilities.flows !== 'full' || + !isDeployable('flow', path, cachedDeployUiSettings()) + } + ], + [ + { + displayName: archived ? 'Unarchive' : 'Archive', + icon: Archive, + action: () => { + path && archiveFlow(path, !archived) + }, + type: 'delete', + disabled: !owner || !canEdit, hide: hideForOperator }, { @@ -233,127 +365,11 @@ } }, type: 'delete', - // A draft-only row is always the authed user's own draft (the - // list endpoint only surfaces own/legacy draft-only rows), so - // discarding it never requires write permission on the path. - disabled: !showEditButton, + disabled: !owner || !canEdit, hide: hideForOperator } ] - } - return [ - ...selectMenuItems(rowSelection), - { - displayName: 'View runs', - icon: List, - href: `/runs/${path}` - }, - { - displayName: 'Duplicate/Fork', - icon: GitFork, - href: `/flows/add?template=${path}`, - disabled: !showEditButton, - hide: hideForOperator - }, - { - displayName: editInForkLabel($workspaceStore, $userWorkspaces), - icon: Pen, - // No `href`: the handler resolves the destination asynchronously, and a melt - // menu item's anchor navigates before a delegated onclick can preventDefault it. - action: (e) => onEditInForkClick(e, 'flow', path), - hide: - !$roleCapabilities.code || - isCloudHosted() || - !editInForkAllowed($workspaceStore, $userWorkspaces) - }, - { - displayName: 'Audit logs', - icon: Eye, - href: `/audit_logs?resource=${path}`, - hide: !$roleCapabilities.code || !$roleCapabilities.visibility.audit_logs - }, - { - displayName: 'Move/Rename', - icon: FolderOpen, - action: () => { - moveDrawer.openDrawer(path, flow.summary, 'flow') - }, - disabled: !owner || archived || !canEdit, - hide: hideForOperator - }, - { - displayName: 'Copy path', - icon: Copy, - action: () => { - copyToClipboard(path) - } - }, - ...(isDeployable('flow', path, await getDeployUiSettings()) - ? [ - { - displayName: 'Deploy to staging/prod', - icon: ChevronUpSquare, - action: () => { - deploymentDrawer.openDrawer(path, 'flow') - }, - disabled: archived, - hide: $roleCapabilities.flows !== 'full' - } - ] - : []), - { - displayName: 'Deployments', - icon: HistoryIcon, - action: () => { - flowHistory?.open() - }, - hide: hideForOperator - }, - { - displayName: 'Schedule', - icon: Calendar, - action: () => { - scheduleEditor?.openNew(true, path) - }, - disabled: archived, - hide: hideForOperator || !$roleCapabilities.manage_schedules - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal.openDrawer && shareModal.openDrawer(path, 'flow') - }, - hide: hideForOperator - }, - { - displayName: archived ? 'Unarchive' : 'Archive', - icon: Archive, - action: () => { - path && archiveFlow(path, !archived) - }, - type: 'delete', - disabled: !owner || !canEdit, - hide: hideForOperator - }, - { - displayName: 'Delete', - icon: Trash, - action: (event) => { - // @ts-ignore - if (event?.shiftKey) { - deleteFlow(path) - } else { - deleteConfirmedCallback = () => { - deleteFlow(path) - } - } - }, - type: 'delete', - disabled: !owner || !canEdit, - hide: hideForOperator - } - ] + ) }} on:open={() => { menuOpen = true diff --git a/frontend/src/lib/components/common/table/RawAppRow.svelte b/frontend/src/lib/components/common/table/RawAppRow.svelte index 1740566bdc..c1feaa2a38 100644 --- a/frontend/src/lib/components/common/table/RawAppRow.svelte +++ b/frontend/src/lib/components/common/table/RawAppRow.svelte @@ -8,7 +8,7 @@ import type DeployWorkspaceDrawer from '$lib/components/DeployWorkspaceDrawer.svelte' import { Globe, Shield } from 'lucide-svelte' import { isDeployable } from '$lib/utils_deployable' - import { getDeployUiSettings } from '$lib/components/home/deploy_ui' + import { cachedDeployUiSettings, warmDeployUiSettings } from '$lib/components/home/deploy_ui' interface Props { app: ListableRawApp & { canWrite: boolean } @@ -29,6 +29,8 @@ menuOpen = $bindable(false), keyboardSelected = false }: Props = $props() + + warmDeployUiSettings() </script> <Row @@ -48,11 +50,11 @@ {#snippet actions()} <Dropdown size="sm" - items={async () => { + items={() => { let { path } = app return [ - ...(isDeployable('app', path, await getDeployUiSettings()) + ...(isDeployable('app', path, cachedDeployUiSettings()) ? [ { displayName: 'Deploy to prod/staging', diff --git a/frontend/src/lib/components/common/table/ScriptRow.svelte b/frontend/src/lib/components/common/table/ScriptRow.svelte index 9c300b9477..5044cf4339 100644 --- a/frontend/src/lib/components/common/table/ScriptRow.svelte +++ b/frontend/src/lib/components/common/table/ScriptRow.svelte @@ -45,7 +45,9 @@ Shield, Trash, History, - FileText + FileText, + Clock, + Send } from 'lucide-svelte' import ScriptVersionHistory from '$lib/components/ScriptVersionHistory.svelte' import WacExportDrawer from '$lib/components/scripts/WacExportDrawer.svelte' @@ -55,7 +57,8 @@ import AskAiRowButton from './AskAiRowButton.svelte' import Popover from '$lib/components/Popover.svelte' import Tooltip from '$lib/components/Tooltip.svelte' - import { getDeployUiSettings } from '$lib/components/home/deploy_ui' + import { cachedDeployUiSettings, warmDeployUiSettings } from '$lib/components/home/deploy_ui' + import { menuGroups, submenu } from './menuGroups' import { claimTab, editInForkAllowed, @@ -105,6 +108,8 @@ const dispatch = createEventDispatcher() + warmDeployUiSettings() + async function archiveScript(path: string): Promise<void> { await ScriptService.archiveScriptByPath({ workspace: $workspaceStore!, path }) dispatch('change') @@ -262,12 +267,66 @@ size="sm" aiId={`script-row-dropdown-${script.summary?.length > 0 ? script.summary : script.path}`} aiDescription={`Open dropdown for script ${script.summary?.length > 0 ? script.summary : script.path} options`} - items={async () => { + items={() => { let owner = isOwner(script.path, $userStore, $workspaceStore) const canEdit = script.canWrite && showEditButton if (script.draft_only) { - return [ - ...selectMenuItems(rowSelection), + return menuGroups( + selectMenuItems(rowSelection), + [ + { + displayName: 'View code', + icon: Code, + action: () => { + showCode(script.path, script.summary) + } + }, + { + displayName: 'Move/Rename', + icon: FolderOpen, + action: () => { + // Addressed by the generated path its draft row sits at, but + // named by the path typed in the editor. + moveDrawer.openDrawer( + script.draft_path ?? script.path, + script.summary, + 'script', + { + storagePath: script.path + } + ) + }, + disabled: !showEditButton, + hide: !$roleCapabilities.code + } + ], + [ + { + displayName: 'Delete', + icon: Trash, + action: (event) => { + // TODO + // @ts-ignore + if (event?.shiftKey) { + deleteScript(script.path) + } else { + deleteConfirmedCallback = () => { + deleteScript(script.path) + } + } + }, + type: dlt, + // A draft-only row is always the authed user's own draft (the + // list endpoint only surfaces own/legacy draft-only rows), so + // discarding it never requires write permission on the path. + disabled: !showEditButton + } + ] + ) + } + return menuGroups( + selectMenuItems(rowSelection), + [ { displayName: 'View code', icon: Code, @@ -275,25 +334,154 @@ showCode(script.path, script.summary) } }, + { + displayName: 'View JSON/YAML', + icon: FileText, + action: async () => { + const fullScript = await ScriptService.getScriptByPath({ + workspace: $workspaceStore!, + path: script.path + }) + wacExportDrawer?.open(fullScript) + }, + hide: script.auto_kind !== 'wac' + }, + { + displayName: 'View runs', + icon: List, + href: `/runs/${script.path}` + }, + submenu('History', Clock, [ + { + displayName: 'Versions', + icon: History, + action: () => { + versionsDrawerOpen = true + } + }, + { + displayName: 'Audit logs', + icon: Eye, + href: `/audit_logs?resource=${script.path}`, + hide: !$roleCapabilities.code || !$roleCapabilities.visibility.audit_logs + } + ]) + ], + [ + { + displayName: 'Duplicate/Fork', + icon: GitFork, + href: `/scripts/add?template=${script.path}`, + disabled: !showEditButton, + hide: !$roleCapabilities.code + }, + { + displayName: editInForkLabel($workspaceStore, $userWorkspaces), + icon: Pen, + // No `href`: the handler resolves the destination asynchronously, and a melt + // menu item's anchor navigates before a delegated onclick can preventDefault it. + action: (e) => onEditInForkClick(e, 'script', script.path), + hide: + !$roleCapabilities.code || + isCloudHosted() || + !editInForkAllowed($workspaceStore, $userWorkspaces) + }, + { + displayName: 'Copy path', + icon: Copy, + action: () => { + copyToClipboard(script.path) + } + } + ], + [ { displayName: 'Move/Rename', icon: FolderOpen, action: () => { - // Addressed by the generated path its draft row sits at, but - // named by the path typed in the editor. - moveDrawer.openDrawer(script.draft_path ?? script.path, script.summary, 'script', { - storagePath: script.path - }) + moveDrawer.openDrawer(script.path, script.summary, 'script') }, - disabled: !showEditButton, + disabled: !owner || script.archived || !canEdit, + hide: !$roleCapabilities.code + }, + { + displayName: 'Schedule', + icon: Calendar, + action: () => { + scheduleEditor?.openNew(false, script.path) + }, + disabled: script.archived, + hide: !$roleCapabilities.code || !$roleCapabilities.manage_schedules + }, + { + displayName: 'Permissions', + icon: Shield, + action: () => { + shareModal.openDrawer && shareModal.openDrawer(script.path, 'script') + }, + disabled: script.archived, + hide: !$roleCapabilities.code + }, + submenu('Deploy & publish', Send, [ + { + displayName: 'Deploy to staging/prod', + icon: ChevronUpSquare, + action: () => { + deploymentDrawer.openDrawer(script.path, 'script') + }, + disabled: script.archived, + hide: + !$roleCapabilities.code || + !isDeployable('script', script.path, cachedDeployUiSettings()) + }, + { + displayName: 'Publish to Hub', + icon: Globe2, + action: async () => { + // The row only carries metadata, so the code has to be fetched first; the tab is + // claimed before that, since Safari won't open one after an await. + const tab = claimTab() + try { + const fullScript = await ScriptService.getScriptByPath({ + workspace: $workspaceStore!, + path: script.path + }) + const url = scriptToHubUrl(fullScript, $hubBaseUrlStore).toString() + if (tab) { + tab.show(url) + } else if (!window.open(url)) { + sendUserToast('Allow popups to publish this script to the Hub', true) + } + } catch (e: any) { + tab?.discard() + sendUserToast( + `Could not load ${script.path}: ${e?.body ?? e?.message ?? e}`, + true + ) + } + }, + // Operators can't write scripts, so they have nothing to publish. + hide: $disableHubStore || !$roleCapabilities.code + } + ]) + ], + [ + { + displayName: script.archived ? 'Unarchive' : 'Archive', + icon: Archive, + action: () => { + script.archived + ? script.path && unarchiveScript(script.path) + : script.path && archiveScript(script.path) + }, + type: 'delete', + disabled: !owner || !canEdit, hide: !$roleCapabilities.code }, { displayName: 'Delete', icon: Trash, action: (event) => { - // TODO - // @ts-ignore if (event?.shiftKey) { deleteScript(script.path) } else { @@ -303,180 +491,11 @@ } }, type: dlt, - // A draft-only row is always the authed user's own draft (the - // list endpoint only surfaces own/legacy draft-only rows), so - // discarding it never requires write permission on the path. - disabled: !showEditButton + disabled: !canEdit, + hide: !$roleCapabilities.code || !($userStore?.is_admin || $userStore?.is_super_admin) } ] - } - return [ - ...selectMenuItems(rowSelection), - { - displayName: 'View code', - icon: Code, - action: () => { - showCode(script.path, script.summary) - } - }, - ...(script.auto_kind === 'wac' - ? [ - { - displayName: 'View JSON/YAML', - icon: FileText, - action: async () => { - const fullScript = await ScriptService.getScriptByPath({ - workspace: $workspaceStore!, - path: script.path - }) - wacExportDrawer?.open(fullScript) - } - } - ] - : []), - { - displayName: 'Duplicate/Fork', - icon: GitFork, - href: `/scripts/add?template=${script.path}`, - disabled: !showEditButton, - hide: !$roleCapabilities.code - }, - { - displayName: editInForkLabel($workspaceStore, $userWorkspaces), - icon: Pen, - // No `href`: the handler resolves the destination asynchronously, and a melt - // menu item's anchor navigates before a delegated onclick can preventDefault it. - action: (e) => onEditInForkClick(e, 'script', script.path), - hide: - !$roleCapabilities.code || - isCloudHosted() || - !editInForkAllowed($workspaceStore, $userWorkspaces) - }, - { - displayName: 'Move/Rename', - icon: FolderOpen, - action: () => { - moveDrawer.openDrawer(script.path, script.summary, 'script') - }, - disabled: !owner || script.archived || !canEdit, - hide: !$roleCapabilities.code - }, - ...(isDeployable('script', script.path, await getDeployUiSettings()) - ? [ - { - displayName: 'Deploy to staging/prod', - icon: ChevronUpSquare, - action: () => { - deploymentDrawer.openDrawer(script.path, 'script') - }, - disabled: script.archived, - hide: !$roleCapabilities.code - } - ] - : []), - { - displayName: 'View runs', - icon: List, - href: `/runs/${script.path}` - }, - { - displayName: 'Versions', - icon: History, - action: () => { - versionsDrawerOpen = true - } - }, - { - displayName: 'Audit logs', - icon: Eye, - href: `/audit_logs?resource=${script.path}`, - hide: !$roleCapabilities.code || !$roleCapabilities.visibility.audit_logs - }, - { - displayName: 'Schedule', - icon: Calendar, - action: () => { - scheduleEditor?.openNew(false, script.path) - }, - disabled: script.archived, - hide: !$roleCapabilities.code || !$roleCapabilities.manage_schedules - }, - { - displayName: 'Permissions', - icon: Shield, - action: () => { - shareModal.openDrawer && shareModal.openDrawer(script.path, 'script') - }, - disabled: script.archived, - hide: !$roleCapabilities.code - }, - { - displayName: 'Copy path', - icon: Copy, - action: () => { - copyToClipboard(script.path) - } - }, - { - displayName: 'Publish to Hub', - icon: Globe2, - action: async () => { - // The row only carries metadata, so the code has to be fetched first; the tab is - // claimed before that, since Safari won't open one after an await. - const tab = claimTab() - try { - const fullScript = await ScriptService.getScriptByPath({ - workspace: $workspaceStore!, - path: script.path - }) - const url = scriptToHubUrl(fullScript, $hubBaseUrlStore).toString() - if (tab) { - tab.show(url) - } else if (!window.open(url)) { - sendUserToast('Allow popups to publish this script to the Hub', true) - } - } catch (e: any) { - tab?.discard() - sendUserToast(`Could not load ${script.path}: ${e?.body ?? e?.message ?? e}`, true) - } - }, - // Operators can't write scripts, so they have nothing to publish. - hide: $disableHubStore || !$roleCapabilities.code - }, - { - displayName: script.archived ? 'Unarchive' : 'Archive', - icon: Archive, - action: () => { - script.archived - ? script.path && unarchiveScript(script.path) - : script.path && archiveScript(script.path) - }, - type: 'delete', - disabled: !owner || !canEdit, - hide: !$roleCapabilities.code - }, - - ...($userStore?.is_admin || $userStore?.is_super_admin - ? [ - { - displayName: 'Delete', - icon: Trash, - action: (event) => { - if (event?.shiftKey) { - deleteScript(script.path) - } else { - deleteConfirmedCallback = () => { - deleteScript(script.path) - } - } - }, - type: dlt, - disabled: !canEdit, - hide: !$roleCapabilities.code - } - ] - : []) - ] + ) }} on:open={() => { menuOpen = true diff --git a/frontend/src/lib/components/common/table/menuGroups.ts b/frontend/src/lib/components/common/table/menuGroups.ts new file mode 100644 index 0000000000..7071b6f11e --- /dev/null +++ b/frontend/src/lib/components/common/table/menuGroups.ts @@ -0,0 +1,25 @@ +import type { Item } from '$lib/utils' + +/** + * Joins a row menu's groups into one list with a divider above each group. The divider rides on + * the group's first visible item, so a group whose items are all hidden leaves no stray line. + */ +export function menuGroups(...groups: Item[][]): Item[] { + const out: Item[] = [] + for (const group of groups) { + const visible = group.filter((item) => !item.hide) + if (visible.length === 0) continue + out.push({ ...visible[0], separatorTop: out.length > 0 }, ...visible.slice(1)) + } + return out +} + +/** + * A submenu for actions that share a heading. With a single visible child the child is shown + * in place, since a submenu of one is an extra hover for nothing. + */ +export function submenu(displayName: string, icon: any, children: Item[]): Item { + const visible = children.filter((item) => !item.hide) + if (visible.length === 1) return visible[0] + return { displayName, icon, submenuItems: visible, hide: visible.length === 0 } +} diff --git a/frontend/src/lib/components/copilot/aiResourceProvider.ts b/frontend/src/lib/components/copilot/aiResourceProvider.ts index 91ae926dfc..cd80bb21c0 100644 --- a/frontend/src/lib/components/copilot/aiResourceProvider.ts +++ b/frontend/src/lib/components/copilot/aiResourceProvider.ts @@ -1,9 +1,20 @@ import type { AIProvider } from '$lib/gen' import { AI_PROVIDERS } from './lib' +/** A resource type that authenticates the same provider another way, offered as a switch when + * creating a resource of the first, since a picker scoped to a provider only creates its main type. */ +export const AI_RESOURCE_TYPE_ALTERNATIVES: Record<string, { resourceType: string; label: string }> = { + anthropic: { + resourceType: 'anthropic_federated', + label: 'Authenticate with Workload Identity Federation instead of an API key' + }, + anthropic_federated: { resourceType: 'anthropic', label: 'Use an API key instead' } +} + /** The AI provider a resource of this type holds credentials for, if any. */ export function aiResourceProvider(resourceType: string | undefined): AIProvider | undefined { if (resourceType === 'openai_client_credentials_oauth') return 'openai' + if (resourceType === 'anthropic_federated') return 'anthropic' return resourceType && Object.hasOwn(AI_PROVIDERS, resourceType) ? (resourceType as AIProvider) : undefined diff --git a/frontend/src/lib/components/copilot/chat/AIChat.svelte b/frontend/src/lib/components/copilot/chat/AIChat.svelte index a9312bc5fb..51a8dfa55b 100644 --- a/frontend/src/lib/components/copilot/chat/AIChat.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChat.svelte @@ -24,6 +24,7 @@ onSent = undefined, inputPreface, actingOn, + workModeLock = undefined, initialInstructions = undefined, onDraftChange = undefined, initialAttachments = undefined, @@ -49,6 +50,7 @@ inputPreface?: import('svelte').Snippet /** Forwarded to AIChatDisplay: where the session acts, first among the controls. */ actingOn?: import('svelte').Snippet + workModeLock?: import('./workMode').WorkModeLock // Seed / observe the composer's draft (forwarded to AIChatDisplay). initialInstructions?: string onDraftChange?: (text: string) => void @@ -224,6 +226,7 @@ {onSent} {inputPreface} {actingOn} + {workModeLock} {initialInstructions} {onDraftChange} {initialAttachments} diff --git a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte index c5fede3162..a740c5fb5f 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatDisplay.svelte @@ -29,7 +29,6 @@ import { pendingUserAction, pendingUserActionDetail, type DisplayMessage } from './shared' import AutonomyModePicker from './AutonomyModePicker.svelte' import { availableAutonomyModeOptions, resolveAutonomyMode } from './autonomyModes' - import { PLAN_MODE_MESSAGES } from './planModeMessages' import type { ContextElement } from './context' import ChatQuickActions from './ChatQuickActions.svelte' import ContextUsageIndicator from './ContextUsageIndicator.svelte' @@ -44,13 +43,18 @@ import Markdown from 'svelte-exmarkdown' import { twMerge } from 'tailwind-merge' import { AIAutonomyMode, AIMode } from './AIChatManager.svelte' + import { AIWorkMode, type WorkModeLock } from './workMode' import { getChatViewHost } from './chatViewHost' import { getAiChatManager } from './aiChatManagerContext' import ChatTypingIndicator from './ChatTypingIndicator.svelte' import ToolGroupDisplay from './ToolGroupDisplay.svelte' import { chatItemKey, createChatItemGrouper } from './toolGroups' import AIChatInput from './AIChatInput.svelte' - import { STARTER_PLACEHOLDER, type ComposerSelection } from './composerBox' + import { + STARTER_PLACEHOLDER, + USE_STARTER_PLACEHOLDER, + type ComposerSelection + } from './composerBox' import AttachedFilesBar from './files/AttachedFilesBar.svelte' import QueuedMessageChip from './QueuedMessageChip.svelte' import JobsSegment from './JobsSegment.svelte' @@ -118,6 +122,7 @@ onSent = undefined, inputPreface, actingOn, + workModeLock = undefined, footerSettings, initialInstructions = undefined, onDraftChange = undefined, @@ -165,6 +170,8 @@ inputPreface?: Snippet /** Where the session acts: first among the controls after the attach button. */ actingOn?: Snippet + /** Holds a session chat in Use, and says why in the picker. */ + workModeLock?: WorkModeLock /** The settings control at the footer's right edge, where the copilot puts its * model picker. A host that configures its turn elsewhere replaces it here. */ footerSettings?: Snippet @@ -569,14 +576,14 @@ } const autonomyAvailability = $derived({ autoAcceptEditsAvailable: chatHost.autoAcceptEditsAvailable, - autoAcceptToolConfirmationsAvailable: chatHost.autoAcceptToolConfirmationsAvailable, - planModeAvailable: chatHost.planModeAvailable + autoAcceptToolConfirmationsAvailable: chatHost.autoAcceptToolConfirmationsAvailable }) const effectiveAutonomyMode = $derived( resolveAutonomyMode(chatHost.autonomyMode, autonomyAvailability) ) const showAutonomyModeSelector = $derived( - !disabled && availableAutonomyModeOptions(autonomyAvailability).length > 1 + !disabled && + (chatHost.workModesAvailable || availableAutonomyModeOptions(autonomyAvailability).length > 1) ) // The typing-dots indicator implies the AI is busy, which is misleading while @@ -588,6 +595,9 @@ // The block's layout holds while the chat is empty, usable or not; only the taller box // and the suggestions need a composer that can send. const starterLayout = $derived(!!starter && messages.length === 0) + const heldInUse = $derived( + chatHost.workModesAvailable && (!!workModeLock || chatHost.workMode === AIWorkMode.USE) + ) // Home is a page, not a pane: nothing there to centre in. const centreComposer = $derived(starterLayout && starter?.where === 'session') @@ -1057,7 +1067,11 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> bind:this={aiChatInput} bind:selectedContext {availableContext} - placeholder={starterLayout ? STARTER_PLACEHOLDER : placeholder} + placeholder={starterLayout + ? heldInUse + ? USE_STARTER_PLACEHOLDER + : STARTER_PLACEHOLDER + : placeholder} {initialInstructions} {onDraftChange} {initialAttachments} @@ -1274,11 +1288,16 @@ the panel, or the Escape-to-stop focus check would wrongly reject them. --> mode={chatHost.autonomyMode} availability={autonomyAvailability} onChange={chatHost.setAutonomyMode} + work={chatHost.workModesAvailable + ? { + mode: chatHost.workMode, + planAvailable: chatHost.planModeAvailable, + lock: workModeLock, + onChange: chatHost.setWorkMode + } + : undefined} /> {/if} - {#if effectiveAutonomyMode === AIAutonomyMode.PLAN} - <span class="text-2xs text-secondary">{PLAN_MODE_MESSAGES.modeNote}</span> - {/if} {#if effectiveAutonomyMode === AIAutonomyMode.YOLO && chatHost.autoAcceptToolConfirmationsAvailable} <Tooltip small placement="top"> <AlertTriangle class="w-3 h-3 text-red-500" /> diff --git a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte index 13aaccec94..ac6f21844e 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatInput.svelte +++ b/frontend/src/lib/components/copilot/chat/AIChatInput.svelte @@ -162,7 +162,10 @@ const toolbarInBox = $derived(!!toolbar && hero) // Picked at mount-time so each new session lands on a different prompt. - const permissions = useSessionPermissions(() => chatHost.operatingWorkspace) + const permissions = useSessionPermissions( + () => chatHost.operatingWorkspace, + () => chatHost + ) const suggestionPick = Math.random() const globalSuggestion = $derived( globalComposerSuggestion(suggestionPick, permissions.canOpenEditor) diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 2e998e9cc6..445814ef38 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -139,8 +139,23 @@ import { } from '$lib/aiStore' import type { WorkspaceMutationTarget } from './workspaceTools' import { resolveSessionAccess } from './global/sessionAccess' -import type { SessionAccess } from './sessionCapabilities' -import { filterSessionTools } from './global/sessionToolset' +import { + canBuild, + isReadOnlyAccess, + USE_MODE_ACCESS, + type SessionAccess, + type SessionTool +} from './sessionCapabilities' +import { + AIWorkMode, + ENTER_BUILD_MODE_TOOL, + ENTER_USE_MODE_TOOL, + isWorkModeSwitchTool, + WORK_MODE_SWITCH_TARGET, + type WorkModeSwitchTool +} from './workMode' +import { workModeSwitchTools } from './workModeSwitch' +import { filterSessionTools, sessionToolAllowed } from './global/sessionToolset' import { listFolderInstructions, type FolderInstruction, @@ -269,7 +284,6 @@ export enum AIMode { } export enum AIAutonomyMode { - PLAN = 'plan', DEFAULT = 'default', ACCEPT_EDIT = 'acceptedit', YOLO = 'yolo' @@ -332,7 +346,7 @@ function getPersistedAutonomyMode(): AIAutonomyMode { return AIAutonomyMode.ACCEPT_EDIT } const persistedMode = getLocalSetting(key) - if (isAIAutonomyMode(persistedMode) && persistedMode !== AIAutonomyMode.PLAN) { + if (isAIAutonomyMode(persistedMode)) { return persistedMode } // No stored preference: default to auto-accepting edits (tool calls still @@ -345,11 +359,6 @@ function getPersistedAutonomyMode(): AIAutonomyMode { } function persistAutonomyMode(mode: AIAutonomyMode) { - // Plan is session-only: persisting it would re-block a later session where the - // picker never offered Plan. The stored pre-plan baseline is what a reload restores. - if (mode === AIAutonomyMode.PLAN) { - return - } const key = scopedKey(AI_AUTONOMY_MODE_STORAGE_KEY) if (!BROWSER || !key) { return @@ -466,8 +475,8 @@ function planModeHostFor(m: AIChatManager): PlanModeHost { return m.artifacts }, openArtifact: (id, name, version) => m.openArtifact?.(id, name, version), - enter: () => m.setAutonomyMode(AIAutonomyMode.PLAN), - restore: () => m.setAutonomyMode(m.prePlanAutonomyMode ?? AIAutonomyMode.DEFAULT) + enter: () => m.setWorkMode(AIWorkMode.PLAN), + build: () => m.setWorkMode(AIWorkMode.BUILD) } } @@ -773,12 +782,48 @@ export class AIChatManager implements ChatViewHost { this.autonomyMode === AIAutonomyMode.YOLO) ) autoAcceptToolConfirmationsAvailable = $derived(supportsAutoAcceptToolConfirmations(this.mode)) + /** Use, Plan or Build. A session keeps its own: the runtime seeds it from the session record + * and stores each change through `onWorkModeChanged`. */ + workMode = $state<AIWorkMode>(AIWorkMode.BUILD) + onWorkModeChanged: ((mode: AIWorkMode) => void) | undefined = undefined + workModesAvailable = $derived(this.isSessionChat && supportsPlanMode(this.mode)) + /** Use is the only mode: the workspace is protected, or the user's role builds nothing in it. */ + #useOnly = $derived(this.workModesAvailable && !canBuild(this.sessionAccess)) + // A plan ends in edits, which neither case allows. The user's own Use mode leaves Plan open: + // it is one click away from Build anyway. + planModeAvailable = $derived(this.workModesAvailable && !this.#useOnly) + planModeActive = $derived(this.workMode === AIWorkMode.PLAN && this.planModeAvailable) + // Not while planning: approving the plan is the user's to do, and Yolo applies once they have. autoAcceptToolConfirmationsActive = $derived( - this.autonomyMode === AIAutonomyMode.YOLO && this.autoAcceptToolConfirmationsAvailable + this.autonomyMode === AIAutonomyMode.YOLO && + this.autoAcceptToolConfirmationsAvailable && + !this.planModeActive ) - planModeAvailable = $derived(this.isSessionChat && supportsPlanMode(this.mode)) - planModeActive = $derived(this.autonomyMode === AIAutonomyMode.PLAN && this.planModeAvailable) - prePlanAutonomyMode = $state<AIAutonomyMode | undefined>(undefined) + /** The chat is in Use, by the user's pick or because Use is the only mode. */ + useModeActive = $derived( + this.workModesAvailable && (this.workMode === AIWorkMode.USE || this.#useOnly) + ) + /** Why the chat is in Use; undefined in Plan and Build. A protected workspace's access builds + * nothing too, so it is told apart from the role first. */ + #useReason = $derived.by((): 'protected' | 'role' | 'chosen' | undefined => { + if (!this.useModeActive) return undefined + if (isReadOnlyAccess(this.sessionAccess)) return 'protected' + return this.#useOnly ? 'role' : 'chosen' + }) + #workModeSwitches: Record<WorkModeSwitchTool, SessionTool<any>> = workModeSwitchTools({ + offers: (tool) => this.workModeSwitchOffered === tool, + setWorkMode: (mode) => this.setWorkMode(mode) + }) + /** The switch the current mode offers between Use and Use & build. None from Plan, which + * leaves through its plan's approval, from a session held in Use, or while confirmations are + * auto-accepted: the switch is the user's answer, which that posture would give for them. */ + workModeSwitchOffered = $derived.by((): WorkModeSwitchTool | undefined => { + if (!this.workModesAvailable || this.autoAcceptToolConfirmationsActive) return undefined + if (this.#useReason === 'chosen') return ENTER_BUILD_MODE_TOOL + return this.useModeActive || this.planModeActive ? undefined : ENTER_USE_MODE_TOOL + }) + /** What the tools and the prompt are built for. */ + #toolAccess = $derived(this.useModeActive ? USE_MODE_ACCESS : this.sessionAccess) // The posture's own state — its two tools, the plan document and the planning round. // Everything it needs from this manager goes through the host above. planMode = new PlanModeController(planModeHostFor(this)) @@ -795,15 +840,33 @@ export class AIChatManager implements ChatViewHost { /** Both views narrow the same way — concatenate every source, then filter — so a tool * reaching either one can never arrive unfiltered. A session withholds what its user's * capabilities do not cover; elsewhere `sessionAccess` is unset and nothing is dropped. */ - #shipped = (planTools: Tool<any>[]): Tool<any>[] => - filterSessionTools([...this.#assembledTools, ...planTools], this.sessionAccess) - /** What the request carries: the assembled tools plus the plan-mode transition the current + #shipped = (modeTools: Tool<any>[]): Tool<any>[] => + filterSessionTools([...this.#assembledTools, ...modeTools], this.#toolAccess) + /** What the request carries: the assembled tools plus the mode transitions the current * posture offers. Read by the request path and by the YOLO disclosure. */ - tools: Tool<any>[] = $derived(this.#shipped(this.planMode.tools)) + tools: Tool<any>[] = $derived( + this.#shipped([ + ...this.planMode.tools, + ...(this.workModeSwitchOffered ? [this.#workModeSwitches[this.workModeSwitchOffered]] : []) + ]) + ) /** What the assistant can call in this session, posture-independent: both plan-mode - * transitions, whichever one is offered right now. A reference answer, so flipping the - * autonomy picker must not change it. */ - availableTools: Tool<any>[] = $derived(this.#shipped(this.planMode.availableTools)) + * transitions and both switches, whichever one is offered right now. A reference answer, so + * flipping the autonomy picker must not change it. */ + availableTools: Tool<any>[] = $derived( + this.#shipped([ + ...this.planMode.availableTools, + ...(this.workModesAvailable && !this.#useOnly ? Object.values(this.#workModeSwitches) : []) + ]) + ) + /** Whether a tool the call was made with is no longer shipped: a switch to Use withdraws + * every write while its card waits. The mode transitions are not assembled, so never are. */ + isToolWithdrawn = (toolName: string): boolean => { + const access = this.#toolAccess + if (!access) return false + const tool = this.#assembledTools.find((t) => t.def.function.name === toolName) + return !!tool && !sessionToolAllowed(tool, access) + } #copilotInfo = fromStore(copilotInfo) #sessionModel = fromStore(copilotSessionModel) /** Capabilities the provider runs on its own servers, attached by the adapter at send @@ -1990,8 +2053,10 @@ export class AIChatManager implements ChatViewHost { } /** enter_plan_mode never qualifies: YOLO means "stop asking and run it", and a research - * posture inverts that. Every accept path asks here rather than carrying its own copy. */ - private autoAcceptsTool = (toolName: string | undefined) => toolName !== ENTER_PLAN_MODE_TOOL + * posture inverts that. Nor does a switch between Use and Use & build, which only the user + * picks. Every accept path asks here rather than carrying its own copy. */ + private autoAcceptsTool = (toolName: string | undefined) => + toolName !== ENTER_PLAN_MODE_TOOL && !isWorkModeSwitchTool(toolName) /** Asked before the confirmation wait is skipped, so a tool the posture will not answer * for still gets a card rather than running unasked. */ @@ -2031,7 +2096,7 @@ export class AIChatManager implements ChatViewHost { } } - private resolvePendingPlanCard = (toolName: string, confirmed: boolean) => { + private resolvePendingModeCard = (toolName: string, confirmed: boolean) => { for (const [toolId, cb] of this.confirmationCallbacks) { if (cb.toolName === toolName) { cb.resolve(confirmed) @@ -2041,24 +2106,8 @@ export class AIChatManager implements ChatViewHost { } setAutonomyMode = (mode: AIAutonomyMode) => { - const enteringPlan = mode === AIAutonomyMode.PLAN && this.autonomyMode !== AIAutonomyMode.PLAN - const leavingPlan = mode !== AIAutonomyMode.PLAN && this.autonomyMode === AIAutonomyMode.PLAN - if (enteringPlan) { - this.prePlanAutonomyMode = this.autonomyMode - this.planMode.startRound() - } else if (mode !== AIAutonomyMode.PLAN) { - this.prePlanAutonomyMode = undefined - this.planMode.resetBlocks() - } this.autonomyMode = mode persistAutonomyMode(mode) - - if (enteringPlan) { - this.resolvePendingPlanCard(ENTER_PLAN_MODE_TOOL, true) - } else if (leavingPlan) { - // Opting into YOLO means "run it"; leaving plan mode any other way is not a sign-off. - this.resolvePendingPlanCard(EXIT_PLAN_MODE_TOOL, mode === AIAutonomyMode.YOLO) - } if (this.autoAcceptToolConfirmationsActive) { this.acceptPendingToolConfirmations() } @@ -2067,6 +2116,40 @@ export class AIChatManager implements ChatViewHost { } } + setWorkMode = (mode: AIWorkMode) => { + const enteringPlan = mode === AIWorkMode.PLAN && this.workMode !== AIWorkMode.PLAN + const leavingPlan = mode !== AIWorkMode.PLAN && this.workMode === AIWorkMode.PLAN + const useChanges = (mode === AIWorkMode.USE) !== (this.workMode === AIWorkMode.USE) + if (enteringPlan) { + this.planMode.startRound() + } else if (mode !== AIWorkMode.PLAN) { + this.planMode.resetBlocks() + } + this.workMode = mode + this.onWorkModeChanged?.(mode) + + if (enteringPlan) { + this.resolvePendingModeCard(ENTER_PLAN_MODE_TOOL, true) + } else if (leavingPlan) { + // Building under Yolo means "run it"; leaving plan mode any other way is not a sign-off. + this.resolvePendingModeCard( + EXIT_PLAN_MODE_TOOL, + mode === AIWorkMode.BUILD && this.autonomyMode === AIAutonomyMode.YOLO + ) + } + // A switch card the picker answered: accepted when the picker went where the card asked. + for (const tool of [ENTER_BUILD_MODE_TOOL, ENTER_USE_MODE_TOOL] as const) { + this.resolvePendingModeCard(tool, mode === WORK_MODE_SWITCH_TARGET[tool]) + } + if (this.autoAcceptToolConfirmationsActive) { + this.acceptPendingToolConfirmations() + } + // The helpers and the system prompt are built for one access; the tool list follows alone. + if (useChanges && this.mode === AIMode.GLOBAL) { + this.configureGlobalMode() + } + } + setAutoAcceptToolConfirmations = (enabled: boolean) => { this.setAutonomyMode(enabled ? AIAutonomyMode.YOLO : AIAutonomyMode.DEFAULT) } @@ -2080,7 +2163,6 @@ export class AIChatManager implements ChatViewHost { hydrateUserScopedAutonomy = () => { migrateLegacyAutonomyKeys() this.autonomyMode = getPersistedAutonomyMode() - this.prePlanAutonomyMode = undefined this.planMode.resetRound() } @@ -2493,12 +2575,12 @@ export class AIChatManager implements ChatViewHost { * * Gated on `sendInFlight` as well as `loading`: `loading` only rises after a * send's attachment upkeep, so between the two a click would slip past. */ - sendOrQueue(text: string) { + sendOrQueue(text: string, context?: ContextElement[]) { if (this.loading || this.sendInFlight || this.sendPending) { - this.queueMessage(text) + this.queueMessage(text, [], context) return } - void this.sendRequest({ instructions: text }) + void this.sendRequest({ instructions: text, contextOverride: context }) } /** Remove the queued message and put it back into the input, images included. */ @@ -2713,7 +2795,7 @@ export class AIChatManager implements ChatViewHost { sessionId: this.sessionId, operatingWorkspace: this.operatingWorkspace, artifacts: this.artifacts, - access: this.sessionAccess, + access: this.#toolAccess, getChatId: () => this.historyManager.getCurrentChatId(), openArtifact: this.openArtifact } @@ -2752,11 +2834,19 @@ export class AIChatManager implements ChatViewHost { skills: this.globalSkills, folderInstructions: this.globalFolderInstructions, mcpServers: this.mcpServers, - access: this.sessionAccess, - sessionContext: this.sessionContextResolver?.(), + access: this.#toolAccess, + sessionContext: this.#sessionPromptContext(), pipelineFolders: this.#pipelineFolders() }) + #sessionPromptContext = (): SessionPromptContext | undefined => { + const ctx = this.sessionContextResolver?.() + const reason = this.#useReason + // A protected workspace needs no reason here: the prompt's own read-only line names it. + if (!ctx || !reason || reason === 'protected') return ctx + return { ...ctx, useMode: reason } + } + // Fetch the workspace's AI skills and, if GLOBAL mode is still active, rebuild // the system message so the next chat-loop iteration advertises them. Ignore // stale resolves so workspace changes cannot overwrite newer skills. @@ -3757,7 +3847,10 @@ export class AIChatManager implements ChatViewHost { options.images ?? [], options.files ?? [] ) === true - if (taken) this.#restoreMentionContext(options.contextOverride, requestedMode) + if (taken) { + this.#restoreDomContext(options.contextOverride, true) + this.#restoreMentionContext(options.contextOverride, requestedMode) + } } return false } @@ -3867,7 +3960,10 @@ export class AIChatManager implements ChatViewHost { // mentions on that draft and every turn after it. const taken = this.aiChatInput?.restoreInstructions(this.instructions, pastes, images, files) === true - if (taken) this.#restoreMentionContext(options.contextOverride, requestedMode) + if (taken) { + this.#restoreDomContext(options.contextOverride, true) + this.#restoreMentionContext(options.contextOverride, requestedMode) + } } sendUserToast( `Could not prepare the session before sending: ${ @@ -3917,7 +4013,10 @@ export class AIChatManager implements ChatViewHost { // text, only if the composer took it. const taken = this.aiChatInput?.restoreInstructions(this.instructions, pastes, images, files) === true - if (taken) this.#restoreMentionContext(options.contextOverride, requestedMode) + if (taken) { + this.#restoreDomContext(options.contextOverride, true) + this.#restoreMentionContext(options.contextOverride, requestedMode) + } } return true } @@ -4067,10 +4166,24 @@ export class AIChatManager implements ChatViewHost { workspace: this.operatingWorkspace }) logFeatureUsage('ai_session', 'autonomy', { - key: this.autonomyMode, + // Planning keeps its own key, `plan`, so the series still tells it apart. + key: this.planModeActive ? AIWorkMode.PLAN : this.autonomyMode, entityId: this.sessionId, workspace: this.operatingWorkspace }) + if (this.workModesAvailable) { + // Keyed by the mode the message runs in, not `workMode`, which a held session keeps. + // Held Use has its own keys, so `use_chosen` counts only sessions that could build. + logFeatureUsage('ai_session', 'work_mode', { + key: this.#useReason + ? `use_${this.#useReason}` + : this.planModeActive + ? AIWorkMode.PLAN + : AIWorkMode.BUILD, + entityId: this.sessionId, + workspace: this.operatingWorkspace + }) + } } if (this.mode === AIMode.FLOW && !this.flowAiChatHelpers) { @@ -4341,6 +4454,7 @@ export class AIChatManager implements ChatViewHost { shouldAutoAcceptToolConfirmations: this.shouldAutoAcceptTool, isPlanModeActive: () => this.planModeActive, onToolBlockedByPlanMode: this.planMode.noteBlockedTool, + isToolWithdrawn: this.isToolWithdrawn, requestUserQuestion: this.requestUserQuestion, requestRunArgs: this.requestRunArgs, markRunFormStarted: this.markRunFormStarted, diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts index 1fd92aa67d..582e789cf7 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.test.ts @@ -8,6 +8,7 @@ import type { ChatCompletionMessageParam } from 'openai/resources/chat/completio import type { DisplayMessage } from './shared' import type { AttachedImage } from './imageUtils' import { AIChatManager, AIMode, AIAutonomyMode } from './AIChatManager.svelte' +import { AIWorkMode, USE_MODE_WITHDRAWN } from './workMode' import { makePasteToken } from './pasteTokens' import { chatState } from './sharedChatState.svelte' import { PLAN_MODE_MESSAGES } from './planModeMessages' @@ -51,9 +52,12 @@ vi.mock('monaco-editor', () => ({ vi.mock('$lib/utils/featureUsage', () => ({ logFeatureUsage: vi.fn() })) vi.mock('$lib/gen', () => ({ - WorkspaceService: {}, + WorkspaceService: { + listProtectionRules: vi.fn(async () => []) + }, ResourceService: { - listResource: mocks.listResource + listResource: mocks.listResource, + listResourceTypeNames: vi.fn(async () => []) }, ScriptService: {}, FlowService: {}, @@ -986,13 +990,11 @@ describe('AIChatManager autonomy mode', () => { }) // The posture's own behaviour lives in planModeController.test.ts. What is left here is the -// wiring only the manager owns: which pending confirmation cards a change of autonomy mode -// answers, and with what. +// wiring only the manager owns: which pending confirmation cards a change of work mode or of +// autonomy mode answers, and with what. describe('AIChatManager plan mode posture', () => { beforeEach(() => { localStorage.clear() - // Plan mode is never the persisted posture, so a case starts from the one it is - // entered from and hands back to. localStorage.setItem(`ai-chat-autonomy-mode::${TEST_EMAIL}`, AIAutonomyMode.DEFAULT) vi.clearAllMocks() }) @@ -1005,7 +1007,7 @@ describe('AIChatManager plan mode posture', () => { return manager } - it('enters plan mode through the tool and remembers the posture to hand back to', async () => { + it('enters plan mode through the tool and leaves the confirmations setting alone', async () => { const manager = sessionManager(AIAutonomyMode.ACCEPT_EDIT) await manager.planMode.enterTool.fn({ @@ -1017,12 +1019,12 @@ describe('AIChatManager plan mode posture', () => { }) expect(manager.planModeActive).toBe(true) - expect(manager.prePlanAutonomyMode).toBe(AIAutonomyMode.ACCEPT_EDIT) + expect(manager.autonomyMode).toBe(AIAutonomyMode.ACCEPT_EDIT) }) it('refuses to move a session chat out of GLOBAL, so the gate cannot lift under it', () => { const manager = sessionManager(AIAutonomyMode.ACCEPT_EDIT) - manager.setAutonomyMode(AIAutonomyMode.PLAN) + manager.setWorkMode(AIWorkMode.PLAN) expect(manager.planModeActive).toBe(true) // Without a configured model changeMode returns early on SCRIPT, and the case would pass // against the very guard it is meant to pin. @@ -1083,21 +1085,74 @@ describe('AIChatManager plan mode posture', () => { it('answers a pending plan card the way the picker was moved', async () => { const entering = sessionManager() const enterPending = entering.requestConfirmation('call_enter', 'enter_plan_mode') - entering.setAutonomyMode(AIAutonomyMode.PLAN) + entering.setWorkMode(AIWorkMode.PLAN) expect(await enterPending).toBe(true) // Leaving plan mode any other way is not a sign-off on the plan on the card. - const leaving = sessionManager(AIAutonomyMode.PLAN) + const leaving = sessionManager() + leaving.setWorkMode(AIWorkMode.PLAN) const exitPending = leaving.requestConfirmation('call_exit', 'exit_plan_mode') - leaving.setAutonomyMode(AIAutonomyMode.DEFAULT) + leaving.setWorkMode(AIWorkMode.BUILD) expect(await exitPending).toBe(false) - // Opting into YOLO does mean "run it". - const yolo = sessionManager(AIAutonomyMode.PLAN) + // Building under YOLO does mean "run it". + const yolo = sessionManager(AIAutonomyMode.YOLO) + yolo.setWorkMode(AIWorkMode.PLAN) const yoloPending = yolo.requestConfirmation('call_exit', 'exit_plan_mode') - yolo.setAutonomyMode(AIAutonomyMode.YOLO) + yolo.setWorkMode(AIWorkMode.BUILD) expect(await yoloPending).toBe(true) }) + + it('waits for the user on a plan card while planning under YOLO', async () => { + // YOLO is the posture an approved plan is carried out in; the approval itself is not its + // to give, or a plan no card ever showed would be run. + const manager = sessionManager() + manager.setWorkMode(AIWorkMode.PLAN) + manager.setAutonomyMode(AIAutonomyMode.YOLO) + let settled = false + void manager.requestConfirmation('call_exit', 'exit_plan_mode').then(() => (settled = true)) + await Promise.resolve() + expect(settled).toBe(false) + expect(manager.planMode.tools.map((t) => t.def.function.name)).toEqual(['exit_plan_mode']) + }) + + // The call was made with Build's tools; approving its card after the switch to Use must not + // run the write the session no longer ships. + it('does not run a write approved after the session switched to Use', async () => { + const { processToolCall } = await import('./shared') + const manager = sessionManager() + manager.configureGlobalMode() + const write = manager.availableTools.find((t) => t.def.function.name === 'write_script')! + const fn = vi.fn(async () => 'written') + let asked = false + const result = processToolCall({ + tools: [{ ...write, fn, requiresConfirmation: true }] as any, + toolCall: { + id: 'call_write', + type: 'function', + function: { name: 'write_script', arguments: '{}' } + } as any, + helpers: {}, + workspace: 'test-workspace', + toolCallbacks: { + setToolStatus: vi.fn(), + removeToolStatus: vi.fn(), + requestConfirmation: (id: string, name?: string) => { + asked = true + return manager.requestConfirmation(id, name) + }, + shouldAutoAcceptToolConfirmations: manager.shouldAutoAcceptTool, + isToolWithdrawn: manager.isToolWithdrawn + } as any + }) + await vi.waitFor(() => expect(asked).toBe(true)) + + manager.setWorkMode(AIWorkMode.USE) + manager.handleToolConfirmation('call_write', true) + + expect((await result).content).toBe(USE_MODE_WITHDRAWN.result) + expect(fn).not.toHaveBeenCalled() + }) }) describe('AIChatManager persisted autonomy default', () => { @@ -2891,6 +2946,26 @@ describe('AIChatManager queued messages', () => { expect(manager.contextManager.getSelectedContext()).toEqual([mention]) }) + // A hand-off's chip only ever lived in contextOverride, never in the selection. + it('restores a DOM chip carried only by contextOverride when the preflight is cancelled', async () => { + const manager = createManager(createInputMock()) + manager.mode = AIMode.GLOBAL + const chip = { + type: 'app_dom_selector' as const, + selector: 'h1', + appPath: 'f/app', + tagName: 'h1' + } + manager.contextManager.setSelectedContext([]) + manager.beforeSend = vi.fn().mockImplementation(async () => { + manager.cancel('user_cancelled') + }) + + await manager.sendRequest({ instructions: 'make this bigger', contextOverride: [chip] }) + + expect(manager.contextManager.getSelectedContext()).toMatchObject([{ selector: 'h1' }]) + }) + // The gate, and the reason the restore is not unconditional: an occupied // composer declines the dead draft's text, and its mentions would then ride the // draft the user is writing now and every turn after it. @@ -4927,6 +5002,26 @@ describe('AIChatManager tool views', () => { expect(manager.systemMessage.content).not.toContain('write_script') }) + // Use is the user's own pick, so no profile resolves for it: the switch alone has to strip + // the writes from the tools and the prompt, and hand them back on Build. + it('ships only reads and runs in Use, and the writes again in Build', () => { + const manager = new AIChatManager() + manager.mode = AIMode.GLOBAL + manager.isSessionChat = true + manager.configureGlobalMode() + const names = () => manager.availableTools.map((t) => t.def.function.name) + expect(names()).toContain('write_script') + + manager.setWorkMode(AIWorkMode.USE) + expect(names()).toEqual(expect.arrayContaining(['list_workspace_items', 'run_script'])) + expect(names()).not.toContain('write_script') + expect(manager.systemMessage.content).not.toContain('write_script') + + manager.setWorkMode(AIWorkMode.BUILD) + expect(names()).toContain('write_script') + expect(manager.systemMessage.content).toContain('write_script') + }) + // Which transition each posture offers is planModeController.test.ts's; what this pins is // that only `tools` follows the picker. `isSessionChat` is what offers plan mode at all. it('holds availableTools steady across the autonomy picker', async () => { @@ -4943,4 +5038,56 @@ describe('AIChatManager tool views', () => { expect(names(manager.tools)).not.toContain('enter_plan_mode') expect(names(manager.availableTools)).toEqual(before) }) + + it('offers the switch out of the mode the session is in, and switches only once called', async () => { + const manager = new AIChatManager() + manager.mode = AIMode.GLOBAL + manager.isSessionChat = true + manager.configureGlobalMode() + // The posture is persisted, and the test above leaves it on Yolo. + manager.setAutonomyMode(AIAutonomyMode.DEFAULT) + const shipped = () => manager.tools.map((t) => t.def.function.name) + expect(shipped()).toContain('enter_use_mode') + expect(shipped()).not.toContain('enter_build_mode') + + // Plan leaves through its plan's approval, and Yolo would answer the card for the user. + manager.setWorkMode(AIWorkMode.PLAN) + expect(shipped()).not.toContain('enter_use_mode') + expect(shipped()).not.toContain('enter_build_mode') + manager.setWorkMode(AIWorkMode.USE) + manager.setAutonomyMode(AIAutonomyMode.YOLO) + expect(shipped()).not.toContain('enter_build_mode') + manager.setAutonomyMode(AIAutonomyMode.DEFAULT) + + const enterBuild = manager.tools.find((t) => t.def.function.name === 'enter_build_mode') + expect(manager.workMode).toBe(AIWorkMode.USE) + await enterBuild?.fn({} as any) + expect(manager.workMode).toBe(AIWorkMode.BUILD) + expect(manager.systemMessage.content).toContain('write_script') + }) + + // The card is the only consent the switch has: the picker can answer it by going where it + // asked, and turning on Yolo must not approve it. + it('resolves a pending switch card from the picker, and never from Yolo', async () => { + const manager = new AIChatManager() + manager.mode = AIMode.GLOBAL + manager.isSessionChat = true + manager.configureGlobalMode() + manager.setAutonomyMode(AIAutonomyMode.DEFAULT) + manager.setWorkMode(AIWorkMode.USE) + + const toPlan = manager.requestConfirmation('a', 'enter_build_mode') + manager.setWorkMode(AIWorkMode.PLAN) + await expect(toPlan).resolves.toBe(false) + + manager.setWorkMode(AIWorkMode.USE) + const toBuild = manager.requestConfirmation('b', 'enter_build_mode') + manager.setWorkMode(AIWorkMode.BUILD) + await expect(toBuild).resolves.toBe(true) + + manager.setWorkMode(AIWorkMode.USE) + const underYolo = manager.requestConfirmation('c', 'enter_build_mode') + manager.setAutonomyMode(AIAutonomyMode.YOLO) + await expect(underYolo).resolves.toBe(false) + }) }) diff --git a/frontend/src/lib/components/copilot/chat/AutonomyModePicker.svelte b/frontend/src/lib/components/copilot/chat/AutonomyModePicker.svelte index f63db32d0e..e236e4a4c0 100644 --- a/frontend/src/lib/components/copilot/chat/AutonomyModePicker.svelte +++ b/frontend/src/lib/components/copilot/chat/AutonomyModePicker.svelte @@ -1,51 +1,110 @@ <script lang="ts"> - import { ChevronDown } from 'lucide-svelte' + import { ArrowRight, ChevronDown } from 'lucide-svelte' import { Button } from '$lib/components/common' import DropdownV2 from '$lib/components/DropdownV2.svelte' + import type { Item } from '$lib/utils' import type { AIAutonomyMode } from './AIChatManager.svelte' + import { AIWorkMode, type WorkModeLock } from './workMode' import { autonomyModeOption, availableAutonomyModeOptions, resolveAutonomyMode, + workModeOption, + workModeOptions, type AutonomyAvailability } from './autonomyModes' let { mode, availability, - onChange + onChange, + work = undefined }: { mode: AIAutonomyMode availability: AutonomyAvailability onChange: (mode: AIAutonomyMode) => void + /** A session chat's Use / Plan / Build, shown as the picker's first section. */ + work?: { + mode: AIWorkMode + planAvailable: boolean + /** Holds the chat in Use, and says why. */ + lock?: WorkModeLock + onChange: (mode: AIWorkMode) => void + } } = $props() const effective = $derived(resolveAutonomyMode(mode, availability)) const option = $derived(autonomyModeOption(effective)) -</script> + // Plan where the chat no longer offers it falls back to Build, as the manager does. + const workOption = $derived( + !work + ? undefined + : workModeOption( + work.lock + ? AIWorkMode.USE + : work.mode === AIWorkMode.PLAN && !work.planAvailable + ? AIWorkMode.BUILD + : work.mode + ) + ) + const postureLabel = $derived(option.shortLabel ?? option.label) -<DropdownV2 - items={() => - availableAutonomyModeOptions(availability).map((o) => ({ + function items(): Item[] { + const sections = !!work + const postures: Item[] = availableAutonomyModeOptions(availability).map((o, i) => ({ displayName: o.label, + sectionTitle: sections && i === 0 ? 'Confirmations' : undefined, + separatorTop: sections && i === 0, selected: effective === o.mode, action: () => onChange(o.mode) - }))} - placement="bottom-start" - fixedHeight={false} - customWidth={240} -> + })) + const w = work + if (!w) return postures + const lockedOut = (m: AIWorkMode) => !!w.lock && m !== AIWorkMode.USE + const workItems: Item[] = workModeOptions + .filter((o) => o.mode !== AIWorkMode.PLAN || w.planAvailable || w.lock) + .map((o, i) => { + const locked = lockedOut(o.mode) + return { + displayName: o.label, + description: o.description, + icon: o.icon, + // `iconColor` takes a color value; these are text classes. A locked row's icon + // keeps the disabled color its label has. + iconProps: { class: locked ? 'shrink-0' : `shrink-0 ${o.iconColor}` }, + sectionTitle: i === 0 ? 'What the chat can do' : undefined, + selected: workOption?.mode === o.mode, + disabled: locked, + tooltip: locked ? w.lock?.reason : undefined, + action: () => w.onChange(o.mode) + } + }) + const escape = w.lock?.escape + return [ + ...workItems, + ...(escape ? [{ displayName: escape.label, icon: ArrowRight, action: escape.action }] : []), + ...postures + ] + } +</script> + +<DropdownV2 {items} placement="bottom-start" fixedHeight={false} customWidth={work ? 260 : 240}> {#snippet buttonReplacement()} <Button nonCaptureEvent unifiedSize="2xs" variant="subtle" - title={option.tooltip(availability)} - btnClasses={option.triggerClass ?? ''} - startIcon={{ icon: option.icon, classes: option.iconColor }} + title={workOption + ? (work?.lock?.reason ?? `${workOption.description} ${option.tooltip(availability)}`) + : option.tooltip(availability)} + btnClasses={workOption?.triggerClass ?? ''} + startIcon={{ + icon: (workOption ?? option).icon, + classes: (workOption ?? option).iconColor + }} endIcon={{ icon: ChevronDown }} > - {option.shortLabel ?? option.label} + {workOption ? `${workOption.label} · ${postureLabel}` : postureLabel} </Button> {/snippet} </DropdownV2> diff --git a/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte b/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte index 6072b6e341..20e9b1df91 100644 --- a/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/RunArgsFormDisplay.svelte @@ -9,6 +9,7 @@ import { sendUserToast } from '$lib/utils' import { getAiChatManager } from './aiChatManagerContext' import { PLAN_MODE_MESSAGES } from './planModeMessages' + import { USE_MODE_WITHDRAWN } from './workMode' import { scrollFades } from './scrollFades.svelte' import type { RunFormDisplay } from './shared' @@ -54,6 +55,14 @@ // off the entrypoint a `dynselect-` argument runs, which does not wait for Run. A password // field mints at mount, too early to hold off here, so `runThroughForm` gates that instead. const planMode = $derived(aiChatManager.planModeActive) + // A test run previews undeployed code, which Use withholds as plan mode withholds every run. + const refusal = $derived( + planMode + ? PLAN_MODE_MESSAGES.runFormRefused + : runForm.kind === 'test' && aiChatManager.useModeActive + ? USE_MODE_WITHDRAWN.testRunRefused + : undefined + ) onMount(() => { void tick().then(() => cardNode?.scrollIntoView({ block: 'nearest' })) @@ -79,10 +88,10 @@ return } // Ahead of processSecretArgs, which writes ephemeral variables to the workspace: the - // autonomy picker moves while a form sits pending, and the re-gate on the other side - // of the callback runs too late to unmake a write plan mode promised not to do. - if (aiChatManager.planModeActive) { - sendUserToast(PLAN_MODE_MESSAGES.runFormRefused, true) + // mode picker moves while a form sits pending, and the re-gate on the other side of the + // callback runs too late to unmake a write plan mode or Use promised not to do. + if (refusal) { + sendUserToast(refusal, true) return } if (!aiChatManager.beginRunFormSubmit(toolCallId)) return @@ -145,18 +154,19 @@ {#if hasArgs} <!-- Passing `helperScript` is what lets DynamicInput run a job before Run: it executes the `dynselect-` entrypoint on mount, carrying the other args as proposed, - and Cancel does not undo it. Withheld under plan mode, which promised no writes. + and Cancel does not undo it. Withheld under plan mode, which promised no writes, and from + a test run in Use, whose helper is undeployed code too. A test run names its draft by code: `deployed` resolves a stale version, or none. --> <SchemaForm bind:schema={draft.schema} - helperScript={planMode + helperScript={refusal ? undefined : runForm.kind === 'test' ? runForm.code && runForm.lang ? { source: 'inline', code: runForm.code, lang: runForm.lang } : undefined : { source: 'deployed', path: runForm.path, runnable_kind: runnableKind }} - disabled={planMode} + disabled={!!refusal} {workspace} prettifyHeader bind:isValid @@ -210,8 +220,8 @@ <span class="font-mono">{runForm.strippedKeys.join(', ')}</span> </p> {/if} - {#if planMode} - <p class="text-2xs text-secondary">{PLAN_MODE_MESSAGES.runFormRefused}</p> + {#if refusal} + <p class="text-2xs text-secondary">{refusal}</p> {/if} <!-- Reject then confirm at the end of the row, as ToolConfirmationFooter puts them: this @@ -232,7 +242,7 @@ variant="accent" unifiedSize="sm" startIcon={{ icon: Play }} - disabled={!isValid || submitting || planMode} + disabled={!isValid || submitting || !!refusal} onClick={run} > Run diff --git a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte index 2b09d75332..1b267472c8 100644 --- a/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte +++ b/frontend/src/lib/components/copilot/chat/ToolExecutionDisplay.svelte @@ -21,6 +21,12 @@ PLAN_CARD_COPY, PLAN_MODE_TEXT_COLOR } from './planMode' + import { + isWorkModeSwitchTool, + WORK_MODE_SWITCH_CARD_COPY, + WORK_MODE_SWITCH_TARGET + } from './workMode' + import { workModeOption } from './autonomyModes' import { Button } from '$lib/components/common' import { markdownProse } from '$lib/components/markdownProse' import { getChatViewHost } from './chatViewHost' @@ -61,9 +67,17 @@ const mcpServer = $derived(message.mcpServer) const isPlanReview = $derived(message.toolName === EXIT_PLAN_MODE_TOOL) - const isPlanCard = $derived(isPlanCardTool(message.toolName)) + // A switch between Use and Use & build renders as enter_plan_mode's card, with its own copy + // and the icon of the mode it leads to. + const switchTool = $derived(isWorkModeSwitchTool(message.toolName) ? message.toolName : undefined) + const isPlanCard = $derived(isPlanCardTool(message.toolName) || switchTool !== undefined) const planCopy = $derived( - isPlanCardTool(message.toolName) ? PLAN_CARD_COPY[message.toolName] : undefined + isPlanCardTool(message.toolName) + ? PLAN_CARD_COPY[message.toolName] + : switchTool && WORK_MODE_SWITCH_CARD_COPY[switchTool] + ) + const CardIcon = $derived( + switchTool ? workModeOption(WORK_MODE_SWITCH_TARGET[switchTool]).icon : ClipboardList ) // exit_plan_mode carries the plan, enter_plan_mode the one-line justification. const planBody = $derived(isPlanReview ? message.parameters?.summary : message.parameters?.reason) @@ -225,13 +239,18 @@ {#if message.isLoading && !message.needsConfirmation} <Loader2 class="w-3.5 h-3.5 animate-spin text-blue-500 shrink-0" /> {:else if planState === 'settled'} - <Check class={twMerge('w-3.5 h-3.5 shrink-0', PLAN_MODE_TEXT_COLOR)} /> + <Check + class={twMerge( + 'w-3.5 h-3.5 shrink-0', + switchTool ? 'text-secondary' : PLAN_MODE_TEXT_COLOR + )} + /> {:else if planState === 'declined'} <!-- Muted and not red: declining a plan is an outcome, not a failure, and this state also covers a card resolved by leaving plan mode. --> <CircleMinus class="w-3.5 h-3.5 text-tertiary shrink-0" /> {:else} - <ClipboardList class="w-3.5 h-3.5 text-secondary shrink-0" /> + <CardIcon class="w-3.5 h-3.5 text-secondary shrink-0" /> {/if} <span class="text-primary font-medium text-2xs">{planLabel}</span> {#if planBodyText} @@ -264,7 +283,7 @@ toolCallId={message.tool_call_id} rejectLabel={planCopy?.reject} confirmLabel={planCopy?.confirm ?? ''} - confirmIcon={isPlanReview ? undefined : ClipboardList} + confirmIcon={isPlanReview ? undefined : CardIcon} class={extraClass} /> {/snippet} diff --git a/frontend/src/lib/components/copilot/chat/autonomyModes.ts b/frontend/src/lib/components/copilot/chat/autonomyModes.ts index 1cc43c1f2d..b514635911 100644 --- a/frontend/src/lib/components/copilot/chat/autonomyModes.ts +++ b/frontend/src/lib/components/copilot/chat/autonomyModes.ts @@ -1,11 +1,11 @@ -import { ChevronsRight, ClipboardList, Hand } from 'lucide-svelte' +import { ChevronsRight, ClipboardList, Hammer, Hand, Play } from 'lucide-svelte' import { AIAutonomyMode } from './AIChatManager.svelte' import { PLAN_MODE_TEXT_COLOR, PLAN_MODE_TRIGGER_CLASS } from './planMode' +import { AIWorkMode } from './workMode' export type AutonomyAvailability = { autoAcceptEditsAvailable: boolean autoAcceptToolConfirmationsAvailable: boolean - planModeAvailable: boolean } export type AutonomyModeOption = { @@ -14,8 +14,6 @@ export type AutonomyModeOption = { shortLabel?: string icon: typeof Hand iconColor: string - /** Tints the whole trigger, not just its icon. Only plan mode needs it. */ - triggerClass?: string tooltip: (a: AutonomyAvailability) => string isAvailable: (a: AutonomyAvailability) => boolean } @@ -38,17 +36,6 @@ const askPermissionOption: AutonomyModeOption = { // table. `isAvailable` hides the postures that would do nothing in the current AI // mode, which is why the picker can be shorter than this list. const autonomyModeOptions: AutonomyModeOption[] = [ - { - mode: AIAutonomyMode.PLAN, - label: 'Plan (read-only)', - shortLabel: 'Plan', - icon: ClipboardList, - iconColor: PLAN_MODE_TEXT_COLOR, - triggerClass: PLAN_MODE_TRIGGER_CLASS, - tooltip: () => - 'Read-only: the assistant researches and drafts a plan for your approval before it can change anything.', - isAvailable: (a) => a.planModeAvailable - }, askPermissionOption, { mode: AIAutonomyMode.ACCEPT_EDIT, @@ -88,3 +75,44 @@ export function resolveAutonomyMode(mode: AIAutonomyMode, a: AutonomyAvailabilit ? mode : AIAutonomyMode.DEFAULT } + +export type WorkModeOption = { + mode: AIWorkMode + label: string + description: string + icon: typeof Hand + iconColor: string + /** Tints the whole trigger, not just its icon. Only plan mode needs it. */ + triggerClass?: string +} + +/** A session chat's first section, above the postures: what the chat may do. */ +export const workModeOptions: WorkModeOption[] = [ + { + mode: AIWorkMode.USE, + label: 'Use', + description: 'Reads and runs. No changes.', + icon: Play, + iconColor: 'text-secondary' + }, + { + mode: AIWorkMode.PLAN, + label: 'Plan', + description: 'Proposes a plan first.', + icon: ClipboardList, + iconColor: PLAN_MODE_TEXT_COLOR, + triggerClass: PLAN_MODE_TRIGGER_CLASS + }, + { + mode: AIWorkMode.BUILD, + // Named for both halves: building does not take away running what is deployed. + label: 'Use & build', + description: 'Edits, tests and deploys.', + icon: Hammer, + iconColor: 'text-secondary' + } +] + +export function workModeOption(mode: AIWorkMode): WorkModeOption { + return workModeOptions.find((o) => o.mode === mode) ?? workModeOptions[2] +} diff --git a/frontend/src/lib/components/copilot/chat/chatViewHost.ts b/frontend/src/lib/components/copilot/chat/chatViewHost.ts index fc52b67449..5e94f39311 100644 --- a/frontend/src/lib/components/copilot/chat/chatViewHost.ts +++ b/frontend/src/lib/components/copilot/chat/chatViewHost.ts @@ -1,5 +1,6 @@ import { getContext, setContext } from 'svelte' import type { AIMode, AIAutonomyMode } from './AIChatManager.svelte' +import type { AIWorkMode } from './workMode' import { getAiChatManager } from './aiChatManagerContext' import type { DisplayMessage, Tool } from './shared' import type { ContextElement } from './context' @@ -160,6 +161,10 @@ export interface ChatViewHost { readonly tools: readonly Tool<any>[] autonomyMode: AIAutonomyMode setAutonomyMode: (mode: AIAutonomyMode) => void + /** Use, Plan or Build: offered by a session chat only. */ + readonly workModesAvailable: boolean + workMode: AIWorkMode + setWorkMode: (mode: AIWorkMode) => void readonly autoAcceptEditsActive: boolean readonly autoAcceptEditsAvailable: boolean readonly autoAcceptToolConfirmationsAvailable: boolean diff --git a/frontend/src/lib/components/copilot/chat/composerBox.ts b/frontend/src/lib/components/copilot/chat/composerBox.ts index 09dcf04764..3326e58274 100644 --- a/frontend/src/lib/components/copilot/chat/composerBox.ts +++ b/frontend/src/lib/components/copilot/chat/composerBox.ts @@ -33,6 +33,9 @@ export const COMPOSER_FIELD_RESET = /** The placeholder of a composer that opens a session. */ export const STARTER_PLACEHOLDER = 'Ask about this workspace, or describe what to build…' +/** The same, for a session held in Use, which builds nothing. */ +export const USE_STARTER_PLACEHOLDER = 'Ask about this workspace, or run what it holds…' + /** A caret or selected range in the composer's text. */ export type ComposerSelection = { start: number; end: number } diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 61bdea9dc9..17cd4c8960 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -1370,6 +1370,40 @@ describe('global AI tools', () => { expect(localStorageSnapshot()).not.toContain('new-secret-token') }) + // The draft has no null, so a cleared expiry must deploy as an explicit one. + it('clears a variable value expiry through to the deploy body', async () => { + vi.mocked(VariableService.existsVariable).mockResolvedValueOnce(true) // write probe + vi.mocked(VariableService.existsVariable).mockResolvedValueOnce(true) // deploy probe + vi.mocked(VariableService.getVariable).mockResolvedValueOnce({ + path: 'f/secrets/api_key', + value: 'tok', + is_secret: false, + description: 'd', + value_valid_until: '2027-03-15T08:30:00Z' + } as any) + + await callGlobalTool('write_variable', { + path: 'f/secrets/api_key', + value_valid_until: null + }) + + expect( + getBackendDraft<any>('variable', 'f/secrets/api_key', { workspace: WORKSPACE }) + ?.value_valid_until + ).toBeUndefined() + + await callGlobalTool('deploy_workspace_item', { + type: 'variable', + path: 'f/secrets/api_key' + }) + + expect(VariableService.updateVariable).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody: expect.objectContaining({ value_valid_until: null }) + }) + ) + }) + // '' means "no value staged", so there is nothing to create the secret with. it('does not create a secret variable draft that stages no value', async () => { seedBackendDraft( diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 66af4ab332..c93c83eae4 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -73,6 +73,7 @@ import { import { processSecretArgs } from '$lib/components/secretArgUtils' import { parsePipelineAnnotations } from '$lib/components/assets/AssetGraph/parsePipelineAnnotations' import { PLAN_MODE_MESSAGES } from '../planModeMessages' +import { USE_MODE_WITHDRAWN } from '../workMode' import { DEFAULT_DATA as DEFAULT_RAW_APP_DATA, formatDataTableRef @@ -80,7 +81,7 @@ import { import { appSourceToDraftValue } from '$lib/components/raw_apps/rawAppDraftValue' import { conformBuilderAppPolicy } from '$lib/components/raw_apps/builderAppPolicy' import { capabilitiesInWorkspace } from '$lib/operatorWriteRights' -import { capabilitiesIn } from '$lib/roleCapabilities' +import { capabilitiesIn, withoutBuilding } from '$lib/roleCapabilities' import type { RoleCapabilities } from '$lib/gen' import type { RawAppDomQuery } from '$lib/components/raw_apps/rawAppDom' import { dataUrlToImagePart, normalizeImageDataUrl, type AttachedImage } from '../imageUtils' @@ -231,6 +232,7 @@ import type { UserDraftItemKind } from '$lib/gen' import { bundleRawAppDraft } from './rawAppBundlerBridge' import { canOpenEditor, + isReadOnlyAccess, DEPLOY, NONE, RUN_FLOW_PREVIEW, @@ -745,6 +747,15 @@ const writeVariableSchema = variableRequestSchema.extend({ .string() .optional() .describe('The description of the variable. Omit it to keep the current description.'), + // Nullable, unlike the generated schema, so a cleared expiry differs from an omitted one. + value_valid_until: z + .string() + .datetime({ offset: true }) + .nullable() + .optional() + .describe( + "When the value stored in this variable stops working. Triggers the workspace's variable expiration handler ahead of it (1 day by default, set on the handler); never causes the variable to be deleted. Omit it to keep the current expiry, or pass null to clear one — for instance once the value has been rotated." + ), override: draftOverrideField }) @@ -1518,6 +1529,9 @@ const buildGlobalSystemPrompt = ( canDraftResources || canDraftVariables const canDeploy = !access || access.has('deploy') + // Holds none of the capabilities below, so every `can*` reads false; the blocks that blame + // the user's role for that check this first. + const readOnly = isReadOnlyAccess(access) // Whether deploy_workspace_item and delete_workspace_item keep any kind: only schedules and // triggers skip the deploy rules, and an admin passes them. const canDeployAnyKind = @@ -1651,31 +1665,33 @@ The current user's workspace username is "${username}".${instanceLine} ${ canWriteDraft ? 'Use tools to inspect workspace items and create per-user drafts (saved server-side, visible only to this user — not deployed) for scripts, flows, schedules, triggers, resources, variables, and raw apps.' - : `Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit ${listJoin(refusedKinds, 'disjunction')} here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused ${listJoin(refusedCode)} outside this chat too, so for those suggest asking a workspace admin, who can grant their role more in ${OPERATOR_SETTINGS_LOCATION}, rather than creating them in the editor.${when( - canManageSchedules, - ' To make something recur, they can schedule a deployed script or flow themselves; only when none does the job is there something to ask an admin for, and it is that script.' - )} When you decline something, give the reason stated here and keep to it.${when( - buildsFlows, - '\n\nFlows are the exception: this workspace lets them compose flows out of scripts and flows that are already deployed. Write flow drafts with write_flow and patch_flow_json, using only steps that call a deployed script or flow by path. The server refuses inline code anywhere in the flow, so never add a rawscript step or inline code; when the flow needs logic no deployed script provides, say so and suggest asking a workspace admin for that script.' - )}${when( - buildsApps, - `\n\nRaw apps are ${buildsFlows ? 'an exception too' : 'the exception'}: this workspace lets them build raw apps whose backend calls scripts and flows that are already deployed. Call get_instructions with subject "app" first, then write app drafts with init_app and the app file and runnable tools, making every backend runnable a path runnable to a deployed script or flow. The server refuses inline and hub runnables, so never write one; when the app needs logic no deployed script provides, say so and suggest asking a workspace admin for that script. An app this user deploys always runs sandboxed, and runs its runnables as the user who deployed it: turning the sandbox off or running them as the viewer is refused. If asked, say operators cannot, because the app's code would then run with the permissions of whoever opens it.` - )}${when( - !!draftedOperatorKinds, - `\n\nThis workspace lets them create and edit ${draftedOperatorKinds}: write them as drafts with ${listJoin( - [ - canDraftResources && 'write_resource (call search_resource_types first)', - canDraftVariables && 'write_variable', - canDraftSchedules && 'write_schedule', - canDraftTriggers && - 'write_trigger (call get_trigger_schema first: the config fields differ per kind)' - ] - )}${ - deployableKinds.length - ? `, then deploy ${deployableKinds.length === draftedKinds.length ? 'them' : listJoin(deployableKinds)} with deploy_workspace_item once the user asks` - : '' - }.` - )}` + : readOnly + ? "Use tools to inspect workspace items, the workspace's run history and its data, and to run items that are already deployed. This session is read-only: you cannot create, edit, deploy or delete anything here, nor test-run code that is not deployed. When the user asks for such a change, say plainly that it cannot be made in this session, and tell them how to make it as the session context below says, rather than describing steps as if you had made it." + : `Use tools to inspect workspace items and the workspace's run history, and to run items that are already deployed. You cannot create or edit ${listJoin(refusedKinds, 'disjunction')} here — this user's role does not allow it — so when they ask for such a change, say plainly that you cannot make it rather than describing steps as if you had. Their role is refused ${listJoin(refusedCode)} outside this chat too, so for those suggest asking a workspace admin, who can grant their role more in ${OPERATOR_SETTINGS_LOCATION}, rather than creating them in the editor.${when( + canManageSchedules, + ' To make something recur, they can schedule a deployed script or flow themselves; only when none does the job is there something to ask an admin for, and it is that script.' + )} When you decline something, give the reason stated here and keep to it.${when( + buildsFlows, + '\n\nFlows are the exception: this workspace lets them compose flows out of scripts and flows that are already deployed. Write flow drafts with write_flow and patch_flow_json, using only steps that call a deployed script or flow by path. The server refuses inline code anywhere in the flow, so never add a rawscript step or inline code; when the flow needs logic no deployed script provides, say so and suggest asking a workspace admin for that script. An AI agent step may link a saved agent they can read, which runs its tools as the agent defines them. Its `tool_inputs` may only fill tool inputs the agent leaves for the AI to fill: the server refuses a step that changes an input the agent sets.' + )}${when( + buildsApps, + `\n\nRaw apps are ${buildsFlows ? 'an exception too' : 'the exception'}: this workspace lets them build raw apps whose backend calls scripts and flows that are already deployed. Call get_instructions with subject "app" first, then write app drafts with init_app and the app file and runnable tools, making every backend runnable a path runnable to a deployed script or flow. The server refuses inline and hub runnables, so never write one; when the app needs logic no deployed script provides, say so and suggest asking a workspace admin for that script. An app this user deploys always runs sandboxed, and runs its runnables as the user who deployed it: turning the sandbox off or running them as the viewer is refused. If asked, say operators cannot, because the app's code would then run with the permissions of whoever opens it.` + )}${when( + !!draftedOperatorKinds, + `\n\nThis workspace lets them create and edit ${draftedOperatorKinds}: write them as drafts with ${listJoin( + [ + canDraftResources && 'write_resource (call search_resource_types first)', + canDraftVariables && 'write_variable', + canDraftSchedules && 'write_schedule', + canDraftTriggers && + 'write_trigger (call get_trigger_schema first: the config fields differ per kind)' + ] + )}${ + deployableKinds.length + ? `, then deploy ${deployableKinds.length === draftedKinds.length ? 'them' : listJoin(deployableKinds)} with deploy_workspace_item once the user asks` + : '' + }.` + )}` }` const paths = `${when( canWriteAnyDraft, @@ -1707,8 +1723,12 @@ ${findItemsRule}${deployRule}${when(canWriteDraft, `\n${instructionsRule}`)}${wh ${external ? 'More rules:' : `Rules:${draftsOnlyRule}\n${findItemsRule}`} ${groupMembershipRule}- Listings and reads return only what this user can see: items in folders they cannot read are hidden from you both. When an item the user names is missing, say it is not among what they can see and may be in a folder they cannot read, where someone with access can run it — never state that it does not exist or is not deployed. - An agent, as the Agents page lists it, is a resource of type \`ai_agent\`: read it with read_workspace_item type "resource".${when( - canDraftResources, + // An agent draft is a resource draft that the server checks under the flow builder right. + canDraftResources && canWriteFlowDraft, " You may draft a change to an agent, but never deploy or rename one here: the user reviews and deploys it from the agent's page, `/agents/edit/<path>`, so point them there." + )}${when( + buildsFlows, + ' Its tools, like flow steps, may only reference deployed scripts and flows by path: the server refuses inline code and hub scripts.' )}${when( !external, ` @@ -1721,11 +1741,11 @@ ${groupMembershipRule}- Listings and reads return only what this user can see: i ' Use delete_workspace_item only to remove an item that is already deployed in the workspace; it mutates the workspace and fails if nothing is deployed at that path.' )}` )}${when( - !canWriteAnyDraft, + !canWriteAnyDraft && !readOnly, ` - Three changes are still open to you where the server allows them: discard_local_draft drops a draft this user left behind, deploy_workspace_item deploys one when the user asks, and delete_workspace_item removes an item already deployed in the workspace — each only for the kinds its type lists. You cannot create or edit one.` )}${when( - !canManageSchedules || !canManageTriggers, + (!canManageSchedules || !canManageTriggers) && !readOnly, ` - This workspace does not let this user change ${ !canManageSchedules && !canManageTriggers @@ -2868,7 +2888,7 @@ function getResourceInstructions(): string { - Global mode writes complete draft payloads only; it does not save, deploy, run, scaffold local files, or generate metadata. - A resource draft is a workspace item: \`{ type: 'resource', path, summary?, value, isDraft }\`. \`value\` is a CreateResource body: \`{ path, value, description?, resource_type, labels? }\` where the inner \`value\` is the resource type's data shape. - Reading a variable returns \`{ type: 'variable', path, summary?, isSecret, isDraft }\` — never its value, secret or not. \`isSecret\` tells you whether the value is encrypted. -- \`write_variable\` takes \`{ path, value?, is_secret?, description?, is_oauth?, expires_at?, labels? }\`. Creating a variable needs \`value\` and \`is_secret\`; editing one needs only the fields you are changing. Omitting \`value\` keeps the stored value, which is the only way to edit a secret variable — you cannot read its value, so passing any \`value\` you did not get from the user destroys it. +- \`write_variable\` takes \`{ path, value?, is_secret?, description?, is_oauth?, expires_at?, value_valid_until?, labels? }\`. Creating a variable needs \`value\` and \`is_secret\`; editing one needs only the fields you are changing. Omitting \`value\` keeps the stored value, which is the only way to edit a secret variable — you cannot read its value, so passing any \`value\` you did not get from the user destroys it. - For secret fields in a resource value, create the variable with \`write_variable\` and \`is_secret: true\`, and deploy it before the resource (see "Secrets" in the reference below). - Reference formats inside resource values: \`$var:g/all/name\` (global), \`$var:u/user/name\` (user), \`$var:f/folder/name\` (folder). Reference another resource with \`$res:path/to/resource\`. The same strings are also how a resource or variable is passed as a run argument (see the run-argument rule in the resource reference below); what they are never valid as is a variable's own value. - Use \`search_resource_types\` to discover valid \`resource_type\` names and their JSON Schemas. Match the resource value to that schema. @@ -2949,6 +2969,11 @@ export type SessionPromptContext = { forkParentUnknown?: boolean /** Pre-send intent: a staged fork of this workspace is created at first send. */ pendingForkOf?: string + /** The family's dev workspace, where a read-only session sends the user to make changes. */ + devWorkspace?: { id: string; name: string } + /** Why the session is in Use mode, where the workspace itself is not protected: the user + * picked it, or their role builds nothing there. */ + useMode?: 'chosen' | 'role' } /** Session-state guidance appended to the global system prompt so the model @@ -2975,9 +3000,17 @@ export function getSessionContextPromptSection( (!access || access.has('manage_schedules')) && 'schedules', (!access || access.has('manage_triggers')) && 'triggers' ].filter((k): k is string => !!k) + const readOnly = isReadOnlyAccess(access) + // Read-only either because the user chose Use or because the workspace forces it. + const protectedWorkspace = readOnly && !ctx.useMode // With no kind left to deploy, the deploy tool is not shipped, so the lines below cannot name it. - const deployer = canDeploy || unruledKinds.length ? 'deploy_workspace_item' : 'A deploy' - const targets = ['reads', canWriteDraft && 'drafts', canRunPreview && 'test runs', 'deploys'] + const deployer = + !readOnly && (canDeploy || unruledKinds.length) ? 'deploy_workspace_item' : 'A deploy' + const targets = ( + readOnly + ? ['reads', 'runs'] + : ['reads', canWriteDraft && 'drafts', canRunPreview && 'test runs', 'deploys'] + ) .filter(Boolean) .join(', ') const lines = [ @@ -2990,6 +3023,14 @@ export function getSessionContextPromptSection( lines.push( `- No workspace is committed yet: a staged fork of workspace "${ctx.pendingForkOf}" is created automatically when the first message is sent, and all work lands in that fork.` ) + } else if (protectedWorkspace) { + const dev = ctx.devWorkspace?.id !== ctx.workspaceId ? ctx.devWorkspace : undefined + const elsewhere = dev + ? `start a new session in the dev workspace "${dev.id}", which the "New session in ${dev.name} (dev)" entry of the composer's mode picker opens` + : 'start a new session in a fork of this workspace, or, where forking is refused too, make the change in git and open a pull request' + lines.push( + `- Operating workspace: "${ctx.workspaceId}" — a production workspace protected against direct changes, so this session is READ-ONLY whatever the user's role. To make a change, the user needs to ${elsewhere}. This session's mode picker shows Plan and Use & build greyed out and its workspace cannot change once it has started, so never suggest picking Use & build or switching this session.` + ) } else if (ctx.parentWorkspaceId && ctx.isDevWorkspace) { lines.push( `- Operating workspace: "${ctx.workspaceId}" — the user's persistent DEV WORKSPACE, forked from workspace "${ctx.parentWorkspaceId}". ${deployer} publishes into the dev workspace only; the user reviews & promotes changes into "${ctx.parentWorkspaceId}" from the session's deploy panel. Never present a change as live in "${ctx.parentWorkspaceId}".` @@ -3011,9 +3052,18 @@ export function getSessionContextPromptSection( '- No operating workspace is set yet; the user picks one (or a new staged fork) before the first message is sent.' ) } + if (readOnly && ctx.useMode === 'chosen') { + lines.push( + "- The user put this session in Use mode with the composer's mode picker, so it is READ-ONLY until they switch. To make a change, call `enter_build_mode`, which asks them to switch to Use & build, or `enter_plan_mode` to agree on the change first; without those tools, ask them to switch the picker." + ) + } else if (readOnly && ctx.useMode === 'role') { + lines.push( + `- This user's workspace role cannot create or change scripts, flows, apps, schedules or triggers, neither through you nor by hand anywhere in the workspace. That role, not this session, is why the session is held in Use mode and is READ-ONLY. When they ask for one, say that their role does not allow it, and that a workspace admin can create it for them or grant their role more in ${OPERATOR_SETTINGS_LOCATION}. Do not write its code or give steps: they can use neither. Their mode picker shows Plan and Use & build greyed out, so never suggest switching modes. Resources and variables are the exception: every role creates and edits those by hand, outside this session, on the workspace's own Resources and Variables pages.` + ) + } // The kind enum already withholds what the rules refuse; this says why, and where the user // promotes the rest instead. The rules gate deletes too, so it is owed to every such profile. - if (!canDeploy) { + if (!canDeploy && !readOnly) { lines.push( `- This workspace refuses direct deployment for this user${ unruledKinds.length === 2 @@ -3026,7 +3076,7 @@ export function getSessionContextPromptSection( } lines.push( '- Sessions are saved: the sidebar lists them under AI Sessions, and the user can reopen any of them.', - '- The composer has a permission mode picker: Ask permission, Auto-accept edits, Yolo (bypass permissions: tool confirmations are accepted without asking) and Plan (read-only). Some modes are hidden where they do not apply.', + `- The composer has a mode picker in two parts. What you may do: Use (read and run deployed items, change nothing), Plan (research, then propose a plan for approval) and Use & build (everything Use does, plus changes). How tool confirmations work: Ask permission, Auto-accept edits, or Yolo (bypass permissions: tool confirmations are accepted without asking). Some options are hidden where they do not apply${protectedWorkspace ? ', and this workspace locks the picker to Use' : ctx.useMode === 'role' ? ", and this user's role locks the picker to Use" : ''}.`, "- The sliders button next to the model picker opens Assistant settings. Its Permissions section shows what the user's role lets them change and what you can change for them; point them there when they ask what they are allowed to do." ) return lines.join('\n') @@ -3258,7 +3308,9 @@ export async function resolveGlobalPromptIdentity( // for the global side-panel chat. The tool only ever advertises, and only ever acts on, // `pages`. async function allowedOpenPages( - workspaceId: string | undefined = get(workspaceStore) + workspaceId: string | undefined = get(workspaceStore), + /** A read-only session reaches what an operator reaches: never workspace settings. */ + readOnly = false ): Promise<{ pages: OpenPageName[]; roleUnverified: boolean }> { const role = await roleForWorkspace(workspaceId) // A failed lookup says nothing about the role, so guessing a page set can offer one the @@ -3276,7 +3328,9 @@ async function allowedOpenPages( { ...u, is_admin: isAdmin }, get(userWorkspaces).find((w) => w.id === workspaceId) ) - return { pages: roleOpenPages(caps, isAdmin), roleUnverified: false } + return readOnly + ? { pages: roleOpenPages(withoutBuilding(caps), false), roleUnverified: false } + : { pages: roleOpenPages(caps, isAdmin), roleUnverified: false } } // The Runs page only offers its cross-workspace filter to a superadmin or devops user in @@ -3758,7 +3812,10 @@ export const openPageTool: SessionTool<{}> = { // Re-narrow the advertised `page` enum to this user's permissions each iteration, so // the model never sees (or suggests) a page the user can't reach. schemaFor: async (helpers) => { - const access = await allowedOpenPages(operatingWorkspaceFromHelpers(helpers)) + const access = await allowedOpenPages( + operatingWorkspaceFromHelpers(helpers), + isReadOnlyAccess((helpers as GlobalToolHelpers | undefined)?.access) + ) return createToolDef( buildOpenPageDefSchema( access.pages, @@ -3779,7 +3836,10 @@ export const openPageTool: SessionTool<{}> = { // requests one outside the advertised enum. An unverified role means the lookup // failed, not that access was denied — reporting it as denied states a falsehood // about the user's permissions. - const access = await allowedOpenPages(workspaceId) + const access = await allowedOpenPages( + workspaceId, + isReadOnlyAccess((ctx.helpers as GlobalToolHelpers | undefined)?.access) + ) if (!access.pages.includes(page)) { const label = OPEN_PAGE_LABELS[page] ?? page return access.roleUnverified @@ -3971,6 +4031,8 @@ export const globalTools: SessionTool<{}>[] = [ 'update_user_instructions', 'Modify your own persistent personal instructions for the Global assistant. Use when the user asks you to remember a preference, always/never do something, or change/stop a behavior. Edits only the user-level instructions (the USER INSTRUCTIONS block, not workspace-level); changes persist in this browser and take effect on your next message.' ), + // Writes this browser's preference, not the workspace. + readOnlySafe: true, showDetails: true, fn: async ({ args, toolId, toolCallbacks, helpers }) => { const parsed = updateUserInstructionsSchema.parse(args) @@ -4421,6 +4483,7 @@ export const globalTools: SessionTool<{}>[] = [ // permissions, the one run an operator's token allows. `run_flow` likewise. requires: NONE, def: runScriptToolDef, + readOnlySafe: true, fn: async (ctx) => { const parsed = runScriptSchema.parse(ctx.args) return runDeployedScript(parsed, ctx) @@ -4453,6 +4516,7 @@ export const globalTools: SessionTool<{}>[] = [ { requires: NONE, def: runFlowToolDef, + readOnlySafe: true, fn: async (ctx) => { const parsed = runFlowSchema.parse(ctx.args) return runDeployedFlow(parsed, ctx) @@ -4590,6 +4654,8 @@ export const globalTools: SessionTool<{}>[] = [ 'cancel_job', 'Cancel a running or queued job by its id (e.g. a background test run you started that is no longer needed).' ), + // A run the session may start, it may also stop. + readOnlySafe: true, showDetails: true, fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsed = cancelJobSchema.parse(args) @@ -5028,6 +5094,8 @@ export const globalTools: SessionTool<{}>[] = [ { requires: NONE, def: createToolDef(openPreviewSchema, 'open_preview', OPEN_PREVIEW_DESCRIPTION), + // A read-only session holds no draft capability, so the schema below offers it viewers. + readOnlySafe: true, // Withhold the editors a session cannot save, so the model never offers the user a // panel it would not get. schemaFor: async (helpers) => { @@ -5076,6 +5144,7 @@ export const globalTools: SessionTool<{}>[] = [ 'close_page', 'Close one or more preview tabs in the side panel of this AI session. Pass `match` to close the tab(s) whose page name or item path contains that text, or `all: true` to clear the panel. Use this when the user asks to close/dismiss a tab they no longer need. Only works inside a session.' ), + readOnlySafe: true, fn: async (ctx) => { const parsed = closePageSchema.parse(ctx.args) return closeSessionPreviewTabs(parsed, sessionIdFromCtx(ctx)) @@ -5721,7 +5790,8 @@ function variableToDraftState(variable: ListableVariable): VariableDraftState { // reviews. Undefined drops out of the JSON instead. account: variable.account ?? undefined, is_oauth: variable.is_oauth ?? undefined, - expires_at: variable.expires_at ?? undefined + expires_at: variable.expires_at ?? undefined, + value_valid_until: variable.value_valid_until ?? undefined } } @@ -5791,7 +5861,11 @@ function createVariableToDraftState( wsSpecific: args.ws_specific ?? base?.wsSpecific ?? false, account: args.account ?? base?.account, is_oauth: args.is_oauth ?? base?.is_oauth, - expires_at: args.expires_at ?? base?.expires_at + expires_at: args.expires_at ?? base?.expires_at, + // `null` clears and omitted keeps the base's. The draft stores a cleared expiry as + // `undefined`, since a stored draft goes through a JSON round-trip. + value_valid_until: + args.value_valid_until === null ? undefined : (args.value_valid_until ?? base?.value_valid_until) } } @@ -5810,10 +5884,16 @@ function buildVariableCreateRequestBody(draftValue: CreateVariable): CreateVaria // endpoint, and a draft stores '' when it stages no new value — so omitting `value` in // that case is what leaves the stored one untouched. A staged value is sent as-is: the // endpoint decrypts an `$encrypted:` marker and encrypts plaintext. -function buildVariableUpdateRequestBody( - draftValue: CreateVariable -): Omit<CreateVariable, 'value'> & { value?: string } { - const { value, ...rest } = structuredClone(draftValue) +function buildVariableUpdateRequestBody(draftValue: CreateVariable): Omit< + CreateVariable, + 'value' | 'value_valid_until' +> & { + value?: string + value_valid_until?: string | null +} { + const { value, value_valid_until, ...others } = structuredClone(draftValue) + // The draft holds the full state, so an absent expiry was cleared: send `null`. + const rest = { ...others, value_valid_until: value_valid_until ?? null } // A value the draft is never allowed to carry — a secret's, and an OAuth-managed one // dropped by `variableToDraftState` — reaches here as '' whenever this edit staged // none, so sending it would blank the stored value or wipe a rotating token. @@ -6746,6 +6826,17 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<st } const blockedBeforeForm = blockedByPlanMode() if (blockedBeforeForm) return blockedBeforeForm + // The form is this call's confirmation, and a session can switch to Use while it waits. + const withdrawnByUseMode = (): string | undefined => { + if (!toolCallbacks.isToolWithdrawn?.(spec.toolName)) return undefined + toolCallbacks.setToolStatus(toolId, { + content: USE_MODE_WITHDRAWN.label, + isLoading: false, + isStreamingArguments: false, + error: USE_MODE_WITHDRAWN.result + }) + return USE_MODE_WITHDRAWN.result + } const schema = spec.schema // Whether to ask is the only question decided here. What a mounted field would hold — a @@ -6840,7 +6931,7 @@ async function runThroughForm(spec: FormRunSpec, ctx: WriteDraftCtx): Promise<st const adopted = answer.kind === 'job' ? answer : undefined const submitted = answer.args - const blockedBeforeRun = adopted ? undefined : blockedByPlanMode() + const blockedBeforeRun = adopted ? undefined : (blockedByPlanMode() ?? withdrawnByUseMode()) if (blockedBeforeRun) { toolCallbacks.markRunFormEnded?.(toolId) return blockedBeforeRun diff --git a/frontend/src/lib/components/copilot/chat/global/diffSnapshot.test.ts b/frontend/src/lib/components/copilot/chat/global/diffSnapshot.test.ts index 7c8253684d..56fa40655d 100644 --- a/frontend/src/lib/components/copilot/chat/global/diffSnapshot.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/diffSnapshot.test.ts @@ -573,6 +573,20 @@ describe('fork mode', () => { expect(entry?.valueMasked).toBe(true) }) + it('sees an expiry-only variable change', async () => { + mockComparison([comparisonDiff({ kind: 'variable', path: 'f/a/plain' })]) + vi.mocked(VariableService.getVariable).mockImplementation(async ({ workspace }: any) => ({ + path: 'f/a/plain', + is_secret: false, + value: 'same', + description: 'same', + value_valid_until: workspace === FORK ? '2027-09-09T10:00:00Z' : '2027-03-01T10:00:00Z' + })) as any + const entry = await readForkDiffEntryOne(FORK, PARENT, ['variable'], 'f/a/plain') + expect(entry?.status).toBe('modified') + expect(entry?.patch).toContain('2027-09-09') + }) + it('sees flow schema-only changes the shared projection drops', async () => { mockComparison([comparisonDiff({ kind: 'flow', path: 'f/a/fl' })]) vi.mocked(FlowService.getFlowByPath).mockImplementation(async ({ workspace }: any) => ({ diff --git a/frontend/src/lib/components/copilot/chat/global/diffSnapshot.ts b/frontend/src/lib/components/copilot/chat/global/diffSnapshot.ts index 472a9e6e61..9b8d04d370 100644 --- a/frontend/src/lib/components/copilot/chat/global/diffSnapshot.ts +++ b/frontend/src/lib/components/copilot/chat/global/diffSnapshot.ts @@ -904,6 +904,7 @@ async function fetchForkSideValue( value: { description: variable.description, is_secret: variable.is_secret, + value_valid_until: variable.value_valid_until, value: VARIABLE_VALUE_PLACEHOLDER }, valueMasked: true diff --git a/frontend/src/lib/components/copilot/chat/global/mcpTools.ts b/frontend/src/lib/components/copilot/chat/global/mcpTools.ts index e1da0fb624..0898837658 100644 --- a/frontend/src/lib/components/copilot/chat/global/mcpTools.ts +++ b/frontend/src/lib/components/copilot/chat/global/mcpTools.ts @@ -365,6 +365,8 @@ function createCallTool(servers: McpServer[], mode: 'read' | 'write'): SessionTo ...(isRead ? { planModeSafe: true } : { + // Changes the external system, never the workspace, like a deployed run does. + readOnlySafe: true, requiresConfirmation: true, confirmAction: 'run', confirmationMessage: (args: any) => `Call ${args?.tool ?? ''} on ${args?.server ?? ''}` diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts index 89a13403f0..46d744fb10 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.test.ts @@ -1,12 +1,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { whoami, deployRules } = vi.hoisted(() => ({ +const { whoami, deployRules, protectionRules } = vi.hoisted(() => ({ whoami: vi.fn(), - deployRules: vi.fn() + deployRules: vi.fn(), + protectionRules: vi.fn() })) vi.mock('$lib/gen', () => ({ UserService: { whoami } })) vi.mock('$lib/utils_workspace_deploy', () => ({ checkDeployRules: deployRules })) +vi.mock('$lib/workspaceProtectionRules.svelte', () => ({ + readProtectionRulesForWorkspace: protectionRules, + isRuleActiveInRulesets: (rulesets: { rules: string[] }[], kind: string) => + rulesets.some((r) => r.rules.includes(kind)) +})) import { capabilitiesForRole, resolveSessionAccess } from './sessionAccess' import { clearWorkspaceRoleCache } from '$lib/user' @@ -41,6 +47,7 @@ describe('resolveSessionAccess', () => { beforeEach(() => { vi.clearAllMocks() deployRules.mockResolvedValue({ ok: true }) + protectionRules.mockResolvedValue([]) // The role memo is app-wide, so without this each case answers from the previous one. clearWorkspaceRoleCache() }) @@ -54,6 +61,7 @@ describe('resolveSessionAccess', () => { 'manage_flows', 'manage_schedules', 'manage_triggers', + 'modify_workspace', 'run_flow_preview', 'run_preview', 'write_app_draft', @@ -76,6 +84,7 @@ describe('resolveSessionAccess', () => { 'deploy', 'manage_schedules', 'manage_triggers', + 'modify_workspace', 'write_resource_draft', 'write_schedule_draft', 'write_trigger_draft', @@ -89,6 +98,7 @@ describe('resolveSessionAccess', () => { expect([...caps].sort()).toEqual([ 'manage_schedules', 'manage_triggers', + 'modify_workspace', 'write_resource_draft', 'write_schedule_draft', 'write_trigger_draft', @@ -110,6 +120,7 @@ describe('resolveSessionAccess', () => { 'manage_flows', 'manage_schedules', 'manage_triggers', + 'modify_workspace', 'run_flow_preview', 'run_preview', 'write_app_draft', @@ -125,6 +136,26 @@ describe('resolveSessionAccess', () => { } ) + // A production workspace: the session reads and runs, and holds nothing that writes, even + // for an admin the rule would let through. + it('makes the session read-only in a workspace protected by DisableDirectDeployment', async () => { + protectionRules.mockResolvedValue([{ rules: ['DisableDirectDeployment'] }]) + const caps = await capabilitiesFor({ is_admin: true }) + expect([...caps]).toEqual([]) + }) + + // The server holds deploys to the rule but not drafts, so neither failure may hand a + // production session its writes. + it('stays read-only when the rules cannot be read, or the role of a protected workspace', async () => { + protectionRules.mockResolvedValue(undefined) + expect([...(await capabilitiesFor({ is_admin: true }))]).toEqual([]) + + // Another workspace: the role memo answers for the first one. + protectionRules.mockResolvedValue([{ rules: ['DisableDirectDeployment'] }]) + whoami.mockRejectedValueOnce(new Error('network')) + expect([...(await resolveSessionAccess('prod'))]).toEqual([]) + }) + it('keeps drafting when a rule refuses deploy', async () => { deployRules.mockResolvedValue({ ok: false, @@ -149,6 +180,7 @@ describe('resolveSessionAccess', () => { 'manage_flows', 'manage_schedules', 'manage_triggers', + 'modify_workspace', 'run_flow_preview', 'write_flow_draft', 'write_resource_draft', @@ -170,6 +202,7 @@ describe('resolveSessionAccess', () => { 'manage_apps', 'manage_schedules', 'manage_triggers', + 'modify_workspace', 'write_app_draft', 'write_resource_draft', 'write_schedule_draft', diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts index 8d4527091a..6c58900eab 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts @@ -1,11 +1,15 @@ import { get } from 'svelte/store' import { userWorkspaces } from '$lib/stores' -import type { RoleCapabilities } from '$lib/gen' +import type { ProtectionRuleset, RoleCapabilities } from '$lib/gen' import { getWorkspaceRole } from '$lib/user' import { roleCanAuthor, roleCanDraft } from '$lib/editRights' import { capabilitiesIn, FULL_CAPABILITIES } from '$lib/roleCapabilities' import { checkDeployRules } from '$lib/utils_workspace_deploy' -import type { SessionAccess, SessionCapability } from '../sessionCapabilities' +import { + isRuleActiveInRulesets, + readProtectionRulesForWorkspace +} from '$lib/workspaceProtectionRules.svelte' +import { USE_MODE_ACCESS, type SessionAccess, type SessionCapability } from '../sessionCapabilities' const ALL_CAPABILITIES: SessionCapability[] = [ 'write_draft', @@ -25,13 +29,15 @@ const ALL_CAPABILITIES: SessionCapability[] = [ 'manage_apps', 'manage_schedules', 'manage_triggers', - 'admin' + 'admin', + 'modify_workspace' ] -/** Fail open, here and at every resolution failure below: blanking a toolset on a +/** Fail open, here and at every role resolution failure below: blanking a toolset on a * transient error tells a developer mid-session that they cannot author anything, * which is worse and far less legible than the 403 they get by trying. Matches - * `checkDeployPermission`, which fails open for the same reason. */ + * `checkDeployPermission`, which fails open for the same reason. The protection rule is + * the exception (`protects`). */ export function fullSessionAccess(): SessionAccess { return new Set(ALL_CAPABILITIES) } @@ -45,7 +51,7 @@ export function capabilitiesForRole(role: { deployRulesPass: boolean capabilities: RoleCapabilities }): SessionAccess { - const capabilities = new Set<SessionCapability>() + const capabilities = new Set<SessionCapability>(['modify_workspace']) const caps = role.isAdmin ? FULL_CAPABILITIES : role.capabilities if (roleCanDraft('script', caps)) { capabilities.add('write_draft') @@ -102,12 +108,53 @@ export function capabilitiesForRole(role: { return capabilities } +/** Whether `rulesets` make a session read-only. `DisableDirectDeployment` holds everyone, admins + * and bypassers included, since production changes go through its dev workspace or a fork; + * `RestrictDeployToDeployers` alone leaves its users their drafts. Unreadable rules protect too: + * the server holds deploys to the rule but not drafts. */ +function protects(rulesets: ProtectionRuleset[] | undefined): boolean { + return !rulesets || isRuleActiveInRulesets(rulesets, 'DisableDirectDeployment') +} + +// The session UI asks this from every preview tab, the composer and the picker, and the rules +// fetch behind it is not cached, so they share one verdict per workspace for a minute. +const READ_ONLY_TTL_MS = 60_000 +const readOnlyVerdicts = new Map<string, { at: number; verdict: Promise<boolean> }>() +const settledVerdicts = new Map<string, { at: number; readOnly: boolean }>() + +/** The read-only verdict alone, for the UI around a session. Fails closed as the chat does; a + * failed read is not kept, so the next ask reads the rules again. */ +export function isReadOnlySessionWorkspace(workspace: string): Promise<boolean> { + const cached = readOnlyVerdicts.get(workspace) + if (cached && Date.now() - cached.at < READ_ONLY_TTL_MS) return cached.verdict + const at = Date.now() + const verdict = readProtectionRulesForWorkspace(workspace).then((rulesets) => { + if (!rulesets) readOnlyVerdicts.delete(workspace) + else settledVerdicts.set(workspace, { at, readOnly: protects(rulesets) }) + return protects(rulesets) + }) + readOnlyVerdicts.set(workspace, { at, verdict }) + return verdict +} + +/** The verdict read for `workspace` within the minute, without asking: what the UI stands on + * while a fresh component waits for its own ask. */ +export function settledReadOnlyVerdict(workspace: string): boolean | undefined { + const settled = settledVerdicts.get(workspace) + return settled && Date.now() - settled.at < READ_ONLY_TTL_MS ? settled.readOnly : undefined +} + export async function resolveSessionAccess(workspace: string): Promise<SessionAccess> { - // Shares the 5-minute memo with the identity the same pre-flight resolves beside this - // one, so a send costs one `whoami` rather than two, at the cost of a role changed - // elsewhere landing within that window rather than on the very next message. - // `lookup_failed` covers a rejected request AND a body too malformed to map. - const lookup = await getWorkspaceRole(workspace) + const [rulesets, lookup] = await Promise.all([ + // Not the memoized verdict above: the chat's toolset follows a rule change on the next send. + readProtectionRulesForWorkspace(workspace), + // Shares the 5-minute memo with the identity the same pre-flight resolves beside this + // one, so a send costs one `whoami` rather than two, at the cost of a role changed + // elsewhere landing within that window rather than on the very next message. + // `lookup_failed` covers a rejected request AND a body too malformed to map. + getWorkspaceRole(workspace) + ]) + if (protects(rulesets)) return USE_MODE_ACCESS if (lookup.kind !== 'resolved') { return fullSessionAccess() } @@ -115,7 +162,7 @@ export async function resolveSessionAccess(workspace: string): Promise<SessionAc return capabilitiesForRole({ isAdmin: !!me.is_admin || !!me.is_super_admin, - deployRulesPass: (await checkDeployRules(workspace, me)).ok, + deployRulesPass: (await checkDeployRules(workspace, me, rulesets)).ok, capabilities: capabilitiesIn( me, get(userWorkspaces).find((w) => w.id === workspace) diff --git a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts index dda1483c50..15f5e7a246 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts @@ -28,8 +28,15 @@ import { pipelineTools } from '../pipeline/core' import { assembleGlobalSystemMessage, assembleGlobalTools } from './globalAssembly' import { filterSessionTools, sessionToolAllowed } from './sessionToolset' import { capabilitiesForRole, fullSessionAccess } from './sessionAccess' -import type { SessionAccess, SessionCapability, SessionTool } from '../sessionCapabilities' +import { + canBuild, + USE_MODE_ACCESS, + type SessionAccess, + type SessionCapability, + type SessionTool +} from '../sessionCapabilities' import { FULL_CAPABILITIES, legacyOperatorCapabilities } from '$lib/roleCapabilities' +import { buildsAnything } from '$lib/editRights' import type { AuthorLevel, OperatorSettings, RoleCapabilities } from '$lib/gen' const ASSEMBLY_OPTS = { @@ -70,8 +77,9 @@ function shippedSessionTools(access: SessionAccess) { return filterSessionTools(assembleGlobalTools(ASSEMBLY_OPTS), access) } +/** A session that can change the workspace, holding `capabilities` besides. */ function accessWith(capabilities: SessionCapability[]): SessionAccess { - return new Set(capabilities) + return new Set<SessionCapability>(['modify_workspace', ...capabilities]) } const LEVELS: AuthorLevel[] = ['none', 'compose', 'full'] @@ -98,8 +106,8 @@ const ROLES: RoleCapabilities[] = [false, true] .filter((c) => c.code || (c.flows !== 'full' && c.apps !== 'full')) /** The profiles a real session can hold, keyed for the test name, over every combination - * of the role facts `capabilitiesForRole` reads. */ -const REACHABLE_PROFILES = [ + * of the role facts `capabilitiesForRole` reads, and the read-only one. */ +const REACHABLE_PROFILES: [string, SessionAccess][] = [ ...new Map( [false, true].flatMap((isAdmin) => // `checkDeployRules` bypasses on admin, so an admin has only the one outcome. @@ -110,7 +118,8 @@ const REACHABLE_PROFILES = [ }) ) ) - ).entries() + ).entries(), + ['read-only', USE_MODE_ACCESS] ] /** One per branch of getSessionContextPromptSection — each words the deploy target @@ -134,6 +143,15 @@ describe('session tool policies', () => { filtered.forEach((tool, i) => expect(tool).toBe(tools[i])) }) + // The mode picker reads the role and the toolset reads the access built from it: if they + // disagree, the picker offers Build while the chat holds only Use's tools, or the reverse. + it('agrees with the role on whether the session can build', () => { + for (const capabilities of ROLES) { + const access = capabilitiesForRole({ isAdmin: false, deployRulesPass: true, capabilities }) + expect(canBuild(access)).toBe(buildsAnything(capabilities)) + } + }) + it('withholds a tool that arrives without a policy', () => { expect(sessionToolAllowed({ def: globalTools[0].def }, fullSessionAccess())).toBe(false) }) @@ -212,6 +230,17 @@ describe('session tool policies', () => { expect((original.def.function.parameters as any).properties.type.enum).toContain('script') }) + // Keyed on the tags, so a tool added untagged stays out of a production workspace. + it('ships a read-only session the reads and the deployed runs, and nothing that writes', () => { + const shipped = shippedSessionTools(USE_MODE_ACCESS) + expect(shipped.filter((t) => !t.planModeSafe && !t.readOnlySafe)).toEqual([]) + const names = shipped.map((t) => t.def.function.name) + expect(names).toEqual(expect.arrayContaining(['read_workspace_item', 'list_runs', 'run_flow'])) + for (const write of ['write_script', 'test_run_script', 'deploy_workspace_item']) { + expect(names).not.toContain(write) + } + }) + it('offers the workspace settings tools to admins only', () => { const settingsTools = ['get_workspace_settings', 'set_workspace_settings'] for (const [, access] of REACHABLE_PROFILES) { diff --git a/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts b/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts index 0d8f8753a2..7a867f0c01 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts @@ -1,10 +1,12 @@ import type { ChatCompletionFunctionTool } from 'openai/resources/index.mjs' -import type { SessionAccess, SessionToolPolicy } from '../sessionCapabilities' +import { isReadOnlyAccess, type SessionAccess, type SessionToolPolicy } from '../sessionCapabilities' type FilterableTool = { def: ChatCompletionFunctionTool requires?: SessionToolPolicy kindRequires?: Readonly<Partial<Record<string, SessionToolPolicy>>> + planModeSafe?: boolean + readOnlySafe?: boolean } const satisfies = (policy: SessionToolPolicy, access: SessionAccess) => @@ -13,7 +15,15 @@ const satisfies = (policy: SessionToolPolicy, access: SessionAccess) => /** Fails closed: every array that feeds a session is typed to declare `requires`, so a tool * without one arrived some other way, and leaking it is the worse of the two answers. */ export function sessionToolAllowed(tool: FilterableTool, access: SessionAccess): boolean { - return tool.requires ? satisfies(tool.requires, access) : false + if (!tool.requires || !satisfies(tool.requires, access)) return false + return !isReadOnlyAccess(access) || readOnlyToolAllowed(tool) +} + +/** A read-only session keeps the reads `planModeSafe` already marks, plus what opts in with + * `readOnlySafe`. Keyed on the tags rather than a list of writes, so a tool added untagged + * stays out of a production workspace until someone decides it belongs there. */ +function readOnlyToolAllowed(tool: FilterableTool): boolean { + return tool.planModeSafe === true || tool.readOnlySafe === true } /** Filter an assembled toolset, and cut each kind-taking tool's `type` enum to the kinds diff --git a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts index c53aa1b7d1..6368a27e24 100644 --- a/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts +++ b/frontend/src/lib/components/copilot/chat/global/userDraftAdapter.ts @@ -239,6 +239,7 @@ function variableDraftToWorkspaceItem(path: string, draft: VariableDraftState): account: draft.account, is_oauth: draft.is_oauth, expires_at: draft.expires_at, + value_valid_until: draft.value_valid_until, labels: draft.labels, ws_specific: draft.wsSpecific }, diff --git a/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts b/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts index d08fe4fd58..949a016b65 100644 --- a/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts +++ b/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts @@ -108,6 +108,7 @@ export type VariableDraftState = { account?: number is_oauth?: boolean expires_at?: string + value_valid_until?: string } export type WorkspaceItem = { diff --git a/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts b/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts index 01e5040040..c4f4ab6355 100644 --- a/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/planModeController.svelte.ts @@ -21,8 +21,8 @@ import { normalizeChangeNote } from './artifacts/artifactLimits' import { currentVersion } from './artifacts/artifactsDB' import { type SessionArtifactsStore } from './artifacts/artifactsState.svelte' -/** What plan mode needs from the chat it runs in: it reads the autonomy state and asks for - * the two changes it can cause, rather than owning any of it. */ +/** What plan mode needs from the chat it runs in: it reads the work mode and the autonomy + * state and asks for the two changes it can cause, rather than owning any of it. */ export interface PlanModeHost { /** Offered in this chat *and* selected. */ readonly active: boolean @@ -36,8 +36,8 @@ export interface PlanModeHost { readonly artifacts: SessionArtifactsStore openArtifact(id: string, name: string, version: ArtifactVersionTarget): void enter(): void - /** Hand the posture back to whatever preceded plan mode. */ - restore(): void + /** Move to Build, where the approved plan is carried out. */ + build(): void } /** Read off the write that proposed the plan, so nothing re-reads and races a later one. */ @@ -162,7 +162,7 @@ export class PlanModeController { } // Only to the round this approval belongs to: ending a re-entered round would drop the // user out of a planning posture they just chose. - if (this.#host.active && epoch === this.#epoch) this.#host.restore() + if (this.#host.active && epoch === this.#epoch) this.#host.build() return PLAN_MODE_MESSAGES.approvedWithDoc } } diff --git a/frontend/src/lib/components/copilot/chat/planModeController.test.ts b/frontend/src/lib/components/copilot/chat/planModeController.test.ts index 4e5438b906..0b6f1e5c5e 100644 --- a/frontend/src/lib/components/copilot/chat/planModeController.test.ts +++ b/frontend/src/lib/components/copilot/chat/planModeController.test.ts @@ -26,7 +26,7 @@ describe('PlanModeController', () => { let sessionId: string let docs: Doc[] let openArtifact: ReturnType<typeof vi.fn> - let restore: ReturnType<typeof vi.fn> + let build: ReturnType<typeof vi.fn> let artifacts: any let controller: PlanModeController @@ -36,7 +36,7 @@ describe('PlanModeController', () => { sessionId = 'session-1' docs = [] openArtifact = vi.fn() - restore = vi.fn(() => { + build = vi.fn(() => { active = false }) artifacts = { @@ -89,7 +89,7 @@ describe('PlanModeController', () => { enter: () => { active = true }, - restore + build } controller = new PlanModeController(host) controller.startRound() @@ -140,7 +140,7 @@ describe('PlanModeController', () => { expect(docs[0]).toMatchObject({ version: 2, approvedVersion: 1 }) }) - it('restores posture only after proposal and approval pointer are durable', async () => { + it('moves to Build only after proposal and approval pointer are durable', async () => { await propose('# Durable\n\nBuild it.') const result = await controller.exitTool.fn({ args: { summary: '# Durable\n\nBuild it.' }, @@ -151,7 +151,7 @@ describe('PlanModeController', () => { }) expect(docs[0].approvedVersion).toBe(1) - expect(restore).toHaveBeenCalledOnce() + expect(build).toHaveBeenCalledOnce() expect(result).toBe(PLAN_MODE_MESSAGES.approvedWithDoc) }) @@ -167,7 +167,7 @@ describe('PlanModeController', () => { }) expect(active).toBe(true) - expect(restore).not.toHaveBeenCalled() + expect(build).not.toHaveBeenCalled() expect(result).toBe(PLAN_MODE_MESSAGES.persistenceFailed) }) @@ -214,7 +214,7 @@ describe('PlanModeController', () => { it('refuses a second hand-over from the batch that already ended plan mode', async () => { // One response can carry two exit_plan_mode calls, and the tool list they run against is - // snapshotted before the first one restores the posture. Under YOLO nothing asks, so the + // snapshotted before the first one moves the session to Build. Under YOLO nothing asks, so the // stale call would write its own summary and stamp the user's approval on a plan no card // ever showed them. const frozenTools = [controller.exitTool] @@ -294,6 +294,6 @@ describe('PlanModeController', () => { controller.startRound() await approval expect(active).toBe(true) - expect(restore).not.toHaveBeenCalled() + expect(build).not.toHaveBeenCalled() }) }) diff --git a/frontend/src/lib/components/copilot/chat/planModeMessages.ts b/frontend/src/lib/components/copilot/chat/planModeMessages.ts index 85710d0c54..dff3ec1c13 100644 --- a/frontend/src/lib/components/copilot/chat/planModeMessages.ts +++ b/frontend/src/lib/components/copilot/chat/planModeMessages.ts @@ -9,12 +9,9 @@ export const PLAN_MODE_MESSAGES = { blockedLabel: 'Blocked in plan mode', blockedResult: 'Blocked: plan mode is active. Put this change in your plan; call exit_plan_mode when ready for approval.', - /** Sits beside the autonomy picker while plan mode holds. The picker's tooltip carries - * the rest, so this states only the constraint. */ - modeNote: 'Read-only', /** Refuses a pending run form. Its own string because nothing is settled: the form stays * live, so this names the way out rather than telling the user their run was blocked. */ - runFormRefused: 'Plan mode is read-only — switch it off to run this script.', + runFormRefused: 'Plan mode runs nothing — switch to Use, or Use & build, to run this script.', // One pair for both artifact tools: the fact and the way forward are the same whether the // model tried to mint the plan or to rewrite it, and the generic refusal above ("put this // change in your plan") reads as nonsense for a call that writes a document. @@ -46,7 +43,7 @@ export const PLAN_MODE_MESSAGES = { `The plan is too large to save (${bytes} bytes, limit ${MAX_ARTIFACT_BYTES}) and was not shown to the user. Cut it to the decisions and the steps — name the files you will touch instead of quoting them — and call exit_plan_mode again.`, // What the version picker shows for a revision the model did not label. revisionNote: 'Revised the plan', - // States the outcome, not a decision: pressing Stop and moving the autonomy picker out of + // States the outcome, not a decision: pressing Stop and moving the mode picker out of // plan mode both land here too, and this text persists in the transcript — asserting a // rejection would open the next turn interrogating a user who never made one. exitDeclined: diff --git a/frontend/src/lib/components/copilot/chat/readOnlyChatViewHost.svelte.ts b/frontend/src/lib/components/copilot/chat/readOnlyChatViewHost.svelte.ts index d3c58af681..7e80854a01 100644 --- a/frontend/src/lib/components/copilot/chat/readOnlyChatViewHost.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/readOnlyChatViewHost.svelte.ts @@ -1,4 +1,5 @@ import { AIAutonomyMode } from './AIChatManager.svelte' +import { AIWorkMode } from './workMode' import type { ChatViewHost } from './chatViewHost' import type { DisplayMessage } from './shared' import { AttachedFilesStore } from './files/attachedFiles.svelte' @@ -75,6 +76,9 @@ export function readOnlyChatViewHost(options: { tools: [], autonomyMode: AIAutonomyMode.DEFAULT, setAutonomyMode: noop, + workModesAvailable: false, + workMode: AIWorkMode.BUILD, + setWorkMode: noop, autoAcceptEditsActive: false, autoAcceptEditsAvailable: false, autoAcceptToolConfirmationsAvailable: false, diff --git a/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts index 8291a62085..1afe78bcfe 100644 --- a/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts +++ b/frontend/src/lib/components/copilot/chat/sessionCapabilities.ts @@ -50,9 +50,40 @@ export type SessionCapability = | 'manage_triggers' /** Passes `require_admin`. */ | 'admin' + /** Held by every role. A read-only session, in a protected workspace or in Use mode, lacks it + * and ships only the tools `readOnlyToolAllowed` admits. */ + | 'modify_workspace' export type SessionAccess = ReadonlySet<SessionCapability> +/** Whether `access` is a read-only session. `undefined` access answers false, as the toolset + * filter does. */ +export function isReadOnlyAccess(access: SessionAccess | undefined): boolean { + return !!access && !access.has('modify_workspace') +} + +/** What a session in Use mode holds whatever the user's role: nothing, as in a protected + * workspace. Fixed rather than narrowed from the resolved access, so Use ships no write while + * that check is still in flight. */ +export const USE_MODE_ACCESS: SessionAccess = new Set() + +/** `buildsAnything`, read off a session's access. `undefined` access answers true, as the + * toolset filter does. */ +export function canBuild(access: SessionAccess | undefined): boolean { + return ( + !access || + ( + [ + 'write_draft', + 'write_flow_draft', + 'write_app_draft', + 'write_schedule_draft', + 'write_trigger_draft' + ] as const + ).some((c) => access.has(c)) + ) +} + /** * The capabilities without which a tool's call cannot succeed — usually because the * backend refuses it, occasionally because only that capability produces the tool's @@ -97,4 +128,7 @@ export type SessionTool<T> = Tool<T> & { * tool is either too strict or too loose: what each kind's handler needs. A session is * offered only the kinds it can land, and the rest never appear in the schema it sees. */ kindRequires?: Readonly<Partial<Record<string, SessionToolPolicy>>> + /** Ships in a read-only session although it is not `planModeSafe`: a view tool that opens + * no editor there, or a run of a deployed item, which a production workspace is for. */ + readOnlySafe?: boolean } diff --git a/frontend/src/lib/components/copilot/chat/shared.ts b/frontend/src/lib/components/copilot/chat/shared.ts index 71ef91a0b7..13623dc73e 100644 --- a/frontend/src/lib/components/copilot/chat/shared.ts +++ b/frontend/src/lib/components/copilot/chat/shared.ts @@ -8,6 +8,7 @@ import type { UserDraftItemKind } from '$lib/gen' // The gate's two refusals, from a module that holds prose and one size limit: under the // shallow-import rule below, the rest of plan mode is not reachable from here. import { PLAN_MODE_MESSAGES } from './planModeMessages' +import { USE_MODE_WITHDRAWN } from './workMode' import { NONE, type SessionTool } from './sessionCapabilities' import { integrationDisplayName } from '$lib/components/resourceTypeDisplay' // Import-free leaf, so it satisfies the shallow-import rule below. @@ -955,6 +956,7 @@ type ToolCallStatus = | 'declined' | 'rejected' | 'blocked_plan_mode' + | 'withdrawn' | 'held_for_instructions' /** Re-reads the chat loop's current tools and helpers. A call earlier in the same @@ -1038,6 +1040,27 @@ export async function processToolCall<T>({ } } + const withdrawnBlock = (): ChatCompletionMessageParam | undefined => { + if (!tool || !toolCallbacks.isToolWithdrawn?.(tool.def.function.name)) return undefined + logToolOutcome('withdrawn') + toolCallbacks.setToolStatus(toolCall.id, { + content: USE_MODE_WITHDRAWN.label, + parameters: args, + isLoading: false, + isQueued: false, + isStreamingArguments: false, + error: USE_MODE_WITHDRAWN.result, + needsConfirmation: false, + showDetails: tool.showDetails, + autoCollapseDetails: tool.autoCollapseDetails + }) + return { + role: 'tool' as const, + tool_call_id: toolCall.id, + content: USE_MODE_WITHDRAWN.result + } + } + const preConfirmationBlock = planModeBlock() if (preConfirmationBlock) { return preConfirmationBlock @@ -1157,7 +1180,7 @@ export async function processToolCall<T>({ } } - const postConfirmationBlock = planModeBlock() + const postConfirmationBlock = planModeBlock() ?? withdrawnBlock() if (postConfirmationBlock) { return postConfirmationBlock } @@ -1456,6 +1479,10 @@ export interface ToolCallbacks { shouldAutoAcceptToolConfirmations?: (toolName?: string) => boolean isPlanModeActive?: () => boolean onToolBlockedByPlanMode?: () => void + /** Whether the session has stopped shipping this tool since the call was made, as a switch to + * Use does to every write. Asked once a confirmation or a run form has been answered, since + * the switch can land while either waits. */ + isToolWithdrawn?: (toolName: string) => boolean requestUserQuestion?: ( toolId: string, question: UserQuestionDisplay diff --git a/frontend/src/lib/components/copilot/chat/starter/composerSuggestions.test.ts b/frontend/src/lib/components/copilot/chat/starter/composerSuggestions.test.ts new file mode 100644 index 0000000000..1c74750a43 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/composerSuggestions.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { composerSuggestions, type ComposerRole } from './composerSuggestions' + +const developer: ComposerRole = { operator: false, workspaceAdmin: false, instanceAdmin: false } +const operator: ComposerRole = { ...developer, operator: true } +const workspaceAdmin: ComposerRole = { ...developer, workspaceAdmin: true } + +const myDraft = { kind: 'flow', path: 'f/a/sync', name: 'Sync' } +const sharedFlow = { kind: 'flow', path: 'f/billing/charge', name: 'Charge customers' } +const favorite = { kind: 'script', path: 'f/ops/check_ssl', name: 'Check SSL expiry' } +const script = favorite +const app = { kind: 'app', path: 'f/ops/console', name: 'Ops console' } +const folderItem = sharedFlow +const database = { type: 'postgresql', path: 'f/data/main_db', name: 'main_db' } +const slack = { type: 'slack', path: 'f/team/slack', name: 'slack' } + +const labels = (...args: Parameters<typeof composerSuggestions>) => + composerSuggestions(...args).map((s) => s.label) + +describe('composerSuggestions', () => { + it('offers the build examples in an empty workspace, but not to an operator', () => { + expect(labels({ itemCount: 0 }, developer)).toEqual([ + 'Daily metrics to Slack', + 'Approve refunds in Slack', + 'Triage tickets to Slack' + ]) + expect(labels({ itemCount: 0 }, operator)).toEqual([]) + }) + + it("teaches the piece a workspace is missing first, as the reader's role allows", () => { + const fresh = { itemCount: 0, dataTables: [], database: null, channel: null } + expect(labels(fresh, workspaceAdmin)).toEqual([ + 'Set up a data table', + 'Daily metrics to Slack', + 'Approve refunds in Slack', + 'Triage tickets to Slack' + ]) + expect(labels(fresh, developer)[0]).toBe('How do I store data?') + expect(labels({ ...fresh, dataTables: ['main'] }, developer)[0]).toBe('Connect Slack') + // Past its first items a workspace may keep data and Slack where these lists don't look. + const established = { ...fresh, itemCount: 25 } + expect(labels(established, workspaceAdmin)[0]).toBe('Set up a data table') + expect(labels(established, developer)).not.toContain('How do I store data?') + expect(labels(established, developer)).not.toContain('Connect Slack') + }) + + it('builds a new workspace from its own connections, naming their paths', () => { + const s = composerSuggestions( + { itemCount: 0, dataTables: ['main'], database, channel: slack }, + developer + ) + expect(s.map((x) => [x.mode, x.label])).toEqual([ + ['build', 'Daily main_db summary to Slack'], + ['use', "What's in the main data table?"], + ['build', 'Build an app on the main data table'], + ['use', 'Post a Slack message from a script'] + ]) + expect(s[0].prompt).toContain('f/data/main_db') + expect(s[0].prompt).toContain('f/team/slack') + }) + + it("ranks the reader's own work first, then what the workspace holds, once per concept", () => { + const established = { + itemCount: 25, + sharedFlow, + favorite, + script, + app, + folderItem, + database, + channel: slack, + dataTables: ['main'] + } + expect(labels({ ...established, myDraft }, workspaceAdmin)).toEqual([ + 'Finish my draft: Sync', + "What's in the main data table?", + 'Walk me through Charge customers', + 'Run Check SSL expiry' + ]) + expect(labels(established, developer)).toEqual([ + "What's in the main data table?", + 'Walk me through Charge customers', + 'Run Check SSL expiry', + 'Add a page to Ops console' + ]) + }) + + it('keeps an operator to running and reading', () => { + expect( + labels( + { itemCount: 25, myDraft, sharedFlow, favorite, folderItem, dataTables: [], channel: null }, + operator + ) + ).toEqual([ + 'Run Check SSL expiry', + 'Walk me through Charge customers', + 'What can I run here?', + 'Who can see Charge customers?' + ]) + }) + + it('fills with the checks for the role', () => { + expect(labels({ itemCount: 10 }, workspaceAdmin)).toEqual([ + 'Check the schedules', + 'Check recent runs', + 'Check the run queue', + 'Use my scripts from Claude' + ]) + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/starter/composerSuggestions.ts b/frontend/src/lib/components/copilot/chat/starter/composerSuggestions.ts new file mode 100644 index 0000000000..a6a6a00c90 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/composerSuggestions.ts @@ -0,0 +1,387 @@ +import type { StarterPrompt } from '$lib/onboardingProfile' + +/** A prompt offered under the composer: `label` is the pill, `prompt` what it puts in the + * box. `use` looks at, asks about or runs what exists; `build` creates or changes things. */ +export type ComposerSuggestion = StarterPrompt & { mode: 'use' | 'build' } + +/** What the reader may do where the session acts. An instance admin is not assumed to be an + * admin of the workspace. */ +export type ComposerRole = { operator: boolean; workspaceAdmin: boolean; instanceAdmin: boolean } + +export type ComposerItem = { kind: string; path: string; name: string } + +/** A connection the workspace holds; `type` is its resource type. */ +export type ComposerResource = { type: string; path: string; name: string } + +/** Below this many items the workspace is new enough that building leads. */ +export const FEW_ITEMS = 10 + +/** The databases whose tables the chat can read (`get_db_schema`), by resource type. */ +export const DATABASE_TYPES: Record<string, string> = { + postgresql: 'Postgres', + mysql: 'MySQL', + ms_sql_server: 'SQL Server', + snowflake: 'Snowflake', + bigquery: 'BigQuery' +} + +/** Where a job can send a message, by resource type, most preferred first. */ +export const CHANNEL_TYPES: Record< + string, + { name: string; to: string; send: string; message: string } +> = { + slack: { + name: 'Slack', + to: 'to Slack', + send: 'post it to Slack', + message: 'post a Slack message' + }, + ms_teams_webhook: { + name: 'Microsoft Teams', + to: 'to Teams', + send: 'post it to Microsoft Teams', + message: 'post a Teams message' + }, + discord_webhook: { + name: 'Discord', + to: 'to Discord', + send: 'post it to Discord', + message: 'post a Discord message' + }, + telegram: { + name: 'Telegram', + to: 'to Telegram', + send: 'send it on Telegram', + message: 'send a Telegram message' + }, + gmail: { name: 'Gmail', to: 'by email', send: 'email it', message: 'send an email' }, + smtp: { name: 'email', to: 'by email', send: 'email it', message: 'send an email' } +} + +/** What the workspace looks like now: read from the composer's item, resource and data table + * lists. An absent field fires no rule. A `null` channel and an + * empty `dataTables` mean the list answered and holds none, which fires the pills that teach + * the missing piece. */ +export type ComposerSignals = { + /** Capped at the page the list call reads. */ + itemCount?: number + /** The reader's draft among the items the list call reads, the most recently updated. */ + myDraft?: ComposerItem + /** Among the same items, the most recently updated flow outside the reader's user folder. */ + sharedFlow?: ComposerItem + /** Among the same items, the most recently updated deployed script with a main function. */ + script?: ComposerItem + /** Among the same items the reader may edit, the most recently updated full-code app. */ + app?: ComposerItem + /** Among the same items, the most recently updated AI agent. */ + agent?: ComposerItem + /** Among the same items, the most recently updated one in a folder. */ + folderItem?: ComposerItem + /** Among the same items, a script or flow the reader starred. The sidebar's favorites are + * not used: they name no workspace, so after a switch they can still be the last one's. */ + favorite?: ComposerItem + /** Among the connections the resource list reads (one page, ordered by path), the most + * recently edited database. */ + database?: ComposerResource + /** Among the same connections, the one a job would send a message through, the most + * preferred type first. */ + channel?: ComposerResource | null + /** The workspace's data tables, `main` first. */ + dataTables?: string[] +} + +/** Each example shows a different piece of Windmill: a schedule, an approval step, a webhook. */ +const BUILD_EXAMPLES: ComposerSuggestion[] = [ + { + mode: 'build', + label: 'Daily metrics to Slack', + prompt: + "Every weekday at 9am, query postgres for yesterday's signups and revenue and post a summary to the #metrics Slack channel" + }, + { + mode: 'build', + label: 'Approve refunds in Slack', + prompt: + 'Build a flow that asks for approval in Slack before issuing a refund in Stripe, and only refunds if someone approves' + }, + { + mode: 'build', + label: 'Triage tickets to Slack', + prompt: + 'When a support ticket arrives by webhook, classify its urgency with an LLM and post the urgent ones to the #support Slack channel' + } +] + +const MAX_SUGGESTIONS = 4 +const MAX_NAME = 32 + +/** Up to four suggestions that each teach one Windmill concept on the reader's own workspace, + * most pressing first: their unfinished work, the next piece the workspace is missing, then + * what it already holds, the build examples while it is empty, and checks for the role. A + * concept is offered once. A prompt names the exact item or connection path: the chat cannot + * list resources by type, so "our database" would leave it guessing. A pill reads what the + * workspace holds, never how its runs went: one that names a failure would announce it and + * offer the fix at once. The checks claim nothing, so they read the same whether or not + * anything is wrong. */ +export function composerSuggestions( + signals: ComposerSignals, + role: ComposerRole +): ComposerSuggestion[] { + const out: ComposerSuggestion[] = [] + const taught = new Set<string>() + const add = ( + concept: string, + mode: ComposerSuggestion['mode'], + label: string, + prompt: string + ) => { + if (taught.has(concept)) return + taught.add(concept) + out.push({ mode, label, prompt }) + } + const empty = signals.itemCount === 0 + const few = signals.itemCount !== undefined && signals.itemCount < FEW_ITEMS + const { database, channel, dataTables, script, sharedFlow } = signals + const dataTable = dataTables?.[0] + // Operators run what others built and author nothing. + const builder = !role.operator + + if (builder) { + const draft = signals.myDraft + if (draft) + add( + 'draft', + 'build', + `Finish my draft: ${shortName(draft.name)}`, + `Help me finish my draft of the ${draft.kind} ${draft.path}` + ) + + // The next piece the workspace is missing, one at a time: where data lives, then a + // channel to send messages through. Past its first items a workspace may keep its data + // in its own database and reach Slack through workspace settings, neither of which these + // lists see, so only the admin's data table setup is offered there. + if (dataTables?.length === 0) { + // Only an admin can set up a data table. + if (role.workspaceAdmin) + add( + 'next', + 'build', + 'Set up a data table', + '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.' + ) + else if (few) + add( + 'next', + 'use', + 'How do I store data?', + 'How should my scripts store data in Windmill? Explain data tables and show me how a script reads and writes one.' + ) + } + if (channel === null && few) + add( + 'next', + 'build', + 'Connect Slack', + 'Help me connect Slack to this workspace so my scripts can post messages.' + ) + } + + const send = channel ? CHANNEL_TYPES[channel.type] : undefined + const summary = () => { + if (!few || !builder || !database || !channel || !send) return + add( + 'summary', + 'build', + `Daily ${shortName(database.name)} summary ${send.to}`, + `Every weekday at 9am, summarize yesterday's activity in the ${DATABASE_TYPES[database.type]} database ${database.path} and ${send.send} with the resource ${channel.path}` + ) + } + const exploreTable = () => { + if (!dataTable) return + add( + 'datatable', + 'use', + `What's in the ${shortName(dataTable)} data table?`, + `List the tables in the data table ${dataTable} and tell me what each one holds.` + ) + } + const walkThrough = () => { + const flow = sharedFlow + if (flow) + add( + 'walk', + 'use', + `Walk me through ${shortName(flow.name)}`, + `Walk me through what the flow ${flow.path} does, step by step.` + ) + const agent = signals.agent + if (agent) + add( + 'walk', + 'use', + `Walk me through ${shortName(agent.name)}`, + `Walk me through what the AI agent ${agent.path} does and which tools it can use.` + ) + } + const run = () => { + const favorite = signals.favorite + if (favorite) + add( + 'run', + 'use', + `Run ${shortName(favorite.name)}`, + `Run the ${favorite.kind} ${favorite.path} for me.` + ) + } + const access = () => { + const item = signals.folderItem + if (item) + add( + 'access', + 'use', + `Who can see ${shortName(item.name)}?`, + `Who can see and edit the ${item.kind} ${item.path}, and why?` + ) + } + + if (role.operator) { + run() + walkThrough() + exploreTable() + if (!empty) + add( + 'operator', + 'use', + 'What can I run here?', + 'Which scripts and flows can I run in this workspace, and what does each one do?' + ) + access() + } else { + summary() + exploreTable() + walkThrough() + run() + const app = signals.app + if (app) + add( + 'app', + 'build', + `Add a page to ${shortName(app.name)}`, + `Add a page to the app ${app.path}, and ask me what it should show.` + ) + else if (dataTable) + add( + 'app', + 'build', + `Build an app on the ${shortName(dataTable)} data table`, + `Build a full-code app to browse and edit the rows of a table in the data table ${dataTable}.` + ) + if (channel && send) + add( + 'channel', + 'use', + `${capitalize(send.message)} from a script`, + `Show me how a script uses the resource ${channel.path} to ${send.message}, with a short example.` + ) + if (database?.type === 'postgresql') + add( + 'postgres', + 'build', + `React to new rows in ${shortName(database.name)}`, + `Run a script whenever a row is inserted into a table of the Postgres database ${database.path}, and explain how Postgres triggers work.` + ) + if (script) { + add( + 'endpoint', + 'build', + `Make ${shortName(script.name)} an API endpoint`, + `Expose the script ${script.path} as an HTTP API endpoint that requires a Windmill token, and show me how to call it.` + ) + if (few) + add( + 'schedule', + 'build', + `Run ${shortName(script.name)} every morning`, + `Run the script ${script.path} every weekday at 9am. Ask me for any input it needs, and explain how schedules work.` + ) + } + if (sharedFlow) + add( + 'webhook', + 'use', + `Call ${shortName(sharedFlow.name)} by webhook`, + `How do I start the flow ${sharedFlow.path} from another app with its webhook? Show me the URL and an example request.` + ) + access() + // While the workspace is new; an empty one needs its build examples more. + if (few && !empty) + add( + 'secret', + 'build', + 'Store an API key as a secret', + 'I have an API key my scripts need. Store it as a secret and show me how a script reads it with wmill.getVariable.' + ) + if (channel && send) + add( + 'hub', + 'use', + `Find Hub scripts for ${send.name}`, + `Find scripts on the Windmill Hub for ${send.name} that I could use with the resource ${channel.path}.` + ) + } + + if (empty && builder) { + const [dailyMetrics, ...others] = BUILD_EXAMPLES + out.push(...(taught.has('summary') ? others : [dailyMetrics, ...others])) + } + + // A workspace with nothing in it has nothing to check yet. Keep these to what the chat's + // tools can answer: its run listing has no date filter and stops at 100 runs, so a prompt + // naming a period ("today", "this week") gets a guess. + if (!empty) { + if (role.instanceAdmin) + add( + 'workers', + 'use', + 'Check worker health', + '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.' + ) + if (role.workspaceAdmin) + add( + 'schedules', + 'use', + 'Check the schedules', + 'Do any schedules in this workspace keep failing? If so, which ones and why?' + ) + add( + 'runs', + 'use', + 'Check recent runs', + 'Look at the recent runs in this workspace: did any fail, and why? Is anything getting slower?' + ) + add( + 'queue', + 'use', + 'Check the run queue', + "Is anything stuck in this workspace's queue? Which jobs are waiting or suspended, and why?" + ) + } + + if (builder && !empty) + add( + 'mcp', + 'use', + 'Use my scripts from Claude', + 'How do I connect Claude to this workspace with MCP so it can run my scripts and flows?' + ) + + return out.slice(0, MAX_SUGGESTIONS) +} + +function shortName(name: string): string { + return name.length > MAX_NAME ? `${name.slice(0, MAX_NAME - 1)}…` : name +} + +function capitalize(text: string): string { + return text.charAt(0).toUpperCase() + text.slice(1) +} diff --git a/frontend/src/lib/components/copilot/chat/starter/starterPrompts.svelte.ts b/frontend/src/lib/components/copilot/chat/starter/starterPrompts.svelte.ts index 9bb9341a0b..11ce754710 100644 --- a/frontend/src/lib/components/copilot/chat/starter/starterPrompts.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/starter/starterPrompts.svelte.ts @@ -1,147 +1,275 @@ import { onboardingProfile, type StarterPrompt } from '$lib/onboardingProfile' -import { roleCapabilities } from '$lib/stores' -import { fromStore } from 'svelte/store' +import { roleCapabilities, userStore } from '$lib/stores' +import { canEditItem } from '$lib/editRights' +import { scopedKey } from '$lib/userScopedStorage' +import { fromStore, get } from 'svelte/store' import { resource } from 'runed' -import { ScriptService } from '$lib/gen' +import { + ResourceService, + ScriptService, + WorkspaceService, + type ListableResource, + type RunnableItem +} from '$lib/gen' +import { + CHANNEL_TYPES, + composerSuggestions, + DATABASE_TYPES, + FEW_ITEMS, + type ComposerItem, + type ComposerResource, + type ComposerRole, + type ComposerSignals, + type ComposerSuggestion +} from './composerSuggestions' -/** A prompt offered under the composer: `label` is the pill, `prompt` what it puts in the - * box. `use` looks at, asks about or runs what exists; `build` creates or changes things. */ -export type ComposerSuggestion = StarterPrompt & { mode: 'use' | 'build' } +export type { ComposerSuggestion } -const USE_EXAMPLES: ComposerSuggestion[] = [ - { - mode: 'use', - label: 'What failed today?', - prompt: 'Which runs failed in this workspace today, and why?' - }, - { - mode: 'use', - label: 'Which schedules run the most?', - prompt: - 'Which schedules ran the most today, and does any of them run more often than it should?' - } -] - -const BUILD_EXAMPLES: ComposerSuggestion[] = [ - { - mode: 'build', - label: 'Sync Salesforce', - prompt: 'Sync new Salesforce leads into a postgres table every hour' - }, - { - mode: 'build', - label: 'Weekly Slack report', - prompt: 'Generate a weekly sales report from postgres and post it to Slack every Monday' - }, - { - mode: 'build', - label: 'Ban Discord users', - prompt: - 'Build a workflow that triggers on a Discord message, checks for offensive language using an LLM, and possibly block them' - } -] - -/** A session for a role that cannot code can run what is deployed, read runs and call MCP - * servers, but cannot author scripts, so none of these asks it to build anything. */ -const OPERATOR_EXAMPLES: ComposerSuggestion[] = [ - USE_EXAMPLES[0], - { - mode: 'use', - label: 'Rerun failed jobs', - prompt: 'List the jobs that failed today, tell me why, and rerun the ones that timed out' - }, - { - mode: 'use', - label: 'Run a flow', - prompt: 'Find the flow that onboards a new employee and run it for Jane Doe, starting Monday' - }, - { - mode: 'use', - label: 'Summarize GitHub issues', - prompt: "Read this week's open GitHub issues and summarize the ones that mention billing" - } -] - -/** From this many items on, Home's composer rests collapsed: below it the workspace is new - * enough that the composer is the page's main way in. */ -const FEW_ITEMS = 10 +/** The newest items read: enough to find a shared flow beside the reader's own work. */ +const ITEMS_READ = 25 +/** The connections read. A full page may have cut the channel off, so only a page shorter + * than this proves the workspace has none. */ +const RESOURCES_READ = 20 /** The suggestions for the reader's role and the workspace the session acts on, and whether * that workspace is still small. An invite that wrote prompts for this person replaces the - * examples outright: a prompt written for someone's own stack next to "Ban Discord users" - * reads as the generic one. */ + * suggestions outright: a prompt written for someone's own stack next to "Approve refunds in + * Slack" reads as the generic one. */ export class StarterPrompts { #invite = $state<StarterPrompt[]>() + #user = fromStore(userStore) #capabilities = fromStore(roleCapabilities) #workspace: () => string | undefined - #itemCount: { readonly current: number | undefined } + #builds: () => boolean + #signals: { readonly current: Tagged<ComposerSignals> | undefined } - /** `workspace`: where the session acts. */ - constructor(workspace: () => string | undefined) { + /** `workspace`: where the session acts. `builds`: false while the session is held in Use. */ + constructor(workspace: () => string | undefined, builds: () => boolean = () => true) { this.#workspace = workspace + this.#builds = builds void onboardingProfile().then((p) => { if (p?.starter_prompts?.length) this.#invite = p.starter_prompts }) - this.#itemCount = resource(workspace, async (ws) => (ws ? workspaceItemCount(ws) : undefined)) + // runed keeps whichever fetch settles last, so after a workspace or account switch an + // earlier fetch can land on top of the current one: each answer carries what it is for. + this.#signals = resource( + () => this.#tag, + async (tag) => { + const ws = this.#workspace() + return ws ? { tag, value: await readComposerSignals(ws) } : undefined + } + ) } - /** At most `FEW_ITEMS`; undefined until the workspace has answered. A count already read - * this page load stands in while it is asked again, so a remount does not wait on it. */ - get #count(): number | undefined { + get #tag(): string { + return `${this.#workspace()}\0${this.#user.current?.email}` + } + + /** Undefined until the user is known. The user is read for Home's workspace, so a session + * acting on another one (a fork) keeps only what is instance-wide. */ + get #role(): ComposerRole | undefined { + const user = this.#user.current + if (!user) return undefined + const own = user.workspace_id === this.#workspace() + return { + operator: own && !this.#capabilities.current.code, + workspaceAdmin: own && user.is_admin, + instanceAdmin: user.is_super_admin + } + } + + /** Signals already read this page load stand in while they are asked again, so a remount + * does not wait on them. */ + get #currentSignals(): ComposerSignals | undefined { + const current = this.#signals.current + if (current?.tag === this.#tag) return current.value const ws = this.#workspace() - return this.#itemCount.current ?? (ws ? itemCounts.get(ws) : undefined) + const key = ws && signalsKey(ws) + return key ? settled.get(key) : undefined } - /** Up to four: two Use then two Build. An empty workspace has nothing to ask about yet. */ readonly suggestions: ComposerSuggestion[] = $derived.by(() => { - if (this.#invite) return this.#invite.map((p) => ({ ...p, mode: 'build' as const })) - if (!this.#capabilities.current.code) return OPERATOR_EXAMPLES - if (this.#count === 0) return BUILD_EXAMPLES - return [...USE_EXAMPLES, ...BUILD_EXAMPLES.slice(0, 2)] + const builds = this.#builds() + // Not while held in Use: the invite's prompts are written to be built. + if (this.#invite && builds) return this.#invite.map((p) => ({ ...p, mode: 'build' as const })) + const signals = this.#currentSignals + const role = this.#role + if (!signals || !role) return [] + // A session held in Use authors nothing, so it gets an operator's suggestions. + return composerSuggestions(signals, builds ? role : { ...role, operator: true }) }) - /** Whether the workspace holds fewer than `FEW_ITEMS`; undefined until known. */ + /** Whether the workspace holds fewer than `FEW_ITEMS`, below which Home's composer rests + * open as the page's main way in; undefined until known. */ get fewItems(): boolean | undefined { - const count = this.#count + const count = this.#currentSignals?.itemCount return count === undefined ? undefined : count < FEW_ITEMS } } -const itemCounts = new Map<string, number>() -const itemCountRequests = new Map<string, Promise<number>>() +type Tagged<T> = { tag: string; value: T } -/** Starts the count for a page about to mount the starter; the starter then takes this - * request rather than sending its own. */ -export function prefetchItemCount(workspace: string): void { - void workspaceItemCount(workspace) +/** Per account: the signals name the reader's private draft. A key is taken when a request + * starts, so one that settles after an account switch cannot answer for the next account; with + * no account known yet, nothing is cached. */ +const signalsKey = (workspace: string) => scopedKey(`composerSignals:${workspace}`) + +const settled = new Map<string, ComposerSignals>() +const requests = new Map<string, { at: number; request: Promise<ComposerSignals> }>() +/** How long an answer is reused before a mount asks again: long enough to cover the prefetch + * that preceded the mount, short enough that returning to Home sees what changed. */ +const REUSE_MS = 30_000 + +/** Starts the reads for a page about to mount the starter; the starter then takes these + * requests rather than sending its own. */ +export function prefetchComposerSignals(workspace: string): void { + void readComposerSignals(workspace) } -function workspaceItemCount(workspace: string): Promise<number> { - let request = itemCountRequests.get(workspace) - if (!request) { - request = fetchItemCount(workspace).finally(() => itemCountRequests.delete(workspace)) - itemCountRequests.set(workspace, request) - } - return request -} - -async function fetchItemCount(workspace: string): Promise<number> { - let count = FEW_ITEMS +/** What the starter reads for `workspace`: one set of requests, shared by every caller and + * reused for `REUSE_MS`; a failure is not kept. */ +export async function readComposerSignals(workspace: string): Promise<ComposerSignals> { + const key = signalsKey(workspace) try { + let request: Promise<ComposerSignals> + const kept = key && requests.get(key) + if (kept && Date.now() - kept.at < REUSE_MS) request = kept.request + else { + request = fetchSignals(workspace) + if (key) { + requests.set(key, { at: Date.now(), request }) + request.catch(() => { + if (requests.get(key)?.request === request) requests.delete(key) + }) + } + } + const signals = await request + if (!key) return signals + // A request that outlived the reuse window can settle after the one that replaced it. + if (requests.get(key)?.request === request) settled.set(key, signals) + return settled.get(key) ?? signals + } catch (e) { + // Unknown reads as a full workspace: a collapsed composer is the quieter mistake. + console.error(`Could not list the items of workspace ${workspace}:`, e) + return { itemCount: FEW_ITEMS } + } +} + +async function fetchSignals(workspace: string): Promise<ComposerSignals> { + // Read before the requests: a workspace switch while they are pending moves the user store + // to the next workspace's membership. + const member = get(userStore) + const user = member?.workspace_id === workspace ? member : undefined + const capabilities = get(roleCapabilities) + const [res, resources, dataTables] = await Promise.all([ // What Home's list counts: library scripts and the user's own drafts included. - const res = await ScriptService.listRunnables({ + ScriptService.listRunnables({ workspace, includeWithoutMain: true, includeDraftOnly: true, - perPage: FEW_ITEMS + orderBy: 'updated', + orderDesc: true, + perPage: ITEMS_READ + }), + readConnections(workspace), + readDataTables(workspace) + ]) + const items = res.items ?? [] + const deployed = items.filter((i) => !i.draft_only) + const username = user?.username + const draft = items.find( + (i) => + i.is_draft || + i.draft_only || + (username && i.draft_users?.some((u) => u['username'] === username)) + ) + const editable = (i: RunnableItem) => + canEditItem( + i.type === 'app' && i.raw_app ? 'raw_app' : i.type, + i.path, + i.extra_perms, + user, + capabilities + ) + const shared = deployed.find( + (i) => i.type === 'flow' && !(username && i.path.startsWith(`u/${username}/`)) + ) + // Only plain scripts run on their own: a library has no main function, a pipeline script + // runs as its pipeline, and test and preprocessor scripts serve another runnable. + const script = deployed.find( + (i) => + i.type === 'script' && (!i.auto_kind || i.auto_kind === 'script' || i.auto_kind === 'wac') + ) + const starred = deployed.find((i) => i.starred && (i.type === 'script' || i.type === 'flow')) + const pick = (item: RunnableItem | undefined) => item && composerItem(item) + const complete = resources !== undefined && resources.length < RESOURCES_READ + return { + itemCount: items.length, + myDraft: draft && composerItem(draft, draft.draft_path), + sharedFlow: pick(shared), + script: pick(script), + app: pick(deployed.find((i) => i.type === 'app' && i.raw_app && editable(i))), + agent: pick(deployed.find((i) => i.type === 'agent')), + folderItem: pick(deployed.find((i) => i.path.startsWith('f/'))), + favorite: pick(starred), + database: + resources && newest(resources.filter((r) => Object.hasOwn(DATABASE_TYPES, r.resource_type))), + channel: resources && (preferredChannel(resources) ?? (complete ? null : undefined)), + dataTables + } +} + +/** The workspace's databases and channels; unknown when the list fails, which fires none of + * the pills that read them. */ +async function readConnections(workspace: string): Promise<ListableResource[] | undefined> { + try { + return await ResourceService.listResource({ + workspace, + resourceType: [...Object.keys(DATABASE_TYPES), ...Object.keys(CHANNEL_TYPES)].join(','), + perPage: RESOURCES_READ }) - count = res.items?.length ?? 0 } catch (e) { - // Unknown reads as a full workspace: a collapsed composer is the quieter mistake. - console.error(`Could not count the items of workspace ${workspace}:`, e) + console.error(`Could not list the connections of workspace ${workspace}:`, e) + return undefined + } +} + +/** The workspace's data table names, `main` (the one scripts use by default) first; unknown + * when the list fails. */ +async function readDataTables(workspace: string): Promise<string[] | undefined> { + try { + const names = (await WorkspaceService.listDataTables({ workspace })).map((d) => d.name) + return names.includes('main') ? ['main', ...names.filter((n) => n !== 'main')] : names + } catch (e) { + console.error(`Could not list the data tables of workspace ${workspace}:`, e) + return undefined + } +} + +function preferredChannel(resources: ListableResource[]): ComposerResource | undefined { + for (const type of Object.keys(CHANNEL_TYPES)) { + const channel = newest(resources.filter((r) => r.resource_type === type)) + if (channel) return channel + } + return undefined +} + +function newest(resources: ListableResource[]): ComposerResource | undefined { + const r = resources.reduce<ListableResource | undefined>( + (a, b) => (!a || (b.edited_at ?? '') > (a.edited_at ?? '') ? b : a), + undefined + ) + return r?.path && r.resource_type + ? { type: r.resource_type, path: r.path, name: r.path.split('/').pop() || r.path } + : undefined +} + +function composerItem(item: RunnableItem, path = item.path): ComposerItem { + return { + kind: item.type, + path, + name: item.summary || path.split('/').pop() || path } - itemCounts.set(workspace, count) - return count } /** What turns an empty chat into the block that starts a session. */ diff --git a/frontend/src/lib/components/copilot/chat/starter/starterPrompts.test.ts b/frontend/src/lib/components/copilot/chat/starter/starterPrompts.test.ts new file mode 100644 index 0000000000..62713dccd9 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/starter/starterPrompts.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const account = vi.hoisted(() => ({ email: 'ada@example.com' as string | undefined })) +const listRunnables = vi.hoisted(() => vi.fn()) +const listResource = vi.hoisted(() => vi.fn()) +const listDataTables = vi.hoisted(() => vi.fn()) + +vi.mock('$lib/userScopedStorage', () => ({ + scopedKey: (base: string) => (account.email ? `${base}::${account.email}` : undefined) +})) +vi.mock('$lib/onboardingProfile', () => ({ onboardingProfile: async () => undefined })) +vi.mock('$lib/gen', () => ({ + ScriptService: { listRunnables }, + ResourceService: { listResource }, + WorkspaceService: { listDataTables } +})) + +import { readComposerSignals } from './starterPrompts.svelte' + +const draftOf = (owner: string) => ({ + items: [{ type: 'flow', path: `u/${owner}/secret_plan`, summary: '', is_draft: true }] +}) + +beforeEach(() => { + listRunnables.mockReset() + listResource.mockReset() + listResource.mockResolvedValue([]) + listDataTables.mockReset() + listDataTables.mockResolvedValue([]) +}) + +describe('readComposerSignals', () => { + it("never answers one account's signals for another", async () => { + listRunnables.mockResolvedValueOnce(draftOf('ada')) + expect((await readComposerSignals('acme')).myDraft?.path).toBe('u/ada/secret_plan') + + // Another account in the same browser, well inside the reuse window. + account.email = 'bob@example.com' + listRunnables.mockResolvedValueOnce({ items: [] }) + expect((await readComposerSignals('acme')).myDraft).toBeUndefined() + expect(listRunnables).toHaveBeenCalledTimes(2) + }) + + it('picks the newest database and the preferred channel', async () => { + listRunnables.mockResolvedValueOnce({ items: [] }) + listResource.mockResolvedValueOnce([ + { path: 'f/mail/smtp', resource_type: 'smtp', edited_at: '2026-10-03' }, + { path: 'f/data/old_db', resource_type: 'postgresql', edited_at: '2026-01-01' }, + { path: 'f/team/slack', resource_type: 'slack', edited_at: '2026-02-01' }, + { path: 'f/data/main_db', resource_type: 'mysql', edited_at: '2026-09-01' } + ]) + listDataTables.mockResolvedValueOnce([{ name: 'analytics' }, { name: 'main' }]) + const signals = await readComposerSignals('globex') + expect(signals.database).toEqual({ type: 'mysql', path: 'f/data/main_db', name: 'main_db' }) + expect(signals.channel?.path).toBe('f/team/slack') + expect(signals.dataTables).toEqual(['main', 'analytics']) + }) + + it('tells a list that holds nothing from one that failed or was cut off', async () => { + listRunnables.mockResolvedValueOnce({ items: [] }) + listDataTables.mockRejectedValueOnce(new Error('offline')) + const signals = await readComposerSignals('initech') + expect(signals.channel).toBeNull() + expect(signals.dataTables).toBeUndefined() + + // A full page of databases may have pushed the channel past it. + listRunnables.mockResolvedValueOnce({ items: [] }) + listResource.mockResolvedValueOnce( + Array.from({ length: 20 }, (_, i) => ({ path: `u/ada/db_${i}`, resource_type: 'postgresql' })) + ) + expect((await readComposerSignals('hooli')).channel).toBeUndefined() + }) +}) diff --git a/frontend/src/lib/components/copilot/chat/workMode.ts b/frontend/src/lib/components/copilot/chat/workMode.ts new file mode 100644 index 0000000000..ffedf912b5 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/workMode.ts @@ -0,0 +1,63 @@ +// A leaf: the chat hosts and the session UI around them read these without loading the manager. + +/** What a session chat may do, picked in the composer beside the confirmations posture. */ +export enum AIWorkMode { + /** Reads and runs deployed items, and changes nothing. Locks the session's preview too. */ + USE = 'use', + /** Researches, then proposes a plan for the user's approval. Runs nothing. */ + PLAN = 'plan', + /** Shown as "Use & build": everything Use does, plus edits, tests and deploys. */ + BUILD = 'build' +} + +/** Why a session cannot leave Use, and where the user can build instead. */ +export type WorkModeLock = { + reason: string + escape?: { label: string; action: () => void } +} + +/** The answer to a call approved after its session switched to Use: the card or form it + * waited on was approved, but the mode the session is in now withholds the tool. */ +export const USE_MODE_WITHDRAWN = { + label: 'Not run: the session is in Use', + result: + 'Not run: the user switched this session to Use mode, which changes nothing, while this call waited for them. Do not retry it; it can run once they switch back to Use & build.', + /** On a test-run form left open across the switch. */ + testRunRefused: 'Use mode does not run undeployed code. Switch to Use & build to run this test.' +} + +/** The chat's tools that ask the user to move the session between Use and Use & build, each + * with the mode its card leads to. Named here because the card renderer and the picker resolve + * them by name. */ +export const ENTER_BUILD_MODE_TOOL = 'enter_build_mode' +export const ENTER_USE_MODE_TOOL = 'enter_use_mode' +export const WORK_MODE_SWITCH_TARGET = { + [ENTER_BUILD_MODE_TOOL]: AIWorkMode.BUILD, + [ENTER_USE_MODE_TOOL]: AIWorkMode.USE +} as const + +export type WorkModeSwitchTool = keyof typeof WORK_MODE_SWITCH_TARGET + +export function isWorkModeSwitchTool(name: string | undefined): name is WorkModeSwitchTool { + // hasOwn, not `in`: the name comes from the model, and `in` would accept `toString`. + return name !== undefined && Object.hasOwn(WORK_MODE_SWITCH_TARGET, name) +} + +/** In the shape of plan mode's card copy, whose card these render as. `declined` names the + * outcome: picking another mode in the picker resolves the card too. */ +export const WORK_MODE_SWITCH_CARD_COPY = { + [ENTER_BUILD_MODE_TOOL]: { + pending: 'Switch to Use & build?', + confirm: 'Switch to Use & build', + reject: 'Stay in Use', + settled: 'Switched to Use & build', + declined: 'Not switched to Use & build' + }, + [ENTER_USE_MODE_TOOL]: { + pending: 'Switch to Use?', + confirm: 'Switch to Use', + reject: 'Stay in Use & build', + settled: 'Switched to Use', + declined: 'Not switched to Use' + } +} as const diff --git a/frontend/src/lib/components/copilot/chat/workModeSwitch.ts b/frontend/src/lib/components/copilot/chat/workModeSwitch.ts new file mode 100644 index 0000000000..1752ee4cdf --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/workModeSwitch.ts @@ -0,0 +1,84 @@ +import { z } from 'zod' +import { createToolDef } from './shared' +import { NONE, type SessionTool } from './sessionCapabilities' +import { + ENTER_BUILD_MODE_TOOL, + ENTER_USE_MODE_TOOL, + WORK_MODE_SWITCH_CARD_COPY, + WORK_MODE_SWITCH_TARGET, + type AIWorkMode, + type WorkModeSwitchTool +} from './workMode' + +const switchArgs = z.object({ + reason: z + .string() + .describe('One concise sentence on why, shown to the user on the card that asks them.') +}) + +const DESCRIPTIONS: Record<WorkModeSwitchTool, string> = { + [ENTER_BUILD_MODE_TOOL]: + 'Asks the user to switch this session from Use to Use & build, the mode that also edits, tests and deploys. Call it when they ask for a change, which Use cannot make: the switch, and the tools that change the workspace, come only if they accept. Not for questions or for running deployed items, which Use already does.', + [ENTER_USE_MODE_TOOL]: + 'Asks the user to switch this session to Use, which reads and runs deployed items and changes nothing. Call it only when they ask you to stop changing the workspace in this session, never for a single question. The switch happens only if they accept.' +} + +// `declined` names the switch, not a mode: picking another mode in the picker declines the card too. +const RESULTS: Record<WorkModeSwitchTool, { switched: string; declined: string }> = { + [ENTER_BUILD_MODE_TOOL]: { + switched: + 'The user switched this session to Use & build. The tools that change the workspace are available from your next step: go on with the change they asked for.', + declined: + 'The user did not switch this session to Use & build. Do not make the change: carry on in the mode the session is in now, and say they can pick Use & build in the mode picker whenever they want it.' + }, + [ENTER_USE_MODE_TOOL]: { + switched: + 'The user switched this session to Use: from your next step you read and run deployed items, and change nothing.', + declined: + 'The user did not switch this session to Use. Carry on in the mode the session is in now.' + } +} + +const NO_LONGER_APPLIES = { + label: 'Switch no longer applies', + result: + 'Not run: the session changed mode before the user saw this switch. Carry on in the mode it is in now.' +} + +/** What a switch needs from the chat it runs in. */ +export interface WorkModeSwitchHost { + /** Whether the chat offers this switch now, from the mode the session is in. */ + offers(tool: WorkModeSwitchTool): boolean + setWorkMode(mode: AIWorkMode): void +} + +export function workModeSwitchTools( + host: WorkModeSwitchHost +): Record<WorkModeSwitchTool, SessionTool<any>> { + return { + [ENTER_BUILD_MODE_TOOL]: workModeSwitchTool(ENTER_BUILD_MODE_TOOL, host), + [ENTER_USE_MODE_TOOL]: workModeSwitchTool(ENTER_USE_MODE_TOOL, host) + } +} + +/** The switch happens in `fn`, so only once the user accepted the card: the chat stops offering + * the switches while confirmations are auto-accepted, and that posture never answers one. */ +function workModeSwitchTool(tool: WorkModeSwitchTool, host: WorkModeSwitchHost): SessionTool<any> { + return { + requires: NONE, + def: createToolDef(switchArgs, tool, DESCRIPTIONS[tool]), + // It changes no workspace item, only this session's mode. Not planModeSafe: a call left + // over from Use must not take a session out of Plan past its plan's approval. + readOnlySafe: true, + requiresConfirmation: true, + // A card queued behind another call can come up after the picker moved on. + validateBeforeConfirmation: () => (host.offers(tool) ? undefined : NO_LONGER_APPLIES), + confirmationMessage: WORK_MODE_SWITCH_CARD_COPY[tool].pending, + cancellationMessage: RESULTS[tool].declined, + showDetails: true, + fn: async () => { + host.setWorkMode(WORK_MODE_SWITCH_TARGET[tool]) + return RESULTS[tool].switched + } + } +} diff --git a/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts b/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts index e4817e190c..c4acf87ffb 100644 --- a/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts +++ b/frontend/src/lib/components/copilot/chat/workspaceToolsZod.gen.ts @@ -490,7 +490,8 @@ export const variableRequestSchema = z.object({ "description": z.string().describe("The description of the variable"), "account": z.number().int().describe("Ignored. A variable is linked to an OAuth account only when `createAccount` creates both.").optional(), "is_oauth": z.boolean().describe("Whether the variable is an OAuth variable").optional(), - "expires_at": z.string().datetime({ offset: true }).describe("The expiration date of the variable").optional(), + "expires_at": z.string().datetime({ offset: true }).describe("When to garbage-collect the variable. Used for ephemeral secrets; the variable is deleted once this is reached. Not the value's expiry \u2014 see value_valid_until.\n").optional(), + "value_valid_until": z.string().datetime({ offset: true }).describe("When the value stored in this variable stops working. Triggers the workspace's variable expiration handler ahead of it, by the handler's `notify_before_secs` (1 day by default); never causes the variable to be deleted.\n").optional(), "labels": z.array(z.string()).optional(), "ws_specific": z.boolean().optional() }) diff --git a/frontend/src/lib/components/details/FlowDetail.svelte b/frontend/src/lib/components/details/FlowDetail.svelte index b700269e93..862435fb57 100644 --- a/frontend/src/lib/components/details/FlowDetail.svelte +++ b/frontend/src/lib/components/details/FlowDetail.svelte @@ -79,6 +79,7 @@ import { setOperatingWorkspace, useOperatingUser, + useReadOnlyWorkspace, useOperatingWorkspaceHref } from '$lib/components/operatingWorkspace.svelte' import FlowChat from '$lib/components/flows/conversations/FlowChat.svelte' @@ -139,13 +140,16 @@ const operatingHref = useOperatingWorkspaceHref() const actingUser = $derived(operatingUser.current) const editRights = useEditRights() + const readOnly = useReadOnlyWorkspace() let flow: Flow | undefined = $state() // Derived, not assigned during the load: in a fork the acting user is looked up // asynchronously, and a value read after `getFlowByPath` resolves can land before that // lookup does. Assigned once, that leaves the page permanently read-only — `canWrite` // refuses an unknown user — with nothing to recompute it. - const can_write = $derived(!!flow && canWrite(flow.path, flow.extra_perms!, actingUser)) + const can_write = $derived( + !!flow && !readOnly(workspace) && canWrite(flow.path, flow.extra_perms!, actingUser) + ) const promptForAi = $derived((flow?.schema?.prompt_for_ai as string | undefined) ?? '') let shareModal: ShareModal | undefined = $state() @@ -699,7 +703,8 @@ } } scriptOrFlowPath={flow?.path ?? ''} - errorHandlerKind="flow" + itemKind="flow" + errorHandlerKind={readOnly(workspace) ? undefined : 'flow'} tag={flow?.tag ?? ''} labels={flow?.labels} inheritedLabels={flow?.inherited_labels} diff --git a/frontend/src/lib/components/details/ScriptDetail.svelte b/frontend/src/lib/components/details/ScriptDetail.svelte index 618bafe3ab..160a8f49b8 100644 --- a/frontend/src/lib/components/details/ScriptDetail.svelte +++ b/frontend/src/lib/components/details/ScriptDetail.svelte @@ -103,6 +103,7 @@ import { setOperatingWorkspace, useOperatingUser, + useReadOnlyWorkspace, useOperatingWorkspaceHref } from '$lib/components/operatingWorkspace.svelte' import { useEditRights } from '$lib/operatorWriteRights' @@ -173,10 +174,12 @@ // lookup does. Assigned once, that leaves the page permanently read-only — `canWrite` // refuses an unknown user — with nothing to recompute it. A hub script is never writable. const promptForAi = $derived((script?.schema?.prompt_for_ai as string | undefined) ?? '') + const readOnly = useReadOnlyWorkspace() const can_write = $derived( !isHubScript && !!script && script.workspace_id == workspace && + !readOnly(workspace) && canWrite(script.path, script.extra_perms!, actingUser) ) const editRights = useEditRights() @@ -877,7 +880,8 @@ if (script !== undefined) script.ws_error_handler_muted = v } } - errorHandlerKind="script" + itemKind="script" + errorHandlerKind={readOnly(workspace) ? undefined : 'script'} scriptOrFlowPath={script?.path ?? ''} tag={script?.tag ?? ''} labels={script?.labels} diff --git a/frontend/src/lib/components/flow_builder.ts b/frontend/src/lib/components/flow_builder.ts index 1a4b7e5c26..8f2f634643 100644 --- a/frontend/src/lib/components/flow_builder.ts +++ b/frontend/src/lib/components/flow_builder.ts @@ -10,9 +10,9 @@ import type { WorkspaceItem } from './workspacePicker' export type FlowBuilderProps = { initialPath?: string - /** The draft row's own path (the URL path). `initialPath` is re-seeded from the - * draft's `draft_path` so the topbar shows the pending name, so it can't be used - * to resolve what is actually deployed. */ + /** The draft row's own path (the URL path). For a draft-only flow `initialPath` is + * re-seeded from the draft's `draft_path` so the topbar shows the pending name, so + * it can't be used to resolve what is actually deployed. */ userDraftPath?: string /** Moves the draft's base to the head and keeps its content; offered in the diff * drawer while the draft is behind, and called with the version the drawer shows diff --git a/frontend/src/lib/components/flows/agentActions.ts b/frontend/src/lib/components/flows/agentActions.ts index f80a6e4343..b99229d4d3 100644 --- a/frontend/src/lib/components/flows/agentActions.ts +++ b/frontend/src/lib/components/flows/agentActions.ts @@ -25,7 +25,7 @@ export function agentMenuItems(agent: { /** Never deployed: no resource yet to hold permissions or to deploy onward. */ draftOnly?: boolean wsSpecific?: boolean - /** Hides what a role that cannot code has no page for, as on a flow's menu. */ + /** Hides what a role that cannot code has no page or right for, as on a flow's menu. */ noCode?: boolean /** Undefined until loaded, when nothing is offered for deploy. */ deployUiSettings: WorkspaceDeployUISettings | undefined @@ -35,6 +35,7 @@ export function agentMenuItems(agent: { onDelete: (event?: MouseEvent) => void }): Item[] { const deployable = + !agent.noCode && !agent.wsSpecific && !agent.draftOnly && isDeployable('resource', agent.path, agent.deployUiSettings) diff --git a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte index f3ee578050..7fde7a56f4 100644 --- a/frontend/src/lib/components/flows/content/AgentEditorModal.svelte +++ b/frontend/src/lib/components/flows/content/AgentEditorModal.svelte @@ -35,6 +35,7 @@ import PathEditPopover from '$lib/components/PathEditPopover.svelte' import { emptyString } from '$lib/utils' import Tooltip from '$lib/components/Tooltip.svelte' + import { useEditRights } from '$lib/operatorWriteRights' const operatingWorkspace = useOperatingWorkspace() @@ -134,9 +135,17 @@ let draftOnly = $derived(draft?.noDeployed ?? false) // Evals run against the deployed agent, and a draft-only one has none: the backend's // `require_agent` would reject every run. Evaluating is authoring, so it is for those who can - // edit the agent. Unknown until the load answers, and warming the pane loads it. + // edit the agent, except a role without `code`: eval datasets are refused to it + // (`check_role_can_code`) whatever its flows right. Unknown until the load answers, and warming + // the pane loads it. + const editRights = useEditRights(() => ws) let canEvaluate = $derived( - draft != undefined && !draft.loading && !draftOnly && !readOnly && !refused + draft != undefined && + !draft.loading && + !draftOnly && + !readOnly && + !refused && + editRights.capabilities.code ) // The draft's path, so a rename shows where it is typed — including a never-deployed agent, // stored at a minted `draft_<uuid>` path and named by the path its first deploy will create, diff --git a/frontend/src/lib/components/flows/content/AgentSettings.svelte b/frontend/src/lib/components/flows/content/AgentSettings.svelte index 9d02183812..5ed0799df3 100644 --- a/frontend/src/lib/components/flows/content/AgentSettings.svelte +++ b/frontend/src/lib/components/flows/content/AgentSettings.svelte @@ -11,6 +11,7 @@ import AgentSlackSettings from './AgentSlackSettings.svelte' import { WorkspaceService } from '$lib/gen' import { userStore } from '$lib/stores' + import { useActingUser } from '$lib/actingUser.svelte' import type { AgentDraftHandle, AgentRunAs } from '../agentDraft.svelte' /** An agent's own settings, laid out as the top of the resource editor: what it is saved as @@ -27,6 +28,7 @@ } let { draft, path, workspace, error = $bindable(), onRunAsChange = undefined }: Props = $props() + const acting = useActingUser(() => workspace) let readOnly = $derived(!draft.canWrite) // As the deploy resolves it: the deployer, unless one who may keep another picked them. @@ -130,8 +132,11 @@ {/if} </div> </Section> - <Section label="Slack"> - <AgentSlackSettings {draft} {path} {workspace} {nextRunAs} /> - </Section> + <!-- Operators cannot share agents in Slack, builder rights or not (slack_agents.rs). --> + {#if acting.current?.operator === false} + <Section label="Slack"> + <AgentSlackSettings {draft} {path} {workspace} {nextRunAs} /> + </Section> + {/if} </div> {/if} diff --git a/frontend/src/lib/components/flows/content/AgentToolBindings.svelte b/frontend/src/lib/components/flows/content/AgentToolBindings.svelte index dc3fad1ea7..b3c956b7fc 100644 --- a/frontend/src/lib/components/flows/content/AgentToolBindings.svelte +++ b/frontend/src/lib/components/flows/content/AgentToolBindings.svelte @@ -4,6 +4,7 @@ import type { InputTransform, ScriptLang } from '$lib/gen' import InputTransformSchemaForm from '$lib/components/InputTransformSchemaForm.svelte' import HighlightCode from '$lib/components/HighlightCode.svelte' + import InputTransformsViewer from '$lib/components/InputTransformsViewer.svelte' import { Button } from '$lib/components/common' import { ChevronDown, ChevronRight, Wrench } from 'lucide-svelte' import type { PickableProperties } from '../previousResults' @@ -16,6 +17,7 @@ } from '../agentToolUtils' import { flowLocalAgentSchema, toolInputOverrides } from '../agentResourceUtils' import { loadSchemaFromModule } from '../flowInfers' + import { useRoleCapabilities } from '$lib/operatorWriteRights' let { tools, @@ -35,6 +37,42 @@ } = $props() let flowTools = $derived(tools.filter(isFlowModuleTool)) + const roleCaps = useRoleCapabilities() + // The server checks bindings only below `full` (`check_operator_can_build`). + const composeOnlyFlows = $derived($roleCaps.flows === 'compose') + + /** Mirrors the backend's `InputTransform::is_completed`: an input the agent fills is hidden from + * the model, and the server refuses a builder's binding of it. */ + function isSetByAgent(transform: InputTransform | undefined): boolean { + if (transform?.type === 'static') { + return transform.value !== undefined && transform.value !== null + } + return transform?.type === 'javascript' && !!transform.expr?.trim() + } + + /** A builder binds only the inputs the agent leaves to the model. */ + function shownSchema(tool: FlowModuleTool): any { + const schema = schemas[tool.id] + if (!composeOnlyFlows || !schema?.properties) return schema + const base = baseInputs(tool) + const keep = Object.keys(schema.properties).filter((k) => !isSetByAgent(base[k])) + return { + ...schema, + properties: Object.fromEntries(keep.map((k) => [k, schema.properties[k]])), + required: (schema.required ?? []).filter((k: string) => keep.includes(k)) + } + } + + /** The inputs `shownSchema` hides, shown read-only so the tool code reads in context. */ + function agentSetInputs(tool: FlowModuleTool): Record<string, InputTransform> { + if (!composeOnlyFlows) return {} + const base = baseInputs(tool) + return Object.fromEntries( + Object.keys(schemas[tool.id]?.properties ?? {}) + .filter((k) => isSetByAgent(base[k])) + .map((k) => [k, base[k]]) + ) + } function baseInputs(tool: FlowModuleTool): Record<string, InputTransform> { return ((tool.value as { input_transforms?: Record<string, InputTransform> }) @@ -182,7 +220,7 @@ {:else if schemas[tool.id] !== undefined && localArgs[tool.id] !== undefined} <InputTransformSchemaForm class="px-3 pt-1" - schema={schemas[tool.id]} + schema={shownSchema(tool)} {pickableProperties} {extraLib} {workspace} @@ -197,6 +235,13 @@ } } /> + {@const setInputs = agentSetInputs(tool)} + {#if Object.keys(setInputs).length > 0} + <div class="flex flex-col gap-1 px-3 pb-2"> + <span class="text-xs font-semibold text-emphasis">Static inputs</span> + <InputTransformsViewer inputTransforms={setInputs} /> + </div> + {/if} {:else} <div class="px-3 py-2 text-2xs text-tertiary">Loading inputs...</div> {/if} diff --git a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts index 1aba4c2d02..6009d73ccd 100644 --- a/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts +++ b/frontend/src/lib/components/flows/conversations/flowChatViewHost.svelte.ts @@ -20,6 +20,7 @@ import type { } from '$lib/components/copilot/chat/chatViewHost' import type { DisplayMessage, WebSearchResult } from '$lib/components/copilot/chat/shared' import type { AIAutonomyMode } from '$lib/components/copilot/chat/AIChatManager.svelte' +import { AIWorkMode } from '$lib/components/copilot/chat/workMode' import { isPlanCardTool } from '$lib/components/copilot/chat/planMode' import { AttachedFilesStore } from '$lib/components/copilot/chat/files/attachedFiles.svelte' import { SessionArtifactsStore } from '$lib/components/copilot/chat/artifacts/artifactsState.svelte' @@ -737,6 +738,9 @@ export class FlowChatViewHost implements ChatViewHost, DraftSender<ComposerAttac // runtime: its unit test would otherwise load the manager and the editor it pulls in. autonomyMode = 'default' as AIAutonomyMode.DEFAULT setAutonomyMode = () => {} + workModesAvailable = false + workMode = AIWorkMode.BUILD + setWorkMode = () => {} autoAcceptEditsActive = false autoAcceptEditsAvailable = false autoAcceptToolConfirmationsAvailable = false diff --git a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte index 2b08653d28..b4228ea0a3 100644 --- a/frontend/src/lib/components/flows/map/InsertModuleInner.svelte +++ b/frontend/src/lib/components/flows/map/InsertModuleInner.svelte @@ -84,19 +84,20 @@ let savedAgentsLoading = $state(false) let savedAgentsWs: string | undefined = undefined async function loadSavedAgents() { - // The backend refuses a linked agent in a builder's flow. - if (!ws || savedAgentsWs === ws || composeOnlyFlows) { + const requested = ws + if (!requested || savedAgentsWs === requested) { return } savedAgentsLoading = true try { const rs = await ResourceService.listResource({ - workspace: ws, + workspace: requested, resourceType: 'ai_agent', perPage: 1000 }) + if (requested !== ws) return savedAgents = rs.map((r) => ({ path: r.path, description: r.description })) - savedAgentsWs = ws + savedAgentsWs = requested } catch { savedAgents = [] } finally { @@ -386,9 +387,7 @@ <kbd class="!text-xs">↵</kbd> {/if} </Button> - {#if composeOnlyFlows} - <!-- Linked agents are refused to builders, see `loadSavedAgents`. --> - {:else if savedAgentsLoading} + {#if savedAgentsLoading} <div class="flex items-center gap-2 p-2 text-xs text-tertiary"> <Loader2 size={13} class="animate-spin" /> Loading saved agents </div> diff --git a/frontend/src/lib/components/handlerDraft.svelte.ts b/frontend/src/lib/components/handlerDraft.svelte.ts index a1422e42ed..ac73fc1d6a 100644 --- a/frontend/src/lib/components/handlerDraft.svelte.ts +++ b/frontend/src/lib/components/handlerDraft.svelte.ts @@ -2,7 +2,7 @@ import type { ErrorHandler } from '$lib/gen' import { WORKSPACE_SLACK_BOT_TOKEN_PATH } from '$lib/consts' import hubPaths from '$lib/hubPaths.json' -export type HandlerMode = 'error' | 'recovery' | 'success' +export type HandlerMode = 'error' | 'recovery' | 'success' | 'variableExpiration' type BuiltinDestination = 'slack' | 'teams' | 'email' export type HandlerSlot = { path?: string; args: Record<string, any> } @@ -32,6 +32,10 @@ const BUILTIN_SUFFIXES: Record<HandlerMode, Record<'slack' | 'teams', string>> = success: { slack: '/schedule-success-handler-slack', teams: '/schedule-success-handler-teams' + }, + variableExpiration: { + slack: '/variable-expiration-handler-slack', + teams: '/variable-expiration-handler-teams' } } diff --git a/frontend/src/lib/components/home/CreateActionsMenu.svelte b/frontend/src/lib/components/home/CreateActionsMenu.svelte index 72b75b82e1..9b8db55066 100644 --- a/frontend/src/lib/components/home/CreateActionsMenu.svelte +++ b/frontend/src/lib/components/home/CreateActionsMenu.svelte @@ -16,8 +16,10 @@ Workflow, Import, Store, - PanelLeftClose + PanelLeftClose, + Folder } from 'lucide-svelte' + import MenuItem from '$lib/components/meltComponents/MenuItem.svelte' import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte' import DbtIcon from '$lib/components/icons/DbtIcon.svelte' import { PythonIcon, TypeScriptIcon } from '$lib/components/common/languageIcons' @@ -48,9 +50,17 @@ * serve both this menu and the empty state's own link. */ onImportHubProject?: () => void + /** Opens the host's folder drawer. The entry is offered only when this is set. */ + onCreateFolder?: () => void } - let { trigger, triggerElement, source = 'toolbar', onImportHubProject }: Props = $props() + let { + trigger, + triggerElement, + source = 'toolbar', + onImportHubProject, + onCreateFolder + }: Props = $props() type Variant = { label: string @@ -713,8 +723,26 @@ {/if} {/each} - <!-- bottom import section: one entry whose submenu imports any artifact --> + <!-- bottom section: a folder, then one entry whose submenu imports any artifact --> <div class="mx-1 my-1 border-t border-gray-200 dark:border-gray-700"></div> + {#if onCreateFolder} + <!-- Focus also arrives on hover: melt focuses the highlighted item. --> + <MenuItem + {item} + class="w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover" + onFocusIn={() => highlightOption(undefined)} + onClick={() => onCreateFolder()} + > + <div + class="w-6 h-6 rounded-md flex items-center justify-center shrink-0 bg-gray-100 dark:bg-gray-700" + > + <Folder size={14} class="text-gray-600 dark:text-gray-300" /> + </div> + <span class="text-xs font-medium text-primary flex-1 min-w-0 whitespace-nowrap"> + Folder + </span> + </MenuItem> + {/if} <button use:melt={$importSubTrigger} class="w-full flex flex-row items-center gap-2.5 rounded-md px-2 py-1.5 text-left cursor-pointer transition-colors focus:outline-none data-[highlighted]:bg-surface-hover hover:bg-surface-hover" diff --git a/frontend/src/lib/components/home/HomeAIChat.svelte b/frontend/src/lib/components/home/HomeAIChat.svelte index 0aad0adb1a..3816a18898 100644 --- a/frontend/src/lib/components/home/HomeAIChat.svelte +++ b/frontend/src/lib/components/home/HomeAIChat.svelte @@ -20,7 +20,6 @@ import { currentWorkspaceRootId } from '../sessions/sessionScope.svelte' import { HOME_SHOW_HUB } from '$lib/consts' import { getLocalSetting, storeLocalSetting } from '$lib/utils' - import { isRuleActive, protectionRulesState } from '$lib/workspaceProtectionRules.svelte' import { useReducedMotion } from '$lib/svelte5Utils.svelte' import { BROWSER } from 'esm-env' import HomeConnectDrawer from './HomeConnectDrawer.svelte' @@ -32,7 +31,7 @@ import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import DropdownV2 from '$lib/components/DropdownV2.svelte' import { pageHeader } from '$lib/components/pageHeaderRegistry.svelte' - import { prefetchItemCount } from '../copilot/chat/starter/starterPrompts.svelte' + import { prefetchComposerSignals } from '../copilot/chat/starter/starterPrompts.svelte' import { useWorkspaceAiHidden } from '../copilot/workspaceAiHidden.svelte' import { homeQuickAccessSettings } from './homeQuickAccessSettings.svelte' @@ -79,28 +78,12 @@ pulseEndTimer = setTimeout(() => (pulsing = false), PULSE_MS) } - // A workspace locked against direct deployment is run, not authored in, so its home page drops - // the composer and the button to reopen it. This is about the workspace, not the caller: - // `createSession` would steer a prompt into the paired dev workspace and an admin bypasses the - // lock outright, yet neither makes prod the place to start one. Unresolved rules read as - // unlocked, so the far commoner unlocked workspace never pops the composer in mid-load. - let runOnlyWorkspace = $derived(isRuleActive('DisableDirectDeployment')) - - // The draft is created when the block mounts, and a new draft steers to the dev workspace - // while the rules are unknown (a locked prod could not be edited), so the block waits for - // them: mounted earlier, an unlocked prod's draft would act on its dev. Once shown for a - // family it stays: switching to a fork resets the rules, and remounting would flash it. - let rulesReady = $derived( - protectionRulesState.rulesets !== undefined && - protectionRulesState.workspace === $workspaceStore - ) - let shownForRoot = $state<string>() - let HomeSessionStarter = $state(loadedStarter) if (!loadedStarter) void loadStarter().then((c) => (HomeSessionStarter = c)) - // The starter waits on the workspace's item count before it mounts; asked alongside its code - // rather than once the code has loaded, it is no extra wait. - if ($workspaceStore) prefetchItemCount($workspaceStore) + // The starter waits on the workspace's item count before it mounts, and its suggestions on + // the other signals; asked alongside its code rather than once the code has loaded, they + // are no extra wait. + if ($workspaceStore) prefetchComposerSignals($workspaceStore) // Whether Home's own workspace hides the assistant, read for that workspace: once the block // shows, the shared copilot config is the draft's, so a draft acting on a workspace that hides @@ -111,7 +94,7 @@ // of the sessions beta — so hide it from them (the prompt would be silently dropped) while the // AI-independent CLI/MCP row below stays. let showComposer = $derived( - prefersSessionHandoff(!$roleCapabilities.code) && !runOnlyWorkspace && !homeAiHidden.hidden + prefersSessionHandoff(!$roleCapabilities.code) && !homeAiHidden.hidden ) let hero = $derived(showComposer && !removed) @@ -134,10 +117,9 @@ <!-- The block is a session's chat, so it waits for the sessions it might reuse. Keyed on the workspace family, not the workspace: another family needs its own draft, but a fork shares its parent's, and remounting for it only flashes the block. --> - {#if HomeSessionStarter && sessionState.hydrated && (rulesReady || shownForRoot === $currentWorkspaceRootId)} + {#if HomeSessionStarter && sessionState.hydrated} {#key $currentWorkspaceRootId} <HomeSessionStarter - onShown={() => (shownForRoot = $currentWorkspaceRootId)} {placeholder} onSettled={(height) => rememberComposerHeight($workspaceStore ?? undefined, height)} /> diff --git a/frontend/src/lib/components/home/ItemsList.svelte b/frontend/src/lib/components/home/ItemsList.svelte index d5383734ec..36099ac1b9 100644 --- a/frontend/src/lib/components/home/ItemsList.svelte +++ b/frontend/src/lib/components/home/ItemsList.svelte @@ -57,6 +57,8 @@ import HighlightCode from '../HighlightCode.svelte' import DrawerContent from '../common/drawer/DrawerContent.svelte' import TreeViewRoot from './TreeViewRoot.svelte' + import FolderEditorDrawer from '../FolderEditorDrawer.svelte' + import { isDemoWorkspaceRestricted } from '$lib/cloud' import Item from './Item.svelte' import PipelineRow from '../common/table/PipelineRow.svelte' import { TreeOpenMemory } from './treeViewState.svelte' @@ -818,6 +820,41 @@ storeLocalSetting(FLAT_LIST_SETTING_NAME, v ? 'true' : undefined) } + // Unfiltered, the tree lists every folder, empty ones too, so a workspace holding only + // folders is not empty: its first folder is created from here and managed from its row. + // A role without the Folders page gets no folder menu, so empty rows would be noise. + let keepEmptyFolders = $derived(itemKind === 'all' && $roleCapabilities.visibility.folders) + // Only drops rows inside the tree. Whether the tree shows at all, rather than the "no items" + // or empty-workspace state, must not depend on it: either state can take down the settings + // menu, or replace owners whose items sit past the loaded window. + const HIDE_EMPTY_FOLDERS_SETTING_NAME = 'homeHideEmptyFolders' + let hideEmptyFolders = $state(getLocalSetting(HIDE_EMPTY_FOLDERS_SETTING_NAME) == 'true') + function setHideEmptyFolders(v: boolean) { + hideEmptyFolders = v + storeLocalSetting(HIDE_EMPTY_FOLDERS_SETTING_NAME, v ? 'true' : undefined) + } + // A folder created here starts empty; hiding it would read as a failed create. It stays + // listed until the page reloads. + let createdFolders = $state<{ workspace: string; name: string }[]>([]) + let keptFolders = $derived( + createdFolders.filter((f) => f.workspace === $workspaceStore).map((f) => f.name) + ) + + // Reload from the server whenever an input the endpoint resolves changes: order, + // archived/library scope, kind, the selected owner/folder, or entering/leaving + // search (see the reload effect below). Only the label filter and fuzzy ranking + // stay client-side over the loaded pages, so they don't reload here. + let searching = $derived(filter !== '') + // Lazy owner tree is active only in the tree when browsing all (no owner selected, no + // search, no label filter): every folder AND user shows as a top-level node and paginates + // on expand, its items coming from the separate `treeOwnerItems` store. In the flat list, + // or with a selected owner / active search / label filter, the lazy store is unused + // (reloadItems must not touch it), so this is false and the view reads the global loaded + // window instead. + let treeLazyMode = $derived( + !flatList && !searching && ownerFilter == undefined && labelFilter == undefined + ) + let listSettingsItems: MenuItem[] = $derived([ { displayName: 'Flat list', @@ -825,6 +862,15 @@ toggle: flatList, action: () => setFlatList(!flatList) }, + { + displayName: 'Hide empty folders', + description: + 'Folders with no scripts, flows, apps, agents or data pipelines. They may still hold resources, variables or schedules.', + toggle: hideEmptyFolders, + // Only the unfiltered tree lists folders it holds no rows of. + hide: !treeLazyMode || archived || !keepEmptyFolders, + action: () => setHideEmptyFolders(!hideEmptyFolders) + }, { displayName: 'Show library scripts', description: 'Scripts without a main function', @@ -933,6 +979,7 @@ ) }) let viewCodeDrawer: Drawer | undefined = $state() + let folderEditorDrawer: FolderEditorDrawer | undefined = $state() let viewCodeTitle: string | undefined = $state() let script: Script | undefined = $state() async function showCode(path: string, summary: string) { @@ -1034,19 +1081,8 @@ }) ) }) - // Reload from the server whenever an input the endpoint resolves changes: order, - // archived/library scope, kind, the selected owner/folder, or entering/leaving - // search (see the reload effect below). Only the label filter and fuzzy ranking - // stay client-side over the loaded pages, so they don't reload here. - let searching = $derived(filter !== '') - // Lazy owner tree is active only in the tree when browsing all (no owner selected, no - // search, no label filter): every folder AND user shows as a top-level node and paginates - // on expand, its items coming from the separate `treeOwnerItems` store. In the flat list, - // or with a selected owner / active search / label filter, the lazy store is unused - // (reloadItems must not touch it), so this is false and the view reads the global loaded - // window instead. - let treeLazyMode = $derived( - !flatList && !searching && ownerFilter == undefined && labelFilter == undefined + let treeShowsFolders = $derived( + treeLazyMode && !archived && keepEmptyFolders && (folderNamesRes.current?.length ?? 0) > 0 ) // How many runnables each owner (`f/<folder>` / `u/<user>`) holds for this user, // in one request. It labels every tree node up front — a lazy owner's own count is @@ -1199,6 +1235,11 @@ let canCreateHere = $derived( (editRights.roleCanAuthor('flow') || editRights.roleCanAuthor('raw_app')) && showEditButtons ) + // The Folders page's own rule for its "New folder" button. + let canCreateFolder = $derived( + $roleCapabilities.visibility.folders && + !isDemoWorkspaceRestricted($workspaceStore, $userStore?.is_admin, $userStore?.is_super_admin) + ) // The workspace itself holds nothing — no filter is narrowing the list away. It stays // false until the first load resolves: a skeleton already means "loading", and the @@ -1212,6 +1253,7 @@ filteredItems != undefined && filteredItems.length === 0 && visiblePipelineFolders.size === 0 && + !treeShowsFolders && !hasMoreServer ) /** @@ -1870,6 +1912,22 @@ {opts} /> +<!-- Permission changes can hide or reveal rows for this user, so a save reloads them. --> +<FolderEditorDrawer + bind:this={folderEditorDrawer} + onSaved={async (name, created) => { + if (created) { + // Folder pickers and the tree's folder menu derive write access from these, which + // the server only resends on the next whoami. The creator is the folder's owner. + $userStore?.folders.push(name) + $userStore?.folders_owners.push(name) + if ($workspaceStore) createdFolders.push({ workspace: $workspaceStore, name }) + await folderNamesRes.refetch() + } + await reloadItemsAndCounts() + }} +/> + <Drawer bind:this={viewCodeDrawer} on:close={() => { @@ -1990,6 +2048,7 @@ onImportHubProject={$disableHubStore || !editRights.roleCanAuthor('script') ? undefined : () => (hubPickerOpen = true)} + onCreateFolder={canCreateFolder ? () => folderEditorDrawer?.initNew() : undefined} /> {/if} </div> @@ -2033,7 +2092,7 @@ have scrolled away. --> <div class="sticky z-10 border-b mx-5" style:top="{toolbarHeight}px"></div> {/if} - {#if filteredItems?.length == 0 && !workspaceEmpty} + {#if filteredItems?.length == 0 && !workspaceEmpty && !treeShowsFolders} <div class="mt-10"></div> {/if} <!-- Inset so a row's hover fill clears the card's border; the controls above take the same @@ -2060,7 +2119,7 @@ <Skeleton layout={[[4], 0.5]} /> {/each} </div> - {:else if filteredItems.length === 0 && (filter !== '' || visiblePipelineFolders.size === 0)} + {:else if filteredItems.length === 0 && (filter !== '' || visiblePipelineFolders.size === 0) && !treeShowsFolders} <div class="px-4 pb-3"> <!-- Pipelines aren't part of the text filter, so only fall through to show them (list rows / injected tree folders) when not actively searching; @@ -2077,6 +2136,7 @@ <WorkspaceEmptyState archivedOnly={archivedProbe?.hasArchived === true} canCreate={canCreateHere} + onCreateFolder={canCreateFolder ? () => folderEditorDrawer?.initNew() : undefined} onPick={(project) => (hubPick = project)} onShowArchived={() => (filterValues.val = { ...filterValues.val, archived: true })} @@ -2173,6 +2233,11 @@ {showCode} showEditButton={showEditButtons} framed={false} + onManageFolder={$roleCapabilities.visibility.folders + ? (name) => folderEditorDrawer?.initEdit(name) + : undefined} + keepEmptyFolders={keepEmptyFolders && !hideEmptyFolders} + {keptFolders} /> {/key} {/if} diff --git a/frontend/src/lib/components/home/TreeOwnerRow.svelte b/frontend/src/lib/components/home/TreeOwnerRow.svelte index 6a5f95dee3..29a055fa59 100644 --- a/frontend/src/lib/components/home/TreeOwnerRow.svelte +++ b/frontend/src/lib/components/home/TreeOwnerRow.svelte @@ -18,9 +18,20 @@ toggleLabel?: string /** The line under the name, usually its item count. */ detail: Snippet + /** Controls rendered next to the name. Clicks on them do not toggle the row. */ + actions?: Snippet } - let { icon: Icon, name, opened, onToggle, indent = 0, toggleLabel, detail }: Props = $props() + let { + icon: Icon, + name, + opened, + onToggle, + indent = 0, + toggleLabel, + detail, + actions + }: Props = $props() </script> <!-- svelte-ignore a11y_click_events_have_key_events --> @@ -29,7 +40,7 @@ onclick={onToggle} class="px-4 py-2 {rowRule( unruled - )} w-full flex flex-row items-center justify-between cursor-pointer hover:bg-surface-hover" + )} group/row w-full flex flex-row items-center justify-between cursor-pointer hover:bg-surface-hover" > <div class="flex flex-row items-center gap-4" @@ -37,7 +48,22 @@ > <Icon size={16} class="text-secondary shrink-0" /> <div> - <span class="whitespace-nowrap text-xs text-emphasis font-semibold">{name}</span> + <div class="flex flex-row items-center gap-1"> + <span class="whitespace-nowrap text-xs text-emphasis font-semibold">{name}</span> + {#if actions} + <!-- Shown for the hovered row, a tabbed-into one, or one whose menu is open. + `-my-0.5` keeps the 20px trigger from making this line taller than the + name alone, so rows with and without actions line up. --> + <!-- svelte-ignore a11y_click_events_have_key_events --> + <!-- svelte-ignore a11y_no_static_element_interactions --> + <div + class="-my-0.5 opacity-0 transition-opacity duration-100 group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100 has-[[aria-expanded=true]]:opacity-100" + onclick={(e) => e.stopPropagation()} + > + {@render actions()} + </div> + {/if} + </div> <div class="text-2xs font-normal text-secondary whitespace-nowrap"> {@render detail()} </div> diff --git a/frontend/src/lib/components/home/TreeView.svelte b/frontend/src/lib/components/home/TreeView.svelte index 7331318727..f7b99ae5d7 100644 --- a/frontend/src/lib/components/home/TreeView.svelte +++ b/frontend/src/lib/components/home/TreeView.svelte @@ -3,7 +3,9 @@ import { createEventDispatcher, onDestroy, untrack, type Snippet } from 'svelte' import ResizeTransitionWrapper from '$lib/components/common/ResizeTransitionWrapper.svelte' - import { Folder, FolderTree, User } from 'lucide-svelte' + import { Eye, Folder, FolderTree, Pen, User } from 'lucide-svelte' + import { userStore } from '$lib/stores' + import Dropdown from '$lib/components/DropdownV2.svelte' import TreeOwnerRow from './TreeOwnerRow.svelte' import PipelineRow from '../common/table/PipelineRow.svelte' import Item from './Item.svelte' @@ -56,6 +58,9 @@ // Remembers which nodes the user opened by hand, so they reopen on the next visit // while expand/collapse-all is in its collapsed state. openMemory?: TreeOpenMemory + // Adds a "Manage folder" menu ("View folder" for a non-owner) to top-level folder rows. + // Subfolders are path segments, not folders with permissions of their own, so get none. + onManageFolder?: (folderName: string) => void } let { @@ -75,7 +80,8 @@ ancestorHasMore = false, indent = 0, leaf, - openMemory + openMemory, + onManageFolder }: Props = $props() const unruled = inUnruledList() @@ -270,6 +276,29 @@ } </script> +{#snippet manageFolderMenu()} + {#if isFolder(item)} + {@const folderName = item.folderName} + <!-- The Folders page's write rule. A non-owner's drawer opens read-only. --> + {@const canWrite = + $userStore?.is_admin || + $userStore?.is_super_admin || + $userStore?.folders_owners.includes(folderName)} + <Dropdown + size="xs" + fixedHeight={false} + placement="bottom-start" + items={[ + { + displayName: canWrite ? 'Manage folder' : 'View folder', + icon: canWrite ? Pen : Eye, + action: () => onManageFolder?.(folderName) + } + ]} + /> + {/if} +{/snippet} + {#if isFolder(item) || isUser(item)} <div> <TreeOwnerRow @@ -278,6 +307,7 @@ {opened} onToggle={toggleOwner} indent={visualDepth * 16} + actions={onManageFolder && depth === 0 && isFolder(item) ? manageFolderMenu : undefined} > {#snippet detail()} {#if ownerCount != undefined} @@ -331,6 +361,7 @@ depth={depth + 1} {indent} {leaf} + {onManageFolder} /> {/each} {#if effectiveMax < item.items.length} diff --git a/frontend/src/lib/components/home/TreeViewRoot.svelte b/frontend/src/lib/components/home/TreeViewRoot.svelte index 86c1984254..935157d152 100644 --- a/frontend/src/lib/components/home/TreeViewRoot.svelte +++ b/frontend/src/lib/components/home/TreeViewRoot.svelte @@ -53,6 +53,13 @@ leaf?: Snippet<[ItemType, number]> // See TreeView's `openMemory`. openMemory?: TreeOpenMemory + // See TreeView's `onManageFolder`. + onManageFolder?: (folderName: string) => void + // Inject folders `ownerCounts` calls empty, as "0 items". Off under a kind filter, + // where those counts are per kind and every folder lacking that kind would show. + keepEmptyFolders?: boolean + // Folders injected even when empty and `keepEmptyFolders` is off. + keptFolders?: string[] } let { @@ -76,7 +83,10 @@ framed = true, groupOtherUsers = false, leaf, - openMemory + openMemory, + onManageFolder, + keepEmptyFolders = false, + keptFolders }: Props = $props() const unruled = inUnruledList() @@ -103,6 +113,8 @@ allUsers ownerCounts selfUsername + keepEmptyFolders + keptFolders untrack(() => { // While searching, `items` is already relevance-ranked and the sort // selector is disabled, so keep that order: a no-op leaf comparator @@ -127,15 +139,21 @@ .filter((g) => 'folderName' in g) .map((g) => (g as { folderName: string }).folderName) ) - // Once counts are in, an owner holding nothing the user can see is dropped - // rather than injected — a workspace's folder list is mostly noise in the - // tree otherwise. Pipeline folders are exempt: their entry is the pipeline - // itself, whose member scripts are folded out of the runnable count. + // Once counts are in, a user space holding nothing the user can see is dropped + // rather than injected — the member list is mostly noise in the tree otherwise. + // Empty folders are dropped too unless `keepEmptyFolders` or `keptFolders`. Pipeline folders are + // exempt: their entry is the pipeline, whose scripts the count folds out. const isEmptyOwner = (prefix: string) => ownerCounts != undefined && !ownerCounts[prefix] const missingFolders: { folderName: string; items: [] }[] = [] for (const folderName of [...(pipelineFolders ?? []), ...allFolders]) { if (presentFolders.has(folderName)) continue - if (!pipelineFolders?.has(folderName) && isEmptyOwner(`f/${folderName}`)) continue + if ( + !keepEmptyFolders && + !pipelineFolders?.has(folderName) && + !keptFolders?.includes(folderName) && + isEmptyOwner(`f/${folderName}`) + ) + continue presentFolders.add(folderName) missingFolders.push({ folderName, items: [] }) } @@ -276,6 +294,7 @@ {showEditButton} {leaf} {openMemory} + {onManageFolder} /> {/snippet} diff --git a/frontend/src/lib/components/home/WorkspaceEmptyState.svelte b/frontend/src/lib/components/home/WorkspaceEmptyState.svelte index 813cb3a265..5014e2842b 100644 --- a/frontend/src/lib/components/home/WorkspaceEmptyState.svelte +++ b/frontend/src/lib/components/home/WorkspaceEmptyState.svelte @@ -26,9 +26,17 @@ * take — the archived link stays, since reading archived items is not a write. */ canCreate?: boolean + /** See CreateActionsMenu's `onCreateFolder`. */ + onCreateFolder?: () => void } - let { onPick, archivedOnly = false, onShowArchived, canCreate = true }: Props = $props() + let { + onPick, + archivedOnly = false, + onShowArchived, + canCreate = true, + onCreateFolder + }: Props = $props() // Row opacities: the list fading out of existence. Static on purpose — motion is what // makes a skeleton mean "loading", and this state means "empty". @@ -136,7 +144,7 @@ </Popover> or {/if} - <CreateActionsMenu source="empty_state" triggerElement={newLinkEl}> + <CreateActionsMenu source="empty_state" triggerElement={newLinkEl} {onCreateFolder}> {#snippet trigger()} <!-- The full stop rides inside the snippet: across a component boundary Svelte keeps the markup whitespace, which would leave a gap before it. --> diff --git a/frontend/src/lib/components/home/deploy_ui.ts b/frontend/src/lib/components/home/deploy_ui.ts index 1134789e76..ea51129347 100644 --- a/frontend/src/lib/components/home/deploy_ui.ts +++ b/frontend/src/lib/components/home/deploy_ui.ts @@ -4,14 +4,55 @@ import { ALL_DEPLOYABLE } from '$lib/utils_deployable' import { get, writable } from 'svelte/store' const deployUiStore = writable<Record<string, WorkspaceDeployUISettings>>({}) +let pending: Record<string, Promise<WorkspaceDeployUISettings>> = {} +let generation = 0 + +// Until the license loads, a load reads as CE and resolves to ALL_DEPLOYABLE. Rows warm the cache +// on mount, often before then, so a license arriving drops whatever was loaded without it. +enterpriseLicense.subscribe((license) => { + if (!license) return + const hadSettings = Object.keys(get(deployUiStore)).length > 0 || Object.keys(pending).length > 0 + generation++ + pending = {} + deployUiStore.set({}) + if (hadSettings) warmDeployUiSettings() +}) + export async function getDeployUiSettings(): Promise<WorkspaceDeployUISettings> { - let deployUiSettings = get(deployUiStore) - let workspace = get(workspaceStore) - if (!deployUiSettings[workspace!]) { - deployUiSettings[workspace!] = await getDeployUiSettingsInner() - deployUiStore.set(deployUiSettings) + const workspace = get(workspaceStore)! + const cached = get(deployUiStore)[workspace] + if (cached) return cached + const gen = generation + const load = (pending[workspace] ??= getDeployUiSettingsInner()) + let settings: WorkspaceDeployUISettings + try { + settings = await load + } finally { + if (pending[workspace] === load) delete pending[workspace] } - return deployUiSettings[workspace!] + if (gen !== generation) return getDeployUiSettings() + deployUiStore.update((s) => ({ ...s, [workspace]: settings })) + return settings +} + +/** + * Starts loading the settings without waiting for them. Every row on a page calls this, so a + * failed fetch must not reject: the root layout toasts each unhandled rejection, once per row. + */ +export function warmDeployUiSettings(): void { + getDeployUiSettings().catch(() => {}) +} + +/** + * The current workspace's settings if a load has already resolved. Row menus read this so they + * can build their items synchronously: a menu whose items arrive after it opens is positioned for + * its loading spinner and then flips once the real list is taller. A miss starts a new load, so a + * fetch that failed is retried on the next open. + */ +export function cachedDeployUiSettings(): WorkspaceDeployUISettings | undefined { + const settings = get(deployUiStore)[get(workspaceStore)!] + if (!settings) warmDeployUiSettings() + return settings } async function getDeployUiSettingsInner(): Promise<WorkspaceDeployUISettings> { diff --git a/frontend/src/lib/components/operatingWorkspace.svelte.ts b/frontend/src/lib/components/operatingWorkspace.svelte.ts index 70f1c63c1c..f25fa1415a 100644 --- a/frontend/src/lib/components/operatingWorkspace.svelte.ts +++ b/frontend/src/lib/components/operatingWorkspace.svelte.ts @@ -22,6 +22,24 @@ export function setOperatingWorkspace(resolve: () => string | undefined): void { setContext(KEY, outer ? () => resolve() ?? outer() : resolve) } +const READ_ONLY_KEY = Symbol('readOnlyWorkspace') + +/** Declare which workspaces this subtree may not change: a session in Use mode. `useEditRights` + * refuses every check there, so each control gated on it, the operator gates included, follows. + * Reads context: call during component initialisation. */ +export function setReadOnlyWorkspace(isReadOnly: (workspace: string | undefined) => boolean): void { + setContext(READ_ONLY_KEY, isReadOnly) +} + +/** Whether this subtree may not change `workspace`. Reads context: call during component + * initialisation. */ +export function useReadOnlyWorkspace(): (workspace: string | undefined) => boolean { + return ( + getContext<((workspace: string | undefined) => boolean) | undefined>(READ_ONLY_KEY) ?? + (() => false) + ) +} + /** This subtree's operating workspace as an entry for a `ContextBridge` contexts map. A snippet * handed to the page header's band renders outside the subtree that declared the workspace, so * without this it would quietly act on the navigation workspace instead. Reads context: call diff --git a/frontend/src/lib/components/raw_apps/InlineElementPrompt.svelte b/frontend/src/lib/components/raw_apps/InlineElementPrompt.svelte index 33a6617ace..721b3b4bfe 100644 --- a/frontend/src/lib/components/raw_apps/InlineElementPrompt.svelte +++ b/frontend/src/lib/components/raw_apps/InlineElementPrompt.svelte @@ -7,6 +7,7 @@ x, y, label, + inNewSession = false, onSend, onClose }: { @@ -15,11 +16,15 @@ y: number /** Short element label shown as the placeholder hint (e.g. "button.btn"). */ label: string - onSend: (prompt: string) => void + /** Sending leaves this page for a new AI session, so the hint says so. */ + inNewSession?: boolean + /** Resolving to `false` keeps the box open with its text. */ + onSend: (prompt: string) => void | Promise<boolean | void> onClose: () => void } = $props() let value = $state('') + let sending = $state(false) let textarea = $state<HTMLTextAreaElement | undefined>(undefined) // Focus when the anchor first appears (the element was just selected). @@ -27,16 +32,20 @@ textarea?.focus() }) - function submit() { + async function submit() { const v = value.trim() - if (!v) return - onSend(v) - value = '' + if (!v || sending) return + sending = true + try { + if ((await onSend(v)) !== false) value = '' + } finally { + sending = false + } } </script> <!-- Floating mini-composer anchored to the selected element in the preview. Sends - the prompt to the session chat with that element as context. --> + the prompt to the session chat (or a new session) with that element as context. --> <!-- The whole pill is the field: the border/focus-ring lives on this box and the send arrow sits inside it, centered against a single-line input. --> <div @@ -50,7 +59,7 @@ bind:value use:autosize={{ maxHeight: '120px', minHeight: 20 }} rows={1} - placeholder={`Ask about ${label}…`} + placeholder={inNewSession ? 'Ask in a new AI session…' : `Ask about ${label}…`} class="min-w-0 flex-1 resize-none !border-0 !bg-transparent !shadow-none px-1.5 !pt-[5px] !pb-[3px] text-xs leading-tight !ring-0 focus:!border-0 focus:!ring-0 focus:outline-none" onkeydown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -67,7 +76,7 @@ variant="accent" iconOnly startIcon={{ icon: ArrowUp }} - disabled={value.trim().length === 0} + disabled={sending || value.trim().length === 0} on:click={submit} title="Send" /> diff --git a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte index 3d22087317..55b211ecdd 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditor.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditor.svelte @@ -48,10 +48,15 @@ LintResult, InspectorElementInfo } from '../copilot/chat/app/core' - import { createAppSelectedContext, type AppCodeSelectionElement } from '../copilot/chat/context' + import { + createAppDomSelectorElement, + createAppSelectedContext, + type AppCodeSelectionElement + } from '../copilot/chat/context' import { captureScale, MAX_IMAGE_EDGE } from '../copilot/chat/imageUtils' import { rawAppLintStore } from './lintStore' import { dbSchemas, userStore } from '$lib/stores' + import { copilotInfo } from '$lib/aiStore' import { useActingUser } from '$lib/actingUser.svelte' import { appPreviewOnBehalfOf } from '$lib/utils/previewOnBehalfOf' import RawAppSdkConsent from './RawAppSdkConsent.svelte' @@ -77,6 +82,8 @@ import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' import { UserDraft } from '$lib/userDraft.svelte' import { setOpenInSessionHandoff } from '$lib/components/sessions/openInSessionContext' + import { prefersSessionHandoff } from '$lib/components/copilot/chat/global/gate' + import { openSourceInSession } from '$lib/components/sessions/sessionSwitch.svelte' import { buildDataTableWhitelist, parseDataTableRef, @@ -166,6 +173,8 @@ // Threaded to the AutosaveIndicator's "Reset to deployed" button. /** `Exit & see details`: see RawAppEditorHeader. */ onDetails?: (e: { path: string }) => void + /** See RawAppEditorHeader. */ + onOpenFork?: (path: string) => void onResetToDeployed?: () => void | Promise<void> /** The app_version the draft forked from, for the deploy-time "new version * deployed" guard: deploying is refused with a confirmation while it is not @@ -237,6 +246,7 @@ compact = false, pendingDraftPath = $bindable(undefined), onDetails, + onOpenFork, onResetToDeployed, loadedFromDraft = false, othersDraftsCount = 0, @@ -478,6 +488,12 @@ addEventListener('message', function (e) { } let previewIframe: HTMLIFrameElement | undefined = $state(undefined) let previewIframeLoaded = $state(false) + // The shell answers every build with `inspectorClear`, and the UI Builder + // rebuilds unchanged code (twice on load). Only a changed build can move what a + // chip points at, so the clears of the others are skipped: they would drop a + // chip handed in from the editor before it is sent. + let previewShellBuild: { css: string; js: string } | undefined = undefined + let previewClearsToSkip = 0 // Sandboxed only: the handshake's port, the one channel to the preview that // can't reach a document app code navigated the frame to. let previewPort: MessagePort | undefined = undefined @@ -1544,7 +1560,7 @@ addEventListener('message', function (e) { // Escape inside the preview exits inspect mode, and also dismisses a lingering // green "selected" overlay after a pick (which auto-disables hover). if (fromPreview && e.data.type === 'previewEscape') { - if (inspectorEnabled || inspectorElement || selectedDomSelectors.length > 0) { + if (inspectorEnabled || inspectorElement || previewSelectors.length > 0) { disableInspector() } return @@ -1581,15 +1597,28 @@ addEventListener('message', function (e) { // picking (the chip list, not the harness, holds the selection). Shift // held → add to the selection; a plain click replaces it. if (onInspectorSelect) onInspectorSelect(inspectorElement, !!e.data.additive) - else inspectorEnabled = false + else { + inspectorEnabled = false + // The preview keeps picking until told to stop. + postToPreview({ type: 'inspectorDisable' }) + } return } if (fromPreview && e.data.type === 'inspectorDeselect') { // User clicked × on a selected overlay in the preview — drop that chip. if (typeof e.data.selector === 'string') onInspectorDeselect?.(e.data.selector) + if (!onInspectorSelect && e.data.selector === inspectorElement?.path) { + inspectorElement = undefined + } return } if (fromPreview && e.data.type === 'inspectorClear') { + if (previewClearsToSkip > 0) { + previewClearsToSkip-- + // The shell dropped its outlines along with the old DOM. + postToPreview({ type: 'inspectorSetSelection', selectors: [...previewSelectors] }) + return + } inspectorElement = undefined // A rebuild invalidates the selection — clear all session chips. onInspectorClearAll?.() @@ -1851,6 +1880,13 @@ addEventListener('message', function (e) { if (previewSdkPending || previewUserPending || !previewIframeLoaded) return runtimeError = undefined emptyRender = false + if ( + !previewShellBuild || + (previewShellBuild.css === build.css && previewShellBuild.js === build.js) + ) { + previewClearsToSkip++ + } + previewShellBuild = build postToPreview({ type: 'preview', css: build.css, @@ -2106,19 +2142,56 @@ addEventListener('message', function (e) { return { text } } - // ---- Inline "prompt this element" mini-composer (session preview only) ---- + // What the preview outlines as selected: the session's chips when a session + // drives the inspector, else this editor's single pick. + const previewSelectors = $derived.by(() => + onInspectorSelect ? selectedDomSelectors : inspectorElement ? [inspectorElement.path] : [] + ) + + // ---- Inline "prompt this element" mini-composer ---- // Anchored over the most-recently selected element in the live preview. It's a // host overlay (position: fixed in the top document) computed from the element's // rect inside the preview iframe, repositioned as the preview scrolls. let inlinePromptDismissed = $state(false) let inlinePromptPos = $state<{ x: number; y: number } | undefined>(undefined) let inlinePromptLabel = $state('') + // Outside a session, with sessions on, there is no docked chat to take the pick: + // the prompt opens a new session instead, carrying the element as its chip. + const inlinePrompt: ((selector: string, prompt: string) => void | Promise<boolean>) | undefined = + $derived( + onInlinePrompt ?? + (!onInspectorSelect && sessionOpen && prefersSessionHandoff(!$roleCaps.code) + ? sendPickToNewSession + : undefined) + ) const inlinePromptSelector = $derived( - onInlinePrompt && selectedDomSelectors.length > 0 - ? selectedDomSelectors[selectedDomSelectors.length - 1] + inlinePrompt && previewSelectors.length > 0 + ? previewSelectors[previewSelectors.length - 1] : undefined ) + async function sendPickToNewSession(selector: string, prompt: string) { + const el = inspectorElement + if (!sessionOpen || !el) return false + try { + await openSourceInSession(sessionOpen, { + seedPrompt: prompt, + autoSend: true, + seedDomElement: createAppDomSelectorElement({ + selector, + appPath: sessionOpen.target.path, + tagName: el.tagName, + id: el.id, + className: el.className + }) + }) + return true + } catch (e) { + sendUserToast(e instanceof Error ? e.message : String(e), true) + return false + } + } + function shortElementLabel(el: { tagName: string; id: string; className: string }): string { const tag = el.tagName.toLowerCase() const id = el.id ? `#${el.id}` : '' @@ -2181,7 +2254,7 @@ addEventListener('message', function (e) { // overlay pinned as the host scrolls/resizes. The preview's own scrolls arrive // as `previewScroll` messages. inlinePromptSelector - selectedDomSelectors + previewSelectors previewIframeLoaded inlinePromptDismissed updateInlinePromptPos() @@ -2299,6 +2372,8 @@ addEventListener('message', function (e) { }) function onPreviewShellReady() { previewIframeLoaded = true + previewShellBuild = undefined + previewClearsToSkip = 0 // Replay the last build so the preview repopulates without // waiting for the user to trigger another bundle. if (lastBuild) { @@ -2342,7 +2417,7 @@ addEventListener('message', function (e) { // Push the chat's DOM-selector chips into the preview (source of truth): // the harness renders one highlight per selector. Re-posts on every // selection change and on preview (re)load. - const selectors = selectedDomSelectors + const selectors = previewSelectors if (previewIframeLoaded) { postToPreview({ type: 'inspectorSetSelection', selectors: [...selectors] }) } @@ -2491,7 +2566,7 @@ addEventListener('message', function (e) { // selection (highlights + the inline prompt), so a chip-only selection // (picking already toggled off) must still be dismissable. Bail only // when there is genuinely nothing to dismiss. - if (!inspectorEnabled && !inspectorElement && selectedDomSelectors.length === 0) return + if (!inspectorEnabled && !inspectorElement && previewSelectors.length === 0) return inspectorEnabled = false // `inspectorDisable` only stops hover/click; the green "selected" // overlay from a prior pick persists until we explicitly clear it. @@ -2514,7 +2589,7 @@ addEventListener('message', function (e) { const onEscapeCapture = (e: KeyboardEvent) => { if ( e.key === 'Escape' && - (inspectorEnabled || inspectorElement || selectedDomSelectors.length > 0) + (inspectorEnabled || inspectorElement || previewSelectors.length > 0) ) { disableInspector() e.stopImmediatePropagation() @@ -2654,6 +2729,7 @@ addEventListener('message', function (e) { {onNavigate} {onDeploy} {onDetails} + {onOpenFork} {onResetToDeployed} {loadedFromDraft} {othersDraftsCount} @@ -2873,28 +2949,30 @@ addEventListener('message', function (e) { {bundlerType} </Button> {/if} - <Button - variant="subtle" - unifiedSize="sm" - iconOnly - startIcon={{ icon: MousePointerSquareDashed }} - selected={inspectorEnabled} - title={inspectorEnabled - ? 'Click to disable element inspector' - : 'Click to enable element inspector'} - aria-label="Toggle element inspector" - aria-pressed={inspectorEnabled} - onClick={() => { - if (inspectorEnabled) { - // Turning off is a full exit: stop picking and clear the - // selection + inline prompt (mirrors Escape). - disableInspector() - } else { - inspectorEnabled = true - postToPreview({ type: 'inspectorEnable' }) - } - }} - /> + {#if !$copilotInfo.workspaceDisabled} + <Button + variant="subtle" + unifiedSize="sm" + iconOnly + startIcon={{ icon: MousePointerSquareDashed }} + selected={inspectorEnabled} + title={inspectorEnabled + ? 'Click to disable element inspector' + : 'Click to enable element inspector'} + aria-label="Toggle element inspector" + aria-pressed={inspectorEnabled} + onClick={() => { + if (inspectorEnabled) { + // Turning off is a full exit: stop picking and clear the + // selection + inline prompt (mirrors Escape). + disableInspector() + } else { + inspectorEnabled = true + postToPreview({ type: 'inspectorEnable' }) + } + }} + /> + {/if} <Button variant="subtle" unifiedSize="sm" @@ -3054,7 +3132,7 @@ addEventListener('message', function (e) { </div> </div> -{#if inlinePromptPos && inlinePromptSelector && onInlinePrompt} +{#if inlinePromptPos && inlinePromptSelector && inlinePrompt} <!-- Key on the selector so selecting a different element remounts the input (fresh value + autofocus); repositioning on scroll keeps the same key so it never steals focus mid-scroll. --> @@ -3063,8 +3141,9 @@ addEventListener('message', function (e) { x={inlinePromptPos.x} y={inlinePromptPos.y} label={inlinePromptLabel} - onSend={(prompt) => { - onInlinePrompt?.(inlinePromptSelector!, prompt) + inNewSession={!onInlinePrompt} + onSend={async (prompt) => { + if ((await inlinePrompt?.(inlinePromptSelector!, prompt)) === false) return false inlinePromptDismissed = true }} onClose={() => (inlinePromptDismissed = true)} diff --git a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte index bc14c298e9..a03d5512ca 100644 --- a/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte +++ b/frontend/src/lib/components/raw_apps/RawAppEditorHeader.svelte @@ -51,6 +51,7 @@ import AppEditorHeaderDeploy from '../apps/editor/AppEditorHeaderDeploy.svelte' import type { Runnable } from './RawAppInlineScriptRunnable.svelte' import { updateRawAppPolicy } from './rawAppPolicy' + import { saveRawAppFork } from './forkRawApp.svelte' import { aiChatManager } from '../copilot/chat/AIChatManager.svelte' import { getContext } from 'svelte' @@ -188,6 +189,9 @@ /** `Exit & see details`: leaves the editor for the app's deployed page. A session hosts that * page itself, so it flips the tab there rather than navigating out of the session. */ onDetails?: (e: { path: string }) => void + /** Opens the draft `Fork` saved. Without it, the fork opens in a new browser tab; a + * session opens it as one of its own tabs instead. */ + onOpenFork?: (path: string) => void onResetToDeployed?: () => void | Promise<void> // See ScriptBuilderProps — same semantics for the raw-app editor's // indicator. @@ -239,6 +243,7 @@ onDeploy = undefined, pendingDraftPath = $bindable(undefined), onDetails, + onOpenFork, onResetToDeployed, loadedFromDraft = false, othersDraftsCount = 0, @@ -257,6 +262,21 @@ const opWorkspace = $derived(autosaveWorkspace ?? $operatingWorkspace) const indicatorPath = $derived(autosavePath ?? liveEditorDraftStoragePath) + async function forkApp() { + if (!opWorkspace || !files) return + const workspace = opWorkspace + // Opened before the save: a tab opened after an await is no longer tied to the + // click, and browsers block it as a popup. + const tab = onOpenFork ? undefined : window.open('', '_blank') + const forkPath = await saveRawAppFork(workspace, { files, runnables, data, policy }) + if (!forkPath) { + tab?.close() + return + } + if (onOpenFork) onOpenFork(forkPath) + else if (tab) tab.location.href = `/apps_raw/edit/${forkPath}?workspace=${workspace}` + } + $effect(() => { const typed = newEditedPath const baseline = savedApp?.path ?? '' @@ -459,8 +479,7 @@ async function syncWithDeployed(opening?: number) { const deployedApp = await AppService.getAppByPath({ workspace: opWorkspace!, - path: appPath!, - withStarredInfo: true + path: appPath! }) // A superseded opening must not write these: the current one would then render @@ -1217,9 +1236,7 @@ ]), { label: 'Fork', - onClick: () => { - window.open(`/apps/add?template=${appPath}`) - } + onClick: forkApp }, ...(!isCloudHosted() && editInForkAllowed(opWorkspace, $userWorkspaces) ? [ diff --git a/frontend/src/lib/components/raw_apps/forkRawApp.svelte.ts b/frontend/src/lib/components/raw_apps/forkRawApp.svelte.ts new file mode 100644 index 0000000000..ffeab765da --- /dev/null +++ b/frontend/src/lib/components/raw_apps/forkRawApp.svelte.ts @@ -0,0 +1,55 @@ +import { get } from 'svelte/store' +import type { Policy } from '$lib/gen' +import { userStore } from '$lib/stores' +import { sendUserToast } from '$lib/toast' +import { UserDraft } from '$lib/userDraft.svelte' +import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' +import { mintDraftPath } from '$lib/mintDraftPath' +import { userPathPrefix } from '$lib/utils' +import { random_adj } from '$lib/components/random_positive_adjetive' +import type { RawAppDraft } from '$lib/components/sessions/appDraftCodec' +import type { RawAppData } from './dataTableRefUtils' + +/** + * Saves what the editor holds as a new raw-app draft under a fresh path and returns that + * path, or `undefined` when the save failed. The copy is taken from the editor rather than + * reloaded from the server, which would miss edits autosave has not written (or never will, + * with auto-save off). + */ +export async function saveRawAppFork( + workspace: string, + source: { + files: Record<string, string> + runnables: Record<string, any> + data: RawAppData + policy: Policy | undefined + } +): Promise<string | undefined> { + const user = get(userStore) + const forkPath = mintDraftPath() + const draft: RawAppDraft = { + summary: '', + files: $state.snapshot(source.files), + runnables: $state.snapshot(source.runnables), + data: $state.snapshot(source.data), + // The forker's identity, since the source's would publish as its owner, but the + // source's sandbox and SDK scopes: the copied code's SDK calls depend on them. + policy: { + on_behalf_of: user?.username.includes('@') ? user.username : `u/${user?.username}`, + on_behalf_of_email: user?.email, + execution_mode: 'publisher', + sandbox: source.policy?.sandbox ?? false, + frontend_sdk_scopes: $state.snapshot(source.policy?.frontend_sdk_scopes) + }, + // Without it the fork would deploy under its `draft_<uuid>` storage path. + draft_path: `${userPathPrefix(user?.username)}${random_adj()}_app` + } + UserDraft.save('raw_app', forkPath, draft, { workspace }) + await UserDraft.forcePersist('raw_app', forkPath, { workspace }) + const saved = UserDraftDbSyncer.getState({ workspace, itemKind: 'raw_app', path: forkPath }) + if (saved.state === 'failed') { + sendUserToast(`Could not save the fork: ${saved.failureMessage ?? 'unknown error'}`, true) + return undefined + } + return forkPath +} diff --git a/frontend/src/lib/components/sessions/FlowEditorView.svelte b/frontend/src/lib/components/sessions/FlowEditorView.svelte index 9e34b1f7a4..ae5e25097d 100644 --- a/frontend/src/lib/components/sessions/FlowEditorView.svelte +++ b/frontend/src/lib/components/sessions/FlowEditorView.svelte @@ -131,10 +131,10 @@ onDetails={onSeeDetails} condensedHeader={true} customUi={{ topBar: { aiBuilder: false } }} - onDeploy={() => { + onDeploy={(e) => { // FlowBuilder has no deploy toast and the session stays put, so toast here. sendUserToast('Deployed') - runtime.itemDeployed(workspaceId, 'flow', path) + runtime.itemDeployed(workspaceId, 'flow', path, e.path) }} /> {/snippet} diff --git a/frontend/src/lib/components/sessions/HomeSessionStarter.svelte b/frontend/src/lib/components/sessions/HomeSessionStarter.svelte index 9ab68c3742..942025c21c 100644 --- a/frontend/src/lib/components/sessions/HomeSessionStarter.svelte +++ b/frontend/src/lib/components/sessions/HomeSessionStarter.svelte @@ -16,11 +16,15 @@ homeStarterSession, setSessionDraftAttachments, selectSession, + sessionInCurrentFamily, sessionPageHref, sessionState, + setNewSessionWorkspace, setSessionDraftPrompt } from './sessionState.svelte' import SessionWorkspaceBar from './SessionWorkspaceBar.svelte' + import { useWorkModeLock } from './workModeLock.svelte' + import { AIWorkMode } from '$lib/components/copilot/chat/workMode' import { composerSentFromHome, takeComposerFocus } from './homeSessionsTransition.svelte' import { homeComposerContext } from './homeComposerContext.svelte' @@ -42,10 +46,15 @@ setContext<AIChatManager>('aiChatManager', runtime.manager) const session = $derived(sessionState.sessions.find((s) => s.id === sessionId)) + // Switches the page along with the session, as the workspace bar below does. + const workModeLock = useWorkModeLock(() => session, true) const actingWorkspaceId = $derived( (session ? getEffectiveWorkspaceId(session) : undefined) ?? $workspaceStore ?? undefined ) - const starterPrompts = new StarterPrompts(() => actingWorkspaceId) + const starterPrompts = new StarterPrompts( + () => actingWorkspaceId, + () => !workModeLock.current && runtime.manager.workMode !== AIWorkMode.USE + ) // The AI config (models, whether AI is hidden) of the workspace the draft acts on, as the // session page loads it, so a fork picked here shows its own models and the session page @@ -90,6 +99,18 @@ const wrapper = chatWrapper requestAnimationFrame(() => onSettled?.(wrapper.offsetHeight)) }) + // The draft acts on the workspace Home shows, whichever picker switched it: a switch within + // the family keeps this mount, so the draft's pick has to follow. A switch to another family + // remounts with that family's draft, so this one is left where it was. A staged fork of the + // shown workspace stays. On mount, a draft that differs is one that follows Home, moved off it + // by a switch made elsewhere (homeStarterSession). + onMount(() => + workspaceStore.subscribe((ws) => { + const s = sessionState.sessions.find((x) => x.id === sessionId) + if (!ws || !s || s.workspace_id || s.pending_workspace_id === ws) return + if (sessionInCurrentFamily(s)) setNewSessionWorkspace(sessionId, ws) + }) + ) onMount(() => { onShown?.() if (takeComposerFocus()) setTimeout(() => aiChat?.focusInput(), 0) @@ -130,6 +151,7 @@ viewTransitionNames {onSent} {actingOn} + workModeLock={workModeLock.current} /> </div> {:else} diff --git a/frontend/src/lib/components/sessions/ItemViewerView.svelte b/frontend/src/lib/components/sessions/ItemViewerView.svelte index e9fa9b7768..d1adaa952f 100644 --- a/frontend/src/lib/components/sessions/ItemViewerView.svelte +++ b/frontend/src/lib/components/sessions/ItemViewerView.svelte @@ -64,7 +64,10 @@ untrack(() => runtime.manager) ) - const permissions = useSessionPermissions(() => workspaceId) + const permissions = useSessionPermissions( + () => workspaceId, + () => runtime.manager + ) const canEdit = $derived(permissions.canOpenEditor(kind)) // This reads the deployed version over the API rather than from the editor cell the diff --git a/frontend/src/lib/components/sessions/PageItemEditorView.svelte b/frontend/src/lib/components/sessions/PageItemEditorView.svelte index 73efd892d4..a77550a3ef 100644 --- a/frontend/src/lib/components/sessions/PageItemEditorView.svelte +++ b/frontend/src/lib/components/sessions/PageItemEditorView.svelte @@ -10,12 +10,15 @@ import { setOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' import { pageItemKindLabel, + pageItemPageHref, pageItemUrl, TRIGGER_PAGES, type PageItemRef, type TriggerKind } from './previewPaths' import type { SessionRuntime } from './sessionRuntime.svelte' + import { useSessionPermissions } from './sessionPermissions.svelte' + import { withWorkspaceParam } from './sessionMode.svelte' let { runtime, @@ -103,6 +106,13 @@ ) ) + // These editors save on click, and a schedule or trigger save passes a production + // workspace's deploy rules, so a read-only session mounts none of them. + const permissions = useSessionPermissions( + () => workspaceId, + () => runtime.manager + ) + function closeTab() { const tab = runtime.previewTabs.tabs.find((t) => t.url === pageItemUrl(item)) if (tab) runtime.previewTabs.close(tab.id) @@ -135,6 +145,21 @@ </p> <Button variant="default" unifiedSize="sm" onClick={closeTab}>Close tab</Button> </div> + {:else if permissions.readOnly} + <div class="flex flex-col items-start gap-3 p-4 text-sm text-secondary"> + <p> + {pageItemKindLabel(item)} <span class="font-mono">{item.path}</span> can't be edited while the + session is in Use mode. + </p> + <Button + variant="default" + unifiedSize="sm" + href={withWorkspaceParam(pageItemPageHref(item), workspaceId)} + target="_blank" + > + Open in workspace + </Button> + </div> {:else} {#key `${item.kind}:${triggerKey}:${item.path}:${workspaceId}:${reloadNonce}:${savedNonce}`} {#if item.kind === 'variable'} diff --git a/frontend/src/lib/components/sessions/PreviewRouterPicker.svelte b/frontend/src/lib/components/sessions/PreviewRouterPicker.svelte index bc03272712..8fa6b465cc 100644 --- a/frontend/src/lib/components/sessions/PreviewRouterPicker.svelte +++ b/frontend/src/lib/components/sessions/PreviewRouterPicker.svelte @@ -50,6 +50,7 @@ section or the flat layout. type PreviewTarget } from './previewRouter' import { useSessionPermissions } from './sessionPermissions.svelte' + import type { AIWorkMode } from '$lib/components/copilot/chat/workMode' type Kind = WorkspaceItemKind type DrillPickerHandle = { @@ -75,6 +76,9 @@ section or the flat layout. // Session artifacts to surface as an "Artifacts" branch; omitted or empty // hides the branch (non-session consumers, sessions without artifacts). artifacts?: PersistedArtifact[] + /** The session chat, whose Use mode opens items in their viewer. Read from the + * `aiChatManager` context when omitted. */ + chat?: { readonly workMode: AIWorkMode } } let { @@ -86,12 +90,16 @@ section or the flat layout. autoFocus = true, flush = false, workspaceId, - artifacts + artifacts, + chat }: Props = $props() const kinds: Kind[] = ['flow', 'script', 'app'] const effectiveWorkspace = $derived(workspaceId ?? $workspaceStore) - const permissions = useSessionPermissions(() => effectiveWorkspace) + const permissions = useSessionPermissions( + () => effectiveWorkspace, + () => chat + ) const canEdit = (item: WorkspaceItem) => permissions.canEditItem(item) let inner = $state<DrillPickerHandle | undefined>(undefined) diff --git a/frontend/src/lib/components/sessions/PreviewTabHost.svelte b/frontend/src/lib/components/sessions/PreviewTabHost.svelte index c2adaf7688..33470fa261 100644 --- a/frontend/src/lib/components/sessions/PreviewTabHost.svelte +++ b/frontend/src/lib/components/sessions/PreviewTabHost.svelte @@ -22,6 +22,7 @@ import ArtifactViewer from '../copilot/chat/artifacts/ArtifactViewer.svelte' import RunFormPreviewSlot from './RunFormPreviewSlot.svelte' import { setOverlayHost } from '../common/overlayHost.svelte' + import { setReadOnlyWorkspace } from '../operatingWorkspace.svelte' let { tab, @@ -80,7 +81,11 @@ const workspaceId = $derived( session ? (getEffectiveWorkspaceId(session) ?? $workspaceStore ?? '') : '' ) - const permissions = useSessionPermissions(() => workspaceId || undefined) + const permissions = useSessionPermissions( + () => workspaceId || undefined, + () => runtime?.manager + ) + setReadOnlyWorkspace((ws) => !!ws && ws === workspaceId && permissions.readOnly) // Every route into an editor ends here, the breadcrumb and the chat's openers included, so // this is where an editor the user cannot save is turned into the deployed page. const mode = $derived( @@ -419,7 +424,9 @@ {:else} <!-- A pipeline has no deployed page, only its editor. --> <div class="flex-1 flex items-center justify-center p-8 text-center text-sm text-secondary"> - The pipeline editor saves drafts, which your role cannot do in this workspace. + {permissions.readOnly + ? 'The pipeline editor saves drafts, which a session in Use mode cannot do.' + : 'The pipeline editor saves drafts, which your role cannot do in this workspace.'} </div> {/if} </div> diff --git a/frontend/src/lib/components/sessions/RawAppEditorView.svelte b/frontend/src/lib/components/sessions/RawAppEditorView.svelte index 8bff98802c..50ea1e054a 100644 --- a/frontend/src/lib/components/sessions/RawAppEditorView.svelte +++ b/frontend/src/lib/components/sessions/RawAppEditorView.svelte @@ -100,7 +100,9 @@ files: { ...result.files }, runnables: { ...result.runnables, - ...(capabilitiesInWorkspace(workspaceId).apps === 'compose' ? {} : structuredClone(STARTER_RUNNABLES)) + ...(capabilitiesInWorkspace(workspaceId).apps === 'compose' + ? {} + : structuredClone(STARTER_RUNNABLES)) }, data: result.data, policy: result.policy @@ -148,12 +150,27 @@ if (dp !== undefined) { surfacedDraftPath = true if (val.draft_path !== dp) val.draft_path = dp - } else if (surfacedDraftPath && val.draft_path !== undefined) { + } else if ( + surfacedDraftPath && + val.draft_path !== undefined && + // Equal to the saved path, it is not stale: a deploy just moved the app + // there. Clearing it would schedule a save of this cell at the old path + // that lands after the deploy discarded it, and the moved draft row + // would come back on the new path. + val.draft_path !== cell.saved.val?.path + ) { val.draft_path = undefined } }) }) + function openFork(forkPath: string) { + runtime.previewTabs.open({ + type: 'item', + item: { kind: 'app', raw_app: true, path: forkPath, summary: '' } + }) + } + async function reloadDeployed() { await runtime.loadRawApp(workspaceId, path, true, true) } @@ -372,6 +389,7 @@ {diffDrawer} {onNavigate} onDetails={onSeeDetails} + onOpenFork={openFork} condensedHeader={true} onResetToDeployed={reloadDeployed} onDeploy={(e) => runtime.itemDeployed(workspaceId, 'raw_app', path, e.path)} diff --git a/frontend/src/lib/components/sessions/ScriptEditorView.svelte b/frontend/src/lib/components/sessions/ScriptEditorView.svelte index ab1bac52c9..5248f14164 100644 --- a/frontend/src/lib/components/sessions/ScriptEditorView.svelte +++ b/frontend/src/lib/components/sessions/ScriptEditorView.svelte @@ -124,7 +124,7 @@ // Fires on every deploy (primary, "Deploy & Stay here", and lib — we // ignore e.stay since the session always stays). sendUserToast('Deployed') - runtime.itemDeployed(workspaceId, 'script', e.path) + runtime.itemDeployed(workspaceId, 'script', path, e.path) }} /> {/if} diff --git a/frontend/src/lib/components/sessions/SessionChangesBar.svelte b/frontend/src/lib/components/sessions/SessionChangesBar.svelte index c6272b8fc7..b7ee23034c 100644 --- a/frontend/src/lib/components/sessions/SessionChangesBar.svelte +++ b/frontend/src/lib/components/sessions/SessionChangesBar.svelte @@ -22,6 +22,7 @@ import { isCloudHosted } from '$lib/cloud' import { sessionState, type Session } from './sessionState.svelte' import { getRuntime } from './sessionRuntime.svelte' + import { useSessionPermissions } from './sessionPermissions.svelte' import SessionDiffDrawer from './SessionDiffDrawer.svelte' import { TOKEN_TRIGGER_CLASS } from './SessionStatusToken.svelte' import { useWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' @@ -69,10 +70,17 @@ // Same gate as the sidebar WorkspaceMenu / SessionWorkspaceBar. On cloud, // forking is a premium-only feature (backend caps it per paid seat). const forksAllowed = $derived( - (!isCloudHosted() || $maybePremium) && canCreateFork($userStore, $roleCapabilities) && $workspaceStore !== 'admins' + (!isCloudHosted() || $maybePremium) && + canCreateFork($userStore, $roleCapabilities) && + $workspaceStore !== 'admins' ) const runtime = $derived(getRuntime(session.id)) + // A session in Use deploys and discards nothing, as its chat does not. + const permissions = useSessionPermissions( + () => committedId, + () => runtime?.manager + ) // The chat's modified-items mask (`${UserDraftItemKind}:${storagePath}`). // undefined = legacy/untracked chat → fall back to showing every draft. @@ -387,8 +395,10 @@ onItemDiscarded={(item) => { void runtime?.manager.removeModifiedItem(item.draftKind, item.path) const folder = pipelineFolderFromBundlePath(item.path) - if (item.draftKind === PIPELINE_DRAFT_KIND && folder) void runtime?.forgetPipelineDrafts(folder) + if (item.draftKind === PIPELINE_DRAFT_KIND && folder) + void runtime?.forgetPipelineDrafts(folder) }} deployPipeline={runtime?.deployPipeline} + readOnly={permissions.readOnly} /> {/if} diff --git a/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte b/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte index d921e435b0..76d7f7e7ab 100644 --- a/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte +++ b/frontend/src/lib/components/sessions/SessionDiffDrawer.svelte @@ -20,7 +20,8 @@ onDataChanged, onItemDeployed, onItemDiscarded, - deployPipeline + deployPipeline, + readOnly = false }: { workspaceId: string parentWorkspaceId?: string @@ -39,6 +40,8 @@ onItemDiscarded?: (item: DeployItem) => void /** Forwarded to the deploy model: deploys a pipeline folder's draft bundle. */ deployPipeline?: (folder: string) => Promise<DeployResult> + /** Forwarded to the deploy model: the session is in Use. */ + readOnly?: boolean } = $props() const isFork = $derived(!!parentWorkspaceId) @@ -55,7 +58,8 @@ onDataChanged, onItemDeployed, onItemDiscarded, - deployPipeline + deployPipeline, + readOnly })) // Editor URL for a row (every item lives in the session workspace). @@ -99,7 +103,11 @@ {ws?.name ?? workspaceId} </span> {#if ws?.is_dev_workspace} - <DevWorkspaceBadge label={ws.dev_workspace_label} color={ws.color} fallbackColor="indigo" /> + <DevWorkspaceBadge + label={ws.dev_workspace_label} + color={ws.color} + fallbackColor="indigo" + /> {/if} <ArrowRight class="w-3 h-3 shrink-0 text-tertiary" /> <span class="font-medium truncate" title={parentWs?.name ?? parentWorkspaceId}> diff --git a/frontend/src/lib/components/sessions/SessionWorkspaceBar.svelte b/frontend/src/lib/components/sessions/SessionWorkspaceBar.svelte index 41cbb73a81..768064576a 100644 --- a/frontend/src/lib/components/sessions/SessionWorkspaceBar.svelte +++ b/frontend/src/lib/components/sessions/SessionWorkspaceBar.svelte @@ -1,16 +1,43 @@ -<script lang="ts"> +<script lang="ts" module> + import { get } from 'svelte/store' import { workspaceStore } from '$lib/stores' + import { setSessionPendingWorkspace, stopFollowingHome } from './sessionState.svelte' + import { switchWorkspace } from '$lib/storeUtils' + import { workspaceAIClients } from '$lib/components/copilot/lib' + import { fixupUrlAfterWorkspaceSwitch } from '$lib/components/sidebar/workspaceSwitchUrl' + + function followPick(id: string, switchesWorkspace: boolean) { + if (!switchesWorkspace || get(workspaceStore) === id) return + workspaceAIClients.init(id) + switchWorkspace(id) + void fixupUrlAfterWorkspaceSwitch(id) + } + + // Before the pick, which stores the session: Home would otherwise move a draft picked off + // Home back to the workspace it shows. + function releaseFromHome(sessionId: string, switchesWorkspace: boolean) { + if (!switchesWorkspace) stopFollowingHome(sessionId) + } + + /** Point a session that has not sent yet at workspace `id`, as a pick in this bar does. */ + export function pickSessionWorkspace(sessionId: string, id: string, switchesWorkspace: boolean) { + releaseFromHome(sessionId, switchesWorkspace) + // Pre-send only: writes the pending pick. workspace_id stays undefined until + // the user sends their first message. The chat targets this pending workspace + // via the manager's workspace resolver, not the global workspaceStore. + setSessionPendingWorkspace(sessionId, id) + followPick(id, switchesWorkspace) + } +</script> + +<script lang="ts"> import { getEffectiveWorkspaceId, setSessionPendingFork, - setSessionPendingWorkspace, type Session } from './sessionState.svelte' import WorkspaceFamilyPicker from './WorkspaceFamilyPicker.svelte' import WorkspaceScopeTrigger from '$lib/components/WorkspaceScopeTrigger.svelte' - import { switchWorkspace } from '$lib/storeUtils' - import { workspaceAIClients } from '$lib/components/copilot/lib' - import { fixupUrlAfterWorkspaceSwitch } from '$lib/components/sidebar/workspaceSwitchUrl' // `switchesWorkspace`: a pick also moves the navigation workspace. For a host whose page // shows the navigation workspace around the composer (Home), where a pick that left it @@ -18,28 +45,18 @@ let { session, switchesWorkspace = false }: { session: Session; switchesWorkspace?: boolean } = $props() - function followPick(id: string) { - if (!switchesWorkspace || $workspaceStore === id) return - workspaceAIClients.init(id) - switchWorkspace(id) - void fixupUrlAfterWorkspaceSwitch(id) - } - // Effective workspace for display: committed → pending pick → active store. const effectiveId = $derived(getEffectiveWorkspaceId(session) ?? $workspaceStore ?? undefined) const pendingFork = $derived(session.pending_fork) function pick(id: string) { - // Pre-send only: writes the pending pick. workspace_id stays undefined until - // the user sends their first message. The chat targets this pending workspace - // via the manager's workspace resolver, not the global workspaceStore. - setSessionPendingWorkspace(session.id, id) - followPick(id) + pickSessionWorkspace(session.id, id, switchesWorkspace) } function stageFork(req: { parent_workspace_id: string; id: string; name: string }) { + releaseFromHome(session.id, switchesWorkspace) setSessionPendingFork(session.id, req) - followPick(req.parent_workspace_id) + followPick(req.parent_workspace_id, switchesWorkspace) } </script> diff --git a/frontend/src/lib/components/sessions/SessionWrapper.svelte b/frontend/src/lib/components/sessions/SessionWrapper.svelte index f6b4b4bf8a..63d2b5594f 100644 --- a/frontend/src/lib/components/sessions/SessionWrapper.svelte +++ b/frontend/src/lib/components/sessions/SessionWrapper.svelte @@ -18,9 +18,12 @@ import { globalComposerSuggestion, STARTER_PLACEHOLDER, + USE_STARTER_PLACEHOLDER, type ComposerSelection } from '$lib/components/copilot/chat/composerBox' import { useSessionPermissions } from './sessionPermissions.svelte' + import { useWorkModeLock } from './workModeLock.svelte' + import { AIWorkMode } from '$lib/components/copilot/chat/workMode' import EditableInput from '$lib/components/common/EditableInput.svelte' import PageHeaderContent from '$lib/components/PageHeaderContent.svelte' import { Button } from '$lib/components/common' @@ -67,6 +70,7 @@ getSessionDraftAttachments, setSessionDraftAttachments, peekSessionAutoSend, + getSessionAutoSendDomElement, takeSessionAutoSend, reconcileAfterWorkspaceChange, renameSession, @@ -81,17 +85,23 @@ import { goto } from '$lib/navigation' import { splitterPointerCapture } from '$lib/utils/splitterPointerCapture' import { StarterPrompts } from '$lib/components/copilot/chat/starter/starterPrompts.svelte' + import { resolvePreviewTab } from './previewRouter' + import { whereIs } from './sessionPreviewTabs.svelte' + import type { SessionPreviewTab } from './sessionState.svelte' let { sessionId, headerInPage = false, - covered = false + covered = false, + previewTabName }: { sessionId: string /** True for the session the page is showing: its header rides the page header bar. */ headerInPage?: boolean /** True while the page holds this wrapper inert under an overlay, where nothing takes focus. */ covered?: boolean + /** The name the preview's tab strip shows for a tab, without its side marker. */ + previewTabName: (tab: SessionPreviewTab) => string } = $props() // Parent keys by sessionId; this wrapper only mounts when the session exists. @@ -111,8 +121,34 @@ // and an empty transcript would show the greeting and then glide the composer away. // The first send commits the workspace, so this flips only after the transcript has. const opensSession = $derived(!session?.workspace_id) - const starterPrompts = new StarterPrompts(() => - opensSession ? (actingWorkspaceId ?? $workspaceStore ?? undefined) : undefined + const permissions = useSessionPermissions( + () => runtime?.manager.operatingWorkspace, + () => runtime?.manager + ) + const workModeLock = useWorkModeLock(() => session) + // Whether the first message goes out in Use. Not `permissions.readOnly`: before that send a + // staged fork's session reads the workspace it forks, while the message builds in the fork. + const heldInUse = $derived(!!workModeLock.current || runtime?.manager.workMode === AIWorkMode.USE) + // The item an unsent session opened on ("Open in AI session", or a tab opened before the + // first send): the chat reads the displayed tab with each message, so the empty chat asks + // about that item instead of greeting. What is on screen only: a collapsed panel hides it. + // Named as its tab is, so a deployed item in View mode gets its summary from the listing. + const previewedItem = $derived.by(() => { + if (!opensSession) return undefined + const tab = runtime?.previewTabs.displayedTab + if (!tab) return undefined + const slot = resolvePreviewTab(whereIs(tab)) + if (slot.kind !== 'editor' && slot.kind !== 'viewer') return undefined + const kind = slot.kind === 'editor' ? slot.editorKind : slot.viewerKind + return { + kind: kind === 'raw_app' ? 'app' : kind, + name: previewTabName(tab) + } + }) + const greets = $derived(opensSession && !previewedItem) + const starterPrompts = new StarterPrompts( + () => (greets ? (actingWorkspaceId ?? $workspaceStore ?? undefined) : undefined), + () => !heldInUse ) // Seed the composer with the unsent prompt a reload preserved on the session @@ -147,7 +183,6 @@ // Drawn once, and shared by the placeholder and the real composer so the text survives the swap. const suggestionPick = Math.random() - const permissions = useSessionPermissions(() => runtime?.manager.operatingWorkspace) const composerPlaceholder = $derived( globalComposerSuggestion(suggestionPick, permissions.canOpenEditor) ) @@ -164,13 +199,14 @@ if (!session?.autoSendDraftAt || !runtime) return untrack(() => { const prompt = getSessionDraftPrompt(sessionId) + const domElement = getSessionAutoSendDomElement(sessionId) // takeSessionAutoSend clears the intent whether or not it is still // fresh, so this cannot re-enter on the next dependency change. if (!takeSessionAutoSend(sessionId) || !prompt) return // sendOrQueue, not sendRequest: a reused session may already be mid-turn. // The runtime's beforeSend awaits loadCopilot for the committed // workspace, so this cannot race the model config on a cold session. - runtime.manager.sendOrQueue(prompt) + runtime.manager.sendOrQueue(prompt, domElement ? [domElement] : undefined) }) }) @@ -576,6 +612,13 @@ // utilities on one element resolve by stylesheet order, not attribute order. const bannerClass = 'flex flex-row items-center justify-between gap-2 py-2 px-3 text-xs border rounded-md' + + const previewedItemPlaceholder = $derived( + previewedItem && + (heldInUse + ? `Ask about the ${previewedItem.kind} ${previewedItem.name}, or run it…` + : `Ask about the ${previewedItem.kind} ${previewedItem.name}, or describe a change…`) + ) </script> {#if !session || !runtime} @@ -583,7 +626,12 @@ {:else} <!-- A sent session whose transcript is still loading (or came back empty) shows nothing rather than the docked copilot's keyboard-shortcut hint, which means nothing here. --> - {#snippet sessionEmptyHint()}{/snippet} + {#snippet sessionEmptyHint()} + {#if previewedItem} + <!-- Holds the composer at the bottom, as in a session under way. --> + <div class="flex-1 min-h-0"></div> + {/if} + {/snippet} {#snippet inputPreface()} {#if showRecoveryNotice} @@ -782,7 +830,7 @@ onDraftChange={(text) => setSessionDraftPrompt(sessionId, text)} initialAttachments={armedAtInit ? undefined : getSessionDraftAttachments(sessionId)} onAttachmentsChange={(a) => setSessionDraftAttachments(sessionId, a)} - placeholder={composerPlaceholder} + placeholder={previewedItemPlaceholder ?? composerPlaceholder} forceDisabled={isUnavailable || !!session.archived || moving} forceDisabledMessage={isUnavailable ? 'This session is linked to a workspace that no longer exists. Move it or discard it from the banner above to keep working.' @@ -791,48 +839,51 @@ : moving ? `Moving this session to ${moveRequest?.targetName ?? 'the fork'}…` : ''} - starter={opensSession + starter={greets ? { where: 'session', suggestions: starterPrompts.suggestions } : undefined} viewTransitionNames={headerInPage && (opensSession || arrivingFromHomeSend())} emptyHint={sessionEmptyHint} {inputPreface} actingOn={hasFirstUserMessage ? undefined : actingOn} + workModeLock={workModeLock.current} /> {:else} <!-- AIChatDisplay's layout, so the composer stays put when the chat replaces it: its transcript area, its column, and its footer row's height; for a session that has never sent, the greeting above the centred composer. --> <div - class="flex flex-col h-full {opensSession ? 'justify-center' : ''}" + class="flex flex-col h-full {greets ? 'justify-center' : ''}" {@attach () => () => runtime.manager.setComposerHasDraft(PLACEHOLDER_DRAFT_KEY, false)} > - {#if opensSession} + {#if greets} <div class="flex-1 min-h-0 flex flex-col justify-end"> <div class="w-full max-w-3xl mx-auto px-7"> <SessionGreeting /> </div> </div> - {:else if runtime.manager.displayMessages.length > 0} + {:else if previewedItem || runtime.manager.displayMessages.length > 0} <div class="flex-1 min-h-0"></div> {/if} <div class="w-full max-w-3xl mx-auto px-7 pb-2"> {@render inputPreface()} <ComposerPlaceholder bind:value={() => draftText, setPlaceholderText} - toolbar={opensSession ? placeholderToolbar : undefined} - placeholder={opensSession - ? STARTER_PLACEHOLDER + toolbar={greets ? placeholderToolbar : undefined} + placeholder={greets + ? heldInUse + ? USE_STARTER_PLACEHOLDER + : STARTER_PLACEHOLDER : runtime.manager.displayMessages.length > 0 ? 'Ask followup' - : composerPlaceholder} + : (previewedItemPlaceholder ?? composerPlaceholder)} disabled={isUnavailable || !!session.archived} autofocus={sessionState.currentSessionId === sessionId && !covered} onSelectionChange={(selection) => (handoff = selection ?? null)} onCompositionChange={(composing) => (placeholderComposing = composing)} /> - {#if !opensSession} + {#if !greets} <div class="mt-1 h-5"></div> {:else if !isUnavailable && !session.archived && starterPrompts.suggestions.length > 0} <div class="pt-2"> @@ -844,7 +895,7 @@ </div> {/if} </div> - {#if opensSession} + {#if greets} <div class="flex-1 min-h-0"></div> {/if} </div> diff --git a/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte b/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte index c35ee5a2fc..11f3219155 100644 --- a/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte +++ b/frontend/src/lib/components/sessions/WorkspaceDiffDrawer.svelte @@ -1059,7 +1059,10 @@ variant="subtle" unifiedSize="sm" destructive - disabled={model.deploying || !!staged[d.key]} + disabled={model.deploying || !!staged[d.key] || model.readOnly} + title={model.readOnly + ? 'A session in Use mode discards nothing. Switch it to Use & build to discard.' + : undefined} onclick={() => confirmDiscard(d)} > {action.secondary[0].label} diff --git a/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte b/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte index 53f930fa15..feffc09405 100644 --- a/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte +++ b/frontend/src/lib/components/sessions/WorkspaceFamilyPicker.svelte @@ -20,8 +20,9 @@ import { useForkableWorkspaces } from '$lib/utils/useForkableWorkspaces.svelte' import { canCreateFork } from '$lib/utils/editInFork' import { forkAccentStyle } from '$lib/utils/forkColor' - import { getUserExt } from '$lib/user' import { WorkspaceService } from '$lib/gen' + import { useSessionReadOnly } from './sessionPermissions.svelte' + import { getUserExt } from '$lib/user' import { fetchProtectionRulesForWorkspace, isRuleActiveInRulesets, @@ -162,36 +163,21 @@ return depth > 0 ? `padding-left: ${12 + depth * 16}px` : undefined } - // Judge the prod root off its OWN protection rules (fetched), not the active - // workspace's — so it reads correctly from a dev/fork too, the same way - // ParentWorkspaceProtectionAlert checks the parent. It's selectable when you - // can deploy to it, and "Fork from prod" shows when you can fork it. + // Judged off the root's OWN rules, so it reads correctly from a dev workspace or a fork too. + // A locked root stays selectable: its session is read-only. + const rootReadOnly = useSessionReadOnly(() => root?.id) + // In `forksOf` mode a new fork is based on that workspace, so its own forking rule is the gate, + // judged with the user's identity there (is_admin and groups are per workspace). const rootRulesetsResource = resource( - () => root?.id, + () => (forksOf ? root?.id : undefined), async (id) => (id ? await fetchProtectionRulesForWorkspace(id) : []) ) const rootRulesets = $derived(rootRulesetsResource.current ?? []) - // Bypass must be judged with the user's identity IN THE ROOT (is_admin/groups are per-workspace), - // not the active workspace's `$userStore` — otherwise an admin of the dev/active workspace who is a - // non-admin of the root would get a false bypass. `getUserExt` returns undefined for a non-member, - // which `canUserBypassRuleKindInRulesets` treats as no bypass. const rootUserInfoResource = resource( - () => root?.id, + () => (forksOf ? root?.id : undefined), async (id) => (id ? await getUserExt(id) : undefined) ) const rootUserInfo = $derived(rootUserInfoResource.current) - const canDeployRoot = $derived( - !isRuleActiveInRulesets(rootRulesets, 'DisableDirectDeployment') || - canUserBypassRuleKindInRulesets(rootRulesets, 'DisableDirectDeployment', rootUserInfo) - ) - // A genuinely deploy-locked root (you can't deploy and can't bypass) is disabled regardless of - // whether a dev workspace exists to steer to — being deploy-locked is the gate, not dev presence. - // Roots with no rules resolve `canDeployRoot` to true, so ordinary families aren't affected. Kept - // disabled while either fetch is in flight (both default to the conservative locked state). - // In `forksOf` mode the top row is where the consumer already is, so there is nothing to gate. - const rootDisabled = $derived( - !forksOf && (rootRulesetsResource.loading || rootUserInfoResource.loading || !canDeployRoot) - ) // Structural gate: hidden in the admins workspace, or when the user can't fork; on cloud, forking // is premium-only (backend caps it per paid seat). DisableWorkspaceForking on the active workspace @@ -262,7 +248,7 @@ if (row.kind === 'create') { requestCreateFork() } else if (row.kind === 'root') { - if (!rootDisabled) void pick(row.id) + void pick(row.id) } else if (row.kind === 'fork') { void pick(row.id) } @@ -432,20 +418,19 @@ <div class="relative group shrink-0"> <button type="button" - disabled={rootDisabled} - title={rootDisabled - ? devOfRoot - ? `${root.name} is locked. Run in its ${devLabelNoun(devOfRoot.dev_workspace_label)} instead.` - : `${root.name} is locked for direct deploys.` + title={rootReadOnly.known + ? `Sessions in ${root.name} can read and run deployed items, but not edit. Pick ${ + devOfRoot ? `its ${devLabelNoun(devOfRoot.dev_workspace_label)}` : 'a fork' + } to make changes.` : undefined} - class={`${rowBase} pr-8 ${rootDisabled ? 'opacity-50 cursor-not-allowed' : ''} ${isSelected(root.id) && !pendingFork ? 'bg-surface-selected' : ''} ${!rootDisabled && keyArrowPos === rootIdx ? 'bg-surface-hover' : !rootDisabled ? 'hover:bg-surface-hover' : ''}`} - onmouseenter={() => !rootDisabled && (keyArrowPos = rootIdx)} - onclick={() => !rootDisabled && void pick(root.id)} + class={`${rowBase} pr-8 ${isSelected(root.id) && !pendingFork ? 'bg-surface-selected' : ''} ${keyArrowPos === rootIdx ? 'bg-surface-hover' : 'hover:bg-surface-hover'}`} + onmouseenter={() => (keyArrowPos = rootIdx)} + onclick={() => void pick(root.id)} > <Building size={14} class="shrink-0 text-tertiary" /> <span class="truncate">{root.name}</span> <span class="text-2xs text-tertiary shrink-0 ml-auto" - >{rootDisabled ? 'locked' : forksOf ? 'current' : 'root'}</span + >{forksOf ? 'current' : rootReadOnly.known ? 'read-only' : 'root'}</span > {#if isSelected(root.id) && !pendingFork} <Check size={14} class="shrink-0 text-accent" /> diff --git a/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts b/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts index 50447d3e06..487f80aaf8 100644 --- a/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionDeployModel.svelte.ts @@ -60,6 +60,13 @@ export interface SessionDeployModelArgs { /** Deploys a pipeline folder's draft bundle, which no per-item deploy can: it * expands into the folder's scripts and triggers. */ deployPipeline?: (folder: string) => Promise<DeployResult> + /** The session is in Use: its edits still show, but nothing deploys or discards. */ + readOnly?: boolean +} + +const USE_MODE_REFUSAL: DeployPermission = { + ok: false, + reason: 'A session in Use mode deploys nothing. Switch it to Use & build to deploy.' } export interface DiffValues { @@ -258,6 +265,7 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) { }) function permissionFor(kind: Kind): DeployPermission { + if (getArgs().readOnly) return USE_MODE_REFUSAL const caps = capabilitiesInWorkspace(getArgs().workspaceId) if (builder?.kinds.includes(kind)) return deployPermissionForKind(builder.perm, kind, caps) return deployPermissionForKind(deployPerm, kind, caps) @@ -305,7 +313,7 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) { /** Deploy (or discard) a single item; returns whether it succeeded. */ async function deployOne(item: DeployItem, discard = false): Promise<boolean> { const plan = discard ? discardPlanFor(item) : deployPlanFor(item) - if (!plan) return false + if (!plan || getArgs().readOnly) return false // Snapshot before the await: the user may switch sessions while the // deploy runs, and the event belongs to the initiating session. const initiatingSessionId = sessionState.currentSessionId @@ -410,6 +418,10 @@ export function useSessionDeployModel(getArgs: () => SessionDeployModelArgs) { deployPermissionForKind(kind: Kind): DeployPermission { return permissionFor(kind) }, + /** The session is in Use, so discarding is refused as deploying is. */ + get readOnly() { + return !!getArgs().readOnly + }, deployRow, discardRow } diff --git a/frontend/src/lib/components/sessions/sessionMirrorPlan.ts b/frontend/src/lib/components/sessions/sessionMirrorPlan.ts index 5d40c35e3b..1aa970aa47 100644 --- a/frontend/src/lib/components/sessions/sessionMirrorPlan.ts +++ b/frontend/src/lib/components/sessions/sessionMirrorPlan.ts @@ -79,8 +79,8 @@ export function isFallbackStorage(name: string): boolean { /** * The part of a session record the backup keeps. Left out on purpose: `name` (a * per-browser legacy label), the unsent-draft fields (`pending_*`, `draftPrompt`, - * `autoSendDraftAt`), `workspace_root_id` (derived on import), `transient`, `restoredAt` - * (this browser's clock), and the two fields reading a session bumps (`lastSeenCount`, + * `autoSendDraftAt`, `autoSendDomElement`), `workspace_root_id` (derived on import), + * `transient`, `restoredAt` (this browser's clock), and the two fields reading a session bumps (`lastSeenCount`, * `lastActivityAt`) — so opening a session and reading its new messages never costs a push. */ export type SessionHead = Pick< diff --git a/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts b/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts index caceb2e623..0db9ac09e7 100644 --- a/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionPermissions.svelte.ts @@ -1,31 +1,89 @@ +import { getContext } from 'svelte' import { fromStore } from 'svelte/store' +import { resource } from 'runed' import { userWorkspaces } from '$lib/stores' import { useActingUser } from '$lib/actingUser.svelte' import { useEditRights } from '$lib/operatorWriteRights' +import { buildsAnything } from '$lib/editRights' +import { + isReadOnlySessionWorkspace, + settledReadOnlyVerdict +} from '$lib/components/copilot/chat/global/sessionAccess' +import { AIWorkMode } from '$lib/components/copilot/chat/workMode' import type { WorkspaceItem } from '$lib/components/workspacePicker' import { previewPageAllowed, type PreviewPage } from './previewRouter' +/** Whether a session in `workspace` is read-only because the workspace is protected: the verdict + * `resolveSessionAccess` gives the chat, so the UI and the toolset agree. */ +export function useSessionReadOnly(workspace: () => string | undefined) { + const verdict = resource(workspace, async (ws) => + ws ? { workspace: ws, readOnly: await isReadOnlySessionWorkspace(ws) } : undefined + ) + // `resource` keeps the previous workspace's verdict while it asks about the next one. + const verdictFor = (ws: string): boolean | undefined => { + const landed = verdict.current + return landed?.workspace === ws ? landed.readOnly : settledReadOnlyVerdict(ws) + } + return { + /** Read-only, and also while the rules have not been read yet: the preview's writes wait + * for the lookup rather than race it. */ + get current() { + const ws = workspace() + return !!ws && (verdictFor(ws) ?? true) + }, + /** Read-only as far as is known, so false while the rules are read. For what only shows the + * verdict, the picker's lock or a label, which would otherwise flash a reason that is not + * true. */ + get known() { + const ws = workspace() + return !!ws && (verdictFor(ws) ?? false) + } + } +} + +/** The session chat whose work mode the session UI follows. */ +type WorkModeHost = { readonly workMode: AIWorkMode } + /** * What the session UI may offer in `workspace`. Its editors save drafts, so it reads * `roleCanDraft` / `canDraftItem` — the rule the chat's draft tools and `open_preview` follow — so * a control and the tool behind it never disagree. An unknown user reads as able to draft, as the toolset does before access * resolves: hiding every editor from a developer while `whoami` is in flight is the worse - * mistake, and the server refuses an operator anyway. Reads context and registers an effect: - * call during component initialisation. + * mistake, and the server refuses an operator anyway. A session held in Use opens no editor: one + * in a protected workspace, one whose role builds nothing there, or one whose chat is in Use + * mode — the chat `chat` returns, else the `aiChatManager` context's. + * Reads context and registers an effect: call during component initialisation. */ -export function useSessionPermissions(workspace: () => string | undefined) { +export function useSessionPermissions( + workspace: () => string | undefined, + chat?: () => WorkModeHost | undefined +) { + const contextChat = getContext<WorkModeHost | undefined>('aiChatManager') const rights = useEditRights(workspace) const user = useActingUser(workspace) + const protectedWorkspace = useSessionReadOnly(workspace) + const readOnly = $derived( + protectedWorkspace.current || + !buildsAnything(rights.capabilities) || + (chat?.() ?? contextChat)?.workMode === AIWorkMode.USE + ) const workspaces = fromStore(userWorkspaces) const entry = $derived(workspaces.current.find((w) => w.id === workspace())) return { + /** The session is read-only in this workspace. */ + get readOnly() { + return readOnly + }, /** Whether this user can save the drafts the `kind` editor writes. */ - canOpenEditor: (kind: string) => rights.roleCanDraft(kind), + canOpenEditor: (kind: string) => !readOnly && rights.roleCanDraft(kind), /** The same, and this item's own permissions let them write it. */ canEditItem: (item: Pick<WorkspaceItem, 'kind' | 'raw_app' | 'path' | 'extraPerms'>) => + !readOnly && rights.canDraftItem(item.raw_app ? 'raw_app' : item.kind, item.path, item.extraPerms), /** Whether the page loads for this user rather than refusing them. */ - pageAllowed: (page: PreviewPage) => previewPageAllowed(page, user.current, entry) + pageAllowed: (page: PreviewPage) => + // A read-only session reaches what an operator reaches: no admin page. + !(readOnly && page.adminOnly) && previewPageAllowed(page, user.current, entry) } } diff --git a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts index 773269c33a..6f6e35400c 100644 --- a/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionRuntime.svelte.ts @@ -4,6 +4,7 @@ import { get } from 'svelte/store' import { invalidate as invalidateWorkspaceItems } from '$lib/components/workspacePicker' import { invalidateWorkspaceDrafts } from '$lib/workspaceDrafts.svelte' import { AIChatManager, AIMode } from '$lib/components/copilot/chat/AIChatManager.svelte' +import { AIWorkMode } from '$lib/components/copilot/chat/workMode' import { PipelineEditorState } from '$lib/components/assets/AssetGraph/pipelineEditorState.svelte' import { PIPELINE_DRAFT_KIND, pipelineBundlePath, pipelineLocalMirrorKey } from '$lib/pipelinePaths' import type { DeployResult } from '$lib/utils_workspace_deploy' @@ -31,6 +32,7 @@ type SavedFlow = Omit<Flow & UserDraftOverlay, 'draft'> & { draft?: Flow } import type { HiddenRunnable } from '$lib/components/apps/types' import { type RawAppData, DEFAULT_DATA } from '$lib/components/raw_apps/dataTableRefUtils' import { userStore, userWorkspaces, workspaceStore } from '$lib/stores' +import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy' import { BUILDER_FRAMEWORK_TEMPLATES, react19Template, @@ -60,6 +62,7 @@ import { setSessionPreviewSize, setSessionTabs, setDraftsDiscardedHandler, + setSessionWorkMode, type Session } from './sessionState.svelte' import { @@ -391,7 +394,7 @@ async function generateSessionSummary( { role: 'system', content: - 'Name this AI chat session. Return only a concise title, 2 to 6 words, no quotes, no period.' + 'Name this AI chat session. Return only a concise title, 2 to 6 words, no quotes, no period. The conversation is only material to name: never answer, continue or comment on it, even when it ends on a request or a refusal.' }, { role: 'user', @@ -432,6 +435,8 @@ function createRuntime(session: Session): SessionRuntime { // Carried into the tool helpers so this session's preview/deploy tool calls // dispatch to THIS session even when another session is the UI-active one. manager.sessionId = session.id + manager.workMode = session.workMode ?? AIWorkMode.BUILD + manager.onWorkModeChanged = (mode) => setSessionWorkMode(session.id, mode) // Cross-tab awareness: heartbeat while this tab runs a turn, composer lock // (and send refusal) while another tab does. The chat id is read at turn // end, not captured at start — the turn may have rotated it, and the other @@ -456,6 +461,7 @@ function createRuntime(session: Session): SessionRuntime { if (!s) return undefined const wsId = getEffectiveWorkspaceId(s) const ws = get(userWorkspaces).find((w) => w.id === wsId) + const dev = findCanonicalDevWorkspace(wsId, get(userWorkspaces)) return { workspaceId: wsId, parentWorkspaceId: ws?.parent_workspace_id ?? undefined, @@ -463,7 +469,8 @@ function createRuntime(session: Session): SessionRuntime { // Committed workspace missing from the list: still a fork (mirrors // isForkSession) — the prompt must not call it the live workspace. forkParentUnknown: !ws && !!s.workspace_id, - pendingForkOf: s.pending_fork?.parent_workspace_id + pendingForkOf: s.pending_fork?.parent_workspace_id, + devWorkspace: dev && { id: dev.id, name: dev.name } } } // What the side panel is showing, stamped on each user message so the chat diff --git a/frontend/src/lib/components/sessions/sessionState.svelte.ts b/frontend/src/lib/components/sessions/sessionState.svelte.ts index 19288c6ca7..ee14811299 100644 --- a/frontend/src/lib/components/sessions/sessionState.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionState.svelte.ts @@ -5,21 +5,16 @@ import { random_adj } from '$lib/components/random_positive_adjetive' import { enterpriseLicense, superadmin, - userStore, userWorkspaces, usersWorkspaceStore, workspaceStore, type UserWorkspace } from '$lib/stores' import { switchWorkspace } from '$lib/storeUtils' -import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy' -import { - isRuleActive, - canUserBypassRuleKind, - protectionRulesState -} from '$lib/workspaceProtectionRules.svelte' import { getLocalSetting, storeLocalSetting } from '$lib/utils' import { logFeatureUsage } from '$lib/utils/featureUsage' +import { isReadOnlySessionWorkspace } from '../copilot/chat/global/sessionAccess' +import { AIWorkMode } from '../copilot/chat/workMode' import { workspaceRootId } from './sessionScope.svelte' import { clearSessionRecovered } from './sessionRecoveryNotice.svelte' import { type DBSchema, type IDBPDatabase } from 'idb' @@ -29,6 +24,7 @@ import { deleteItemsForSession } from '../copilot/chat/files/attachedFilesDB' import { deleteArtifactsForSession } from '../copilot/chat/artifacts/artifactsDB' import { deleteSessionChats } from '../copilot/chat/HistoryManager.svelte' import type { DraftAttachments } from '../copilot/chat/messageDraft.svelte' +import type { AppDomSelectorElement } from '../copilot/chat/context' import { markSessionDirty, markSessionRemoved, @@ -102,6 +98,10 @@ export type Session = { // deferred to first send (via commitSessionWorkspace) so cancelling // the draft doesn't leave an orphan fork behind. pending_fork?: PendingFork + // Home's draft: it acts on whatever workspace Home shows, so a switch made on another page + // moves it on Home's next mount. Cleared by a pick made on the session page, whose picker + // leaves the navigation workspace where it is. + followsHome?: boolean // Stable root workspace id used only for sidebar grouping. Lifecycle follows // workspace_id, not this field. Root sessions store the same id in both fields. workspace_root_id?: string @@ -158,6 +158,8 @@ export type Session = { // Preview split size (preview pane %, 0-100) the user dragged for this session. // Per-session so each session restores its own layout. previewSize?: number + // Use or Build, as the chat's mode picker last left it. Absent reads as Build. + workMode?: AIWorkMode.USE | AIWorkMode.BUILD // Unsent composer text for a pending (uncommitted) session, persisted with // the record so each parallel draft restores its own typed-but-unsent prompt. // Only tracked while unsent; cleared once the workspace commits at first send. @@ -170,6 +172,10 @@ export type Session = { // would otherwise leave a session armed forever and fire on some later visit. // Consumed exactly once, by takeSessionAutoSend. autoSendDraftAt?: number + // The element a preview pick hands over with the auto-sent prompt, sent as its + // chip. Lives and dies with `autoSendDraftAt`: a prompt like "make this bigger" + // must never go out without the element it names. + autoSendDomElement?: AppDomSelectorElement } // One preview tab: `url` is the URL we command the iframe to load, `loc` the @@ -421,13 +427,19 @@ const AUTO_SEND_TTL_MS = 5 * 60_000 // Mark this session's draft prompt for sending on arrival. Written straight to // the record (not via setSessionDraftPrompt's debounce) because the navigation // that follows must not outrun it. -export function setSessionAutoSend(sessionId: string): void { +export function setSessionAutoSend(sessionId: string, domElement?: AppDomSelectorElement): void { const s = sessionState.sessions.find((x) => x.id === sessionId) if (!s) return s.autoSendDraftAt = Date.now() + s.autoSendDomElement = domElement persistTouched(s) } +// The element armed with the auto-send intent; read before takeSessionAutoSend clears both. +export function getSessionAutoSendDomElement(sessionId: string): AppDomSelectorElement | undefined { + return sessionState.sessions.find((x) => x.id === sessionId)?.autoSendDomElement +} + function autoSendIsFresh(s: Session | undefined): boolean { return !!s?.autoSendDraftAt && Date.now() - s.autoSendDraftAt < AUTO_SEND_TTL_MS } @@ -448,6 +460,7 @@ export function takeSessionAutoSend(sessionId: string): boolean { if (!s?.autoSendDraftAt) return false const fresh = autoSendIsFresh(s) delete s.autoSendDraftAt + delete s.autoSendDomElement persistTouched(s) return fresh } @@ -1087,6 +1100,9 @@ export function createSession(): Session { (s) => isDiscardableDraft(s) && sessionInCurrentFamily(s) ) if (reusable) { + // It may be Home's draft; asked for here, it is the session page's now, and callers + // park it on a workspace of their own that Home must not move it off. + delete reusable.followsHome sessionState.currentSessionId = reusable.id // The blank recovery just landed on is exactly what this reuses, so `+` // would otherwise hand back a session still carrying the recovery notice: @@ -1115,24 +1131,9 @@ function newTransientSession(): Session { const discarded = sessionState.sessions.filter(isDiscardableDraft).map((s) => s.id) sessionState.sessions = sessionState.sessions.filter((s) => !isDiscardableDraft(s)) if (discarded.length > 0) onDraftsDiscarded?.(discarded) - // Start in the workspace you're in. The one exception: a root you can't - // deploy to (locked, no bypass) steers to its dev, since a session there - // couldn't edit anything. The picker lets you switch. - const currentWs = get(workspaceStore) - const devOfCurrent = currentWs - ? findCanonicalDevWorkspace(currentWs, get(userWorkspaces))?.id - : undefined - // Only trust the deploy check once the active workspace's rules have actually loaded: until then - // `isRuleActive` reads an empty ruleset and fails open, which would default a new session onto a - // locked prod. Treat "not yet loaded for currentWs" as not-deployable so we steer to the dev (always - // editable) when one exists; the picker still lets the user switch back once rules resolve. - const rulesLoadedForCurrent = - protectionRulesState.rulesets !== undefined && protectionRulesState.workspace === currentWs - const canDeployHere = - rulesLoadedForCurrent && - (!isRuleActive('DisableDirectDeployment') || - canUserBypassRuleKind('DisableDirectDeployment', get(userStore))) - const pending = devOfCurrent && !canDeployHere ? devOfCurrent : currentWs + // Start in the workspace you're in, a locked production root included: the session is then + // read-only, and points the user to a new session in the dev workspace for changes. + const pending = get(workspaceStore) // Friendly default summary so the header reads like "Zippy session" // rather than "Untitled session" — assigned at create time, the user // can still rename it (or it gets overwritten by an editor target). @@ -1155,22 +1156,31 @@ function newTransientSession(): Session { /** The unsent session Home's composer writes into, so a prompt half-typed on Home is the * one the empty session page shows, and the reverse. Never selects it: merely opening Home * must not change which session the AI Sessions switch resumes. A session opened on an - * item (it carries preview tabs) is about that item, so Home starts its own instead. */ + * item (it carries preview tabs) is about that item, and one the session page picked onto + * another workspace of the family is about that workspace: Home acts on the workspace it + * shows, so it starts its own instead of moving either. */ export function homeStarterSession(): Session { + const homeWs = get(workspaceStore) + const homes = (s: Session) => + (s.followsHome || s.pending_workspace_id === homeWs) && sessionInCurrentFamily(s) const unsent = (s: Session) => - !s.archived && !s.workspace_id && !s.previewTabs?.length && sessionInCurrentFamily(s) + !s.archived && !s.workspace_id && !s.previewTabs?.length && homes(s) const current = sessionState.sessions.find((s) => s.id === sessionState.currentSessionId) - if (current && unsent(current)) return current // Stored but never sent: a draft the user touched, by typing or by picking where it acts. // Either is theirs to come back to, and leaving it would strand it in the sidebar. const touched = sessionState.sessions .filter((s) => unsent(s) && !s.transient) .sort((a, b) => sessionLastActivityAt(b) - sessionLastActivityAt(a))[0] - return ( + const draft = + (current && unsent(current) ? current : undefined) ?? touched ?? - sessionState.sessions.find((s) => isDiscardableDraft(s) && sessionInCurrentFamily(s)) ?? + sessionState.sessions.find((s) => isDiscardableDraft(s) && homes(s)) ?? newTransientSession() - ) + if (!draft.followsHome) { + draft.followsHome = true + if (!draft.transient) void putSession(draft) + } + return draft } // Promote an in-memory transient session to a persisted IndexedDB record. @@ -1194,6 +1204,13 @@ export function setSessionPendingWorkspace(id: string, workspace_id: string) { if (changed) persistTouched(s) } +export function stopFollowingHome(id: string) { + const s = sessionState.sessions.find((x) => x.id === id) + if (!s?.followsHome) return + delete s.followsHome + if (!s.transient) void putSession(s) +} + // Park a just-created session on a workspace. Deliberately does not persist a // still-transient draft: picking where it will live is part of creating it, not a // user touch, so an unused draft still vanishes on reload (see `transient`). @@ -1283,12 +1300,16 @@ export async function commitSessionWorkspace( // The draft prompt has been consumed as the first message. delete s.draftPrompt await putSession(s) - // A picked workspace can itself be an existing fork — classify by root. - logFeatureUsage('ai_session', 'created', { - key: ws === s.workspace_root_id ? 'root' : 'fork', - entityId: s.id, - workspace: ws - }) + // A picked workspace can itself be an existing fork — classify by root. A read-only root is + // counted apart, to see whether production sessions are used. + const sessionId = s.id + void isReadOnlySessionWorkspace(ws).then((readOnly) => + logFeatureUsage('ai_session', 'created', { + key: readOnly ? 'read_only' : ws === s.workspace_root_id ? 'root' : 'fork', + entityId: sessionId, + workspace: ws + }) + ) // The global workspaceStore is intentionally left untouched (see the fork // branch above): the session chat reads its committed workspace through the // manager's workspace resolver, not the active workspaceStore. @@ -1327,6 +1348,16 @@ export function setSessionPreviewSize(id: string, size: number): void { persistTouched(s) } +// Persist the session's Use or Build choice (a touch). Plan is left out: a reload ends the +// planning round, and the session reopens in the mode it was entered from. +export function setSessionWorkMode(id: string, mode: AIWorkMode): void { + if (mode === AIWorkMode.PLAN) return + const s = sessionState.sessions.find((x) => x.id === id) + if (!s || (s.workMode ?? AIWorkMode.BUILD) === mode) return + s.workMode = mode + persistTouched(s) +} + export function selectSession(id: string) { sessionState.currentSessionId = id } diff --git a/frontend/src/lib/components/sessions/sessionState.test.ts b/frontend/src/lib/components/sessions/sessionState.test.ts index 4c6666a349..5dd3570c6b 100644 --- a/frontend/src/lib/components/sessions/sessionState.test.ts +++ b/frontend/src/lib/components/sessions/sessionState.test.ts @@ -3,6 +3,7 @@ import { get } from 'svelte/store' import { commitSessionWorkspace, createSession, + setNewSessionWorkspace, decideSessionLifecycle, findEmptyLandingSession, getSessionDraftAttachments, @@ -714,7 +715,7 @@ describe('withOpenSessionTeardown — the gate that holds recovery off during a describe('homeStarterSession', () => { it("shows the family's stored draft, not the conversation in view, and selects nothing", () => { - const restore = withTwoFamilies('rootA') + const restore = withTwoFamilies('forkA') const prevCurrent = sessionState.currentSessionId const conversation = session({ id: 'conversation', workspace_id: 'rootA' }) // Touched only by picking where it acts: no text, but stored, so it is the user's draft. @@ -740,6 +741,62 @@ describe('homeStarterSession', () => { } }) + it('leaves a draft picked onto another workspace of the family to that workspace', () => { + const restore = withTwoFamilies('rootA') + const prevCurrent = sessionState.currentSessionId + const picked = session({ id: 'picked-fork-draft', pending_workspace_id: 'forkA' }) + sessionState.sessions.push(picked) + sessionState.currentSessionId = 'picked-fork-draft' + let createdId: string | undefined + try { + createdId = homeStarterSession().id + expect(createdId).not.toBe('picked-fork-draft') + expect(picked.pending_workspace_id).toBe('forkA') + } finally { + sessionState.sessions = sessionState.sessions.filter( + (s) => !['picked-fork-draft', createdId].includes(s.id) + ) + sessionState.currentSessionId = prevCurrent + restore() + } + }) + + it('keeps its own draft when the workspace was switched on another page', () => { + const restore = withTwoFamilies('forkA') + const typed = session({ + id: 'home-draft', + pending_workspace_id: 'rootA', + draftPrompt: 'typed on Home', + followsHome: true + }) + sessionState.sessions.push(typed) + try { + expect(homeStarterSession().id).toBe('home-draft') + } finally { + sessionState.sessions = sessionState.sessions.filter((s) => s.id !== 'home-draft') + restore() + } + }) + + it('lets go of its draft once the session page reuses it for a new session', () => { + const restore = withTwoFamilies('forkA') + const prevCurrent = sessionState.currentSessionId + const ids: string[] = [] + try { + ids.push(homeStarterSession().id) + const reused = createSession() + expect(reused.id).toBe(ids[0]) + setNewSessionWorkspace(reused.id, 'rootA') + ids.push(homeStarterSession().id) + expect(ids[1]).not.toBe(ids[0]) + expect(reused.pending_workspace_id).toBe('rootA') + } finally { + sessionState.sessions = sessionState.sessions.filter((s) => !ids.includes(s.id)) + sessionState.currentSessionId = prevCurrent + restore() + } + }) + it('reports the never-touched drafts it drops, so their runtimes can be disposed', () => { const restore = withTwoFamilies('rootB') const prevCurrent = sessionState.currentSessionId diff --git a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts index 30b7792487..2ef63bacfc 100644 --- a/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts +++ b/frontend/src/lib/components/sessions/sessionSwitch.svelte.ts @@ -20,6 +20,10 @@ import { findMountedOpenInSessionSource } from './openInSessionContext' // Type-only: erased at compile time, so the component graph stays out of this // navigation seam (see the dynamic import in openEditorInSession). import type { OpenInSessionSource } from './OpenInSessionButton.svelte' +import type { AppDomSelectorElement } from '../copilot/chat/context' + +// `seedDomElement` rides only with `autoSend`: it is the chip of the prompt being sent. +type SeedOpts = { seedPrompt?: string; autoSend?: boolean; seedDomElement?: AppDomSelectorElement } // The session/navigation switch turns the global rail into either the workspace // navigation (navigation mode) or the sessions sidebar (session mode). Session @@ -171,7 +175,7 @@ export async function openEditorInSession( target: SessionTarget, workspaceId?: string, previewParams?: Record<string, string>, - opts?: { seedPrompt?: string; autoSend?: boolean } + opts?: SeedOpts ): Promise<void> { await openInSession( withPreviewParams(sessionTargetHref(target), previewParams), @@ -186,7 +190,7 @@ export async function openEditorInSession( export async function openPageInSession( href: string, workspaceId?: string, - opts?: { seedPrompt?: string; autoSend?: boolean } + opts?: SeedOpts ): Promise<void> { await openInSession(href, workspaceId, opts) } @@ -194,7 +198,7 @@ export async function openPageInSession( async function openInSession( url: string | undefined, workspaceId?: string, - opts?: { seedPrompt?: string; autoSend?: boolean } + opts?: SeedOpts ): Promise<void> { await goto(await sessionHrefFor(url, workspaceId, opts)) } @@ -212,7 +216,7 @@ export function editorSessionHref( async function sessionHrefFor( url: string | undefined, workspaceId?: string, - opts?: { seedPrompt?: string; autoSend?: boolean } + opts?: SeedOpts ): Promise<string> { // Seed the fresh session's preview with a single tab on `url` so it opens // straight onto what the caller wants (resetSessionPreviewTabs also writes @@ -221,7 +225,7 @@ async function sessionHrefFor( if (workspaceId) setSessionPendingWorkspace(session.id, workspaceId) if (opts?.seedPrompt) { setSessionDraftPrompt(session.id, opts.seedPrompt) - if (opts.autoSend) setSessionAutoSend(session.id) + if (opts.autoSend) setSessionAutoSend(session.id, opts.seedDomElement) } if (url) { // Dynamic import: a static one would drag the runtime's heavy graph @@ -243,12 +247,13 @@ async function sessionHrefFor( // step; `OpenInSessionButton` is the declarative equivalent. export async function openSourceInSession( source: OpenInSessionSource, - overrides?: { previewParams?: Record<string, string>; seedPrompt?: string; autoSend?: boolean } + overrides?: { previewParams?: Record<string, string> } & SeedOpts ): Promise<void> { await source.beforeOpen?.() const opts = { seedPrompt: overrides?.seedPrompt ?? source.seedPrompt, - autoSend: overrides?.autoSend ?? source.autoSend + autoSend: overrides?.autoSend ?? source.autoSend, + seedDomElement: overrides?.seedDomElement } if (source.target) { await openEditorInSession( @@ -267,15 +272,14 @@ export async function openSourceInSession( // composer. For entry points that carry a question rather than a target (the // global search's "Ask AI"). Always a new session rather than the most recent // one (`enterSessionMode`), so the seed cannot overwrite a prompt the user has -// already typed into a session they are mid-way through. +// already typed into a session they are mid-way through. `workspace` is the way +// out of a read-only session, whose workspace is fixed once it has started. export async function startSessionWithPrompt( prompt: string, - opts?: { autoSend?: boolean } + opts?: { autoSend?: boolean; workspace?: string } ): Promise<void> { - // No setSessionPendingWorkspace: createSession already picked the workspace, - // steering off a root the user cannot deploy to onto its dev. Overwriting it - // with the raw current workspace would land the session where it cannot edit. const session = createSession() + if (opts?.workspace) setSessionPendingWorkspace(session.id, opts.workspace) setSessionDraftPrompt(session.id, prompt) // An empty prompt has nothing to send; leave the composer focused instead. if (opts?.autoSend && prompt.trim()) setSessionAutoSend(session.id) diff --git a/frontend/src/lib/components/sessions/sessionSwitch.test.ts b/frontend/src/lib/components/sessions/sessionSwitch.test.ts index 497e3cbad9..3ee386a9ac 100644 --- a/frontend/src/lib/components/sessions/sessionSwitch.test.ts +++ b/frontend/src/lib/components/sessions/sessionSwitch.test.ts @@ -9,12 +9,14 @@ import { takeNewSessionSeed } from './sessionSwitch.svelte' import { + getSessionAutoSendDomElement, peekSessionAutoSend, sessionState, takeSessionAutoSend, type Session } from './sessionState.svelte' import { usersWorkspaceStore, workspaceStore, type UserWorkspace } from '$lib/stores' +import { createAppDomSelectorElement } from '../copilot/chat/context' vi.mock('$lib/navigation', () => ({ goto: vi.fn().mockResolvedValue(undefined) })) import { goto } from '$lib/navigation' @@ -176,6 +178,31 @@ describe('auto-send intent', () => { } }) + // A picked element names what the prompt is about ("make this bigger"): it must + // arrive with the prompt and be consumed with it, never outlive the intent. + it('carries a picked element with the prompt and clears it on claim', async () => { + const prevCurrent = sessionState.currentSessionId + let createdId: string | undefined + const el = createAppDomSelectorElement({ + selector: 'main > h1', + appPath: 'u/me/app', + tagName: 'H1' + }) + try { + await openSourceInSession( + { target: { kind: 'raw_app', path: 'u/me/app' } }, + { seedPrompt: 'make this bigger', autoSend: true, seedDomElement: el } + ) + createdId = sessionState.currentSessionId! + expect(getSessionAutoSendDomElement(createdId)).toEqual(el) + expect(takeSessionAutoSend(createdId)).toBe(true) + expect(getSessionAutoSendDomElement(createdId)).toBeUndefined() + } finally { + sessionState.sessions = sessionState.sessions.filter((s) => s.id !== createdId) + sessionState.currentSessionId = prevCurrent + } + }) + // `goto` resolves even when a beforeNavigate cancels it, so an abandoned // hand-off leaves the session armed. The claim must go stale rather than fire // a turn at whatever the user is doing whenever that session next opens. @@ -226,10 +253,9 @@ describe('startSessionWithPrompt', () => { vi.mocked(goto).mockClear() }) - // The hand-off must not re-pick the workspace: createSession already steers off - // a root the user cannot deploy to onto its dev, and overwriting that with the - // raw current workspace lands the session somewhere it cannot edit. - it('seeds the composer and keeps createSession’s workspace choice', async () => { + // A question asked on prod is answered against prod, in a read-only session, even + // when a dev workspace exists to make changes in. + it('seeds the composer and starts in the current workspace', async () => { const prevUsers = get(usersWorkspaceStore) const prevWs = get(workspaceStore) const prevCurrent = sessionState.currentSessionId @@ -244,7 +270,7 @@ describe('startSessionWithPrompt', () => { createdId = sessionState.currentSessionId const created = sessionState.sessions.find((s) => s.id === createdId) expect(created?.draftPrompt).toBe('list my flows') - expect(created?.pending_workspace_id).toBe('prod-dev') + expect(created?.pending_workspace_id).toBe('prod') } finally { sessionState.sessions = sessionState.sessions.filter((s) => s.id !== createdId) sessionState.currentSessionId = prevCurrent diff --git a/frontend/src/lib/components/sessions/workModeLock.svelte.ts b/frontend/src/lib/components/sessions/workModeLock.svelte.ts new file mode 100644 index 0000000000..e32645625b --- /dev/null +++ b/frontend/src/lib/components/sessions/workModeLock.svelte.ts @@ -0,0 +1,64 @@ +import { fromStore } from 'svelte/store' +import { userWorkspaces } from '$lib/stores' +import { useEditRights } from '$lib/operatorWriteRights' +import { buildsAnything } from '$lib/editRights' +import { findCanonicalDevWorkspace } from '$lib/utils/workspaceHierarchy' +import type { WorkModeLock } from '../copilot/chat/workMode' +import { pickSessionWorkspace } from './SessionWorkspaceBar.svelte' +import { useSessionReadOnly } from './sessionPermissions.svelte' +import { getEffectiveWorkspaceId, type Session } from './sessionState.svelte' +import { startSessionWithPrompt } from './sessionSwitch.svelte' + +/** + * What holds a session's chat in Use, for its mode picker: a protected workspace, whose way + * out is its dev workspace, or a role that builds nothing there. `switchesWorkspace` is the + * session's workspace bar's: on Home, moving the session to the dev workspace moves the page + * too. Reads context and registers an effect: call during component initialisation. + */ +export function useWorkModeLock(session: () => Session | undefined, switchesWorkspace = false) { + const workspace = () => { + const s = session() + return s ? getEffectiveWorkspaceId(s) : undefined + } + const protectedWorkspace = useSessionReadOnly(workspace) + const rights = useEditRights(workspace) + const workspaces = fromStore(userWorkspaces) + const lock = $derived.by((): WorkModeLock | undefined => { + const s = session() + const ws = workspace() + // A staged fork is created at the first send and the session builds there, so nothing + // about the workspace it forks holds the session in Use. + if (!s || !ws || s.pending_fork) return undefined + if (protectedWorkspace.known) { + const dev = findCanonicalDevWorkspace(ws, workspaces.current) + if (!dev) + return { reason: 'This workspace is protected against direct changes. Build in a fork.' } + const label = `${dev.name} (dev)` + return { + reason: `This workspace is protected against direct changes. Build in ${label}.`, + // Until its first message a session can still change workspace; after it, only a new + // one can act elsewhere. + escape: s.workspace_id + ? { + label: `New session in ${label}`, + action: () => void startSessionWithPrompt('', { workspace: dev.id }) + } + : { + label: `Switch to ${label}`, + action: () => pickSessionWorkspace(s.id, dev.id, switchesWorkspace) + } + } + } + if (!buildsAnything(rights.capabilities)) { + return { + reason: "Your role can't build in this workspace. A workspace admin can change that." + } + } + return undefined + }) + return { + get current() { + return lock + } + } +} diff --git a/frontend/src/lib/components/settings/RoleCapabilitiesEditor.svelte b/frontend/src/lib/components/settings/RoleCapabilitiesEditor.svelte index e9823aecde..a9fc0e9911 100644 --- a/frontend/src/lib/components/settings/RoleCapabilitiesEditor.svelte +++ b/frontend/src/lib/components/settings/RoleCapabilitiesEditor.svelte @@ -93,8 +93,9 @@ }, { key: 'flows', - label: 'Flows', - description: 'Compose assembles deployed scripts and flows without code of their own.' + label: 'Flows and AI agents', + description: + 'Compose builds flows and AI agents out of deployed scripts and flows, without code of their own.' }, { key: 'apps', diff --git a/frontend/src/lib/components/variables/VariablesList.svelte b/frontend/src/lib/components/variables/VariablesList.svelte index e3d996804e..c01eb5dbd4 100644 --- a/frontend/src/lib/components/variables/VariablesList.svelte +++ b/frontend/src/lib/components/variables/VariablesList.svelte @@ -50,6 +50,7 @@ Shield, Trash, Building, + CalendarClock, DollarSign, EyeOff, Circle, @@ -60,11 +61,13 @@ import { useHostedPage } from '$lib/components/hostedPage' import { useOperatingWorkspace, - useOperatingUser + useOperatingUser, + useReadOnlyWorkspace } from '$lib/components/operatingWorkspace.svelte' const operatingWorkspace = useOperatingWorkspace() const operatingUser = useOperatingUser() + const readOnly = useReadOnlyWorkspace() const editRights = useEditRights() const hosted = useHostedPage() @@ -439,7 +442,7 @@ </tr> </Head> <tbody class="divide-y"> - {#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft }} + {#each filteredItems as { path, value, is_secret, description, extra_perms, canWrite, account, is_refreshed, is_expired, refresh_error, is_linked, labels, inherited_labels, ws_specific, draft_only, is_draft, value_valid_until }} {@const hasDraft = getLocalDraftHint($operatingWorkspace, 'variable', path) ?? is_draft} <Row> @@ -520,6 +523,21 @@ {/snippet} </Popover> {/if} + {#if value_valid_until} + {@const expired = new Date(value_valid_until).getTime() <= Date.now()} + <Popover notClickable> + <CalendarClock + size={16} + class={expired ? 'text-red-600' : 'text-secondary'} + /> + {#snippet text()} + <div> + The value {expired ? 'expired' : 'expires'} on + {new Date(value_valid_until).toLocaleString()}. + </div> + {/snippet} + </Popover> + {/if} {#if account} <Popover notClickable> <RefreshCw size={16} /> @@ -694,8 +712,8 @@ headers={['Name', 'Value']} data={contextualVariables.filter((x) => x.is_custom)} keys={['name', 'value']} - getRowActions={operatingUser.current?.is_admin || - operatingUser.current?.is_super_admin + getRowActions={!readOnly($operatingWorkspace) && + (operatingUser.current?.is_admin || operatingUser.current?.is_super_admin) ? (row) => { return [ { diff --git a/frontend/src/lib/components/workspaceSettings/AISettings.svelte b/frontend/src/lib/components/workspaceSettings/AISettings.svelte index 05159e3057..79d7be2214 100644 --- a/frontend/src/lib/components/workspaceSettings/AISettings.svelte +++ b/frontend/src/lib/components/workspaceSettings/AISettings.svelte @@ -520,6 +520,9 @@ resourceType={provider === 'openai' && usingOpenaiClientCredentialsOauth ? 'openai_client_credentials_oauth' : provider} + additionalResourceTypes={provider === 'anthropic' + ? ['anthropic_federated'] + : []} initialValue={aiProviders[provider].resource_path} bind:value={ () => aiProviders[provider].resource_path || undefined, diff --git a/frontend/src/lib/editRights.test.ts b/frontend/src/lib/editRights.test.ts index fa3366a284..ef344d7d6d 100644 --- a/frontend/src/lib/editRights.test.ts +++ b/frontend/src/lib/editRights.test.ts @@ -28,9 +28,12 @@ const role = (overrides: Partial<RoleCapabilities>): RoleCapabilities => ({ }) describe('edit rights', () => { - it('lets a compose-only flow role author flows and nothing else', () => { + it('lets a compose-only flow role author flows and agents and nothing else', () => { const builder = role({ flows: 'compose' }) expect(roleCanAuthor('flow', builder)).toBe(true) + expect(roleCanAuthor('agent', builder)).toBe(true) + expect(roleCanDraft('agent', builder)).toBe(true) + expect(roleCanAuthor('agent', role({ apps: 'compose' }))).toBe(false) expect(roleCanAuthor('script', builder)).toBe(false) expect(roleCanAuthor('raw_app', builder)).toBe(false) expect(roleCanDraft('raw_app', builder)).toBe(false) diff --git a/frontend/src/lib/editRights.ts b/frontend/src/lib/editRights.ts index 48ec6c3ee2..24022cc680 100644 --- a/frontend/src/lib/editRights.ts +++ b/frontend/src/lib/editRights.ts @@ -30,8 +30,9 @@ export type EditKind = * Whether a role with `caps` lets its member author items of `kind` (create, edit, deploy or * delete one): the role half of every check against the item handlers. Saving a draft is * `roleCanDraft`'s rule, and which items they may write is `canEditItem`'s other half. - * - code (scripts, agents, pipelines): `code`; - * - flows and full-code apps: `compose` or above (`check_operator_can_build`); + * - code (scripts, pipelines): `code`; + * - flows, agents and full-code apps: `compose` or above (`check_operator_can_build`). An + * agent's tools are checked as a composed flow's steps are, under the flows right; * - low-code apps: `full` apps only (apps.rs `require_full_apps`); * - schedules and triggers: the matching manage right; * - resources, variables and groups: always, their permissions alone decide. @@ -39,6 +40,7 @@ export type EditKind = export function roleCanAuthor(kind: EditKind, caps: RoleCapabilities): boolean { switch (kind) { case 'flow': + case 'agent': return caps.flows !== 'none' case 'raw_app': return caps.apps !== 'none' @@ -67,6 +69,14 @@ export function roleCanDraft(kind: EditKind, caps: RoleCapabilities): boolean { return kind === 'group' ? false : roleCanAuthor(kind, caps) } +/** Whether the role builds anything: code, flows, apps, schedules or triggers. Resources and + * variables do not count, since every role edits them. */ +export function buildsAnything(caps: RoleCapabilities): boolean { + return (['script', 'flow', 'raw_app', 'schedule', 'trigger'] as const).some((kind) => + roleCanDraft(kind, caps) + ) +} + /** `roleCanDraft` for the item at `path`, with its own permissions as in `canEditItem`. */ export function canDraftItem( kind: EditKind, diff --git a/frontend/src/lib/hub.ts b/frontend/src/lib/hub.ts index 9f81614258..41e0c41f66 100644 --- a/frontend/src/lib/hub.ts +++ b/frontend/src/lib/hub.ts @@ -86,6 +86,8 @@ type HubPaths = { teamsErrorHandler: string teamsRecoveryHandler: string teamsSuccessHandler: string + slackVariableExpirationHandler: string + teamsVariableExpirationHandler: string emailErrorHandler: string cloneRepoToS3forGitRepoViewer: string appReport: string diff --git a/frontend/src/lib/hubPaths.json b/frontend/src/lib/hubPaths.json index 74c2d6f9f4..41f7f50c33 100644 --- a/frontend/src/lib/hubPaths.json +++ b/frontend/src/lib/hubPaths.json @@ -8,6 +8,8 @@ "teamsErrorHandler": "hub/19742/workspace-or-schedule-error-handler-teams", "teamsRecoveryHandler": "hub/11593/schedule-recovery-handler-teams", "teamsSuccessHandler": "hub/11596/schedule-success-handler-teams", + "slackVariableExpirationHandler": "hub/30049/variable-expiration-handler-slack", + "teamsVariableExpirationHandler": "hub/30048/variable-expiration-handler-teams", "slackReport": "hub/28792/slack", "discordReport": "hub/9085/discord", "smtpReport": "hub/28242/smtp", diff --git a/frontend/src/lib/mcpEndpointTools.ts b/frontend/src/lib/mcpEndpointTools.ts index e53beeb53b..a81110805c 100644 --- a/frontend/src/lib/mcpEndpointTools.ts +++ b/frontend/src/lib/mcpEndpointTools.ts @@ -193,7 +193,12 @@ export const mcpEndpointTools: EndpointTool[] = [ }, "expires_at": { "type": "string", - "description": "The expiration date of the variable", + "description": "When to garbage-collect the variable. Used for ephemeral secrets; the variable is deleted once this is reached. Not the value's expiry \u2014 see value_valid_until.\n", + "format": "date-time" + }, + "value_valid_until": { + "type": "string", + "description": "When the value stored in this variable stops working. Triggers the workspace's variable expiration handler ahead of it, by the handler's `notify_before_secs` (1 day by default); never causes the variable to be deleted.\n", "format": "date-time" }, "labels": { @@ -281,6 +286,12 @@ export const mcpEndpointTools: EndpointTool[] = [ "type": "string", "description": "The new description of the variable" }, + "value_valid_until": { + "type": "string", + "nullable": true, + "description": "When the value stored in this variable stops working. Omit to leave it unchanged; send null to clear it. Changing it re-arms the workspace's variable expiration handler.\n", + "format": "date-time" + }, "labels": { "type": "array", "items": { diff --git a/frontend/src/lib/operatorWriteRights.ts b/frontend/src/lib/operatorWriteRights.ts index 28a68784e6..5684a8ee56 100644 --- a/frontend/src/lib/operatorWriteRights.ts +++ b/frontend/src/lib/operatorWriteRights.ts @@ -1,10 +1,13 @@ -import { derived, fromStore, get, readable, type Readable } from 'svelte/store' +import { derived, fromStore, get, readable, toStore, type Readable } from 'svelte/store' import type { RoleCapabilities } from '$lib/gen' import { userStore, userWorkspaces, usersWorkspaceStore, workspaceStore } from '$lib/stores' -import { useOperatingWorkspace } from '$lib/components/operatingWorkspace.svelte' +import { + useOperatingWorkspace, + useReadOnlyWorkspace +} from '$lib/components/operatingWorkspace.svelte' import { useActingUser } from '$lib/actingUser.svelte' import { canDraftItem, canEditItem, roleCanAuthor, roleCanDraft } from '$lib/editRights' -import { capabilitiesIn } from '$lib/roleCapabilities' +import { capabilitiesIn, withoutBuilding } from '$lib/roleCapabilities' /** * The `editRights` rules (`roleCanAuthor` / `canEditItem` for the item handlers, `roleCanDraft` / @@ -17,22 +20,29 @@ export function useEditRights(workspace?: () => string | undefined) { const operating = fromStore(useOperatingWorkspace()) const ws = workspace ?? (() => operating.current) const user = useActingUser(ws) + const readOnly = useReadOnlyWorkspace() const workspaces = fromStore(userWorkspaces) - const caps = () => - capabilitiesIn( + const open = () => !readOnly(ws()) + // A read-only workspace leaves the role building nothing, so a gate reading `capabilities` + // follows it as the four checks below do. Those keep their own guard: every role edits + // resources and variables. + const caps = () => { + const roleCaps = capabilitiesIn( user.current, workspaces.current.find((w) => w.id === ws()) ) + return open() ? roleCaps : withoutBuilding(roleCaps) + } return { get capabilities(): RoleCapabilities { return caps() }, - roleCanAuthor: (kind: string) => roleCanAuthor(kind, caps()), + roleCanAuthor: (kind: string) => open() && roleCanAuthor(kind, caps()), canEditItem: (kind: string, path: string, extraPerms: Record<string, boolean> | undefined) => - canEditItem(kind, path, extraPerms, user.current, caps()), - roleCanDraft: (kind: string) => roleCanDraft(kind, caps()), + open() && canEditItem(kind, path, extraPerms, user.current, caps()), + roleCanDraft: (kind: string) => open() && roleCanDraft(kind, caps()), canDraftItem: (kind: string, path: string, extraPerms: Record<string, boolean> | undefined) => - canDraftItem(kind, path, extraPerms, user.current, caps()), + open() && canDraftItem(kind, path, extraPerms, user.current, caps()), /** This hook looks the user up on its own, so an editor that retries a failed lookup on * its acting user (`forgetFailures`) must retry this one too, or it stays read-only. */ forgetFailures: () => user.forgetFailures() @@ -72,10 +82,17 @@ export const capabilitiesInWorkspace = (workspace: string | undefined) => * the shape `title` and `disabled` both want. See `docs/operator-write-rights.md`. */ function writeLock(kind: 'schedule' | 'trigger', noun: string) { - return derived(useRoleCapabilities(), (caps) => + const workspace = fromStore(useOperatingWorkspace()) + const readOnly = useReadOnlyWorkspace() + // A store, so a session switched to Use mid-page locks the toggles already shown. + const sessionReadOnly = toStore(() => readOnly(workspace.current)) + return derived([useRoleCapabilities(), sessionReadOnly], ([caps, inUse]) => { + if (inUse) return `A session in Use mode cannot manage ${noun}` // Worded as the server words its refusal of the same write. - roleCanAuthor(kind, caps) ? undefined : `Your role cannot manage ${noun} in this workspace` - ) + return roleCanAuthor(kind, caps) + ? undefined + : `Your role cannot manage ${noun} in this workspace` + }) } /** Reads context: call during component initialisation. */ diff --git a/frontend/src/lib/roleCapabilities.ts b/frontend/src/lib/roleCapabilities.ts index 55beec9ee0..1a9fc70a61 100644 --- a/frontend/src/lib/roleCapabilities.ts +++ b/frontend/src/lib/roleCapabilities.ts @@ -68,6 +68,19 @@ export function isRestricted(caps: RoleCapabilities): boolean { ) } +/** The role with nothing left to build and the same pages in view: what a session in Use mode + * acts as. */ +export function withoutBuilding(caps: RoleCapabilities): RoleCapabilities { + return { + ...caps, + code: false, + flows: 'none', + apps: 'none', + manage_schedules: false, + manage_triggers: false + } +} + /** Whether the role hides any page. */ export function hidesPages(caps: RoleCapabilities): boolean { return VISIBILITY.some((k) => !caps.visibility[k]) diff --git a/frontend/src/lib/utils.test.ts b/frontend/src/lib/utils.test.ts index 07216d341f..65cef7085c 100644 --- a/frontend/src/lib/utils.test.ts +++ b/frontend/src/lib/utils.test.ts @@ -367,7 +367,8 @@ describe('cleanValueProperties', () => { 'edited_by', 'workspace_id', 'parent_hashes', - 'lock_error_logs' + 'lock_error_logs', + 'starred' ] it('strips every server-managed bookkeeping key', () => { diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts index 7341baef49..f3dfb01746 100644 --- a/frontend/src/lib/utils.ts +++ b/frontend/src/lib/utils.ts @@ -1422,7 +1422,8 @@ function replaceFalseWithUndefinedRec(obj: any) { // `hash` is the deployed version's identity, `assets` is re-derived from the // script content by the editor, and `inherited_labels` is computed at read time // from the parent folder — none is editable content, so all three are noise in a -// fork/workspace or version diff. +// fork/workspace or version diff. `starred` is the viewer's own favorite flag, and a +// starred item's `starred: true` survives `replaceFalseWithUndefined`. // // `lock` and `extra_perms` are deliberately NOT in this set: both are legitimate, // user-meaningful fields in some diff contexts (lockfile changes in version-to-version @@ -1447,7 +1448,8 @@ const CLEANED_VALUE_KEYS = new Set([ 'edited_by', 'workspace_id', 'parent_hashes', - 'lock_error_logs' + 'lock_error_logs', + 'starred' ]) export function cleanValueProperties(obj: Value) { @@ -1664,6 +1666,8 @@ export type Item = { id?: string tooltip?: string separatorTop?: boolean + /** A heading above the row, opening a section of the menu. */ + sectionTitle?: string /** Renders an on/off switch at the end of the row, so `icon` keeps the leading * slot. Presentational: the row's own click is what flips it, so `action` must * apply the change. */ diff --git a/frontend/src/lib/utils_draft_deploy.test.ts b/frontend/src/lib/utils_draft_deploy.test.ts index c141af3a3b..71ee7b560b 100644 --- a/frontend/src/lib/utils_draft_deploy.test.ts +++ b/frontend/src/lib/utils_draft_deploy.test.ts @@ -6,7 +6,7 @@ vi.mock('$lib/gen', () => ({ FlowService: { getFlowByPath: vi.fn(), createFlow: vi.fn(), updateFlow: vi.fn() }, DraftService: { deleteDraft: vi.fn() }, AppService: {}, - VariableService: {}, + VariableService: { getVariable: vi.fn(), createVariable: vi.fn(), updateVariable: vi.fn() }, ResourceService: { getResource: vi.fn(), updateResource: vi.fn(), createResource: vi.fn() }, ScheduleService: {}, HttpTriggerService: {}, @@ -32,7 +32,7 @@ vi.mock('$lib/components/raw_apps/utils', () => ({ canonicalRawAppDiffValue: vi. vi.mock('$lib/appDiffSides', () => ({ classicAppDraftParts: vi.fn() })) vi.mock('$lib/utils_deployable', () => ({ TRIGGER_RUNTIME_IGNORE: [] })) -import { ScriptService, FlowService, ResourceService } from '$lib/gen' +import { ScriptService, FlowService, VariableService, ResourceService } from '$lib/gen' import { UserDraftDbSyncer } from '$lib/userDraftDbSyncer.svelte' // draftBaseIsStale compares a draft's base pointer against the deployed head @@ -143,6 +143,62 @@ describe('deployDraft preserves on_behalf_of', () => { }) }) +// The deploy bodies are built field by field, so a forgotten field is dropped silently. +describe('deployDraft variable value_valid_until', () => { + beforeEach(() => vi.clearAllMocks()) + + const DATE = '2027-03-15T08:30:00.000Z' + + function mockVariable(deployedExpiry: string | undefined, draftExpiry: string | undefined) { + vi.mocked(VariableService.getVariable).mockResolvedValueOnce({ + path: 'u/admin/key', + value: 'v', + is_secret: false, + description: '', + value_valid_until: deployedExpiry, + draft: { + path: 'u/admin/key', + variable: { value: 'v', is_secret: false, description: '' }, + value_valid_until: draftExpiry + } + } as any) + } + + const updateBody = () => + vi.mocked(VariableService.updateVariable).mock.calls[0][0].requestBody as any + + it('sends a date the draft set on a variable that had none', async () => { + mockVariable(undefined, DATE) + await deployDraft('variable', 'u/admin/key', 'ws') + expect(updateBody().value_valid_until).toBe(DATE) + }) + + it('sends null when the draft cleared the deployed date', async () => { + mockVariable(DATE, undefined) + await deployDraft('variable', 'u/admin/key', 'ws') + expect(updateBody().value_valid_until).toBeNull() + }) + + it('omits it when the draft leaves the deployed date alone', async () => { + mockVariable(DATE, DATE) + await deployDraft('variable', 'u/admin/key', 'ws') + expect(updateBody().value_valid_until).toBeUndefined() + }) + + it('omits it when the draft spells the deployed moment differently', async () => { + mockVariable('2027-03-15T08:30:00Z', DATE) + await deployDraft('variable', 'u/admin/key', 'ws') + expect(updateBody().value_valid_until).toBeUndefined() + }) + + it('carries the date onto a draft-only variable', async () => { + mockVariable(undefined, DATE) + await deployDraft('variable', 'u/admin/key', 'ws', { draftOnly: true }) + const body = vi.mocked(VariableService.createVariable).mock.calls[0][0].requestBody as any + expect(body.value_valid_until).toBe(DATE) + }) +}) + // The backend skips the dependency job when `lock` is set, so a draft that inherited the // deployed lock would deploy new content against the old lockfile. describe('deployDraft: script lock', () => { diff --git a/frontend/src/lib/utils_draft_deploy.ts b/frontend/src/lib/utils_draft_deploy.ts index 96b83239b7..716eaca9d7 100644 --- a/frontend/src/lib/utils_draft_deploy.ts +++ b/frontend/src/lib/utils_draft_deploy.ts @@ -219,14 +219,15 @@ function stripScheduleTriggerRuntime(row: any): Record<string, unknown> { function canonicalizeDraftDiffValue(kind: DraftKind, raw: any, isDraft: boolean): unknown { if (!raw || typeof raw !== 'object') return raw ?? {} if (kind === 'variable') { - // draft: { variable: { value, is_secret, description }, labels, wsSpecific } - // deployed row: { value, is_secret, description, labels, ws_specific } + // draft: { variable: { value, is_secret, description }, value_valid_until, labels, wsSpecific } + // deployed row: { value, is_secret, description, value_valid_until, labels, ws_specific } const v = isDraft ? (raw.variable ?? {}) : raw const is_secret = !!v.is_secret return { value: is_secret ? '<secret>' : (v.value ?? ''), is_secret, description: v.description ?? '', + value_valid_until: raw.value_valid_until ?? undefined, labels: raw.labels ?? undefined, ws_specific: (isDraft ? raw.wsSpecific : raw.ws_specific) ?? undefined } @@ -446,6 +447,10 @@ async function deployDraftTriggers( await deployTriggers(triggers, workspace, isAdmin, writable<string[]>([]), path, isNew) } +function timeOf(date: string | null | undefined): number | undefined { + return date ? new Date(date).getTime() : undefined +} + /** * Promote a draft to deployed by replaying the editor's create/update call with * the stored draft value. The matching draft row is deleted server-side by the @@ -594,7 +599,7 @@ export async function deployDraft( } else if (kind === 'variable') { const { deployed, draft: d } = splitOverlay(await OVERLAY_GETTERS.variable!(workspace, path)) // VariableEditor's `VariableState` draft shape: - // { path, variable: { value, is_secret, description }, labels?, wsSpecific } + // { path, variable: { value, is_secret, description }, value_valid_until?, labels?, wsSpecific } if (draftOnly) { await VariableService.createVariable({ workspace, @@ -603,6 +608,7 @@ export async function deployDraft( value: d.variable?.value ?? '', is_secret: !!d.variable?.is_secret, description: d.variable?.description ?? '', + value_valid_until: d.value_valid_until, labels: d.labels, ws_specific: d.wsSpecific } @@ -617,12 +623,17 @@ export async function deployDraft( value: d.variable?.value === '' ? undefined : d.variable?.value, is_secret: d.variable?.is_secret, description: d.variable?.description, + // `null` clears, absent keeps. Diffed against the deployed row by moment, since + // an absent date in the draft means "no expiry", not "unchanged". + value_valid_until: + timeOf(deployed.value_valid_until) !== timeOf(d.value_valid_until) + ? (d.value_valid_until ?? null) + : undefined, labels: d.labels, ws_specific: d.wsSpecific } }) } - void deployed } else if (kind === 'resource') { const overlay = await OVERLAY_GETTERS.resource!(workspace, path) // Adopt the row this promote is based on as the baseline for the delete below. Without one diff --git a/frontend/src/lib/utils_workspace_deploy.ts b/frontend/src/lib/utils_workspace_deploy.ts index efa7f09115..272b2f1dad 100644 --- a/frontend/src/lib/utils_workspace_deploy.ts +++ b/frontend/src/lib/utils_workspace_deploy.ts @@ -26,7 +26,7 @@ import { get } from 'svelte/store' import { userWorkspaces } from '$lib/stores' import { capabilitiesIn } from '$lib/roleCapabilities' import { roleCanAuthor } from '$lib/editRights' -import type { RoleCapabilities } from '$lib/gen' +import type { ProtectionRuleset, RoleCapabilities } from '$lib/gen' import { fetchProtectionRulesForWorkspace, canUserBypassRuleKindInRulesets @@ -838,7 +838,9 @@ export async function checkDeployRules( workspace: string, /** Pre-fetched identity for `workspace`, to save a round trip. Narrowed to the fields * read so a caller holding a `UserExt` can pass it without a cast. */ - whoami?: Pick<User, 'is_admin' | 'is_super_admin' | 'username' | 'groups'> + whoami?: Pick<User, 'is_admin' | 'is_super_admin' | 'username' | 'groups'>, + /** Pre-fetched protection rules for `workspace`, to save another. */ + knownRulesets?: ProtectionRuleset[] ): Promise<DeployPermission> { try { const me = whoami ?? (await UserService.whoami({ workspace })) @@ -851,7 +853,7 @@ export async function checkDeployRules( // A superadmin who is only a plain member of the workspace still bypasses every rule, // so dropping either term here refuses a deploy the server accepts. if (!userInfo.is_admin && !userInfo.is_super_admin) { - const rulesets = await fetchProtectionRulesForWorkspace(workspace) + const rulesets = knownRulesets ?? (await fetchProtectionRulesForWorkspace(workspace)) if (!canUserBypassRuleKindInRulesets(rulesets, 'DisableDirectDeployment', userInfo)) { // The reserved dev-workspace lock carries DisableWorkspaceForking alongside this rule, // so suggesting a fork unconditionally would point at a second blocked action. diff --git a/frontend/src/lib/workspaceProtectionRules.svelte.ts b/frontend/src/lib/workspaceProtectionRules.svelte.ts index 01d122794d..af40138f7d 100644 --- a/frontend/src/lib/workspaceProtectionRules.svelte.ts +++ b/frontend/src/lib/workspaceProtectionRules.svelte.ts @@ -89,12 +89,19 @@ export async function loadProtectionRules(workspace: string): Promise<void> { export async function fetchProtectionRulesForWorkspace( workspace: string ): Promise<ProtectionRuleset[]> { + return (await readProtectionRulesForWorkspace(workspace)) ?? [] +} + +/** The same rules, but `undefined` when they cannot be read, for a caller that has to fail + * closed rather than read an error as "no rules". */ +export async function readProtectionRulesForWorkspace( + workspace: string +): Promise<ProtectionRuleset[] | undefined> { try { - const rulesets = await WorkspaceService.listProtectionRules({ workspace }) - return rulesets + return await WorkspaceService.listProtectionRules({ workspace }) } catch (error) { console.error(`Failed to fetch protection rules for workspace ${workspace}:`, error) - return [] + return undefined } } diff --git a/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte index 0fcb6174e2..f05203e231 100644 --- a/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/agents/get/[...path]/+page.svelte @@ -24,7 +24,8 @@ import AgentHistory from '$lib/components/flows/AgentHistory.svelte' import { getDeployUiSettings } from '$lib/components/home/deploy_ui' import { recentItems } from '$lib/components/home/recentItems.svelte' - import { roleCapabilities, userStore, workspaceStore } from '$lib/stores' + import { userStore, workspaceStore } from '$lib/stores' + import { useEditRights } from '$lib/operatorWriteRights' import { sendUserToast } from '$lib/toast' /** @@ -41,9 +42,10 @@ let testPane = $derived(host?.testPaneHandle()) let config = $derived(agent?.state?.args) let chatAvailable = $derived(keepsManagedMemory(config?.memory)) - let canEdit = $derived( - config != undefined && (agent?.canWrite ?? false) && $roleCapabilities.code - ) + const editRights = useEditRights() + let canAuthor = $derived(editRights.roleCanAuthor('agent')) + let canCode = $derived(editRights.capabilities.code) + let canEdit = $derived(config != undefined && (agent?.canWrite ?? false) && canAuthor) // Recorded once the agent is in: a path with nothing deployed never loads one. let loaded = $derived(config != undefined) @@ -100,12 +102,13 @@ () => ws, () => getDeployUiSettings() ) - // A role that cannot code gets no menu, as on a flow's or a script's page. + // A role that cannot build agents gets no menu, as on a flow's page. let menuItems = $derived( - config != undefined && $roleCapabilities.code + config != undefined && canAuthor ? agentMenuItems({ path, canWrite: canEdit, + noCode: !canCode, wsSpecific: agent?.state?.wsSpecific, deployUiSettings: deployUiSettings.current, onPermissions: () => shareModal?.openDrawer?.(path, 'resource'), @@ -161,8 +164,9 @@ } : undefined} mainButtons={[ - // Evaluating an agent builds datasets and runs against it: authoring, as editing is. - ...(canEdit + // Evaluating an agent builds datasets and runs against it: authoring, as editing is, and + // eval datasets are refused to a role without `code` whatever its flows right. + ...(canEdit && canCode ? [ { label: 'Evals', @@ -173,7 +177,11 @@ title: 'Run this agent against a dataset of cases', onClick: () => evalsModal?.openModal() } - }, + } + ] + : []), + ...(canEdit + ? [ { label: 'Edit', buttonProps: { diff --git a/frontend/src/routes/(root)/(logged)/dev/composer-suggestions/+page.svelte b/frontend/src/routes/(root)/(logged)/dev/composer-suggestions/+page.svelte new file mode 100644 index 0000000000..7053fa8d3f --- /dev/null +++ b/frontend/src/routes/(root)/(logged)/dev/composer-suggestions/+page.svelte @@ -0,0 +1,364 @@ +<script lang="ts"> + // DEV-ONLY playground for the composer's suggestion rules: pick a role, edit the signals by + // hand or load a workspace's real ones, and see the pills the starter would show. Not + // linked anywhere; open at /dev/composer-suggestions. Only reachable on dev builds. + import { Badge, Button } from '$lib/components/common' + import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' + import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' + import TextInput from '$lib/components/text_input/TextInput.svelte' + import Select from '$lib/components/select/Select.svelte' + import ComposerSuggestions from '$lib/components/copilot/chat/starter/ComposerSuggestions.svelte' + import { + CHANNEL_TYPES, + composerSuggestions, + DATABASE_TYPES, + type ComposerRole, + type ComposerSignals + } from '$lib/components/copilot/chat/starter/composerSuggestions' + import { readComposerSignals } from '$lib/components/copilot/chat/starter/starterPrompts.svelte' + import { userWorkspaces, workspaceStore } from '$lib/stores' + import { Download } from 'lucide-svelte' + + type RoleName = + | 'operator' + | 'developer' + | 'workspaceAdmin' + | 'instanceAdmin' + | 'instanceAdminMember' + const ROLES: Record<RoleName, ComposerRole> = { + operator: { operator: true, workspaceAdmin: false, instanceAdmin: false }, + developer: { operator: false, workspaceAdmin: false, instanceAdmin: false }, + workspaceAdmin: { operator: false, workspaceAdmin: true, instanceAdmin: false }, + // Not a member: `whoami` makes them the workspace's admin. + instanceAdmin: { operator: false, workspaceAdmin: true, instanceAdmin: true }, + // A member keeps the role of their member row. + instanceAdminMember: { operator: false, workspaceAdmin: false, instanceAdmin: true } + } + + // Every field as the form holds it: a blank field is an absent signal, which fires no rule. + // "none" is a list that answered and holds none, which fires the pills that teach it. + type Form = { + itemCount: string + draftPath: string + sharedFlowPath: string + scriptPath: string + appPath: string + agentPath: string + folderItemPath: string + favoritePath: string + dataTables: string + databasePath: string + databaseType: string + channelPath: string + channelType: string + } + const BLANK: Form = { + itemCount: '', + draftPath: '', + sharedFlowPath: '', + scriptPath: '', + appPath: '', + agentPath: '', + folderItemPath: '', + favoritePath: '', + dataTables: '', + databasePath: '', + databaseType: 'postgresql', + channelPath: '', + channelType: 'slack' + } + const PRESETS: Record<string, Partial<Form>> = { + 'Empty workspace': { itemCount: '0' }, + 'Fresh workspace': { + itemCount: '0', + dataTables: 'none', + channelType: 'none' + }, + 'Empty, connected': { + itemCount: '0', + dataTables: 'main', + databasePath: 'f/data/main_db', + channelPath: 'f/team/slack' + }, + 'Has items': { itemCount: '25' }, + 'Own work': { + itemCount: '25', + draftPath: 'f/billing/sync_invoices' + }, + Established: { + itemCount: '25', + sharedFlowPath: 'f/billing/charge_customers', + scriptPath: 'f/ops/check_ssl_expiry', + appPath: 'f/ops/console', + folderItemPath: 'f/billing/charge_customers', + favoritePath: 'f/ops/check_ssl_expiry', + dataTables: 'main', + databasePath: 'f/data/main_db', + channelPath: 'f/team/slack' + } + } + type PathKey = + | 'draftPath' + | 'sharedFlowPath' + | 'scriptPath' + | 'appPath' + | 'agentPath' + | 'folderItemPath' + | 'favoritePath' + | 'dataTables' + const PATH_FIELDS: { key: PathKey; label: string; placeholder?: string }[] = [ + { key: 'draftPath', label: 'My latest draft (flow path)' }, + { key: 'sharedFlowPath', label: 'Newest shared flow (path)' }, + { key: 'scriptPath', label: 'Newest script (path)' }, + { key: 'appPath', label: 'Newest full-code app (path)' }, + { key: 'agentPath', label: 'Newest AI agent (path)' }, + { key: 'folderItemPath', label: 'Newest item in a folder (path)' }, + { key: 'favoritePath', label: 'Favorite (script path)' }, + { key: 'dataTables', label: 'Data tables (names, or none)', placeholder: 'unknown' } + ] + const NONE = { value: 'none', label: 'None connected' } + const DATABASE_ITEMS = Object.entries(DATABASE_TYPES).map(([value, label]) => ({ value, label })) + const CHANNEL_ITEMS = [ + NONE, + ...Object.entries(CHANNEL_TYPES).map(([value, { name }]) => ({ value, label: name })) + ] + + let roleName = $state<RoleName>('instanceAdmin') + let form = $state<Form>({ ...BLANK, ...PRESETS['Has items'] }) + let workspace = $state<string | undefined>($workspaceStore ?? undefined) + let loading = $state(false) + let picked = $state<string>() + /** The kinds a loaded workspace reported, which the form's path fields do not hold. */ + let loadedKinds = $state<{ draft?: string; folderItem?: string; favorite?: string }>({}) + + const num = (s: string) => (s.trim() === '' || isNaN(Number(s)) ? undefined : Number(s)) + + const item = (kind: string, path: string) => + path.trim() ? { kind, path: path.trim(), name: path.trim().split('/').pop()! } : undefined + const connection = (type: string, path: string) => { + const i = item(type, path) + return i && { type: i.kind, path: i.path, name: i.name } + } + const names = (text: string) => { + const t = text.trim() + if (t === '') return undefined + if (t === 'none') return [] + return t + .split(',') + .map((n) => n.trim()) + .filter(Boolean) + } + + const signals: ComposerSignals = $derived({ + itemCount: num(form.itemCount), + myDraft: item(loadedKinds.draft ?? 'flow', form.draftPath), + sharedFlow: item('flow', form.sharedFlowPath), + script: item('script', form.scriptPath), + app: item('app', form.appPath), + agent: item('agent', form.agentPath), + folderItem: item(loadedKinds.folderItem ?? 'flow', form.folderItemPath), + favorite: item(loadedKinds.favorite ?? 'script', form.favoritePath), + database: connection(form.databaseType, form.databasePath), + channel: form.channelType === 'none' ? null : connection(form.channelType, form.channelPath), + dataTables: names(form.dataTables) + }) + const suggestions = $derived(composerSuggestions(signals, ROLES[roleName])) + + function applyPreset(name: string) { + form = { ...BLANK, ...PRESETS[name] } + loadedKinds = {} + picked = undefined + } + + async function loadWorkspace() { + const requested = workspace + if (!requested) return + loading = true + // A failed list reads as 10 items with nothing of yours; the console says why. + const s = await readComposerSignals(requested) + loading = false + // The selection changed while this was loading: these signals describe another workspace. + if (workspace !== requested) return + loadedKinds = { + draft: s.myDraft?.kind, + folderItem: s.folderItem?.kind, + favorite: s.favorite?.kind + } + form = { + itemCount: String(s.itemCount ?? ''), + draftPath: s.myDraft?.path ?? '', + sharedFlowPath: s.sharedFlow?.path ?? '', + scriptPath: s.script?.path ?? '', + appPath: s.app?.path ?? '', + agentPath: s.agent?.path ?? '', + folderItemPath: s.folderItem?.path ?? '', + favoritePath: s.favorite?.path ?? '', + dataTables: s.dataTables === undefined ? '' : s.dataTables.join(', ') || 'none', + databasePath: s.database?.path ?? '', + databaseType: s.database?.type ?? BLANK.databaseType, + channelPath: s.channel?.path ?? '', + channelType: s.channel === null ? 'none' : (s.channel?.type ?? BLANK.channelType) + } + picked = undefined + } +</script> + +{#if import.meta.env.DEV} + <div class="max-w-6xl mx-auto px-6 py-8 flex flex-col gap-8"> + <div class="flex flex-col gap-1"> + <h1 class="text-lg font-semibold text-emphasis">Composer suggestions</h1> + <p class="text-xs text-secondary"> + The pills <code>composerSuggestions(signals, role)</code> returns. A blank path means no such + item. Invitation prompts, which replace all of these, are not simulated. + </p> + </div> + + <div class="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-8"> + <div class="flex flex-col gap-6"> + <section class="flex flex-col gap-2"> + <h2 class="text-xs font-semibold text-emphasis">Role</h2> + <ToggleButtonGroup bind:selected={roleName} wrap> + {#snippet children({ item })} + <ToggleButton value="operator" label="Operator" {item} /> + <ToggleButton value="developer" label="Developer" {item} /> + <ToggleButton value="workspaceAdmin" label="Workspace admin" {item} /> + <ToggleButton value="instanceAdmin" label="Instance admin" {item} /> + <ToggleButton + value="instanceAdminMember" + label="Instance admin, developer member" + {item} + /> + {/snippet} + </ToggleButtonGroup> + <p class="text-2xs text-hint">An instance admin here is also a workspace admin.</p> + </section> + + <section class="flex flex-col gap-2"> + <h2 class="text-xs font-semibold text-emphasis">Load a real workspace</h2> + <div class="flex flex-row gap-2 items-center"> + <Select + class="grow" + items={$userWorkspaces.map((w) => ({ label: `${w.name} (${w.id})`, value: w.id }))} + bind:value={workspace} + placeholder="Workspace" + /> + <Button + variant="default" + unifiedSize="md" + startIcon={{ icon: Download }} + disabled={!workspace} + {loading} + onClick={loadWorkspace} + > + Load signals + </Button> + </div> + <p class="text-2xs text-hint"> + Fills the form with what the starter would read there. Answers are reused for 30 s. + </p> + </section> + + <section class="flex flex-col gap-2"> + <h2 class="text-xs font-semibold text-emphasis">Presets</h2> + <div class="flex flex-row flex-wrap gap-2"> + {#each Object.keys(PRESETS) as name (name)} + <Button variant="subtle" unifiedSize="sm" onClick={() => applyPreset(name)}> + {name} + </Button> + {/each} + <Button variant="subtle" unifiedSize="sm" onClick={() => applyPreset('')}>Clear</Button> + </div> + </section> + + <section class="flex flex-col gap-3"> + <h2 class="text-xs font-semibold text-emphasis">Signals</h2> + <label class="flex flex-col gap-1"> + <span class="text-xs text-primary">Items</span> + <TextInput + bind:value={() => form.itemCount, (v) => (form.itemCount = String(v ?? ''))} + size="sm" + inputProps={{ type: 'number', min: 0, placeholder: 'absent' }} + /> + <span class="text-2xs text-hint">0 is empty, under 10 is new; the list reads 25</span> + </label> + {#each PATH_FIELDS as field (field.key)} + <label class="flex flex-col gap-1"> + <span class="text-xs text-primary">{field.label}</span> + <TextInput + bind:value={() => form[field.key], (v) => (form[field.key] = String(v ?? ''))} + size="sm" + inputProps={{ placeholder: field.placeholder ?? 'none' }} + /> + </label> + {/each} + <div class="flex flex-col gap-1"> + <span class="text-xs text-primary">Database (type and path)</span> + <div class="flex flex-row gap-2"> + <Select class="w-40" items={DATABASE_ITEMS} bind:value={form.databaseType} /> + <TextInput + class="grow" + bind:value={form.databasePath} + size="sm" + inputProps={{ placeholder: 'none' }} + /> + </div> + </div> + <div class="flex flex-col gap-1"> + <span class="text-xs text-primary">Channel (type and path)</span> + <div class="flex flex-row gap-2"> + <Select class="w-40" items={CHANNEL_ITEMS} bind:value={form.channelType} /> + <TextInput + class="grow" + bind:value={form.channelPath} + size="sm" + inputProps={{ placeholder: 'none' }} + /> + </div> + </div> + </section> + </div> + + <div class="flex flex-col gap-6"> + <section class="flex flex-col gap-2"> + <h2 class="text-xs font-semibold text-emphasis">Pills</h2> + <div class="rounded-md border border-border-light bg-surface p-3 min-h-12"> + {#if suggestions.length} + <ComposerSuggestions {suggestions} onPick={(p) => (picked = p)} /> + {:else} + <p class="text-xs text-hint">No pills.</p> + {/if} + </div> + {#if picked} + <div class="flex flex-col gap-1"> + <span class="text-2xs text-hint">Clicking that pill puts this in the box:</span> + <p class="text-xs text-primary whitespace-pre-wrap">{picked}</p> + </div> + {/if} + </section> + + <section class="flex flex-col gap-2"> + <h2 class="text-xs font-semibold text-emphasis">In order</h2> + <ol class="flex flex-col gap-3"> + {#each suggestions as s, i (s.label)} + <li class="flex flex-col gap-1"> + <div class="flex flex-row gap-2 items-center"> + <span class="text-2xs text-hint w-3">{i + 1}</span> + <Badge color={s.mode === 'use' ? 'blue' : 'green'}>{s.mode}</Badge> + <span class="text-xs font-medium text-emphasis">{s.label}</span> + </div> + <p class="text-2xs text-secondary pl-5">{s.prompt}</p> + </li> + {/each} + </ol> + </section> + + <section class="flex flex-col gap-2"> + <h2 class="text-xs font-semibold text-emphasis">Signals passed</h2> + <pre class="text-2xs text-secondary bg-surface-secondary rounded-md p-3 overflow-x-auto" + >{JSON.stringify(signals, null, 2)}</pre + > + </section> + </div> + </div> + </div> +{/if} diff --git a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte index cbaf2fd935..8f2ae0b65c 100644 --- a/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/flows/edit/[...path]/+page.svelte @@ -339,7 +339,6 @@ v = undefined } if (tok !== loadFlowToken) return - version = v const backendFlow = await FlowService.getFlowByPath({ workspace: $workspaceStore!, @@ -376,6 +375,9 @@ // Layer the draft (`.draft`, if any) over the deployed payload at the field // level. See /scripts/edit's loader for the rationale. const { draft: draftFromBackend, ...deployedFlow } = backendFlow as any + // Set together with the base below: across the await above, a new head beside + // the previous load's base reads as stale, and the stale prompt latches open. + version = v // The flow_version the draft forked from; undefined when editing the deployed // flow directly (no draft) or for a draft never forked from a deploy. draftBaseVersion = backendFlow.draft_base @@ -395,13 +397,16 @@ deployedBaseline = backendFlow.no_deployed ? undefined : (structuredClone($state.snapshot(deployedFlow)) as Flow) - // Surface the saved `draft_path` to the Path widget so the topbar shows the - // pending name, not the `draft_{uuid}` URL. Else the widget seeds from the - // URL, the first edit clobbers `draft_path`, and the friendly name is lost. - // Otherwise the URL, set on every load: this route instance is reused when the - // editor follows its draft to a moved item, and the path it opened on is where - // deploy would look for the flow. - const renderedDraftPath = (effectiveFlow as any).draft_path as string | undefined + // A draft-only flow surfaces its saved `draft_path` to the Path widget so the + // topbar shows the pending name, not the `draft_{uuid}` URL. Else the widget + // seeds from the URL, the first edit clobbers `draft_path`, and the friendly + // name is lost. A deployed flow keeps the URL even with a staged move: it is + // where deploy updates the flow and where its triggers live (FlowBuilder shows + // the staged path). Set on every load: this route instance is reused when the + // editor follows its draft to a moved item. + const renderedDraftPath = backendFlow.no_deployed + ? ((effectiveFlow as any).draft_path as string | undefined) + : undefined flowInitialPath = renderedDraftPath || (page.params.path ?? '') // "Load another user's draft" handoff: render their value over the deployed diff --git a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte index b648bd4212..19e7b60462 100644 --- a/frontend/src/routes/(root)/(logged)/sessions/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/sessions/+page.svelte @@ -852,13 +852,16 @@ // cell reactively, so the editor mirrors the name onto the tab model). Before // that, the workspace listing names it. Falls back to the plain location label // for summary-less items and non-item pages. - function tabLabelFor(tab: SessionPreviewTab, workspace: string): string { + function tabNameFor(tab: SessionPreviewTab, workspace: string): string { const listed = listedItemFor(tab, workspace) - const name = + return ( tab.friendlyLabel ?? (listed && itemDisplayName(listed.path, listed.draftPath, listed.summary)) ?? previewLocationLabel(tab.loc) - return name + sideSuffix(tab.url) + ) + } + function tabLabelFor(tab: SessionPreviewTab, workspace: string): string { + return tabNameFor(tab, workspace) + sideSuffix(tab.url) } // Hover title for a tab. A summary label is free text the strip truncates, and @@ -1118,6 +1121,8 @@ sessionId={s.id} headerInPage={s.id === activeSession?.id} covered={aiHiddenVerdict !== false} + previewTabName={(tab) => + tabNameFor(tab, getEffectiveWorkspaceId(s) ?? $workspaceStore ?? '')} /> </div> {/each} @@ -1258,6 +1263,7 @@ <PreviewRouterPicker workspaceId={previewWorkspace} artifacts={sessionArtifacts} + chat={activeRuntime?.manager} onPick={(t) => { emptyStateNewTabOpen = false openInNewTab(t) @@ -1393,6 +1399,7 @@ {currentItem} workspaceId={previewWorkspace} artifacts={sessionArtifacts} + chat={activeRuntime?.manager} onPick={(t) => { activeTabPickerOpen = false navigatePreviewTo(t) @@ -1424,6 +1431,7 @@ <PreviewRouterPicker workspaceId={previewWorkspace} artifacts={sessionArtifacts} + chat={activeRuntime?.manager} onPick={(t) => { newTabOpen = false openInNewTab(t) diff --git a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte index 4cda55f662..5ba85cdbe0 100644 --- a/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte +++ b/frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte @@ -3,7 +3,7 @@ import { page } from '$app/stores' import { isCloudHosted } from '$lib/cloud' import CenteredPage from '$lib/components/CenteredPage.svelte' - import { Alert, Button, Section, Skeleton, Tab, Tabs } from '$lib/components/common' + import { Alert, Button, SecondsInput, Section, Skeleton, Tab, Tabs } from '$lib/components/common' import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte' import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.svelte' @@ -60,6 +60,7 @@ import PremiumInfo from '$lib/components/settings/PremiumInfo.svelte' import Toggle from '$lib/components/Toggle.svelte' + import Popover from '$lib/components/Popover.svelte' import SlackChannelAgents from '$lib/components/workspaceSettings/SlackChannelAgents.svelte' import ChangeWorkspaceName from '$lib/components/settings/ChangeWorkspaceName.svelte' @@ -222,6 +223,21 @@ let errorHandlerMutedOnCancel: boolean | undefined = $state(undefined) let errorHandlerMutedOnUserPath: boolean | undefined = $state(undefined) let successHandlerScriptPath: string | undefined = $state(undefined) + const noVariableExpirationHandler = handlerDraftFromStored('variableExpiration', undefined) + let variableExpirationDraft: HandlerDraft = $state(cloneHandlerDraft(noVariableExpirationHandler)) + let variableExpirationMutedOnUserPath: boolean = $state(false) + // The sweep's default when the stored handler has no `notify_before_secs`. + const defaultNotifyBeforeSecs = 24 * 3600 + let variableExpirationNotifyBeforeSecs: number = $state(defaultNotifyBeforeSecs) + let variableExpirationRemindEverySecs: number | undefined = $state(undefined) + // The sweep runs every 5 minutes, so shorter intervals count as 5 minutes. + let variableExpirationRuns = $derived( + variableExpirationRemindEverySecs + ? Math.ceil( + variableExpirationNotifyBeforeSecs / Math.max(variableExpirationRemindEverySecs, 300) + ) + : 1 + ) let criticalAlertUIMuted: boolean | undefined = $state(undefined) let initialCriticalAlertUIMuted: boolean | undefined = $state(undefined) let publicAppRateLimitPerMinute: number | undefined = $state(undefined) @@ -291,6 +307,14 @@ // Track initial success handler for unsaved changes detection let initialSuccessHandlerScriptPath: string | undefined = $state(undefined) + // Track initial variable expiration handler for unsaved changes detection + let initialVariableExpirationDraft: HandlerDraft = $state( + cloneHandlerDraft(noVariableExpirationHandler) + ) + let initialVariableExpirationMutedOnUserPath: boolean = $state(false) + let initialVariableExpirationNotifyBeforeSecs: number = $state(defaultNotifyBeforeSecs) + let initialVariableExpirationRemindEverySecs: number | undefined = $state(undefined) + let s3ResourceSettings: S3ResourceSettings = $state({ resourceType: 's3', resourcePath: undefined, @@ -348,6 +372,43 @@ ) }) + // The mute flag is stored inside the handler setting, which saving with no handler + // clears, so the toggle is gated on having one. + let variableExpirationHasHandler = $derived(!!handlerDraftToStored(variableExpirationDraft).path) + + // The stored handler is nested because `hasUnsavedChanges` drops a top-level `path`. + function variableExpirationSettingsValue( + draft: HandlerDraft, + mutedOnUserPath: boolean, + notifyBeforeSecs: number, + remindEverySecs: number | undefined + ) { + return { + handler: handlerDraftToStored(draft), + mutedOnUserPath, + notifyBeforeSecs, + remindEverySecs: remindEverySecs || undefined + } + } + + let hasVariableExpirationChanges = $derived.by(() => { + if (tab !== 'variable_expiration') return false + return hasUnsavedChanges( + variableExpirationSettingsValue( + initialVariableExpirationDraft, + initialVariableExpirationMutedOnUserPath, + initialVariableExpirationNotifyBeforeSecs, + initialVariableExpirationRemindEverySecs + ), + variableExpirationSettingsValue( + variableExpirationDraft, + variableExpirationMutedOnUserPath, + variableExpirationNotifyBeforeSecs, + variableExpirationRemindEverySecs + ) + ) + }) + // Derived state for checking unsaved changes in critical alert mute setting let hasCriticalAlertMuteChanges = $derived.by(() => { if (tab !== 'critical_alerts') return false @@ -491,6 +552,7 @@ | 'dev_workspace' | 'error_handler' | 'success_handler' + | 'variable_expiration' | 'critical_alerts' | 'ai' | 'windmill_data_tables' @@ -809,6 +871,20 @@ | { path?: string; extra_args?: any } | undefined successHandlerScriptPath = (successHandler?.path ?? '').split('/').slice(1).join('/') + const variableExpirationHandler = settings.variable_expiration_handler + const [variableExpirationKind, ...variableExpirationPath] = ( + variableExpirationHandler?.path ?? '' + ).split('/') + variableExpirationDraft = handlerDraftFromStored( + 'variableExpiration', + variableExpirationPath.join('/'), + variableExpirationKind === 'flow' ? 'flow' : 'script', + variableExpirationHandler?.extra_args + ) + variableExpirationMutedOnUserPath = variableExpirationHandler?.muted_on_user_path ?? false + variableExpirationNotifyBeforeSecs = + variableExpirationHandler?.notify_before_secs ?? defaultNotifyBeforeSecs + variableExpirationRemindEverySecs = variableExpirationHandler?.remind_every_secs || undefined workspaceDefaultAppPath = settings.default_app initialWorkspaceDefaultAppPath = settings.default_app @@ -860,6 +936,12 @@ // Store initial success handler state for unsaved changes detection initialSuccessHandlerScriptPath = successHandlerScriptPath + // Store initial variable expiration handler state for unsaved changes detection + initialVariableExpirationDraft = cloneHandlerDraft(variableExpirationDraft) + initialVariableExpirationMutedOnUserPath = variableExpirationMutedOnUserPath + initialVariableExpirationNotifyBeforeSecs = variableExpirationNotifyBeforeSecs + initialVariableExpirationRemindEverySecs = variableExpirationRemindEverySecs + loadedSettings = true } @@ -1066,6 +1148,46 @@ initialErrorHandlerMutedOnUserPath = errorHandlerMutedOnUserPath } + async function editVariableExpirationHandler() { + const handler = handlerDraftToStored(variableExpirationDraft) + await WorkspaceService.editVariableExpirationHandler({ + workspace: $workspaceStore!, + requestBody: { + path: handler.path ? `${handler.kind}/${handler.path}` : undefined, + extra_args: handler.args, + muted_on_user_path: variableExpirationMutedOnUserPath, + notify_before_secs: variableExpirationNotifyBeforeSecs, + remind_every_secs: variableExpirationRemindEverySecs || undefined + } + }) + sendUserToast( + handler.path + ? `Variable expiration handler set to ${handler.path}` + : handlerDraftToStored(initialVariableExpirationDraft).path + ? 'Variable expiration handler removed' + : 'Variable expiration settings saved' + ) + + // Rebuilt from what was stored, so Discard also drops the other tabs' unsaved edits. + initialVariableExpirationDraft = handlerDraftFromStored( + 'variableExpiration', + handler.path, + handler.kind, + handler.args, + { emptySelected: variableExpirationDraft.selected } + ) + initialVariableExpirationMutedOnUserPath = variableExpirationMutedOnUserPath + initialVariableExpirationNotifyBeforeSecs = variableExpirationNotifyBeforeSecs + initialVariableExpirationRemindEverySecs = variableExpirationRemindEverySecs + } + + function discardVariableExpirationChanges() { + variableExpirationDraft = cloneHandlerDraft(initialVariableExpirationDraft) + variableExpirationMutedOnUserPath = initialVariableExpirationMutedOnUserPath + variableExpirationNotifyBeforeSecs = initialVariableExpirationNotifyBeforeSecs + variableExpirationRemindEverySecs = initialVariableExpirationRemindEverySecs + } + async function editSuccessHandler() { if (successHandlerScriptPath) { await WorkspaceService.editSuccessHandler({ @@ -1330,6 +1452,21 @@ } } } + case 'variable_expiration': + return { + savedValue: variableExpirationSettingsValue( + initialVariableExpirationDraft, + initialVariableExpirationMutedOnUserPath, + initialVariableExpirationNotifyBeforeSecs, + initialVariableExpirationRemindEverySecs + ), + modifiedValue: variableExpirationSettingsValue( + variableExpirationDraft, + variableExpirationMutedOnUserPath, + variableExpirationNotifyBeforeSecs, + variableExpirationRemindEverySecs + ) + } case 'critical_alerts': return { savedValue: { criticalAlertUIMuted: initialCriticalAlertUIMuted }, @@ -1374,6 +1511,9 @@ discardErrorHandlerSettingsChanges() successHandlerScriptPath = initialSuccessHandlerScriptPath break + case 'variable_expiration': + discardVariableExpirationChanges() + break case 'critical_alerts': criticalAlertUIMuted = initialCriticalAlertUIMuted break @@ -1481,6 +1621,13 @@ aiDescription: 'Error and success handler workspace settings', isEE: true }, + { + id: 'variable_expiration', + label: 'Variable expiration', + aiId: 'workspace-settings-variable-expiration', + aiDescription: + 'Runnable executed when a variable value is about to expire, so the value can be rotated' + }, { id: 'critical_alerts', label: 'Critical alerts', @@ -2293,6 +2440,125 @@ export async function main( disabled={!$enterpriseLicense} /> </Section> + {:else if tab == 'variable_expiration'} + <SettingsPageHeader + title="Variable expiration handler" + description="Run a script or flow ahead of the date the value of a workspace variable expires, so it can be rotated before it stops working." + link="https://www.windmill.dev/docs/core_concepts/variables_and_secrets" + /> + + {#if !$enterpriseLicense && variableExpirationDraft.selected !== 'custom'} + <Alert type="warning" title="Slack and Teams handlers are an EE feature"> + Connecting a workspace to Slack or Teams is a Windmill EE feature. + </Alert> + {/if} + + <Section label="Handler"> + <div class="flex flex-col gap-6"> + <ErrorOrRecoveryHandler + noMargin + isEditable={true} + errorOrRecovery="variableExpiration" + customRequiresEE={false} + toggleText="Notify when a variable is about to expire" + bind:draft={variableExpirationDraft} + customScriptTemplate="/scripts/add?hub=hub%2F30050%2Fwindmill%2Fworkspace_variable_expiration_handler_template" + /> + + <SettingCard + label="Notify before expiry" + description="How long before a variable's value expires the handler runs" + class="gap-2" + > + <SecondsInput + bind:seconds={variableExpirationNotifyBeforeSecs} + disabled={!variableExpirationHasHandler} + hideSeconds + hideTotal + /> + <div class="flex flex-col gap-1 mt-4"> + <span class="text-xs font-semibold text-emphasis">Remind every</span> + <span class="text-xs text-secondary"> + Run the handler again at this interval until the value expires. Expiries are + checked every 5 minutes, so shorter intervals act as 5 minutes. Leave empty + or at 0 for a single notice. + </span> + <SecondsInput + bind:seconds={variableExpirationRemindEverySecs} + disabled={!variableExpirationHasHandler} + clearable + hideSeconds + hideTotal + /> + {#if variableExpirationRemindEverySecs && variableExpirationRemindEverySecs >= variableExpirationNotifyBeforeSecs} + <span class="text-xs text-yellow-600 dark:text-yellow-400"> + No reminder will be sent: the interval must be shorter than Notify before + expiry. + </span> + {:else if variableExpirationRuns >= 24} + <span class="text-xs text-yellow-600 dark:text-yellow-400"> + This runs the handler up to {variableExpirationRuns} times before the value + expires. + </span> + {/if} + </div> + </SettingCard> + + <SettingCard class="gap-2"> + <!-- `w-fit`: the trigger is a flex item of the card, so it would otherwise + stretch the full width and anchor the popup away from the toggle. --> + <Popover + notClickable + disablePopup={variableExpirationHasHandler} + placement="bottom-start" + class="w-fit" + > + <Toggle + bind:checked={variableExpirationMutedOnUserPath} + disabled={!variableExpirationHasHandler} + options={{ + right: 'Do not run the handler for variables in u/ paths' + }} + /> + {#snippet text()} + Select a handler first + {/snippet} + </Popover> + </SettingCard> + + <Alert type="info" title="Runs as g/variable_expiration_handler"> + {#if variableExpirationDraft.selected === 'custom'} + The handler is executed by the automatically created group + g/variable_expiration_handler. Rotating a variable means writing to it, so + grant that group write access to the variables it is meant to rotate. It + starts with none. A script that carries an on-behalf-of identity is the + exception: it runs as that user, with their permissions rather than the + group's. + {:else} + The handler is executed by the automatically created group + g/variable_expiration_handler. It only posts a message, so it needs no access + to your variables: it is given the path and the expiry date, never the value. + {#if variableExpirationDraft.selected === 'slack'} + Access to the Slack bot token is granted for you. + {/if} + {/if} + </Alert> + <span class="text-xs text-secondary"> + If the handler fails to start, a critical alert is raised once and the handler + is tried again at every check. The alert is resolved as soon as it starts. + </span> + </div> + + <SettingsFooter + class="mt-6" + inline + hasUnsavedChanges={hasVariableExpirationChanges} + onSave={editVariableExpirationHandler} + onDiscard={discardVariableExpirationChanges} + saveLabel="Save variable expiration handler" + disabled={!$enterpriseLicense && variableExpirationDraft.selected !== 'custom'} + /> + </Section> {:else if tab == 'critical_alerts'} <SettingsPageHeader title="Workspace critical alerts" diff --git a/frontend/src/routes/(styled)/kitchen_sink/deploy_animation/+page.svelte b/frontend/src/routes/(styled)/kitchen_sink/deploy_animation/+page.svelte index 327f64f962..32646b13f0 100644 --- a/frontend/src/routes/(styled)/kitchen_sink/deploy_animation/+page.svelte +++ b/frontend/src/routes/(styled)/kitchen_sink/deploy_animation/+page.svelte @@ -113,6 +113,7 @@ ? { ok: true as const } : { ok: false as const, reason: 'Deploy disabled by the playground toggle' } }, + readOnly: false, async deployRow(item: DeployItem) { setStatus(item.key, { status: 'loading' }) deploying = true diff --git a/frontend/src/routes/(styled)/oauth/callback/[client_name]/+page.svelte b/frontend/src/routes/(styled)/oauth/callback/[client_name]/+page.svelte index bfb6a9b10b..f91e26c976 100644 --- a/frontend/src/routes/(styled)/oauth/callback/[client_name]/+page.svelte +++ b/frontend/src/routes/(styled)/oauth/callback/[client_name]/+page.svelte @@ -29,16 +29,16 @@ // goto('/resources') } else if (code && state) { try { - const extraParams = Object.fromEntries( + const params = Object.fromEntries( Array.from(Object.entries(urlParamsToObject(page.url.searchParams))).filter( ([key]) => key !== 'code' && key !== 'state' && key !== 'error' ) ) const res = await OauthService.connectCallback({ clientName: client_name ?? '', - requestBody: { code, state } + requestBody: { code, state, params } }) - const message = { type: 'success', res, resource_type: client_name, extra: extraParams } + const message = { type: 'success', res, resource_type: client_name } sendUserToast('successful', false) if (window.opener) { console.log('Sending oauth popup message') diff --git a/imgs/architecture-dark.svg b/imgs/architecture-dark.svg new file mode 100644 index 0000000000..d69d89bfa0 --- /dev/null +++ b/imgs/architecture-dark.svg @@ -0,0 +1,94 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="700" viewBox="0 0 1200 700" font-family="Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif"> +<title>Windmill architecture + + + +BUILD AND TRIGGER + +Business teams +Build with AI in AI sessions, +in the Windmill UI, in a fork +or a dev workspace + +Engineers +Code in 20+ languages, +CLI, VS Code, coding agents, +Git sync and CI + +Triggers and callers +HTTP routes and webhooks +Schedules +Kafka, NATS, SQS, MQTT +Postgres CDC, email, WebSocket +Apps and auto-generated UIs +Your services, via the REST API +MCP clients + +WINDMILL CONTROL PLANE + +API servers and web UI +Stateless, scale horizontally: +editor, AI sessions, apps, REST API + +Workspaces + +dev / forks +AI-built and new +changes land here + +prod +Locked: deploys +only after review + + +Review and promotion +Protection rulesets, Compare & Deploy, +Git pull requests, CI tests, audit logs + +PostgreSQL +Job queue, workspaces, scripts, flows, +apps, resources, encrypted secrets, +run history and audit logs + +Object storage (optional) +S3, Azure Blob or GCS: large logs, +dependency cache, pipeline data + +EXECUTION ON YOUR INFRA + +Worker group: dev +Runs jobs from dev and forks, +in a network that only reaches +dev systems + +Worker group: prod +Runs reviewed code only, +next to production systems + +Native workers +SQL, REST and GraphQL jobs + +Agent workers +Remote, connect over HTTP, +no direct database access + +Your systems +Databases and warehouses +Internal APIs and SaaS +AI model providers you +configure, local models included + + + + + + +pull jobs from the queue, by tag + +HTTP + + + + + + \ No newline at end of file diff --git a/imgs/architecture-light.svg b/imgs/architecture-light.svg new file mode 100644 index 0000000000..fb7a74df58 --- /dev/null +++ b/imgs/architecture-light.svg @@ -0,0 +1,94 @@ + +Windmill architecture + + + +BUILD AND TRIGGER + +Business teams +Build with AI in AI sessions, +in the Windmill UI, in a fork +or a dev workspace + +Engineers +Code in 20+ languages, +CLI, VS Code, coding agents, +Git sync and CI + +Triggers and callers +HTTP routes and webhooks +Schedules +Kafka, NATS, SQS, MQTT +Postgres CDC, email, WebSocket +Apps and auto-generated UIs +Your services, via the REST API +MCP clients + +WINDMILL CONTROL PLANE + +API servers and web UI +Stateless, scale horizontally: +editor, AI sessions, apps, REST API + +Workspaces + +dev / forks +AI-built and new +changes land here + +prod +Locked: deploys +only after review + + +Review and promotion +Protection rulesets, Compare & Deploy, +Git pull requests, CI tests, audit logs + +PostgreSQL +Job queue, workspaces, scripts, flows, +apps, resources, encrypted secrets, +run history and audit logs + +Object storage (optional) +S3, Azure Blob or GCS: large logs, +dependency cache, pipeline data + +EXECUTION ON YOUR INFRA + +Worker group: dev +Runs jobs from dev and forks, +in a network that only reaches +dev systems + +Worker group: prod +Runs reviewed code only, +next to production systems + +Native workers +SQL, REST and GraphQL jobs + +Agent workers +Remote, connect over HTTP, +no direct database access + +Your systems +Databases and warehouses +Internal APIs and SaaS +AI model providers you +configure, local models included + + + + + + +pull jobs from the queue, by tag + +HTTP + + + + + + \ No newline at end of file diff --git a/imgs/architecture.svg b/imgs/architecture.svg deleted file mode 100644 index 570f4ebc54..0000000000 --- a/imgs/architecture.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - -
worker
(Rust)
worker...
worker
(Rust)
worker...
worker
(Rust)
worker...
frontend
(SPA svelte)
frontend...
DB (Postgres)
DB (Postgr...
Database shardable by workspace_id
Database shardable by...
server
server
HTTPS
HTTPS
server
server
API server
(stateless Rust)
API server...
HTTPS
HTTPS
Cloud / on-prem
Cloud / on-prem
Co-located with API servers or remote
Co-located with API servers or remote
Browser
Browser


nsjail

nsjail
Python/TS
script exec
Python/TS...
your DB
your DB
your API
your API
Internet
Internet
Horizontally scalable
Horizontally scalable
Horizontally scalable
Horizontally scalable
If workers co-located with your internal DB/Services
If workers co-l...
Text is not SVG - cannot display
\ No newline at end of file diff --git a/imgs/diagram.svg b/imgs/diagram.svg deleted file mode 100644 index a7f50abfd3..0000000000 --- a/imgs/diagram.svg +++ /dev/null @@ -1,780 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/lsp/Pipfile b/lsp/Pipfile index cab32152b8..db712975b4 100644 --- a/lsp/Pipfile +++ b/lsp/Pipfile @@ -4,7 +4,7 @@ verify_ssl = true name = "pypi" [packages] -wmill = ">=1.847.0" +wmill = ">=1.849.0" sendgrid = "*" mysql-connector-python = "*" pymongo = "*" diff --git a/openflow.openapi.yaml b/openflow.openapi.yaml index 489888ee60..91a2d4b79e 100644 --- a/openflow.openapi.yaml +++ b/openflow.openapi.yaml @@ -1,7 +1,7 @@ openapi: '3.0.3' info: - version: 1.847.0 + version: 1.849.0 title: OpenFlow Spec contact: name: Ruben Fiszel diff --git a/powershell-client/WindmillClient/WindmillClient.psd1 b/powershell-client/WindmillClient/WindmillClient.psd1 index 713e75f667..786d50f9fa 100644 --- a/powershell-client/WindmillClient/WindmillClient.psd1 +++ b/powershell-client/WindmillClient/WindmillClient.psd1 @@ -12,7 +12,7 @@ RootModule = 'WindmillClient.psm1' # Version number of this module. - ModuleVersion = '1.847.0' + ModuleVersion = '1.849.0' # Supported PSEditions # CompatiblePSEditions = @() diff --git a/python-client/wmill/pyproject.toml b/python-client/wmill/pyproject.toml index 2ef9c5923b..b365d607b8 100644 --- a/python-client/wmill/pyproject.toml +++ b/python-client/wmill/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "wmill" -version = "1.847.0" +version = "1.849.0" description = "A client library for accessing Windmill server wrapping the Windmill client API" license = "Apache-2.0" homepage = "https://windmill.dev" diff --git a/rust-client/src/client.rs b/rust-client/src/client.rs index ea3d92bf28..6e03372865 100644 --- a/rust-client/src/client.rs +++ b/rust-client/src/client.rs @@ -247,6 +247,7 @@ impl Windmill { account: None, is_oauth: None, expires_at: None, + value_valid_until: None, labels: None, ws_specific: None, }, @@ -263,6 +264,7 @@ impl Windmill { value: Some(value), is_secret: None, description: None, + value_valid_until: None, labels: None, ws_specific: None, }, diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index b97fc5c3d9..e8ccf219d6 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1483,7 +1483,7 @@ write a secret you were not given: keep the placeholder. | \`ai_config\` | Providers, default and completion models, custom prompts, \`copilot_disabled\`. | | \`large_file_storage\` | Workspace object storage: \`{ type, s3_resource_path \\| azure_blob_resource_path \\| gcs_resource_path, public_resource?, secondary_storage?, advanced_permissions? }\`. | | \`git_sync\` | \`{ repositories: [...] }\`, each with \`git_repo_resource_path\`, \`script_path\` and \`settings\` (include/exclude paths and types). Server-owned fields (webhook secrets, sync state, credentials) are never read nor written. | -| \`datatable\` | \`{ datatables: { : { database: { resource_type, resource_path? } } } }\`. Role permissions are \`datatable_permissions\`. | +| \`datatable\` | \`{ datatables: { : { database: { resource_type, resource_path } } } }\`, both required: \`resource_type: postgresql\` with \`resource_path\` a Postgres resource's path, or \`instance\`, or \`external_instance\` (Enterprise only), with \`resource_path\` the name of a database a superadmin set up. A fork's entry can instead be \`{ reference: { workspace_id, datatable } }\`, pointing at its parent's data table; keep it as is. \`main\` is the one scripts use by default. Role permissions are \`datatable_permissions\`. | | \`datatable_permissions\` | \`{ : { default_role, roles: { : [tenants] } } }\`: who may connect to each data table as which role. Tenants are \`u/\`, \`g/\`, \`f/\` or \`*\`; \`admin\` is always a role, and \`default_role: null\` means no default. A data table the value omits is no longer under roles. Only a data table on a cluster Windmill manages can be. | | \`protection_rules\` | \`{ : { rules: [...], bypass_groups: [...], bypass_users: [...] } }\`, rules among \`DisableDirectDeployment\`, \`DisableWorkspaceForking\`, \`RestrictDeployToDeployers\`, \`RestrictAnonymousAppDeployment\`, \`RestrictPublicRunSharing\`, \`RestrictGuestAppDeployment\`, \`RequireIsolatedForks\`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. | | \`ducklake\` | \`{ ducklakes: { : { catalog, storage, ... } } }\`. | diff --git a/system_prompts/auto-generated/skills/workspace-settings/SKILL.md b/system_prompts/auto-generated/skills/workspace-settings/SKILL.md index 6216b92db6..21f02750db 100644 --- a/system_prompts/auto-generated/skills/workspace-settings/SKILL.md +++ b/system_prompts/auto-generated/skills/workspace-settings/SKILL.md @@ -44,7 +44,7 @@ write a secret you were not given: keep the placeholder. | `ai_config` | Providers, default and completion models, custom prompts, `copilot_disabled`. | | `large_file_storage` | Workspace object storage: `{ type, s3_resource_path \| azure_blob_resource_path \| gcs_resource_path, public_resource?, secondary_storage?, advanced_permissions? }`. | | `git_sync` | `{ repositories: [...] }`, each with `git_repo_resource_path`, `script_path` and `settings` (include/exclude paths and types). Server-owned fields (webhook secrets, sync state, credentials) are never read nor written. | -| `datatable` | `{ datatables: { : { database: { resource_type, resource_path? } } } }`. Role permissions are `datatable_permissions`. | +| `datatable` | `{ datatables: { : { database: { resource_type, resource_path } } } }`, both required: `resource_type: postgresql` with `resource_path` a Postgres resource's path, or `instance`, or `external_instance` (Enterprise only), with `resource_path` the name of a database a superadmin set up. A fork's entry can instead be `{ reference: { workspace_id, datatable } }`, pointing at its parent's data table; keep it as is. `main` is the one scripts use by default. Role permissions are `datatable_permissions`. | | `datatable_permissions` | `{ : { default_role, roles: { : [tenants] } } }`: who may connect to each data table as which role. Tenants are `u/`, `g/`, `f/` or `*`; `admin` is always a role, and `default_role: null` means no default. A data table the value omits is no longer under roles. Only a data table on a cluster Windmill manages can be. | | `protection_rules` | `{ : { rules: [...], bypass_groups: [...], bypass_users: [...] } }`, rules among `DisableDirectDeployment`, `DisableWorkspaceForking`, `RestrictDeployToDeployers`, `RestrictAnonymousAppDeployment`, `RestrictPublicRunSharing`, `RestrictGuestAppDeployment`, `RequireIsolatedForks`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. | | `ducklake` | `{ ducklakes: { : { catalog, storage, ... } } }`. | diff --git a/system_prompts/base/workspace-settings.md b/system_prompts/base/workspace-settings.md index ba4e863b85..0a2ae51866 100644 --- a/system_prompts/base/workspace-settings.md +++ b/system_prompts/base/workspace-settings.md @@ -39,7 +39,7 @@ write a secret you were not given: keep the placeholder. | `ai_config` | Providers, default and completion models, custom prompts, `copilot_disabled`. | | `large_file_storage` | Workspace object storage: `{ type, s3_resource_path \| azure_blob_resource_path \| gcs_resource_path, public_resource?, secondary_storage?, advanced_permissions? }`. | | `git_sync` | `{ repositories: [...] }`, each with `git_repo_resource_path`, `script_path` and `settings` (include/exclude paths and types). Server-owned fields (webhook secrets, sync state, credentials) are never read nor written. | -| `datatable` | `{ datatables: { : { database: { resource_type, resource_path? } } } }`. Role permissions are `datatable_permissions`. | +| `datatable` | `{ datatables: { : { database: { resource_type, resource_path } } } }`, both required: `resource_type: postgresql` with `resource_path` a Postgres resource's path, or `instance`, or `external_instance` (Enterprise only), with `resource_path` the name of a database a superadmin set up. A fork's entry can instead be `{ reference: { workspace_id, datatable } }`, pointing at its parent's data table; keep it as is. `main` is the one scripts use by default. Role permissions are `datatable_permissions`. | | `datatable_permissions` | `{ : { default_role, roles: { : [tenants] } } }`: who may connect to each data table as which role. Tenants are `u/`, `g/`, `f/` or `*`; `admin` is always a role, and `default_role: null` means no default. A data table the value omits is no longer under roles. Only a data table on a cluster Windmill manages can be. | | `protection_rules` | `{ : { rules: [...], bypass_groups: [...], bypass_users: [...] } }`, rules among `DisableDirectDeployment`, `DisableWorkspaceForking`, `RestrictDeployToDeployers`, `RestrictAnonymousAppDeployment`, `RestrictPublicRunSharing`, `RestrictGuestAppDeployment`, `RequireIsolatedForks`. A rule the value omits is deleted. The dev workspace lock is not listed and cannot be set here. | | `ducklake` | `{ ducklakes: { : { catalog, storage, ... } } }`. | diff --git a/typescript-client/jsr.json b/typescript-client/jsr.json index 2a1a7b3c15..7606c7fd06 100644 --- a/typescript-client/jsr.json +++ b/typescript-client/jsr.json @@ -1,6 +1,6 @@ { "name": "@windmill/windmill", - "version": "1.847.0", + "version": "1.849.0", "exports": "./src/index.ts", "publish": { "exclude": ["!src", "./s3Types.ts", "./sqlUtils.ts", "./client.ts", "./wacError.ts"] diff --git a/typescript-client/package.json b/typescript-client/package.json index 5f7b4f3fef..70dec2bd09 100644 --- a/typescript-client/package.json +++ b/typescript-client/package.json @@ -1,7 +1,7 @@ { "name": "windmill-client", "description": "Windmill SDK client for browsers and Node.js", - "version": "1.847.0", + "version": "1.849.0", "author": "Ruben Fiszel", "license": "Apache 2.0", "homepage": "https://github.com/windmill-labs/windmill/tree/main/typescript-client#readme", diff --git a/version.txt b/version.txt index 44bc59b37f..d8b82c7f85 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.847.0 +1.849.0 diff --git a/windmill-yaml-validator/package-lock.json b/windmill-yaml-validator/package-lock.json index fe642da3da..211d9f0455 100644 --- a/windmill-yaml-validator/package-lock.json +++ b/windmill-yaml-validator/package-lock.json @@ -1,12 +1,12 @@ { "name": "windmill-yaml-validator", - "version": "1.847.0", + "version": "1.849.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "windmill-yaml-validator", - "version": "1.847.0", + "version": "1.849.0", "license": "Apache 2.0", "dependencies": { "@stoplight/yaml": "^4.3.0", diff --git a/windmill-yaml-validator/package.json b/windmill-yaml-validator/package.json index 43d8faa83f..abfe2607d0 100644 --- a/windmill-yaml-validator/package.json +++ b/windmill-yaml-validator/package.json @@ -1,6 +1,6 @@ { "name": "windmill-yaml-validator", - "version": "1.847.0", + "version": "1.849.0", "description": "YAML validator for Windmill flow, schedule, and trigger files", "main": "dist/index.js", "types": "dist/index.d.ts",